Compare commits

...

11 Commits

Author SHA1 Message Date
Anmol Singh Bhatia
4b9ec8911a chore: code refactor 2025-06-20 16:16:36 +05:30
Anmol Singh Bhatia
c5dc46c5b8 chore: code refactor 2025-06-20 16:01:34 +05:30
Anmol Singh Bhatia
4e554925ba chore: code refactor 2025-06-20 15:19:14 +05:30
Anmol Singh Bhatia
4b50d8f6ba chore: code refactor 2025-06-20 14:56:18 +05:30
Anmol Singh Bhatia
a08671a0b2 chore: code refactor 2025-06-20 14:54:36 +05:30
Anmol Singh Bhatia
27a52f54b4 chore: code refactor 2025-06-19 20:53:22 +05:30
Anmol Singh Bhatia
c97f380d52 chore: changelog modal improvements 2025-06-19 20:43:41 +05:30
Anmol Singh Bhatia
a02b2e1507 chore: changelog service added 2025-06-19 20:42:00 +05:30
Anmol Singh Bhatia
5528ce14d7 chore: payloadcms richtext-lexical added 2025-06-19 20:38:21 +05:30
Anmol Singh Bhatia
7181d91f17 chore: changelog types added 2025-06-19 20:37:32 +05:30
Anmol Singh Bhatia
5d8c43d05a chore: changelog constant added 2025-06-19 20:36:41 +05:30
13 changed files with 1449 additions and 71 deletions

View File

@@ -0,0 +1,9 @@
// CMS_BASE_URL
export const CMS_BASE_URL = process.env.NEXT_PUBLIC_CMS_BASE_URL || "https://content.plane.so";
// Changelog Config
export const ChangelogConfig = {
slug: "community",
limit: 5,
page: 1,
};

View File

@@ -35,3 +35,4 @@ export * from "./settings";
export * from "./icon";
export * from "./estimates";
export * from "./analytics";
export * from "./changelog";

29
packages/types/src/changelog.d.ts vendored Normal file
View File

@@ -0,0 +1,29 @@
import { SerializedEditorState } from "@payloadcms/richtext-lexical/lexical";
export interface ChangelogDoc {
id: string;
title: string;
date: string;
summary: string;
description: SerializedEditorState;
published: boolean;
}
export interface ChangelogPaginationData {
docs: ChangelogDoc[];
hasNextPage: boolean;
hasPrevPage: boolean;
limit: number;
nextPage: number | null;
page: number;
pagingCounter: number;
prevPage: number | null;
totalDocs: number;
totalPages: number;
}
export interface TChangeLogConfig {
slug: string;
limit: number;
page: number;
}

View File

@@ -45,3 +45,4 @@ export * from "./utils";
export * from "./payment";
export * from "./layout";
export * from "./analytics";
export * from "./changelog";

View File

@@ -0,0 +1,28 @@
import React from "react";
import { Megaphone } from "lucide-react";
// types
import { ChangelogDoc } from "@plane/types";
// components
import { RichTextNode } from "./jsxConverter";
type TContentItemProps = {
contentItem: ChangelogDoc;
};
export const ContentItem = (props: TContentItemProps) => {
const { contentItem } = props;
if (!contentItem.published) return null;
return (
<div key={contentItem.id} className="relative mb-20 scroll-mt-[50px] lg:scroll-mt-[64px]">
<div className="flex items-center gap-2 py-2 sticky top-0 z-10 bg-custom-background-100">
<span className="size-8 rounded-full border flex items-center justify-center">
<Megaphone className="size-6" />
</span>
<span className="text-neutral-text-primary text-xl font-bold">{contentItem.title}</span>
</div>
<RichTextNode id={Number(contentItem.id)} description={contentItem.description} />
</div>
);
};

View File

@@ -0,0 +1,21 @@
import { useTranslation } from "@plane/i18n";
export const ChangeLogError = () => {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center justify-center w-full h-full mb-8">
<div className="text-lg font-medium">{t("we_are_having_trouble_fetching_the_updates")}</div>
<div className="text-sm text-custom-text-200">
{t("please_visit")}
<a
href="https://go.plane.so/p-changelog"
target="_blank"
className="text-sm text-custom-primary-100 font-medium hover:text-custom-primary-200 underline underline-offset-1 outline-none"
>
{t("our_changelogs")}
</a>{" "}
{t("for_the_latest_updates")}.
</div>
</div>
);
};

View File

