feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
import type { FC } from "react";
import { useEffect, useState } from "react";
// constants
import type { EPageAccess } from "@plane/constants";
import { PROJECT_PAGE_TRACKER_EVENTS } from "@plane/constants";
import type { TPage } from "@plane/types";
// ui
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { captureSuccess, captureError } from "@/helpers/event-tracker.helper";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web hooks
import type { EPageStoreType } from "@/plane-web/hooks/store";
import { usePageStore } from "@/plane-web/hooks/store";
// local imports
import { PageForm } from "./page-form";
type Props = {
workspaceSlug: string;
projectId: string;
isModalOpen: boolean;
pageAccess?: EPageAccess;
handleModalClose: () => void;
redirectionEnabled?: boolean;
storeType: EPageStoreType;
};
export const CreatePageModal: FC<Props> = (props) => {
const {
workspaceSlug,
projectId,
isModalOpen,
pageAccess,
handleModalClose,
redirectionEnabled = false,
storeType,
} = props;
// states
const [pageFormData, setPageFormData] = useState<Partial<TPage>>({
id: undefined,
name: "",
logo_props: undefined,
});
// router
const router = useAppRouter();
// store hooks
const { createPage } = usePageStore(storeType);
const handlePageFormData = <T extends keyof TPage>(key: T, value: TPage[T]) =>
setPageFormData((prev) => ({ ...prev, [key]: value }));
// update page access in form data when page access from the store changes
useEffect(() => {
setPageFormData((prev) => ({ ...prev, access: pageAccess }));
}, [pageAccess]);
const handleStateClear = () => {
setPageFormData({ id: undefined, name: "", access: pageAccess });
handleModalClose();
};
const handleFormSubmit = async () => {
if (!workspaceSlug || !projectId) return;
try {
const pageData = await createPage(pageFormData);
if (pageData) {
captureSuccess({
eventName: PROJECT_PAGE_TRACKER_EVENTS.create,
payload: {
id: pageData.id,
},
});
handleStateClear();
if (redirectionEnabled) router.push(`/${workspaceSlug}/projects/${projectId}/pages/${pageData.id}`);
}
} catch (error: any) {
captureError({
eventName: PROJECT_PAGE_TRACKER_EVENTS.create,
error,
});
}
};
return (
<ModalCore
isOpen={isModalOpen}
handleClose={handleModalClose}
position={EModalPosition.TOP}
width={EModalWidth.XXL}
>
<PageForm
formData={pageFormData}
handleFormData={handlePageFormData}
handleModalClose={handleStateClear}
handleFormSubmit={handleFormSubmit}
/>
</ModalCore>
);
};

View File

@@ -0,0 +1,99 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
// ui
import { useParams } from "next/navigation";
import { PROJECT_PAGE_TRACKER_EVENTS } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { AlertModalCore } from "@plane/ui";
// constants
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// plane web hooks
import { useAppRouter } from "@/hooks/use-app-router";
import type { EPageStoreType } from "@/plane-web/hooks/store";
import { usePageStore } from "@/plane-web/hooks/store";
// store
import type { TPageInstance } from "@/store/pages/base-page";
type TConfirmPageDeletionProps = {
isOpen: boolean;
onClose: () => void;
page: TPageInstance;
storeType: EPageStoreType;
};
export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((props) => {
const { isOpen, onClose, page, storeType } = props;
// states
const [isDeleting, setIsDeleting] = useState(false);
// store hooks
const { removePage } = usePageStore(storeType);
if (!page || !page.id) return null;
// derived values
const { id: pageId, name } = page;
const handleClose = () => {
setIsDeleting(false);
onClose();
};
const router = useAppRouter();
const { pageId: routePageId } = useParams();
const handleDelete = async () => {
setIsDeleting(true);
await removePage({ pageId })
.then(() => {
captureSuccess({
eventName: PROJECT_PAGE_TRACKER_EVENTS.delete,
payload: {
id: pageId,
},
});
handleClose();
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page deleted successfully.",
});
if (routePageId) {
router.back();
}
})
.catch(() => {
captureError({
eventName: PROJECT_PAGE_TRACKER_EVENTS.delete,
payload: {
id: pageId,
},
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be deleted. Please try again.",
});
});
setIsDeleting(false);
};
return (
<AlertModalCore
handleClose={handleClose}
handleSubmit={handleDelete}
isSubmitting={isDeleting}
isOpen={isOpen}
title="Delete page"
content={
<>
Are you sure you want to delete page-{" "}
<span className="break-words font-medium text-custom-text-100 break-all">{name}</span> ? The Page will be
deleted permanently. This action cannot be undone.
</>
}
/>
);
});

