mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
11 Commits
fix-sideba
...
feat-chang
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b9ec8911a | ||
|
|
c5dc46c5b8 | ||
|
|
4e554925ba | ||
|
|
4b50d8f6ba | ||
|
|
a08671a0b2 | ||
|
|
27a52f54b4 | ||
|
|
c97f380d52 | ||
|
|
a02b2e1507 | ||
|
|
5528ce14d7 | ||
|
|
7181d91f17 | ||
|
|
5d8c43d05a |
9
packages/constants/src/changelog.ts
Normal file
9
packages/constants/src/changelog.ts
Normal 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,
|
||||
};
|
||||
@@ -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
29
packages/types/src/changelog.d.ts
vendored
Normal 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;
|
||||
}
|
||||
1
packages/types/src/index.d.ts
vendored
1
packages/types/src/index.d.ts
vendored
@@ -45,3 +45,4 @@ export * from "./utils";
|
||||
export * from "./payment";
|
||||
export * from "./layout";
|
||||
export * from "./analytics";
|
||||
export * from "./changelog";
|
||||
|
||||
28
web/core/components/global/product-updates/content-item.tsx
Normal file
28
web/core/components/global/product-updates/content-item.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
21
web/core/components/global/product-updates/error.tsx
Normal file
21
web/core/components/global/product-updates/error.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
274
web/core/components/global/product-updates/jsxConverter.tsx
Normal file
274
web/core/components/global/product-updates/jsxConverter.tsx
Normal 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
|
||||
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>
|
||||
);
|
||||
};
|
||||
17
web/core/components/global/product-updates/loader.tsx
Normal file
17
web/core/components/global/product-updates/loader.tsx
Normal 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>
|
||||
);
|
||||
@@ -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>
|
||||
|
||||
21
web/core/components/global/product-updates/root.tsx
Normal file
21
web/core/components/global/product-updates/root.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
web/core/services/changelog.service.ts
Normal file
28
web/core/services/changelog.service.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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": "*",
|
||||
|
||||
Reference in New Issue
Block a user