@@ -0,0 +1,274 @@
"use client";
import { useEffect, useRef } from "react";
import type { SerializedInlineBlockNode } from "@payloadcms/richtext-lexical";
import { SerializedEditorState } from "@payloadcms/richtext-lexical/lexical";
import { RichText, JSXConvertersFunction, JSXConverters } from "@payloadcms/richtext-lexical/react";
import { Info, Lightbulb, Megaphone, Search, Zap } from "lucide-react";
type ChangelogCtaFields = {
title: string;
icon: "Zap" | "Lens" | "Info" | "Idea" | "Announce" | null;
color: "Red" | "Yellow" | "Blue" | "Green";
description: any;
};
type ColoredTextFields = {
text?: string;
tag?: string;
id?: string;
blockType?: "colored-text";
};
type InlineBlockRendererProps = {
node: SerializedInlineBlockNode<ColoredTextFields>;
};
type InlineBlockRendererFn = (props: InlineBlockRendererProps) => React.ReactElement | null;
type InlineBlockRendererMap = {
[key: string]: InlineBlockRendererFn;
};
type InLineBlockConverterType = {
inlineBlocks: InlineBlockRendererMap;
};
const inLineBlockConverter: InLineBlockConverterType = {
inlineBlocks: {
["colored-text"]: ({ node }) => {
const text = node.fields.text;
if (!text) {
console.warn("Node for 'colored-text' inlineBlock is missing 'text' field:", node);
return null;
}
return <span className="text-blue-500 font-bold font-mono break-all">{text}</span>;
},
},
};
const UploadJSXConverter: JSXConverters<any> = {
upload: ({ node }) => {
if (node.value?.url) {
return (
<img src={node.value.url} alt={node.value.alt || "Uploaded image"} className="w-[100%] h-auto object-cover" />
);
}
return null;
},
};
const CalloutJSXConverter: any = {
blocks: {
Callout: ({ node }: { node: SerializedInlineBlockNode<ChangelogCtaFields> }) => {
const { fields } = node;
if (!fields) return null;
const iconMap = {
Zap: <Zap />,
Lens: <Search />,
Info: <Info />,
Idea: <Lightbulb />,
Announce: <Megaphone />,
};
const colorClasses = {
Red: "border border-[#DD4167] dark:border-[#4C182C] dark:bg-[#4C182C]/40 bg-[#DD4167]/40",
Yellow: "border border-[#D4A72C66] dark:border-[#BF8700] bg-[#FFF8C5] dark:bg-[#332E1B]",
Blue: "border border-[#3f76ff] dark:border-[#224f6a] bg-[#d9efff] dark:bg-[#1e2934]",
Green: "border border-[#5CD3B5] dark:border-[#235645] bg-[#D3F9E7] dark:bg-[#1E2B2A]",
};
const iconColorClasses = {
Red: "text-[#DD4167]",
Yellow: "text-[#9A6700]",
Blue: "text-[#3f76ff] dark:text-[#4d9ed0]",
Green: "text-[#208779] dark:text-[#A8F3D0]",
};
return (
<div className="py-4 pb-2 h-full w-full">
<div className={`p-4 rounded-lg flex flex-row gap-3 ${colorClasses[fields.color] ?? colorClasses.Yellow}`}>
<div className={`${iconColorClasses[fields.color] ?? iconColorClasses.Yellow}`}>
{fields.icon && iconMap[fields.icon]}
</div>
<div>
{fields.title && <h4 className="font-semibold mb-1 -mt-0">{fields.title}</h4>}
{fields.description && (
<div className="text-sm">
<RichText
converters={jsxConverters}
data={fields.description}
className="[&>ul]:list-disc [&>ol]:list-decimal [&_a]:underline"
/>
</div>
)}
</div>
</div>
</div>
);
},
video: ({ node }: { node: any }) => {
const { fields } = node;
const { video } = fields;
return (
<div className="h-full relative">
<video controls={false} autoPlay loop muted playsInline>
<source src={video.url} type={video.mimeType || "video/mp4"} />
Your browser does not support the video tag.
</video>
</div>
);
},
},
};
const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
...defaultConverters,
...UploadJSXConverter,
...CalloutJSXConverter,
...inLineBlockConverter,
// ...videoJSXConverter,
});
export const RichTextNode = ({ description, id }: { description: SerializedEditorState; id: number }) => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT, // Only process text nodes
null // No custom filter function needed
// false // deprecated argument
);
let node;
while ((node = walker.nextNode())) {
if (node.nodeValue?.includes("\u00A0")) {
// \u00A0 is the Unicode char for &nbsp;
node.nodeValue = node.nodeValue.replace(/\u00A0/g, " "); // Replace all occurrences with a regular space
}
}
const createId = (text: string): string =>
text
.toLowerCase()
.replace(/[^a-z0-9-\s]/g, "")
.replace(/\s+/g, "-");
const headings = container.querySelectorAll("h1, h2, h3, h4, h5, h6");
headings.forEach((heading) => {
const text = heading.textContent?.trim() || "";
const id = createId(text);
heading.classList.add("text-neutral-text-primary");
if (!heading.id) {
heading.id = id;
}
const htmlHeading = heading as HTMLElement;
switch (htmlHeading.tagName) {
case "H1":
htmlHeading.style.marginTop = "35px";
htmlHeading.style.marginBottom = "15px";
break;
case "H2":
htmlHeading.style.marginTop = "35px";
htmlHeading.style.marginBottom = "20px";
break;
case "H3":
htmlHeading.style.marginTop = "20px";
htmlHeading.style.marginBottom = "18px";
break;
}
});
// Fix list styling to ensure bullet points are visible
const ulElements = container.querySelectorAll("ul");
ulElements.forEach((ul) => {
const htmlUl = ul as HTMLElement;
htmlUl.style.listStyleType = "disc";
htmlUl.style.listStylePosition = "outside";
htmlUl.style.paddingLeft = "20px";
htmlUl.style.marginTop = "10px";
htmlUl.style.marginBottom = "10px";
});
const olElements = container.querySelectorAll("ol");
olElements.forEach((ol) => {
const htmlOl = ol as HTMLElement;
htmlOl.style.listStyleType = "decimal";
htmlOl.style.listStylePosition = "outside";
htmlOl.style.paddingLeft = "20px";
htmlOl.style.marginTop = "10px";
htmlOl.style.marginBottom = "10px";
});
const listItems = container.querySelectorAll("li");
listItems.forEach((listItem) => {
const htmlLi = listItem as HTMLElement;
htmlLi.classList.add("text-custom-text-300");
htmlLi.style.marginTop = "8px";
htmlLi.style.marginBottom = "4px";
htmlLi.style.lineHeight = "1.5";
htmlLi.style.display = "list-item";
// Ensure the list item has proper spacing and bullet point visibility
htmlLi.style.paddingLeft = "4px";
});
const paragraphs = container.querySelectorAll("p");
paragraphs.forEach((paragraph) => {
paragraph.classList.add("text-neutral-text-primary");
paragraph.style.fontSize = "16px";
paragraph.style.marginBottom = "1px";
paragraph.style.marginTop = "10px";
paragraph.style.lineHeight = "24px";
// paragraph.style.overflowWrap = "break-word";
// paragraph.style.whiteSpace = "break-spaces";
});
const links = container.querySelectorAll("a");
links.forEach((link) => {
const htmlLink = link as HTMLAnchorElement;
htmlLink.style.color = "#3191ff";
htmlLink.style.textDecoration = "underline";
htmlLink.target = "_blank";
});
const strongElements = container.querySelectorAll("strong");
strongElements.forEach((strongElement) => {
// strongElement.classList.add("font-mono", "font-bold", "text-blue-500");
strongElement.classList.add("font-bold", "text-neutral-text-primary");
});
const codeElements = container.querySelectorAll("code");
codeElements.forEach((codeElement) => {
const htmlCode = codeElement as HTMLElement;
htmlCode.classList.add("font-mono", "break-all", "font-bold", "text-blue-500");
htmlCode.style.wordBreak = "break-all";
});
const blockquotes = container.querySelectorAll("blockquote");
blockquotes.forEach((blockquote) => {
const htmlBlockquote = blockquote as HTMLElement;
htmlBlockquote.style.padding = "0 16px";
});
}, []);
return (
<div
ref={containerRef}
// style={{letterSpacing: "-0.020em" }}
>
<div key={id}>
<RichText
data={description}
className="[&>ul]:list-disc [&>ul]:ml-5 [&>ol]:list-decimal [&>ol]:ml-5 [&>ul]:pl-5 [&>ol]:pl-5"
converters={jsxConverters} // Pass the custom converters here
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,17 @@
import React from "react";
import { Loader } from "@plane/ui";
export const ChangeLogLoader = () => (
<Loader className="flex flex-col gap-3 h-full w-full ">
{/* header */}
<div className="flex items-center gap-2 mb-3">
<Loader.Item height="44px" width="500px" />
</div>
{/* body */}
<Loader.Item height="36px" width="300px" />
<Loader.Item height="26px" width="60%" />
<Loader.Item height="26px" width="100%" />
<Loader.Item height="26px" width="75%" />
<Loader.Item height="26px" width="80%" />
</Loader>
);

View File

@@ -1,47 +1,40 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { useTranslation } from "@plane/i18n";
// ui
import useSWR from "swr";
// plane imports
import { ChangelogConfig } from "@plane/constants";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// components
import { ProductUpdatesFooter } from "@/components/global";
// hooks
import { useInstance } from "@/hooks/store";
// plane web components
import { ProductUpdatesHeader } from "@/plane-web/components/global";
// services
import { ChangelogService } from "@/services/changelog.service";
// local components
import { ChangeLogError } from "./error";
import { ChangeLogLoader } from "./loader";
import { ChangeLogContentRoot } from "./root";
export type ProductUpdatesModalProps = {
isOpen: boolean;
handleClose: () => void;
};
const changelogService = new ChangelogService();
export const ProductUpdatesModal: FC<ProductUpdatesModalProps> = observer((props) => {
const { isOpen, handleClose } = props;
const { t } = useTranslation();
const { config } = useInstance();
// useSWR
const { data, isLoading, error } = useSWR(isOpen ? `CHANGE_LOG` : null, () =>
changelogService.fetchChangelog(ChangelogConfig)
);
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXXXL}>
<ProductUpdatesHeader />
<div className="flex flex-col h-[60vh] vertical-scrollbar scrollbar-xs overflow-hidden overflow-y-scroll px-6 mx-0.5">
{config?.instance_changelog_url && config?.instance_changelog_url !== "" ? (
<iframe src={config?.instance_changelog_url} className="w-full h-full" />
) : (
<div className="flex flex-col items-center justify-center w-full h-full mb-8">
<div className="text-lg font-medium">{t("we_are_having_trouble_fetching_the_updates")}</div>
<div className="text-sm text-custom-text-200">
{t("please_visit")}
<a
href="https://go.plane.so/p-changelog"
target="_blank"
className="text-sm text-custom-primary-100 font-medium hover:text-custom-primary-200 underline underline-offset-1 outline-none"
>
{t("our_changelogs")}
</a>{" "}
{t("for_the_latest_updates")}.
</div>
</div>
)}
{isLoading ? <ChangeLogLoader /> : error ? <ChangeLogError /> : <ChangeLogContentRoot data={data} />}
</div>
<ProductUpdatesFooter />
</ModalCore>

View File

@@ -0,0 +1,21 @@
import React from "react";
import { ChangelogPaginationData } from "@plane/types";
import { ContentItem } from "./content-item";
type ChangeLogContentRootProps = {
data: ChangelogPaginationData | undefined;
};
export const ChangeLogContentRoot = (props: ChangeLogContentRootProps) => {
const { data } = props;
if (!data || data?.docs?.length <= 0) return <div className="text-center container my-[30vh]">No data available</div>;
return (
<div className="relative h-full mx-auto px-4 container">
{data.docs.map((contentItem) => (
<ContentItem key={contentItem.id} contentItem={contentItem} />
))}
</div>
);
};

View File

@@ -0,0 +1,28 @@
import { CMS_BASE_URL } from "@plane/constants";
import { ChangelogPaginationData, TChangeLogConfig } from "@plane/types";
import { APIService } from "@/services/api.service";
export class ChangelogService extends APIService {
constructor() {
super(CMS_BASE_URL);
}
async fetchChangelog(config: TChangeLogConfig): Promise<ChangelogPaginationData> {
return this.get(`/api/${config.slug}-releases`, {
params: {
limit: config.limit,
page: config.page,
},
})
.then((response) => {
if (!response?.data) {
throw new Error("No data received from changelog API");
}
return response.data;
})
.catch((error) => {
console.error("Error fetching changelog:", error);
throw error;
});
}
}

View File

@@ -21,6 +21,7 @@
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
"@headlessui/react": "^1.7.3",
"@intercom/messenger-js-sdk": "^0.0.12",
"@payloadcms/richtext-lexical": "^3.43.0",
"@plane/constants": "*",
"@plane/editor": "*",
"@plane/hooks": "*",

1049
yarn.lock

File diff suppressed because it is too large Load Diff