View File

@@ -0,0 +1,286 @@
"use client";
import { useState } from "react";
import type { PageProps } from "@react-pdf/renderer";
import { pdf } from "@react-pdf/renderer";
import { Controller, useForm } from "react-hook-form";
// plane editor
import type { EditorRefApi } from "@plane/editor";
// plane ui
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { CustomSelect, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// components
import { PDFDocument } from "@/components/editor/pdf";
// hooks
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
type Props = {
editorRef: EditorRefApi | null;
isOpen: boolean;
onClose: () => void;
pageTitle: string;
};
type TExportFormats = "pdf" | "markdown";
type TPageFormats = Exclude<PageProps["size"], undefined>;
type TContentVariety = "everything" | "no-assets";
type TFormValues = {
export_format: TExportFormats;
page_format: TPageFormats;
content_variety: TContentVariety;
};
const EXPORT_FORMATS: {
key: TExportFormats;
label: string;
}[] = [
{
key: "pdf",
label: "PDF",
},
{
key: "markdown",
label: "Markdown",
},
];
const PAGE_FORMATS: {
key: TPageFormats;
label: string;
}[] = [
{
key: "A4",
label: "A4",
},
{
key: "A3",
label: "A3",
},
{
key: "A2",
label: "A2",
},
{
key: "LETTER",
label: "Letter",
},
{
key: "LEGAL",
label: "Legal",
},
{
key: "TABLOID",
label: "Tabloid",
},
];
const CONTENT_VARIETY: {
key: TContentVariety;
label: string;
}[] = [
{
key: "everything",
label: "Everything",
},
{
key: "no-assets",
label: "No images",
},
];
const defaultValues: TFormValues = {
export_format: "pdf",
page_format: "A4",
content_variety: "everything",
};
export const ExportPageModal: React.FC<Props> = (props) => {
const { editorRef, isOpen, onClose, pageTitle } = props;
// states
const [isExporting, setIsExporting] = useState(false);
// form info
const { control, reset, watch } = useForm<TFormValues>({
defaultValues,
});
// parse editor content
const { replaceCustomComponentsFromHTMLContent, replaceCustomComponentsFromMarkdownContent } =
useParseEditorContent();
// derived values
const selectedExportFormat = watch("export_format");
const selectedPageFormat = watch("page_format");
const selectedContentVariety = watch("content_variety");
const isPDFSelected = selectedExportFormat === "pdf";
const fileName = pageTitle
?.toLowerCase()
?.replace(/[^a-z0-9-_]/g, "-")
.replace(/-+/g, "-");
// handle modal close
const handleClose = () => {
onClose();
setTimeout(() => {
reset();
}, 300);
};
const initiateDownload = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
setTimeout(() => {
URL.revokeObjectURL(url);
}, 1000);
};
// handle export as a PDF
const handleExportAsPDF = async () => {
try {
const pageContent = `<h1 class="page-title">${pageTitle}</h1>${editorRef?.getDocument().html ?? "<p></p>"}`;
const parsedPageContent = await replaceCustomComponentsFromHTMLContent({
htmlContent: pageContent,
noAssets: selectedContentVariety === "no-assets",
});
const blob = await pdf(<PDFDocument content={parsedPageContent} pageFormat={selectedPageFormat} />).toBlob();
initiateDownload(blob, `${fileName}-${selectedPageFormat.toString().toLowerCase()}.pdf`);
} catch (error) {
throw new Error(`Error in exporting as a PDF: ${error}`);
}
};
// handle export as markdown
const handleExportAsMarkdown = async () => {
try {
const markdownContent = editorRef?.getMarkDown() ?? "";
const parsedMarkdownContent = replaceCustomComponentsFromMarkdownContent({
markdownContent,
noAssets: selectedContentVariety === "no-assets",
});
const blob = new Blob([parsedMarkdownContent], { type: "text/markdown" });
initiateDownload(blob, `${fileName}.md`);
} catch (error) {
throw new Error(`Error in exporting as markdown: ${error}`);
}
};
// handle export
const handleExport = async () => {
setIsExporting(true);
try {
if (selectedExportFormat === "pdf") {
await handleExportAsPDF();
}
if (selectedExportFormat === "markdown") {
await handleExportAsMarkdown();
}
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page exported successfully.",
});
handleClose();
} catch (error) {
console.error("Error in exporting page:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be exported. Please try again later.",
});
} finally {
setIsExporting(false);
}
};
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.SM}>
<div>
<div className="p-5 space-y-5">
<h3 className="text-xl font-medium text-custom-text-200">Export page</h3>
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h6 className="flex-shrink-0 text-sm text-custom-text-200">Export format</h6>
<Controller
control={control}
name="export_format"
render={({ field: { onChange, value } }) => (
<CustomSelect
label={EXPORT_FORMATS.find((format) => format.key === value)?.label}
buttonClassName="border-none"
value={value}
onChange={(val: TExportFormats) => onChange(val)}
className="flex-shrink-0"
placement="bottom-end"
>
{EXPORT_FORMATS.map((format) => (
<CustomSelect.Option key={format.key} value={format.key}>
{format.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div className="flex items-center justify-between gap-2">
<h6 className="flex-shrink-0 text-sm text-custom-text-200">Include content</h6>
<Controller
control={control}
name="content_variety"
render={({ field: { onChange, value } }) => (
<CustomSelect
label={CONTENT_VARIETY.find((variety) => variety.key === value)?.label}
buttonClassName="border-none"
value={value}
onChange={(val: TContentVariety) => onChange(val)}
className="flex-shrink-0"
placement="bottom-end"
>
{CONTENT_VARIETY.map((variety) => (
<CustomSelect.Option key={variety.key} value={variety.key}>
{variety.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
{isPDFSelected && (
<div className="flex items-center justify-between gap-2">
<h6 className="flex-shrink-0 text-sm text-custom-text-200">Page format</h6>
<Controller
control={control}
name="page_format"
render={({ field: { onChange, value } }) => (
<CustomSelect
label={PAGE_FORMATS.find((format) => format.key === value)?.label}
buttonClassName="border-none"
value={value}
onChange={(val: TPageFormats) => onChange(val)}
className="flex-shrink-0"
placement="bottom-end"
>
{PAGE_FORMATS.map((format) => (
<CustomSelect.Option key={format.key.toString()} value={format.key}>
{format.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
)}
</div>
</div>
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button variant="primary" size="sm" loading={isExporting} onClick={handleExport}>
{isExporting ? "Exporting" : "Export"}
</Button>
</div>
</div>
</ModalCore>
);
};

View File

@@ -0,0 +1,157 @@
"use client";
import type { FormEvent } from "react";
import { useState } from "react";
import type { LucideIcon } from "lucide-react";
import { Globe2, Lock } from "lucide-react";
// plane imports
import { ETabIndices, EPageAccess } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { PageIcon } from "@plane/propel/icons";
import type { TPage } from "@plane/types";
import { EmojiIconPicker, EmojiIconPickerTypes, Input } from "@plane/ui";
import { convertHexEmojiToDecimal, getTabIndex } from "@plane/utils";
// components
import { AccessField } from "@/components/common/access-field";
import { Logo } from "@/components/common/logo";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
formData: Partial<TPage>;
handleFormData: <T extends keyof TPage>(key: T, value: TPage[T]) => void;
handleModalClose: () => void;
handleFormSubmit: () => Promise<void>;
};
const PAGE_ACCESS_SPECIFIERS: {
key: EPageAccess;
i18n_label: string;
icon: LucideIcon;
}[] = [
{ key: EPageAccess.PUBLIC, i18n_label: "common.access.public", icon: Globe2 },
{ key: EPageAccess.PRIVATE, i18n_label: "common.access.private", icon: Lock },
];
export const PageForm: React.FC<Props> = (props) => {
const { formData, handleFormData, handleModalClose, handleFormSubmit } = props;
// hooks
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
// state
const [isOpen, setIsOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const i18n_access_label = PAGE_ACCESS_SPECIFIERS.find((access) => access.key === formData.access)?.i18n_label;
const { getIndex } = getTabIndex(ETabIndices.PROJECT_PAGE, isMobile);
const handlePageFormSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
try {
setIsSubmitting(true);
await handleFormSubmit();
setIsSubmitting(false);
} catch {
setIsSubmitting(false);
}
};
const isTitleLengthMoreThan255Character = formData.name ? formData.name.length > 255 : false;
return (
<form onSubmit={handlePageFormSubmit}>
<div className="space-y-5 p-5">
<h3 className="text-xl font-medium text-custom-text-200">Create page</h3>
<div className="flex items-start gap-2 h-9 w-full">
<EmojiIconPicker
isOpen={isOpen}
handleToggle={(val: boolean) => setIsOpen(val)}
className="flex items-center justify-center flex-shrink0"
buttonClassName="flex items-center justify-center"
label={
<span className="grid h-9 w-9 place-items-center rounded-md bg-custom-background-90">
<>
{formData?.logo_props?.in_use ? (
<Logo logo={formData?.logo_props} size={18} type="lucide" />
) : (
<PageIcon className="h-4 w-4 text-custom-text-300" />
)}
</>
</span>
}
onChange={(val: any) => {
let logoValue = {};
if (val?.type === "emoji")
logoValue = {
value: convertHexEmojiToDecimal(val.value.unified),
url: val.value.imageUrl,
};
else if (val?.type === "icon") logoValue = val.value;
handleFormData("logo_props", {
in_use: val?.type,
[val?.type]: logoValue,
});
setIsOpen(false);
}}
defaultIconColor={
formData?.logo_props?.in_use && formData?.logo_props?.in_use === "icon"
? formData?.logo_props?.icon?.color
: undefined
}
defaultOpen={
formData?.logo_props?.in_use && formData?.logo_props?.in_use === "emoji"
? EmojiIconPickerTypes.EMOJI
: EmojiIconPickerTypes.ICON
}
/>
<div className="space-y-1 flew-grow w-full">
<Input
id="name"
type="text"
value={formData.name}
onChange={(e) => handleFormData("name", e.target.value)}
placeholder="Title"
className="w-full resize-none text-base"
tabIndex={getIndex("name")}
required
autoFocus
/>
{isTitleLengthMoreThan255Character && (
<span className="text-xs text-red-500">Max length of the name should be less than 255 characters</span>
)}
</div>
</div>
</div>
<div className="px-5 py-4 flex items-center justify-between gap-2 border-t-[0.5px] border-custom-border-200">
<div className="flex items-center gap-2">
<AccessField
onChange={(access) => handleFormData("access", access)}
value={formData?.access ?? EPageAccess.PUBLIC}
accessSpecifiers={PAGE_ACCESS_SPECIFIERS}
isMobile={isMobile}
/>
<h6 className="text-xs font-medium">{t(i18n_access_label || "")}</h6>
</div>
<div className="flex items-center justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleModalClose} tabIndex={getIndex("cancel")}>
Cancel
</Button>
<Button
variant="primary"
size="sm"
type="submit"
loading={isSubmitting}
disabled={isTitleLengthMoreThan255Character}
tabIndex={getIndex("submit")}
>
{isSubmitting ? "Creating" : "Create Page"}
</Button>
</div>
</div>
</form>
);
};