diff --git a/dev-wiki/.env.example b/dev-wiki/.env.example new file mode 100644 index 0000000000..15d7a36a9d --- /dev/null +++ b/dev-wiki/.env.example @@ -0,0 +1,12 @@ +NEXT_PUBLIC_API_BASE_URL="http://localhost:8000" + +NEXT_PUBLIC_WEB_BASE_URL="http://localhost:3000" + +NEXT_PUBLIC_ADMIN_BASE_URL="http://localhost:3001" +NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" + +NEXT_PUBLIC_SPACE_BASE_URL="http://localhost:3002" +NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" + +NEXT_PUBLIC_LIVE_BASE_URL="http://localhost:3100" +NEXT_PUBLIC_LIVE_BASE_PATH="/live" diff --git a/dev-wiki/.eslintignore b/dev-wiki/.eslintignore new file mode 100644 index 0000000000..84f01402dd --- /dev/null +++ b/dev-wiki/.eslintignore @@ -0,0 +1,3 @@ +.next/* +out/* +public/* \ No newline at end of file diff --git a/dev-wiki/.eslintrc.js b/dev-wiki/.eslintrc.js new file mode 100644 index 0000000000..5a6f060678 --- /dev/null +++ b/dev-wiki/.eslintrc.js @@ -0,0 +1,6 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ["@plane/eslint-config/next.js"], + parser: "@typescript-eslint/parser", +}; diff --git a/dev-wiki/.prettierignore b/dev-wiki/.prettierignore new file mode 100644 index 0000000000..e841c6b328 --- /dev/null +++ b/dev-wiki/.prettierignore @@ -0,0 +1,5 @@ +.next +.turbo +out/ +dist/ +build/ \ No newline at end of file diff --git a/dev-wiki/.prettierrc b/dev-wiki/.prettierrc new file mode 100644 index 0000000000..87d988f1b2 --- /dev/null +++ b/dev-wiki/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/dev-wiki/.yarnrc.yml b/dev-wiki/.yarnrc.yml new file mode 100644 index 0000000000..f53151fc10 --- /dev/null +++ b/dev-wiki/.yarnrc.yml @@ -0,0 +1,4 @@ +packageExtensions: + "@react-pdf/pdfkit@*": + dependencies: + "pako": "*" diff --git a/dev-wiki/Dockerfile.dev b/dev-wiki/Dockerfile.dev new file mode 100644 index 0000000000..64465755ee --- /dev/null +++ b/dev-wiki/Dockerfile.dev @@ -0,0 +1,12 @@ +FROM node:20-alpine +RUN apk add --no-cache libc6-compat +# Set working directory +WORKDIR /app + + +COPY . . +RUN yarn global add turbo +RUN yarn install +EXPOSE 3000 +VOLUME [ "/app/node_modules", "/app/web/node_modules" ] +CMD ["yarn", "dev", "--filter=web"] diff --git a/dev-wiki/Dockerfile.web b/dev-wiki/Dockerfile.web new file mode 100644 index 0000000000..ed206c2989 --- /dev/null +++ b/dev-wiki/Dockerfile.web @@ -0,0 +1,112 @@ +FROM node:20-alpine as base + +# ***************************************************************************** +# STAGE 1: Build the project +# ***************************************************************************** +FROM base AS builder +RUN apk add --no-cache libc6-compat +# Set working directory +WORKDIR /app + +RUN yarn global add turbo +COPY . . + +RUN turbo prune --scope=web --docker + +# ***************************************************************************** +# STAGE 2: Install dependencies & build the project +# ***************************************************************************** +# Add lockfile and package.json's of isolated subworkspace +FROM base AS installer + +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# First install the dependencies (as they change less often) +COPY .gitignore .gitignore +COPY --from=builder /app/out/json/ . +COPY --from=builder /app/out/yarn.lock ./yarn.lock +RUN yarn install --network-timeout 500000 + +# Build the project +COPY --from=builder /app/out/full/ . +COPY turbo.json turbo.json + +ARG NEXT_PUBLIC_API_BASE_URL="" +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_URL="" +ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" +ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH + +ARG NEXT_PUBLIC_LIVE_BASE_URL="" +ENV NEXT_PUBLIC_LIVE_BASE_URL=$NEXT_PUBLIC_LIVE_BASE_URL + +ARG NEXT_PUBLIC_LIVE_BASE_PATH="/live" +ENV NEXT_PUBLIC_LIVE_BASE_PATH=$NEXT_PUBLIC_LIVE_BASE_PATH + +ARG NEXT_PUBLIC_SPACE_BASE_URL="" +ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL + +ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" +ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH + +ARG NEXT_PUBLIC_WEB_BASE_URL="" +ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL + +ARG NEXT_PUBLIC_SILO_BASE_URL="" +ENV NEXT_PUBLIC_SILO_BASE_URL=$NEXT_PUBLIC_SILO_BASE_URL + +ARG NEXT_PUBLIC_SILO_BASE_PATH="/silo" +ENV NEXT_PUBLIC_SILO_BASE_PATH=$NEXT_PUBLIC_SILO_BASE_PATH + +ENV NEXT_TELEMETRY_DISABLED 1 +ENV TURBO_TELEMETRY_DISABLED 1 + +RUN yarn turbo run build --filter=web + +# ***************************************************************************** +# STAGE 3: Copy the project and start it +# ***************************************************************************** +FROM base AS runner +WORKDIR /app + +COPY --from=installer /app/web/next.config.js . +COPY --from=installer /app/web/package.json . + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=installer /app/web/.next/standalone ./ +COPY --from=installer /app/web/.next ./web/.next +COPY --from=installer /app/web/public ./web/public + +ARG NEXT_PUBLIC_API_BASE_URL="" +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_URL="" +ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL + +ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" +ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH + +ARG NEXT_PUBLIC_LIVE_BASE_URL="" +ENV NEXT_PUBLIC_LIVE_BASE_URL=$NEXT_PUBLIC_LIVE_BASE_URL + +ARG NEXT_PUBLIC_LIVE_BASE_PATH="/live" +ENV NEXT_PUBLIC_LIVE_BASE_PATH=$NEXT_PUBLIC_LIVE_BASE_PATH + +ARG NEXT_PUBLIC_SPACE_BASE_URL="" +ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL + +ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" +ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH + +ARG NEXT_PUBLIC_WEB_BASE_URL="" +ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL + +ENV NEXT_TELEMETRY_DISABLED 1 +ENV TURBO_TELEMETRY_DISABLED 1 + +EXPOSE 3000 diff --git a/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/archived/layout.tsx b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/archived/layout.tsx new file mode 100644 index 0000000000..ce3c314c47 --- /dev/null +++ b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/archived/layout.tsx @@ -0,0 +1,17 @@ +"use client"; + +// components +import { AppHeader } from "@/components/core"; +// local components +import { PageTypeFiltersHeader } from "../filters-header"; +import { PageTypeHeader } from "../header"; + +export default function ArchivedPagesListLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + + {children} + + ); +} diff --git a/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/archived/page.tsx b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/archived/page.tsx new file mode 100644 index 0000000000..f48d31a734 --- /dev/null +++ b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/archived/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +// plane web components +import { WikiPagesListLayoutRoot } from "@/plane-web/components/pages"; + +export default function ArchivedPagesList() { + return ; +} diff --git a/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/filters-header.tsx b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/filters-header.tsx new file mode 100644 index 0000000000..8d0ca39ab9 --- /dev/null +++ b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/filters-header.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useCallback, useEffect } from "react"; +import { observer } from "mobx-react"; +import { usePathname } from "next/navigation"; +// types +import { TPageFilterProps } from "@plane/types"; +// components +import { PageAppliedFiltersList } from "@/components/pages"; +// helpers +import { calculateTotalFilters } from "@/helpers/filter.helper"; +import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store/use-page-store"; +// plane web hooks + +export const PageTypeFiltersHeader = observer(() => { + // params + const pathname = usePathname(); + // store hooks + const { filters, updateFilters, clearAllFilters } = usePageStore(EPageStoreType.WORKSPACE); + // derived values + const isFiltersApplied = calculateTotalFilters(filters?.filters ?? {}) !== 0; + // handle remove filter + const handleRemoveFilter = useCallback( + (key: keyof TPageFilterProps, value: string | null) => { + let newValues = filters.filters?.[key]; + + if (key === "favorites") newValues = !!value; + if (Array.isArray(newValues)) { + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value); + } + + updateFilters("filters", { [key]: newValues }); + }, + [filters.filters, updateFilters] + ); + + useEffect(() => { + clearAllFilters(); + updateFilters("searchQuery", ""); + }, [clearAllFilters, pathname, updateFilters]); + + if (!isFiltersApplied) return null; + + return ( +
+ +
+ ); +}); diff --git a/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/header.tsx b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/header.tsx new file mode 100644 index 0000000000..fc905ff4f5 --- /dev/null +++ b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/header.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams, usePathname, useRouter } from "next/navigation"; +import { FileText } from "lucide-react"; +// plane imports +import { EPageAccess } from "@plane/constants"; +import { TPage, TPageNavigationTabs } from "@plane/types"; +// ui +import { Breadcrumbs, Button, Header, setToast, TOAST_TYPE } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common"; +// import { FiltersDropdown } from "@/components/issues"; +import { PageOrderByDropdown, PageSearchInput } from "@/components/pages"; +// helpers +import { calculateTotalFilters } from "@/helpers/filter.helper"; +import { capitalizeFirstLetter } from "@/helpers/string.helper"; +// hooks +// plane web hooks +import { useMember } from "@/hooks/store/use-member"; +import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store/use-page-store"; + +type Props = { + pageType: TPageNavigationTabs; +}; + +export const PageTypeHeader: React.FC = observer((props) => { + const { pageType } = props; + // states + const [isCreatingPage, setIsCreatingPage] = useState(false); + // params + const router = useRouter(); + const { workspaceSlug } = useParams(); + const pathname = usePathname(); + // store hooks + const { + workspace: { workspaceMemberIds }, + } = useMember(); + const { createPage, filters, updateFilters, clearAllFilters } = usePageStore(EPageStoreType.WORKSPACE); + // derived values + const isFiltersApplied = calculateTotalFilters(filters?.filters ?? {}) !== 0; + // handle page create + const handleCreatePage = async () => { + setIsCreatingPage(true); + + const payload: Partial = { + access: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC, + }; + + await createPage(payload) + .then((res) => { + const pageId = `/${workspaceSlug}/pages/${res?.id}`; + router.push(pageId); + }) + .catch((err: { data: { error: any } }) => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err?.data?.error || "Page could not be created. Please try again.", + }) + ) + .finally(() => setIsCreatingPage(false)); + }; + + useEffect(() => { + clearAllFilters(); + updateFilters("searchQuery", ""); + }, [clearAllFilters, pathname, updateFilters]); + + return ( +
+ +
+ + } + /> + } + /> + } + /> + +
+
+ + updateFilters("searchQuery", val)} + /> + { + if (val.key) updateFilters("sortKey", val.key); + if (val.order) updateFilters("sortBy", val.order); + }} + /> + {/* } */} + {/* title="Filters" */} + {/* placement="bottom-end" */} + {/* isFiltersApplied={isFiltersApplied} */} + {/* > */} + {/* */} + {/* */} + + +
+ ); +}); diff --git a/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/private/layout.tsx b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/private/layout.tsx new file mode 100644 index 0000000000..d2615fb77b --- /dev/null +++ b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/private/layout.tsx @@ -0,0 +1,17 @@ +"use client"; + +// components +import { AppHeader } from "@/components/core"; +// local components +import { PageTypeFiltersHeader } from "../filters-header"; +import { PageTypeHeader } from "../header"; + +export default function PrivatePagesListLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + + {children} + + ); +} diff --git a/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/private/page.tsx b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/private/page.tsx new file mode 100644 index 0000000000..0fc8b39614 --- /dev/null +++ b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/private/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +// plane web components +import { WikiPagesListLayoutRoot } from "@/plane-web/components/pages"; + +export default function PrivatePagesList() { + return ; +} diff --git a/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/public/layout.tsx b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/public/layout.tsx new file mode 100644 index 0000000000..69d9433562 --- /dev/null +++ b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/public/layout.tsx @@ -0,0 +1,17 @@ +"use client"; + +// components +import { AppHeader } from "@/components/core"; +// local components +import { PageTypeFiltersHeader } from "../filters-header"; +import { PageTypeHeader } from "../header"; + +export default function PublicPagesListLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + + {children} + + ); +} diff --git a/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/public/page.tsx b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/public/page.tsx new file mode 100644 index 0000000000..96a4f722e2 --- /dev/null +++ b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/public/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +// plane web components +import { WikiPagesListLayoutRoot } from "@/plane-web/components/pages"; + +export default function PublicPagesList() { + return ; +} diff --git a/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/[pageId]/header.tsx b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/[pageId]/header.tsx new file mode 100644 index 0000000000..a57d8e3629 --- /dev/null +++ b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/[pageId]/header.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams, useRouter } from "next/navigation"; +import useSWR from "swr"; +import { FileText, ChevronRight } from "lucide-react"; +// constants +import { EUserWorkspaceRoles, EUserPermissionsLevel } from "@plane/constants"; +// types +import { TPage } from "@plane/types"; +// ui +import { Button, CustomMenu, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common"; +import { PageBreadcrumbItem } from "@/components/pages"; +import { PageHeaderActions } from "@/components/pages/header/actions"; +import { PageSyncingBadge } from "@/components/pages/header/syncing-badge"; +// helpers +import { SPACE_BASE_PATH, SPACE_BASE_URL } from "@/helpers/common.helper"; +// hooks +import { useUserPermissions } from "@/hooks/store"; +// plane web components +import { PublishPageModal, CollaboratorsList } from "@/plane-web/components/pages"; +// plane web hooks +import { EPageStoreType, usePage, usePageStore, usePublishPage } from "@/plane-web/hooks/store"; + +const storeType = EPageStoreType.WORKSPACE; + +export interface IPagesHeaderProps { + showButton?: boolean; +} + +export const PageDetailsHeader = observer(() => { + // states + const [isPublishModalOpen, setIsPublishModalOpen] = useState(false); + // params + const { workspaceSlug, pageId } = useParams(); + const router = useRouter(); + // store hooks + const { allowPermissions } = useUserPermissions(); + const { fetchParentPages } = usePageStore(storeType); + const page = usePage({ + pageId: pageId?.toString() ?? "", + storeType, + }); + const { fetchWorkspacePagePublishSettings, getPagePublishSettings, publishWorkspacePage, unpublishWorkspacePage } = + usePublishPage(); + const { anchor, isCurrentUserOwner } = page ?? {}; + // derived values + const isDeployed = !!anchor; + const pagePublishSettings = getPagePublishSettings(pageId.toString()); + const isPublishAllowed = + isCurrentUserOwner || allowPermissions([EUserWorkspaceRoles.ADMIN], EUserPermissionsLevel.WORKSPACE); + + // Always fetch parent pages to keep the cache up-to-date + const { data: parentPagesList, isLoading: isParentPagesLoading } = useSWR( + workspaceSlug && pageId ? `WORKSPACE_PARENT_PAGES_LIST_${pageId.toString()}` : null, + workspaceSlug && pageId ? () => fetchParentPages(pageId.toString()) : null + ); + + // Transform the data once it's available using useMemo + const orderedParentPages = useMemo(() => { + if (!parentPagesList) return undefined; + return createOrderedParentChildArray(parentPagesList); + }, [parentPagesList]); + + // Now use orderedParentPages instead of parentPagesList for your UI logic + const isRootPage = orderedParentPages?.length === 1; + const rootParentDetails = orderedParentPages?.[0]; // First item is the root + const middleParents = orderedParentPages?.slice(1, -1) ?? []; // Middle items (excluding root and current) + + const SPACE_APP_URL = SPACE_BASE_URL.trim() === "" ? window.location.origin : SPACE_BASE_URL; + const publishLink = `${SPACE_APP_URL}${SPACE_BASE_PATH}/pages/${anchor}`; + + const BreadcrumbSeparator = () => ( +
+ +
+ ); + + if (!page) return null; + + return ( + <> + await fetchWorkspacePagePublishSettings(pageId.toString())} + isOpen={isPublishModalOpen} + onClose={() => setIsPublishModalOpen(false)} + pagePublishSettings={pagePublishSettings} + publishPage={(data) => publishWorkspacePage(pageId.toString(), data)} + unpublishPage={() => unpublishWorkspacePage(pageId.toString())} + /> +
+ +
+
+
+ } + /> +
+ + {isParentPagesLoading ? ( +
+ +
+
+
+
+ ) : ( + <> + {!isRootPage && rootParentDetails?.id && ( +
+ + +
+ )} + + {middleParents.length > 0 && ( +
+ + + {middleParents.map( + (parent, index) => + parent.id && ( +
+ router.push(`/${workspaceSlug}/pages/${parent.id}`)} + > + + +
+ ) + )} +
+
+ )} + + )} + +
+ + +
+
+
+ + +
+ + + {isDeployed && ( + + + Live + + )} + {isPublishAllowed && ( + + )} + +
+
+
+ + ); +}); + +function createOrderedParentChildArray(parentPagesList: TPage[]) { + // If the list is empty or has only one item, return it as is + if (!parentPagesList || parentPagesList.length <= 1) { + return parentPagesList; + } + + // Create a map for quick lookups by ID + const pagesMap = new Map(); + parentPagesList.forEach((page) => { + pagesMap.set(page.id, page); + }); + + const rootPage = parentPagesList.find((page) => page.parent_id === null); + + if (!rootPage) { + console.error("No root page found in the list"); + return parentPagesList; + } + + const result: TPage[] = []; + + function buildHierarchy(currentPage: TPage) { + result.push(currentPage); + + // Find all direct children of the current page + const children = parentPagesList.filter((page) => page.parent_id === currentPage.id); + + // Process each child + children.forEach((child) => { + buildHierarchy(child); + }); + } + + // Start building from the root + buildHierarchy(rootPage); + + return result; +} diff --git a/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/[pageId]/layout.tsx b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/[pageId]/layout.tsx new file mode 100644 index 0000000000..4d71b7ef4a --- /dev/null +++ b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/[pageId]/layout.tsx @@ -0,0 +1,15 @@ +"use client"; + +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +// layout +import { PageDetailsHeader } from "./header"; + +export default function PageDetailsLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/[pageId]/page.tsx b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/[pageId]/page.tsx new file mode 100644 index 0000000000..c4449ed880 --- /dev/null +++ b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/[pageId]/page.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useCallback, useEffect, useMemo } from "react"; +import { observer } from "mobx-react-lite"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// plane types +import { TSearchEntityRequestPayload, TWebhookConnectionQueryParams } from "@plane/types"; +import { EFileAssetType } from "@plane/types/src/enums"; +// ui +import { getButtonStyling } from "@plane/ui"; +// components +import { LogoSpinner } from "@/components/common"; +import { PageHead } from "@/components/core"; +// import { IssuePeekOverview } from "@/components/issues"; // Removed for dev-wiki - not needed for pages +import { PageRoot, TPageRootConfig, TPageRootHandlers } from "@/components/pages"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useEditorConfig } from "@/hooks/editor"; +import { useEditorAsset, useWorkspace } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web hooks +import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store"; +// plane web services +import { WorkspaceService } from "@/plane-web/services"; +import { WorkspacePageService, WorkspacePageVersionService } from "@/plane-web/services/page"; +// services +const workspaceService = new WorkspaceService(); +const workspacePageService = new WorkspacePageService(); +const workspacePageVersionService = new WorkspacePageVersionService(); + +const storeType = EPageStoreType.WORKSPACE; + +const PageDetailsPage = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, pageId } = useParams(); + // flag + // store hooks + const { getWorkspaceBySlug } = useWorkspace(); + const { createPage, fetchPageDetails, isNestedPagesEnabled } = usePageStore(storeType); + const page = usePage({ + pageId: pageId?.toString() ?? "", + storeType, + }); + const { uploadEditorAsset } = useEditorAsset(); + // derived values + const workspaceId = useMemo( + () => (workspaceSlug ? (getWorkspaceBySlug(workspaceSlug?.toString())?.id ?? "") : ""), + [getWorkspaceBySlug, workspaceSlug] + ); + const { canCurrentUserAccessPage, id, name, updateDescription } = page ?? {}; + // entity search handler + const fetchEntityCallback = useCallback( + async (payload: TSearchEntityRequestPayload) => + await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", payload), + [workspaceSlug] + ); + // editor config + const { getEditorFileHandlers } = useEditorConfig(); + // fetch page details + const { error: pageDetailsError } = useSWR( + pageId ? `PAGE_DETAILS_${pageId}` : null, + pageId ? () => fetchPageDetails(pageId.toString()) : null, + { + revalidateIfStale: true, + revalidateOnFocus: true, + revalidateOnReconnect: true, + } + ); + // page root handlers + const pageRootHandlers: TPageRootHandlers = useMemo( + () => ({ + create: createPage, + fetchAllVersions: async (pageId) => { + if (!workspaceSlug) return; + return await workspacePageVersionService.fetchAllVersions(workspaceSlug.toString(), pageId); + }, + fetchDescriptionBinary: async () => { + if (!workspaceSlug || !id) return; + return await workspacePageService.fetchDescriptionBinary(workspaceSlug.toString(), id); + }, + fetchEntity: fetchEntityCallback, + fetchVersionDetails: async (pageId, versionId) => { + if (!workspaceSlug) return; + return await workspacePageVersionService.fetchVersionById(workspaceSlug.toString(), pageId, versionId); + }, + getRedirectionLink: (pageId) => { + if (pageId) { + return `/${workspaceSlug}/pages/${pageId}`; + } else { + return `/${workspaceSlug}/pages`; + } + }, + updateDescription: updateDescription ?? (async () => {}), + }), + [createPage, fetchEntityCallback, id, updateDescription, workspaceSlug] + ); + // page root config + const pageRootConfig: TPageRootConfig = useMemo( + () => ({ + fileHandler: getEditorFileHandlers({ + uploadFile: async (blockId, file) => { + const { asset_id } = await uploadEditorAsset({ + blockId, + file, + data: { + entity_identifier: id ?? "", + entity_type: EFileAssetType.PAGE_DESCRIPTION, + }, + workspaceSlug: workspaceSlug?.toString() ?? "", + }); + return asset_id; + }, + workspaceId, + workspaceSlug: workspaceSlug?.toString() ?? "", + }), + }), + [getEditorFileHandlers, id, uploadEditorAsset, workspaceId, workspaceSlug] + ); + + const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo( + () => ({ + documentType: "workspace_page", + workspaceSlug: workspaceSlug?.toString() ?? "", + }), + [workspaceSlug] + ); + + useEffect(() => { + if (page?.deleted_at && page?.id) { + router.push(pageRootHandlers.getRedirectionLink()); + } + }, [page?.deleted_at, page?.id, router, pageRootHandlers]); + + if ((!page || !id) && !pageDetailsError) + return ( +
+ +
+ ); + + if (!isNestedPagesEnabled(workspaceSlug?.toString()) && page?.parent_id) + return ( +
+

Please upgrade your plan to view this nested page

+

+ Please upgrade your plan to view this nested page +

+ + View other Pages + +
+ ); + + if (pageDetailsError || !canCurrentUserAccessPage) + return ( +
+

Page not found

+

+ The page you are trying to access doesn{"'"}t exist or you don{"'"}t have permission to view it. +

+ + View other Pages + +
+ ); + + if (!page) return null; + + return ( + <> + +
+
+ + {/* */} +
+
+ + ); +}); + +export default PageDetailsPage; diff --git a/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/header.tsx b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/header.tsx new file mode 100644 index 0000000000..63873a7c1d --- /dev/null +++ b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/header.tsx @@ -0,0 +1,48 @@ +"use client"; + +import Image from "next/image"; +import { useTheme } from "next-themes"; +import { Home } from "lucide-react"; +// images +import githubBlackImage from "/public/logos/github-black.png"; +import githubWhiteImage from "/public/logos/github-white.png"; +// plane imports +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common"; + +export const PagesAppDashboardHeader = () => { + // hooks + const { resolvedTheme } = useTheme(); + + return ( +
+ +
+ + } />} + /> + +
+
+ + + GitHub Logo + Star us on GitHub + + +
+ ); +}; diff --git a/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/layout.tsx b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/layout.tsx new file mode 100644 index 0000000000..81bbd524e5 --- /dev/null +++ b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/layout.tsx @@ -0,0 +1,52 @@ +"use client"; + +// layouts +import { useParams } from "next/navigation"; +// components +import { EUserPermissions } from "@plane/constants"; +// wrappers +import WorkspaceAccessWrapper from "@/layouts/access/workspace-wrapper"; +import { AuthenticationWrapper } from "@/lib/wrappers"; +// plane web components +// import { PagesAppCommandPalette } from "@/plane-web/components/command-palette"; +import { WithFeatureFlagHOC } from "@/plane-web/components/feature-flags"; +import { WorkspacePagesUpgrade } from "@/plane-web/components/pages"; +// plane web layouts +import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper"; +// local components +// import { FloatingActionsRoot } from "../../(projects)/floating-action-bar"; +import { PagesAppSidebar } from "./sidebar"; + +export default function WorkspacePagesLayout({ children }: { children: React.ReactNode }) { + // router + const { workspaceSlug } = useParams(); + + return ( + + + } + > + + {/* */} +
+ +
+ {children} +
+ {/* */} + {/* */} + {/* */} +
+
+
+
+
+ ); +} diff --git a/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/page.tsx b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/page.tsx new file mode 100644 index 0000000000..d395b0984b --- /dev/null +++ b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/page.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useParams } from "next/navigation"; +// components +import { AppHeader } from "@/components/core"; +// import { RecentActivityWidget } from "@/components/home"; +import { UserGreetingsView } from "@/components/user"; +// hooks +import { useUser } from "@/hooks/store"; +// plane web components +import { PagesAppDashboardHeader } from "./header"; + +export default function WorkspacePagesPage() { + // navigation + const { workspaceSlug } = useParams(); + // store hooks + const { data: currentUser } = useUser(); + + return ( + <> + } /> +
+ {currentUser && } + {workspaceSlug && ( +
+
+ {/* */} +
+ {/*
*/} + {/* */} + {/*
*/} +
+ )} +
+ + ); +} diff --git a/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/sidebar.tsx b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/sidebar.tsx new file mode 100644 index 0000000000..5331da283b --- /dev/null +++ b/dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/sidebar.tsx @@ -0,0 +1,112 @@ +import { useRef, useState, useEffect, useCallback } from "react"; +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +import useSWR from "swr"; +// plane helpers +import { useOutsideClickDetector } from "@plane/hooks"; +// components +import { SidebarDropdown } from "@/components/workspace"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useAppTheme } from "@/hooks/store"; +// plane web components +import { PagesAppSidebarList, PagesAppSidebarMenu, PagesAppSidebarQuickActions } from "@/plane-web/components/pages"; +// import { SidebarAppSwitcher } from "@/plane-web/components/sidebar"; +// plane web hooks +import { usePageStore, EPageStoreType } from "@/plane-web/hooks/store/use-page-store"; + +export const PagesAppSidebar = observer(() => { + // params + const { workspaceSlug, pageId } = useParams(); + const pathname = usePathname(); + // state + const [expandedPageIds, setExpandedPageIds] = useState([]); + // store hooks + const { toggleSidebar, sidebarCollapsed } = useAppTheme(); + const { fetchParentPages } = usePageStore(EPageStoreType.WORKSPACE); + // refs + const ref = useRef(null); + + // Fetch parent pages if we're on a page detail view + const { data: parentPagesList } = useSWR( + workspaceSlug && pageId && pathname?.includes("/pages/") ? `PARENT_PAGES_LIST_${pageId.toString()}` : null, + workspaceSlug && pageId && pathname?.includes("/pages/") ? () => fetchParentPages(pageId.toString()) : null + ); + + // Optimized parent pages expansion + const handleParentPagesExpansion = useCallback((parentPages: any[]) => { + if (!parentPages || parentPages.length === 0) return; + + // Extract all valid page IDs from the parent chain + const parentIds = parentPages.map((page) => page.id).filter((id) => id !== undefined) as string[]; + + // Only add IDs that aren't already in the expanded list + setExpandedPageIds((prev) => { + // Create a Set for efficient lookup + const existingIds = new Set(prev); + let hasChanges = false; + + // Check each parent ID + parentIds.forEach((id) => { + if (!existingIds.has(id)) { + existingIds.add(id); + hasChanges = true; + } + }); + + // Only create a new array if changes were made + return hasChanges ? Array.from(existingIds) : prev; + }); + }, []); + + // Only expand parent pages when the page changes + useEffect(() => { + if (parentPagesList && parentPagesList.length > 0) { + handleParentPagesExpansion(parentPagesList); + } + }, [parentPagesList, handleParentPagesExpansion]); + + useOutsideClickDetector(ref, () => { + if (sidebarCollapsed === false) { + if (window.innerWidth < 768) { + toggleSidebar(); + } + } + }); + + return ( +
+
+ +
+ {/* */} + +
+ +
+ +
+
+ ); +}); diff --git a/dev-wiki/app/(all)/[workspaceSlug]/layout.tsx b/dev-wiki/app/(all)/[workspaceSlug]/layout.tsx new file mode 100644 index 0000000000..5640b62134 --- /dev/null +++ b/dev-wiki/app/(all)/[workspaceSlug]/layout.tsx @@ -0,0 +1,3 @@ +export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/dev-wiki/app/(all)/layout.preload.tsx b/dev-wiki/app/(all)/layout.preload.tsx new file mode 100644 index 0000000000..18ca3b4b38 --- /dev/null +++ b/dev-wiki/app/(all)/layout.preload.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect } from "react"; +import ReactDOM from "react-dom"; + +// https://nextjs.org/docs/app/api-reference/functions/generate-metadata#link-relpreload +export const usePreloadResources = () => { + useEffect(() => { + const preloadItem = (url: string) => { + ReactDOM.preload(url, { as: "fetch", crossOrigin: "use-credentials" }); + }; + + const urls = [ + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/instances/`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/profile/`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/settings/`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/workspaces/?v=${Date.now()}`, + ]; + + urls.forEach(url => preloadItem(url)); + }, []); +}; + +export const PreloadResources = () => { + usePreloadResources(); + return null; +}; diff --git a/dev-wiki/app/(all)/layout.tsx b/dev-wiki/app/(all)/layout.tsx new file mode 100644 index 0000000000..32589c4bf0 --- /dev/null +++ b/dev-wiki/app/(all)/layout.tsx @@ -0,0 +1,31 @@ +import { Metadata, Viewport } from "next"; + +import { PreloadResources } from "./layout.preload"; + +// styles +import "@/styles/command-pallette.css"; +import "@/styles/emoji.css"; +import "@/styles/react-day-picker.css"; + +export const metadata: Metadata = { + robots: { + index: false, + follow: false, + }, +}; + +export const viewport: Viewport = { + minimumScale: 1, + initialScale: 1, + width: "device-width", + viewportFit: "cover", +}; + +export default function AppLayout({ children }: { children: React.ReactNode }) { + return ( + <> + + {children} + + ); +} diff --git a/dev-wiki/app/error.tsx b/dev-wiki/app/error.tsx new file mode 100644 index 0000000000..348bc6b64a --- /dev/null +++ b/dev-wiki/app/error.tsx @@ -0,0 +1,77 @@ +"use client"; + +// ui +import Link from "next/link"; +import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +// helpers +import { cn } from "@plane/utils"; +import { API_BASE_URL } from "@/helpers/common.helper"; +// hooks +import { useAppRouter } from "@/hooks/use-app-router"; +// layouts +import DefaultLayout from "@/layouts/default-layout"; +// services +import { AuthService } from "@/services/auth.service"; + +// services +const authService = new AuthService(); + +export default function CustomErrorComponent() { + const router = useAppRouter(); + + const handleRefresh = () => { + window.location.reload(); + }; + + const handleSignOut = async () => { + await authService + .signOut(API_BASE_URL) + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Failed to sign out. Please try again.", + }) + ) + .finally(() => router.push("/")); + }; + + return ( + +
+
+
+
+

Yikes! That doesn{"'"}t look good.

+

+ That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more + details, please write to{" "} + + support@plane.so + {" "} + or on our{" "} + + Discord + + . +

+
+
+ + Go to home + + +
+
+
+
+
+ ); +} diff --git a/dev-wiki/app/global-error.tsx b/dev-wiki/app/global-error.tsx new file mode 100644 index 0000000000..b0b191e411 --- /dev/null +++ b/dev-wiki/app/global-error.tsx @@ -0,0 +1,17 @@ +"use client"; + +import NextError from "next/error"; + +export default function GlobalError() { + return ( + + + {/* `NextError` is the default Next.js error page component. Its type + definition requires a `statusCode` prop. However, since the App Router + does not expose status codes for errors, we simply pass 0 to render a + generic error message. */} + + + + ); +} diff --git a/dev-wiki/app/layout.tsx b/dev-wiki/app/layout.tsx new file mode 100644 index 0000000000..db702d5ca0 --- /dev/null +++ b/dev-wiki/app/layout.tsx @@ -0,0 +1,105 @@ +import { Metadata, Viewport } from "next"; +import Script from "next/script"; +// styles +import "@/styles/globals.css"; +import { SITE_DESCRIPTION, SITE_NAME } from "@plane/constants"; +// helpers +import { cn } from "@/helpers/common.helper"; +// local +import { AppProvider } from "./provider"; + +export const metadata: Metadata = { + title: "Plane | Simple, extensible, open-source project management tool.", + description: SITE_DESCRIPTION, + metadataBase: new URL("https://app.plane.so"), + openGraph: { + title: "Plane | Simple, extensible, open-source project management tool.", + description: "Open-source project management tool to manage work items, cycles, and product roadmaps easily", + url: "https://app.plane.so/", + images: [ + { + url: "/og-image.png", + width: 1200, + height: 630, + alt: "Plane - Modern project management", + }, + ], + }, + keywords: + "software development, plan, ship, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration", + twitter: { + site: "@planepowers", + card: "summary_large_image", + images: [ + { + url: "/og-image.png", + width: 1200, + height: 630, + alt: "Plane - Modern project management", + }, + ], + }, +}; + +export const viewport: Viewport = { + minimumScale: 1, + initialScale: 1, + maximumScale: 1, + userScalable: false, + width: "device-width", + viewportFit: "cover", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + const isSessionRecorderEnabled = parseInt(process.env.NEXT_PUBLIC_ENABLE_SESSION_RECORDER || "0"); + + return ( + + + + + + + + {/* Meta info for PWA */} + + + + + + + + + + + + + +
+
+ +
+
{children}
+
+
+ + {process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN && ( + + )} + + ); +} diff --git a/dev-wiki/app/not-found.tsx b/dev-wiki/app/not-found.tsx new file mode 100644 index 0000000000..1f1ec0e2c0 --- /dev/null +++ b/dev-wiki/app/not-found.tsx @@ -0,0 +1,46 @@ +"use client"; + +import React from "react"; +import { Metadata } from "next"; +import Image from "next/image"; +import Link from "next/link"; +// ui +import { Button } from "@plane/ui"; +// images +import Image404 from "@/public/404.svg"; + +export const metadata: Metadata = { + title: "404 - Page Not Found", + robots: { + index: false, + follow: false, + }, +}; + +const PageNotFound = () => ( +
+
+
+
+ 404- Page not found +
+
+

Oops! Something went wrong.

+

+ Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is + temporarily unavailable. +

+
+ + + + + +
+
+
+); + +export default PageNotFound; diff --git a/dev-wiki/app/provider.tsx b/dev-wiki/app/provider.tsx new file mode 100644 index 0000000000..bad790d78e --- /dev/null +++ b/dev-wiki/app/provider.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { FC, ReactNode } from "react"; +import dynamic from "next/dynamic"; +import { useTheme, ThemeProvider } from "next-themes"; +import { SWRConfig } from "swr"; +// Plane Imports +import { WEB_SWR_CONFIG } from "@plane/constants"; +import { TranslationProvider } from "@plane/i18n"; +import { Toast } from "@plane/ui"; +//helpers +import { resolveGeneralTheme } from "@/helpers/theme.helper"; +// nprogress +import { AppProgressBar } from "@/lib/n-progress"; +// polyfills +import "@/lib/polyfills"; +// mobx store provider +import { StoreProvider } from "@/lib/store-context"; +// wrappers +import { InstanceWrapper } from "@/lib/wrappers"; +// dynamic imports +const StoreWrapper = dynamic(() => import("@/lib/wrappers/store-wrapper"), { ssr: false }); + +export interface IAppProvider { + children: ReactNode; +} + +const ToastWithTheme = () => { + const { resolvedTheme } = useTheme(); + return ; +}; + +export const AppProvider: FC = (props) => { + const { children } = props; + // themes + return ( + <> + + + + + + + + {children} + + + + + + + ); +}; diff --git a/dev-wiki/ce/components/pages/extra-actions.tsx b/dev-wiki/ce/components/pages/extra-actions.tsx new file mode 100644 index 0000000000..dd15dab7e6 --- /dev/null +++ b/dev-wiki/ce/components/pages/extra-actions.tsx @@ -0,0 +1,8 @@ +// store +import { TPageInstance } from "@/store/pages/base-page"; + +export type TPageHeaderExtraActionsProps = { + page: TPageInstance; +}; + +export const PageDetailsHeaderExtraActions: React.FC = () => null; diff --git a/dev-wiki/ce/layouts/workspace-wrapper.tsx b/dev-wiki/ce/layouts/workspace-wrapper.tsx new file mode 100644 index 0000000000..fcde83e7f3 --- /dev/null +++ b/dev-wiki/ce/layouts/workspace-wrapper.tsx @@ -0,0 +1,15 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +// layouts +import { WorkspaceAuthWrapper as CoreWorkspaceAuthWrapper } from "@/layouts/auth-layout"; + +export type IWorkspaceAuthWrapper = { + children: React.ReactNode; +}; + +export const WorkspaceAuthWrapper: FC = observer((props) => { + // props + const { children } = props; + + return {children}; +}); diff --git a/dev-wiki/core/components/auth-screens/index.ts b/dev-wiki/core/components/auth-screens/index.ts new file mode 100644 index 0000000000..daff2fb08a --- /dev/null +++ b/dev-wiki/core/components/auth-screens/index.ts @@ -0,0 +1,2 @@ +export * from "./workspace"; +export * from "./not-authorized-view"; diff --git a/dev-wiki/core/components/auth-screens/not-authorized-view.tsx b/dev-wiki/core/components/auth-screens/not-authorized-view.tsx new file mode 100644 index 0000000000..78fe56217f --- /dev/null +++ b/dev-wiki/core/components/auth-screens/not-authorized-view.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +// layouts +import { cn } from "@plane/utils"; +import DefaultLayout from "@/layouts/default-layout"; +// images +import ProjectNotAuthorizedImg from "@/public/auth/project-not-authorized.svg"; +import Unauthorized from "@/public/auth/unauthorized.svg"; +import WorkspaceNotAuthorizedImg from "@/public/auth/workspace-not-authorized.svg"; + +type Props = { + actionButton?: React.ReactNode; + section?: "settings" | "general"; + isProjectView?: boolean; + className?: string; +}; + +export const NotAuthorizedView: React.FC = observer((props) => { + const { actionButton, section = "general", isProjectView = false, className } = props; + + // assets + const settingAsset = isProjectView ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg; + const asset = section === "settings" ? settingAsset : Unauthorized; + + return ( + +
+
+ ProjectSettingImg +
+

Oops! You are not authorized to view this page

+ {actionButton} +
+
+ ); +}); diff --git a/dev-wiki/core/components/auth-screens/workspace/index.ts b/dev-wiki/core/components/auth-screens/workspace/index.ts new file mode 100644 index 0000000000..8283243122 --- /dev/null +++ b/dev-wiki/core/components/auth-screens/workspace/index.ts @@ -0,0 +1 @@ +export * from "./not-a-member"; diff --git a/dev-wiki/core/components/auth-screens/workspace/not-a-member.tsx b/dev-wiki/core/components/auth-screens/workspace/not-a-member.tsx new file mode 100644 index 0000000000..404709846e --- /dev/null +++ b/dev-wiki/core/components/auth-screens/workspace/not-a-member.tsx @@ -0,0 +1,35 @@ +"use client"; + +import Link from "next/link"; +// ui +import { Button } from "@plane/ui"; +// layouts +import DefaultLayout from "@/layouts/default-layout"; + +export const NotAWorkspaceMember = () => ( + +
+
+
+

Not Authorized!

+

+ You{"'"}re not a member of this workspace. Please contact the workspace admin to get an invitation or check + your pending invitations. +

+
+
+ + + + + + + + + + +
+
+
+
+); diff --git a/dev-wiki/core/components/common/access-field.tsx b/dev-wiki/core/components/common/access-field.tsx new file mode 100644 index 0000000000..f2c50934d2 --- /dev/null +++ b/dev-wiki/core/components/common/access-field.tsx @@ -0,0 +1,53 @@ +import { LucideIcon } from "lucide-react"; +// plane ui +import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/ui"; +// plane utils +import { cn } from "@plane/utils"; + +type Props = { + onChange: (value: number) => void; + value: number; + accessSpecifiers: { + key: number; + i18n_label?: string; + label?: string; + icon: LucideIcon; + }[]; + isMobile?: boolean; +}; + +// TODO: Remove label once i18n is done +export const AccessField = (props: Props) => { + const { onChange, value, accessSpecifiers, isMobile = false } = props; + const { t } = useTranslation(); + + return ( +
+ {accessSpecifiers.map((access, index) => { + const label = access.i18n_label ? t(access.i18n_label) : access.label; + return ( + + + + ); + })} +
+ ); +}; diff --git a/dev-wiki/core/components/common/activity/activity-block.tsx b/dev-wiki/core/components/common/activity/activity-block.tsx new file mode 100644 index 0000000000..61c1a0b7b4 --- /dev/null +++ b/dev-wiki/core/components/common/activity/activity-block.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { FC, ReactNode } from "react"; +import { Network } from "lucide-react"; +// types +import { TWorkspaceBaseActivity } from "@plane/types"; +// ui +import { Tooltip } from "@plane/ui"; +// helpers +import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "@/helpers/date-time.helper"; +// hooks +import { usePlatformOS } from "@/hooks/use-platform-os"; +// local components +import { User } from "./user"; + +type TActivityBlockComponent = { + icon?: ReactNode; + activity: TWorkspaceBaseActivity; + ends: "top" | "bottom" | undefined; + children: ReactNode; + customUserName?: string; +}; + +export const ActivityBlockComponent: FC = (props) => { + const { icon, activity, ends, children, customUserName } = props; + // hooks + const { isMobile } = usePlatformOS(); + + if (!activity) return <>; + return ( +
+
+ {icon ? icon : } +
+
+
+ {children} +
+
+ + + {calculateTimeAgo(activity.created_at)} + + +
+
+
+ ); +}; diff --git a/dev-wiki/core/components/common/activity/activity-item.tsx b/dev-wiki/core/components/common/activity/activity-item.tsx new file mode 100644 index 0000000000..643d897383 --- /dev/null +++ b/dev-wiki/core/components/common/activity/activity-item.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; + +import { TProjectActivity } from "@/plane-web/types"; +import { ActivityBlockComponent } from "./activity-block"; +import { iconsMap, messages } from "./helper"; + +type TActivityItem = { + activity: TProjectActivity; + showProject?: boolean; + ends?: "top" | "bottom" | undefined; +}; + +export const ActivityItem: FC = observer((props) => { + const { activity, showProject = true, ends } = props; + + if (!activity) return null; + + const activityType = activity.field; + if (!activityType) return null; + + const { message, customUserName } = messages(activity); + const icon = iconsMap[activityType] || iconsMap.default; + + return ( + + <>{message} + + ); +}); diff --git a/dev-wiki/core/components/common/activity/helper.tsx b/dev-wiki/core/components/common/activity/helper.tsx new file mode 100644 index 0000000000..b3d16e7d04 --- /dev/null +++ b/dev-wiki/core/components/common/activity/helper.tsx @@ -0,0 +1,281 @@ +import { ReactNode } from "react"; +import { + Signal, + RotateCcw, + Network, + Link as LinkIcon, + Calendar, + Tag, + Inbox, + AlignLeft, + Users, + Paperclip, + Type, + Triangle, + FileText, + Globe, + Hash, + Clock, + Bell, + LayoutGrid, + GitBranch, + Timer, + ListTodo, + Layers, +} from "lucide-react"; + +// components +import { ArchiveIcon, DoubleCircleIcon, ContrastIcon, DiceIcon, Intake } from "@plane/ui"; +import { store } from "@/lib/store-context"; +import { TProjectActivity } from "@/plane-web/types"; + +type ActivityIconMap = { + [key: string]: ReactNode; +}; +export const iconsMap: ActivityIconMap = { + priority: , + archived_at: , + restored: , + link: , + start_date: , + target_date: , + label: , + inbox: , + description: , + assignee: , + attachment: , + name: , + state: , + estimate: , + cycle: , + module: , + page: , + network: , + identifier: , + timezone: , + is_project_updates_enabled: , + is_epic_enabled: , + is_workflow_enabled: , + is_time_tracking_enabled: , + is_issue_type_enabled: , + default: , + module_view: , + cycle_view: , + issue_views_view: , + page_view: , + intake_view: , +}; + +export const messages = (activity: TProjectActivity): { message: string | ReactNode; customUserName?: string } => { + const activityType = activity.field; + const newValue = activity.new_value; + const oldValue = activity.old_value; + const verb = activity.verb; + const workspaceDetail = store.workspaceRoot.getWorkspaceById(activity.workspace); + + const getBooleanActionText = (value: string | undefined) => { + if (value === "true") return "enabled"; + if (value === "false") return "disabled"; + return verb; + }; + + switch (activityType) { + case "priority": + return { + message: ( + <> + set the priority to {newValue || "none"} + + ), + }; + case "archived_at": + return { + message: newValue === "restore" ? "restored the project" : "archived the project", + customUserName: newValue === "archive" ? "Plane" : undefined, + }; + case "name": + return { + message: ( + <> + renamed the project to {newValue} + + ), + }; + case "description": + return { + message: newValue ? "updated the project description" : "removed the project description", + }; + case "start_date": + return { + message: ( + <> + {newValue ? ( + <> + set the start date to {newValue} + + ) : ( + "removed the start date" + )} + + ), + }; + case "target_date": + return { + message: ( + <> + {newValue ? ( + <> + set the target date to {newValue} + + ) : ( + "removed the target date" + )} + + ), + }; + case "state": + return { + message: ( + <> + set the state to {newValue || "none"} + + ), + }; + case "estimate": + return { + message: ( + <> + {newValue ? ( + <> + set the estimate point to {newValue} + + ) : ( + <> + removed the estimate point + {oldValue && ( + <> + {" "} + {oldValue} + + )} + + )} + + ), + }; + case "cycles": + return { + message: ( + <> + + {verb} this project {verb === "removed" ? "from" : "to"} the cycle{" "} + + {verb !== "removed" ? ( + + {activity.new_value} + + ) : ( + {activity.old_value || "Unknown cycle"} + )} + + ), + }; + case "modules": + return { + message: ( + <> + + {verb} this project {verb === "removed" ? "from" : "to"} the module{" "} + + + {verb === "removed" ? oldValue : newValue || "Unknown module"} + + + ), + }; + case "labels": + return { + message: ( + <> + {verb} the label{" "} + {newValue || oldValue || "Untitled label"} + + ), + }; + case "inbox": + return { + message: <>{newValue ? "enabled" : "disabled"} inbox, + }; + case "page": + return { + message: ( + <> + {newValue ? "created" : "removed"} the project page{" "} + {newValue || oldValue || "Untitled page"} + + ), + }; + case "network": + return { + message: <>{newValue ? "enabled" : "disabled"} network access, + }; + case "identifier": + return { + message: ( + <> + updated project identifier to {newValue || "none"} + + ), + }; + case "timezone": + return { + message: ( + <> + changed project timezone to{" "} + {newValue || "default"} + + ), + }; + case "module_view": + case "cycle_view": + case "issue_views_view": + case "page_view": + case "intake_view": + return { + message: ( + <> + {getBooleanActionText(newValue)} {activityType.replace(/_view$/, "").replace(/_/g, " ")} view + + ), + }; + case "is_project_updates_enabled": + return { + message: <>{getBooleanActionText(newValue)} project updates, + }; + case "is_epic_enabled": + return { + message: <>{getBooleanActionText(newValue)} epics, + }; + case "is_workflow_enabled": + return { + message: <>{getBooleanActionText(newValue)} custom workflow, + }; + case "is_time_tracking_enabled": + return { + message: <>{getBooleanActionText(newValue)} time tracking, + }; + case "is_issue_type_enabled": + return { + message: <>{getBooleanActionText(newValue)} work item types, + }; + default: + return { + message: `${verb} ${activityType?.replace(/_/g, " ")} `, + }; + } +}; diff --git a/dev-wiki/core/components/common/activity/index.ts b/dev-wiki/core/components/common/activity/index.ts new file mode 100644 index 0000000000..8ab32d3d90 --- /dev/null +++ b/dev-wiki/core/components/common/activity/index.ts @@ -0,0 +1 @@ +export * from "./activity-item"; diff --git a/dev-wiki/core/components/common/activity/user.tsx b/dev-wiki/core/components/common/activity/user.tsx new file mode 100644 index 0000000000..c82ab36162 --- /dev/null +++ b/dev-wiki/core/components/common/activity/user.tsx @@ -0,0 +1,37 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +// types +import { TWorkspaceBaseActivity } from "@plane/types"; +// store hooks +import { useMember, useWorkspace } from "@/hooks/store"; + +type TUser = { + activity: TWorkspaceBaseActivity; + customUserName?: string; +}; + +export const User: FC = observer((props) => { + const { activity, customUserName } = props; + // store hooks + const { getUserDetails } = useMember(); + const { getWorkspaceById } = useWorkspace(); + // derived values + const actorDetail = getUserDetails(activity.actor); + const workspaceDetail = getWorkspaceById(activity.workspace); + + return ( + <> + {customUserName || actorDetail?.display_name.includes("-intake") ? ( + {customUserName || "Plane"} + ) : ( + + {actorDetail?.display_name} + + )} + + ); +}); diff --git a/dev-wiki/core/components/common/applied-filters/date.tsx b/dev-wiki/core/components/common/applied-filters/date.tsx new file mode 100644 index 0000000000..9dcde53a43 --- /dev/null +++ b/dev-wiki/core/components/common/applied-filters/date.tsx @@ -0,0 +1,56 @@ +import { observer } from "mobx-react"; +// icons +import { X } from "lucide-react"; +// plane constants +import { DATE_BEFORE_FILTER_OPTIONS } from "@plane/constants"; +// helpers +import { renderFormattedDate } from "@/helpers/date-time.helper"; +import { capitalizeFirstLetter } from "@/helpers/string.helper"; + +type Props = { + editable: boolean | undefined; + handleRemove: (val: string) => void; + values: string[]; +}; + +export const AppliedDateFilters: React.FC = observer((props) => { + const { editable, handleRemove, values } = props; + + const getDateLabel = (value: string): string => { + let dateLabel = ""; + + const dateDetails = DATE_BEFORE_FILTER_OPTIONS.find((d) => d.value === value); + + if (dateDetails) dateLabel = dateDetails.name; + else { + const dateParts = value.split(";"); + + if (dateParts.length === 2) { + const [date, time] = dateParts; + + dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`; + } + } + + return dateLabel; + }; + + return ( + <> + {values.map((date) => ( +
+ {getDateLabel(date)} + {editable && ( + + )} +
+ ))} + + ); +}); diff --git a/dev-wiki/core/components/common/applied-filters/index.ts b/dev-wiki/core/components/common/applied-filters/index.ts new file mode 100644 index 0000000000..77f9f6304c --- /dev/null +++ b/dev-wiki/core/components/common/applied-filters/index.ts @@ -0,0 +1,2 @@ +export * from "./date"; +export * from "./members"; diff --git a/dev-wiki/core/components/common/applied-filters/members.tsx b/dev-wiki/core/components/common/applied-filters/members.tsx new file mode 100644 index 0000000000..87d3393666 --- /dev/null +++ b/dev-wiki/core/components/common/applied-filters/members.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { observer } from "mobx-react"; +import { X } from "lucide-react"; +// plane ui +import { Avatar } from "@plane/ui"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; +// types +import { useMember } from "@/hooks/store"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; + editable: boolean | undefined; +}; + +export const AppliedMembersFilters: React.FC = observer((props) => { + const { handleRemove, values, editable } = props; + // store hooks + const { + workspace: { getWorkspaceMemberDetails }, + } = useMember(); + + return ( + <> + {values.map((memberId) => { + const memberDetails = getWorkspaceMemberDetails(memberId)?.member; + + if (!memberDetails) return null; + + return ( +
+ + {memberDetails.display_name} + {editable && ( + + )} +
+ ); + })} + + ); +}); diff --git a/dev-wiki/core/components/common/breadcrumb-link.tsx b/dev-wiki/core/components/common/breadcrumb-link.tsx new file mode 100644 index 0000000000..bf421a50c2 --- /dev/null +++ b/dev-wiki/core/components/common/breadcrumb-link.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { ReactNode } from "react"; +import Link from "next/link"; +import { Tooltip } from "@plane/ui"; +import { usePlatformOS } from "@/hooks/use-platform-os"; + +type Props = { + label?: string | ReactNode; + href?: string; + icon?: React.ReactNode | undefined; + disableTooltip?: boolean; +}; + +export const BreadcrumbLink: React.FC = (props) => { + const { href, label, icon, disableTooltip = false } = props; + const { isMobile } = usePlatformOS(); + return ( + +
  • +
    + {href ? ( + + {icon && ( +
    {icon}
    + )} + {label && ( +
    {label}
    + )} + + ) : ( +
    + {icon &&
    {icon}
    } +
    {label}
    +
    + )} +
    +
  • +
    + ); +}; diff --git a/dev-wiki/core/components/common/count-chip.tsx b/dev-wiki/core/components/common/count-chip.tsx new file mode 100644 index 0000000000..0b5820d75a --- /dev/null +++ b/dev-wiki/core/components/common/count-chip.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { FC } from "react"; +// +import { cn } from "@/helpers/common.helper"; + +type TCountChip = { + count: string | number; + className?: string; +}; + +export const CountChip: FC = (props) => { + const { count, className = "" } = props; + + return ( +
    + {count} +
    + ); +}; diff --git a/dev-wiki/core/components/common/custom-error.tsx b/dev-wiki/core/components/common/custom-error.tsx new file mode 100644 index 0000000000..1b82b08afb --- /dev/null +++ b/dev-wiki/core/components/common/custom-error.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { Button } from "@plane/ui"; + +export const CustomError = () => { + const handleRefresh = () => { + window.location.reload(); + }; + + return ( +
    +
    +
    +
    +

    Yikes! That doesn{"'"}t look good.

    +

    + That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more + details, please write to{" "} + + support@plane.so + {" "} + or on our{" "} + + Discord + + . +

    +
    +
    + +
    +
    +
    +
    + ); +}; diff --git a/dev-wiki/core/components/common/empty-state.tsx b/dev-wiki/core/components/common/empty-state.tsx new file mode 100644 index 0000000000..5fdd210d91 --- /dev/null +++ b/dev-wiki/core/components/common/empty-state.tsx @@ -0,0 +1,50 @@ +"use client"; +import React from "react"; + +import Image from "next/image"; + +// ui +import { Button } from "@plane/ui"; + +type Props = { + title: string; + description?: React.ReactNode; + image: any; + primaryButton?: { + icon?: any; + text: string; + onClick: () => void; + }; + secondaryButton?: React.ReactNode; + disabled?: boolean; +}; + +export const EmptyState: React.FC = ({ + title, + description, + image, + primaryButton, + secondaryButton, + disabled = false, +}) => ( +
    +
    + {primaryButton?.text +
    {title}
    + {description &&

    {description}

    } +
    + {primaryButton && ( + + )} + {secondaryButton} +
    +
    +
    +); diff --git a/dev-wiki/core/components/common/filters/created-at.tsx b/dev-wiki/core/components/common/filters/created-at.tsx new file mode 100644 index 0000000000..8a43a4985d --- /dev/null +++ b/dev-wiki/core/components/common/filters/created-at.tsx @@ -0,0 +1,76 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react"; +// plane constants +import { DATE_BEFORE_FILTER_OPTIONS } from "@plane/constants"; +import { DateFilterModal } from "@/components/core"; +// import { FilterHeader, FilterOption } from "@/components/issues"; +// helpers +import { isInDateFormat } from "@/helpers/date-time.helper"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string | string[]) => void; + searchQuery: string; +}; + +export const FilterCreatedDate: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + const [previewEnabled, setPreviewEnabled] = useState(true); + const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = DATE_BEFORE_FILTER_OPTIONS.filter((d) => + d.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const isCustomDateSelected = () => { + const isValidDateSelected = appliedFilters?.filter((f) => isInDateFormat(f.split(";")[0])) || []; + return isValidDateSelected.length > 0 ? true : false; + }; + const handleCustomDate = () => { + if (isCustomDateSelected()) { + const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || []; + handleUpdate(updateAppliedFilters); + } else setIsDateFilterModalOpen(true); + }; + + return ( + <> + {isDateFilterModalOpen && ( + setIsDateFilterModalOpen(false)} + isOpen={isDateFilterModalOpen} + onSelect={(val) => handleUpdate(val)} + title="Created date" + /> + )} + {/* 0 ? ` (${appliedFiltersCount})` : ""}`} */} + {/* isPreviewEnabled={previewEnabled} */} + {/* handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} */} + {/* /> */} + {/* {previewEnabled && ( */} + {/*
    */} + {/* {filteredOptions.length > 0 ? ( */} + {/* <> */} + {/* {filteredOptions.map((option) => ( */} + {/* handleUpdate(option.value)} */} + {/* title={option.name} */} + {/* multiple */} + {/* /> */} + {/* ))} */} + {/* */} + {/* */} + {/* ) : ( */} + {/*

    No matches found

    */} + {/* )} */} + {/*
    */} + {/* )} */} + + ); +}); diff --git a/dev-wiki/core/components/common/filters/created-by.tsx b/dev-wiki/core/components/common/filters/created-by.tsx new file mode 100644 index 0000000000..c1476d28ab --- /dev/null +++ b/dev-wiki/core/components/common/filters/created-by.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useMemo, useState } from "react"; +import sortBy from "lodash/sortBy"; +import { observer } from "mobx-react"; +// ui +import { Avatar, Loader } from "@plane/ui"; +// components +// import { FilterHeader, FilterOption } from "@/components/issues"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; +// hooks +import { useMember, useUser } from "@/hooks/store"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + memberIds: string[] | undefined; + searchQuery: string; +}; + +export const FilterCreatedBy: React.FC = observer((props: Props) => { + const { appliedFilters, handleUpdate, memberIds, searchQuery } = props; + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + // store hooks + const { getUserDetails } = useMember(); + const { data: currentUser } = useUser(); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const sortedOptions = useMemo(() => { + const filteredOptions = (memberIds || []).filter((memberId) => + getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return sortBy(filteredOptions, [ + (memberId) => !(appliedFilters ?? []).includes(memberId), + (memberId) => memberId !== currentUser?.id, + (memberId) => getUserDetails(memberId)?.display_name.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); + + const handleViewToggle = () => { + if (!sortedOptions) return; + + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); + }; + + return ( + <> + {/* 0 ? ` (${appliedFiltersCount})` : ""}`} */} + {/* isPreviewEnabled={previewEnabled} */} + {/* handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} */} + {/* /> */} + {/* {previewEnabled && ( */} + {/*
    */} + {/* {sortedOptions ? ( */} + {/* sortedOptions.length > 0 ? ( */} + {/* <> */} + {/* {sortedOptions.slice(0, itemsToRender).map((memberId) => { */} + {/* const member = getUserDetails(memberId); */} + {/**/} + {/* if (!member) return null; */} + {/* return ( */} + {/* handleUpdate(member.id)} */} + {/* icon={ */} + {/* */} + {/* } */} + {/* title={currentUser?.id === member.id ? "You" : member?.display_name} */} + {/* /> */} + {/* ); */} + {/* })} */} + {/* {sortedOptions.length > 5 && ( */} + {/* */} + {/* )} */} + {/* */} + {/* ) : ( */} + {/*

    No matches found

    */} + {/* ) */} + {/* ) : ( */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* )} */} + {/*
    */} + {/* )} */} + + ); +}); diff --git a/dev-wiki/core/components/common/filters/index.ts b/dev-wiki/core/components/common/filters/index.ts new file mode 100644 index 0000000000..6f0b5dbdef --- /dev/null +++ b/dev-wiki/core/components/common/filters/index.ts @@ -0,0 +1,2 @@ +export * from "./created-at"; +export * from "./created-by"; diff --git a/dev-wiki/core/components/common/index.ts b/dev-wiki/core/components/common/index.ts new file mode 100644 index 0000000000..5a9aa4c34b --- /dev/null +++ b/dev-wiki/core/components/common/index.ts @@ -0,0 +1,11 @@ +export * from "./empty-state"; +export * from "./latest-feature-block"; +export * from "./breadcrumb-link"; +export * from "./logo-spinner"; +export * from "./logo"; +export * from "./pro-icon"; +export * from "./custom-error"; +export * from "./count-chip"; +export * from "./activity"; +export * from "./switcher-label"; +export * from "./page-access-icon"; diff --git a/dev-wiki/core/components/common/latest-feature-block.tsx b/dev-wiki/core/components/common/latest-feature-block.tsx new file mode 100644 index 0000000000..35aacddcfe --- /dev/null +++ b/dev-wiki/core/components/common/latest-feature-block.tsx @@ -0,0 +1,40 @@ +import Image from "next/image"; +import Link from "next/link"; +import { useTheme } from "next-themes"; +// icons +import { Lightbulb } from "lucide-react"; +// images +import latestFeatures from "@/public/onboarding/onboarding-pages.webp"; + +export const LatestFeatureBlock = () => { + const { resolvedTheme } = useTheme(); + + return ( + <> +
    + +

    + Pages gets a facelift! Write anything and use Galileo to help you start.{" "} + + Learn more + +

    +
    +
    +
    + Plane Work items +
    +
    + + ); +}; diff --git a/dev-wiki/core/components/common/logo-spinner.tsx b/dev-wiki/core/components/common/logo-spinner.tsx new file mode 100644 index 0000000000..abcebfe76c --- /dev/null +++ b/dev-wiki/core/components/common/logo-spinner.tsx @@ -0,0 +1,17 @@ +import Image from "next/image"; +import { useTheme } from "next-themes"; +// assets +import LogoSpinnerDark from "@/public/images/logo-spinner-dark.gif"; +import LogoSpinnerLight from "@/public/images/logo-spinner-light.gif"; + +export const LogoSpinner = () => { + const { resolvedTheme } = useTheme(); + + const logoSrc = resolvedTheme === "dark" ? LogoSpinnerDark : LogoSpinnerLight; + + return ( +
    + logo +
    + ); +}; diff --git a/dev-wiki/core/components/common/logo.tsx b/dev-wiki/core/components/common/logo.tsx new file mode 100644 index 0000000000..49d4d60a12 --- /dev/null +++ b/dev-wiki/core/components/common/logo.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { FC } from "react"; +import { Emoji } from "emoji-picker-react"; +// Due to some weird issue with the import order, the import of useFontFaceObserver +// should be after the imported here rather than some below helper functions as it is in the original file +// eslint-disable-next-line import/order +import useFontFaceObserver from "use-font-face-observer"; +// types +import { TLogoProps } from "@plane/types"; +// ui +import { LUCIDE_ICONS_LIST } from "@plane/ui"; +// helpers +import { emojiCodeToUnicode } from "@/helpers/emoji.helper"; + +type Props = { + logo: TLogoProps; + size?: number; + type?: "lucide" | "material"; +}; + +export const Logo: FC = (props) => { + const { logo, size = 16, type = "material" } = props; + + // destructuring the logo object + const { in_use, emoji, icon } = logo; + + // derived values + const value = in_use === "emoji" ? emoji?.value : icon?.name; + const color = icon?.color; + const lucideIcon = LUCIDE_ICONS_LIST.find((item) => item.name === value); + + const isMaterialSymbolsFontLoaded = useFontFaceObserver([ + { + family: `Material Symbols Rounded`, + style: `normal`, + weight: `normal`, + stretch: `condensed`, + }, + ]); + // if no value, return empty fragment + if (!value) return <>; + + if (!isMaterialSymbolsFontLoaded) { + return ( + + ); + } + + // emoji + if (in_use === "emoji") { + return ; + } + + // icon + if (in_use === "icon") { + return ( + <> + {type === "lucide" ? ( + <> + {lucideIcon && ( + + )} + + ) : ( + + {value} + + )} + + ); + } + + // if no value, return empty fragment + return <>; +}; diff --git a/dev-wiki/core/components/common/new-empty-state.tsx b/dev-wiki/core/components/common/new-empty-state.tsx new file mode 100644 index 0000000000..e04cb3ed05 --- /dev/null +++ b/dev-wiki/core/components/common/new-empty-state.tsx @@ -0,0 +1,114 @@ +"use client"; +import React, { useState } from "react"; + +import Image from "next/image"; + +// ui +import { Button } from "@plane/ui"; + +type Props = { + title: string; + description?: React.ReactNode; + image: any; + comicBox?: { + direction: "left" | "right"; + title: string; + description: string; + extraPadding?: boolean; + }; + primaryButton?: { + icon?: any; + text: string; + onClick: () => void; + }; + disabled?: boolean; +}; + +export const NewEmptyState: React.FC = ({ + title, + description, + image, + primaryButton, + disabled = false, + comicBox, +}) => { + const [isHovered, setIsHovered] = useState(false); + + const handleMouseEnter = () => { + setIsHovered(true); + }; + + const handleMouseLeave = () => { + setIsHovered(false); + }; + return ( +
    +
    +
    +

    {title}

    + {description &&

    {description}

    } +
    + {primaryButton?.text +
    + +
    + {primaryButton && ( + + )} + {comicBox && + isHovered && + (comicBox.direction === "right" ? ( +
    +
    +
    +
    +
    +

    +

    {comicBox?.title}

    +

    {comicBox?.description}

    + +
    +
    + ) : ( +
    +
    +
    +
    +
    +

    +

    {comicBox?.title}

    +

    {comicBox?.description}

    + +
    +
    + ))} +
    +
    +
    +
    + ); +}; diff --git a/dev-wiki/core/components/common/page-access-icon.tsx b/dev-wiki/core/components/common/page-access-icon.tsx new file mode 100644 index 0000000000..9f547a1163 --- /dev/null +++ b/dev-wiki/core/components/common/page-access-icon.tsx @@ -0,0 +1,15 @@ +import { ArchiveIcon, Earth, Lock } from "lucide-react"; +import { EPageAccess } from "@plane/constants"; +import { TPage } from "@plane/types"; + +export const PageAccessIcon = (page: TPage) => ( +
    + {page.archived_at ? ( + + ) : page.access === EPageAccess.PUBLIC ? ( + + ) : ( + + )} +
    +); diff --git a/dev-wiki/core/components/common/pro-icon.tsx b/dev-wiki/core/components/common/pro-icon.tsx new file mode 100644 index 0000000000..39d4c2a901 --- /dev/null +++ b/dev-wiki/core/components/common/pro-icon.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { FC } from "react"; +import { Crown } from "lucide-react"; +// helpers +import { cn } from "@/helpers/common.helper"; + +type TProIcon = { + className?: string; +}; + +export const ProIcon: FC = (props) => { + const { className } = props; + + return ; +}; diff --git a/dev-wiki/core/components/common/switcher-label.tsx b/dev-wiki/core/components/common/switcher-label.tsx new file mode 100644 index 0000000000..e1426461c9 --- /dev/null +++ b/dev-wiki/core/components/common/switcher-label.tsx @@ -0,0 +1,27 @@ +import { FC } from "react"; +import { TLogoProps } from "@plane/types"; +import { ISvgIcons, Logo } from "@plane/ui"; +import { getFileURL } from "@plane/utils"; +import { truncateText } from "@/helpers/string.helper"; +type TSwitcherLabelProps = { + logo_props?: TLogoProps; + logo_url?: string; + name?: string; + LabelIcon: FC; +}; + +export const SwitcherLabel: FC = (props) => { + const { logo_props, name, LabelIcon, logo_url } = props; + return ( +
    + {logo_props?.in_use ? ( + + ) : logo_url ? ( + logo + ) : ( + + )} + {truncateText(name ?? "", 40)} +
    + ); +}; diff --git a/dev-wiki/core/components/core/app-header.tsx b/dev-wiki/core/components/core/app-header.tsx new file mode 100644 index 0000000000..8320bd9f71 --- /dev/null +++ b/dev-wiki/core/components/core/app-header.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { ReactNode } from "react"; +// components +import { Row } from "@plane/ui"; +import { SidebarHamburgerToggle } from "@/components/core"; +// plane web components + +export interface AppHeaderProps { + header: ReactNode; + mobileHeader?: ReactNode; +} + +export const AppHeader = (props: AppHeaderProps) => { + const { header, mobileHeader } = props; + + return ( +
    + +
    + +
    +
    {header}
    +
    + {mobileHeader && mobileHeader} +
    + ); +}; diff --git a/dev-wiki/core/components/core/content-overflow-HOC.tsx b/dev-wiki/core/components/core/content-overflow-HOC.tsx new file mode 100644 index 0000000000..f8089c34bc --- /dev/null +++ b/dev-wiki/core/components/core/content-overflow-HOC.tsx @@ -0,0 +1,156 @@ +import { ReactNode, useEffect, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +import { cn } from "@plane/utils"; + +interface IContentOverflowWrapper { + children: ReactNode; + maxHeight?: number; + gradientColor?: string; + buttonClassName?: string; + containerClassName?: string; + fallback?: ReactNode; + customButton?: ReactNode; +} + +export const ContentOverflowWrapper = observer((props: IContentOverflowWrapper) => { + const { + children, + maxHeight = 625, + buttonClassName = "text-sm font-medium text-custom-primary-100", + containerClassName, + fallback = null, + customButton, + } = props; + + // states + const [containerHeight, setContainerHeight] = useState(0); + const [showAll, setShowAll] = useState(false); + const [isTransitioning, setIsTransitioning] = useState(false); + + // refs + const contentRef = useRef(null); + const containerRef = useRef(null); + + // hooks + const { t } = useTranslation(); + + useEffect(() => { + if (!contentRef?.current) return; + + const updateHeight = () => { + if (contentRef.current) { + const height = contentRef.current.getBoundingClientRect().height; + setContainerHeight(height); + } + }; + + // Initial height measurement + updateHeight(); + + // Create ResizeObserver for size changes + const resizeObserver = new ResizeObserver(updateHeight); + resizeObserver.observe(contentRef.current); + + // Create MutationObserver for content changes + const mutationObserver = new MutationObserver((mutations) => { + const shouldUpdate = mutations.some( + (mutation) => + mutation.type === "childList" || + (mutation.type === "attributes" && (mutation.attributeName === "style" || mutation.attributeName === "class")) + ); + + if (shouldUpdate) { + updateHeight(); + } + }); + + mutationObserver.observe(contentRef.current, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["style", "class"], + }); + + return () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, [contentRef?.current]); + + useEffect(() => { + if (!containerRef.current) return; + + const handleTransitionEnd = () => { + setIsTransitioning(false); + }; + + containerRef.current.addEventListener("transitionend", handleTransitionEnd); + + return () => { + containerRef.current?.removeEventListener("transitionend", handleTransitionEnd); + }; + }, []); + + const handleToggle = () => { + setIsTransitioning(true); + setShowAll((prev) => !prev); + }; + + if (!children) return fallback; + + return ( +
    maxHeight, + }, + containerClassName + )} + style={{ + height: showAll ? `${containerHeight}px` : `${Math.min(maxHeight, containerHeight)}px`, + }} + > +
    + {children} +
    + + {containerHeight > maxHeight && ( +
    + {customButton || ( + + )} +
    + )} +
    + ); +}); diff --git a/dev-wiki/core/components/core/content-wrapper.tsx b/dev-wiki/core/components/core/content-wrapper.tsx new file mode 100644 index 0000000000..eefc96b1e0 --- /dev/null +++ b/dev-wiki/core/components/core/content-wrapper.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { ReactNode } from "react"; +// helpers +import { cn } from "@/helpers/common.helper"; + +export interface ContentWrapperProps { + className?: string; + children: ReactNode; +} + +export const ContentWrapper = ({ className, children }: ContentWrapperProps) => ( +
    +
    {children}
    +
    +); diff --git a/dev-wiki/core/components/core/filters/date-filter-modal.tsx b/dev-wiki/core/components/core/filters/date-filter-modal.tsx new file mode 100644 index 0000000000..a1c76da4b4 --- /dev/null +++ b/dev-wiki/core/components/core/filters/date-filter-modal.tsx @@ -0,0 +1,170 @@ +"use client"; +import { Fragment } from "react"; +import { Controller, useForm } from "react-hook-form"; + +import { X } from "lucide-react"; +import { Dialog, Transition } from "@headlessui/react"; + +import { Button, Calendar } from "@plane/ui"; + +import { renderFormattedPayloadDate, renderFormattedDate, getDate } from "@/helpers/date-time.helper"; +import { DateFilterSelect } from "./date-filter-select"; +type Props = { + title: string; + handleClose: () => void; + isOpen: boolean; + onSelect: (val: string[]) => void; +}; + +type TFormValues = { + filterType: "before" | "after" | "range"; + date1: Date; + date2: Date; +}; + +const defaultValues: TFormValues = { + filterType: "range", + date1: new Date(), + date2: new Date(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()), +}; + +export const DateFilterModal: React.FC = ({ title, handleClose, isOpen, onSelect }) => { + const { handleSubmit, watch, control } = useForm({ + defaultValues, + }); + + const handleFormSubmit = (formData: TFormValues) => { + const { filterType, date1, date2 } = formData; + + if (filterType === "range") + onSelect([`${renderFormattedPayloadDate(date1)};after`, `${renderFormattedPayloadDate(date2)};before`]); + else onSelect([`${renderFormattedPayloadDate(date1)};${filterType}`]); + + handleClose(); + }; + + const date1 = getDate(watch("date1")); + const date2 = getDate(watch("date1")); + + const isInvalid = watch("filterType") === "range" && date1 && date2 ? date1 > date2 : false; + + return ( + + + +
    + +
    +
    + + +
    +
    + ( + + )} + /> + +
    +
    + { + const dateValue = getDate(value); + const date2Value = getDate(watch("date2")); + return ( + { + if (!date) return; + onChange(date); + }} + mode="single" + disabled={date2Value ? [{ after: date2Value }] : undefined} + /> + ); + }} + /> + {watch("filterType") === "range" && ( + { + const dateValue = getDate(value); + const date1Value = getDate(watch("date1")); + return ( + { + if (!date) return; + onChange(date); + }} + mode="single" + disabled={date1Value ? [{ before: date1Value }] : undefined} + /> + ); + }} + /> + )} +
    + {watch("filterType") === "range" && ( +
    + After: + {renderFormattedDate(watch("date1"))} + Before: + {!isInvalid && {renderFormattedDate(watch("date2"))}} +
    + )} +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/dev-wiki/core/components/core/filters/date-filter-select.tsx b/dev-wiki/core/components/core/filters/date-filter-select.tsx new file mode 100644 index 0000000000..45c38c1c30 --- /dev/null +++ b/dev-wiki/core/components/core/filters/date-filter-select.tsx @@ -0,0 +1,59 @@ +"use client"; +import React from "react"; +import { CalendarDays } from "lucide-react"; +// ui +import { CustomSelect, CalendarAfterIcon, CalendarBeforeIcon } from "@plane/ui"; + +type Props = { + title: string; + value: string; + onChange: (value: string) => void; +}; + +type DueDate = { + name: string; + value: string; + icon: any; +}; + +const dueDateRange: DueDate[] = [ + { + name: "before", + value: "before", + icon: , + }, + { + name: "after", + value: "after", + icon: , + }, + { + name: "range", + value: "range", + icon: , + }, +]; + +export const DateFilterSelect: React.FC = ({ title, value, onChange }) => ( + + {dueDateRange.find((item) => item.value === value)?.icon} + + {title} {dueDateRange.find((item) => item.value === value)?.name} + +
    + } + onChange={onChange} + > + {dueDateRange.map((option, index) => ( + +
    + {option.icon} + {title} {option.name} +
    +
    + ))} + +); diff --git a/dev-wiki/core/components/core/filters/index.ts b/dev-wiki/core/components/core/filters/index.ts new file mode 100644 index 0000000000..28e36d3e67 --- /dev/null +++ b/dev-wiki/core/components/core/filters/index.ts @@ -0,0 +1,2 @@ +export * from "./date-filter-modal"; +export * from "./date-filter-select"; diff --git a/dev-wiki/core/components/core/image-picker-popover.tsx b/dev-wiki/core/components/core/image-picker-popover.tsx new file mode 100644 index 0000000000..bade076d9b --- /dev/null +++ b/dev-wiki/core/components/core/image-picker-popover.tsx @@ -0,0 +1,389 @@ +"use client"; + +import React, { useState, useRef, useCallback } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import { useParams } from "next/navigation"; +import { useDropzone } from "react-dropzone"; +import { Control, Controller } from "react-hook-form"; +import useSWR from "swr"; +import { Tab, Popover } from "@headlessui/react"; +// plane imports +import { ACCEPTED_COVER_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE, MAX_FILE_SIZE } from "@plane/constants"; +import { useOutsideClickDetector } from "@plane/hooks"; +// plane types +import { EFileAssetType } from "@plane/types/src/enums"; +// ui +import { Button, Input, Loader, TOAST_TYPE, setToast } from "@plane/ui"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; +// hooks +import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down"; +// services +import { FileService } from "@/services/file.service"; + +const tabOptions = [ + { + key: "unsplash", + title: "Unsplash", + }, + { + key: "images", + title: "Images", + }, + { + key: "upload", + title: "Upload", + }, +]; + +type Props = { + label: string | React.ReactNode; + value: string | null; + control: Control; + onChange: (data: string) => void; + disabled?: boolean; + tabIndex?: number; + isProfileCover?: boolean; + projectId?: string | null; +}; + +// services +const fileService = new FileService(); + +export const ImagePickerPopover: React.FC = observer((props) => { + const { label, value, control, onChange, disabled = false, tabIndex, isProfileCover = false, projectId } = props; + // states + const [image, setImage] = useState(null); + const [isImageUploading, setIsImageUploading] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [searchParams, setSearchParams] = useState(""); + const [formData, setFormData] = useState({ + search: "", + }); + // refs + const ref = useRef(null); + // router params + const { workspaceSlug } = useParams(); + + const { data: unsplashImages, error: unsplashError } = useSWR( + `UNSPLASH_IMAGES_${searchParams}`, + () => fileService.getUnsplashImages(searchParams), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ); + + const { data: projectCoverImages } = useSWR(`PROJECT_COVER_IMAGES`, () => fileService.getProjectCoverImages(), { + revalidateOnFocus: false, + revalidateOnReconnect: false, + }); + + const imagePickerRef = useRef(null); + + const onDrop = useCallback((acceptedFiles: File[]) => { + setImage(acceptedFiles[0]); + }, []); + + const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({ + onDrop, + accept: ACCEPTED_COVER_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE, + maxSize: MAX_FILE_SIZE, + }); + + const handleSubmit = async () => { + if (!image) return; + setIsImageUploading(true); + + const uploadCallback = (url: string) => { + onChange(url); + setIsImageUploading(false); + setImage(null); + setIsOpen(false); + }; + + if (isProfileCover) { + await fileService + .uploadUserAsset( + { + entity_identifier: "", + entity_type: EFileAssetType.USER_COVER, + }, + image + ) + .then((res) => uploadCallback(res.asset_url)) + .catch((error) => { + console.error("Error uploading user cover image:", error); + setIsImageUploading(false); + setToast({ + message: error?.error ?? "The image could not be uploaded", + type: TOAST_TYPE.ERROR, + title: "Image not uploaded", + }); + }); + } else { + if (!workspaceSlug) return; + await fileService + .uploadWorkspaceAsset( + workspaceSlug.toString(), + { + entity_identifier: projectId?.toString() ?? "", + entity_type: EFileAssetType.PROJECT_COVER, + }, + image + ) + .then((res) => uploadCallback(res.asset_url)) + .catch((error) => { + console.error("Error uploading project cover image:", error); + setIsImageUploading(false); + setToast({ + message: error?.error ?? "The image could not be uploaded", + type: TOAST_TYPE.ERROR, + title: "Image not uploaded", + }); + }); + } + }; + + const handleClose = () => { + if (isOpen) setIsOpen(false); + }; + + const toggleDropdown = () => { + setIsOpen((prevIsOpen) => !prevIsOpen); + }; + + const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); + + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + useOutsideClickDetector(ref, handleClose); + + return ( + + + {label} + + + {isOpen && ( + +
    + + + {tabOptions.map((tab) => { + if (!unsplashImages && unsplashError && tab.key === "unsplash") return null; + if (projectCoverImages && projectCoverImages.length === 0 && tab.key === "images") return null; + + return ( + + `rounded px-4 py-1 text-center text-sm outline-none transition-colors ${ + selected ? "bg-custom-primary text-white" : "text-custom-text-100" + }` + } + > + {tab.title} + + ); + })} + + + {(unsplashImages || !unsplashError) && ( + +
    + ( + { + if (e.key === "Enter") { + e.preventDefault(); + setSearchParams(formData.search); + } + }} + value={value} + onChange={(e) => setFormData({ ...formData, search: e.target.value })} + ref={ref} + placeholder="Search for images" + className="w-full text-sm" + /> + )} + /> + +
    + {unsplashImages ? ( + unsplashImages.length > 0 ? ( +
    + {unsplashImages.map((image) => ( +
    { + setIsOpen(false); + onChange(image.urls.regular); + }} + > + {image.alt_description} +
    + ))} +
    + ) : ( +

    No images found.

    + ) + ) : ( + + + + + + + + + + + )} +
    + )} + {(!projectCoverImages || projectCoverImages.length !== 0) && ( + + {projectCoverImages ? ( + projectCoverImages.length > 0 ? ( +
    + {projectCoverImages.map((image, index) => ( +
    { + setIsOpen(false); + onChange(image); + }} + > + {`Default +
    + ))} +
    + ) : ( +

    No images found.

    + ) + ) : ( + + + + + + + + + + + )} +
    + )} + +
    +
    +
    + + {image !== null || (value && value !== "") ? ( + <> + image + + ) : ( +
    + + {isDragActive ? "Drop image here to upload" : "Drag & drop image here"} + +
    + )} + + +
    +
    + {fileRejections.length > 0 && ( +

    + {fileRejections[0].errors[0].code === "file-too-large" + ? "The image size cannot exceed 5 MB." + : "Please upload a file in a valid format."} +

    + )} + +

    File formats supported- .jpeg, .jpg, .png, .webp

    + +
    + + +
    +
    +
    +
    +
    +
    +
    + )} +
    + ); +}); diff --git a/dev-wiki/core/components/core/index.ts b/dev-wiki/core/components/core/index.ts new file mode 100644 index 0000000000..da7b4821d8 --- /dev/null +++ b/dev-wiki/core/components/core/index.ts @@ -0,0 +1,7 @@ +export * from "./filters"; +export * from "./sidebar"; +export * from "./theme"; +export * from "./image-picker-popover"; +export * from "./page-title"; +export * from "./app-header"; +export * from "./content-wrapper"; diff --git a/dev-wiki/core/components/core/list/index.ts b/dev-wiki/core/components/core/list/index.ts new file mode 100644 index 0000000000..d5489c45ea --- /dev/null +++ b/dev-wiki/core/components/core/list/index.ts @@ -0,0 +1,2 @@ +export * from "./list-item"; +export * from "./list-root"; diff --git a/dev-wiki/core/components/core/list/list-item.tsx b/dev-wiki/core/components/core/list/list-item.tsx new file mode 100644 index 0000000000..9a29f61c0e --- /dev/null +++ b/dev-wiki/core/components/core/list/list-item.tsx @@ -0,0 +1,110 @@ +"use client"; +import React, { FC } from "react"; +// ui +import { ControlLink, Row, Tooltip } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useAppRouter } from "@/hooks/use-app-router"; + +interface IListItemProps { + id?: string; + title: string; + itemLink: string; + onItemClick?: (e: React.MouseEvent) => void; + prependTitleElement?: JSX.Element; + appendTitleElement?: JSX.Element; + actionableItems?: JSX.Element; + isMobile?: boolean; + parentRef: React.RefObject; + disableLink?: boolean; + className?: string; + itemClassName?: string; + actionItemContainerClassName?: string; + isSidebarOpen?: boolean; + quickActionElement?: JSX.Element; + preventDefaultNProgress?: boolean; + leftElementClassName?: string; + rightElementClassName?: string; +} + +export const ListItem: FC = (props) => { + const { + id, + title, + prependTitleElement, + appendTitleElement, + actionableItems, + itemLink, + onItemClick, + isMobile = false, + parentRef, + disableLink = false, + className = "", + actionItemContainerClassName = "", + isSidebarOpen = false, + quickActionElement, + itemClassName = "", + preventDefaultNProgress = false, + leftElementClassName = "", + rightElementClassName = "", + } = props; + + // router + const router = useAppRouter(); + + // handlers + const handleControlLinkClick = (e: React.MouseEvent) => { + if (onItemClick) onItemClick(e); + else router.push(itemLink); + }; + + return ( +
    + +
    + +
    + {prependTitleElement && {prependTitleElement}} + + {title} + +
    + {appendTitleElement && ( + {appendTitleElement} + )} +
    + {quickActionElement && quickActionElement} +
    + {actionableItems && ( +
    + {actionableItems} +
    + )} +
    +
    + ); +}; diff --git a/dev-wiki/core/components/core/list/list-root.tsx b/dev-wiki/core/components/core/list/list-root.tsx new file mode 100644 index 0000000000..bd62efb2ef --- /dev/null +++ b/dev-wiki/core/components/core/list/list-root.tsx @@ -0,0 +1,18 @@ +import React, { FC } from "react"; +import { Row, ERowVariant } from "@plane/ui"; + +interface IListContainer { + children: React.ReactNode; +} + +export const ListLayout: FC = (props) => { + const { children } = props; + return ( + + {children} + + ); +}; diff --git a/dev-wiki/core/components/core/page-title.tsx b/dev-wiki/core/components/core/page-title.tsx new file mode 100644 index 0000000000..554a2e0f1f --- /dev/null +++ b/dev-wiki/core/components/core/page-title.tsx @@ -0,0 +1,14 @@ +import { useHead } from "@plane/ui"; + +type PageHeadTitleProps = { + title?: string; + description?: string; +}; + +export const PageHead: React.FC = (props) => { + const { title } = props; + + useHead({ title }); + + return null; +}; diff --git a/dev-wiki/core/components/core/render-if-visible-HOC.tsx b/dev-wiki/core/components/core/render-if-visible-HOC.tsx new file mode 100644 index 0000000000..ea3fc9c2f8 --- /dev/null +++ b/dev-wiki/core/components/core/render-if-visible-HOC.tsx @@ -0,0 +1,86 @@ +import React, { useState, useRef, useEffect, ReactNode, MutableRefObject } from "react"; +import { cn } from "@/helpers/common.helper"; + +type Props = { + defaultHeight?: string; + verticalOffset?: number; + horizontalOffset?: number; + root?: MutableRefObject; + children: ReactNode; + as?: keyof JSX.IntrinsicElements; + classNames?: string; + placeholderChildren?: ReactNode; + defaultValue?: boolean; + shouldRecordHeights?: boolean; + useIdletime?: boolean; + forceRender?: boolean; +}; + +const RenderIfVisible: React.FC = (props) => { + const { + defaultHeight = "300px", + root, + verticalOffset = 50, + horizontalOffset = 0, + as = "div", + children, + classNames = "", + shouldRecordHeights = true, + //placeholder children + placeholderChildren = null, //placeholder children + defaultValue = false, + useIdletime = false, + forceRender = false, + } = props; + const [shouldVisible, setShouldVisible] = useState(defaultValue); + const placeholderHeight = useRef(defaultHeight); + const intersectionRef = useRef(null); + + const isVisible = shouldVisible || forceRender; + + // Set visibility with intersection observer + useEffect(() => { + if (intersectionRef.current) { + const observer = new IntersectionObserver( + (entries) => { + //DO no remove comments for future + if (typeof window !== undefined && window.requestIdleCallback && useIdletime) { + window.requestIdleCallback(() => setShouldVisible(entries[entries.length - 1].isIntersecting), { + timeout: 300, + }); + } else { + setShouldVisible(entries[entries.length - 1].isIntersecting); + } + }, + { + root: root?.current, + rootMargin: `${verticalOffset}% ${horizontalOffset}% ${verticalOffset}% ${horizontalOffset}%`, + } + ); + observer.observe(intersectionRef.current); + return () => { + if (intersectionRef.current) { + // eslint-disable-next-line react-hooks/exhaustive-deps + observer.unobserve(intersectionRef.current); + } + }; + } + }, [intersectionRef, children, root, verticalOffset, horizontalOffset]); + + //Set height after render + useEffect(() => { + if (intersectionRef.current && isVisible && shouldRecordHeights) { + window.requestIdleCallback(() => { + if (intersectionRef.current) placeholderHeight.current = `${intersectionRef.current.offsetHeight}px`; + }); + } + }, [isVisible, intersectionRef, shouldRecordHeights]); + + const child = isVisible ? <>{children} : placeholderChildren; + const style = isVisible || !shouldRecordHeights ? {} : { height: placeholderHeight.current, width: "100%" }; + const className = isVisible || placeholderChildren ? classNames : cn(classNames, "bg-custom-background-80"); + + return React.createElement(as, { ref: intersectionRef, style, className }, child); +}; + +export default RenderIfVisible; diff --git a/dev-wiki/core/components/core/sidebar/index.ts b/dev-wiki/core/components/core/sidebar/index.ts new file mode 100644 index 0000000000..ad5c6f6835 --- /dev/null +++ b/dev-wiki/core/components/core/sidebar/index.ts @@ -0,0 +1,2 @@ +export * from "./single-progress-stats"; +export * from "./sidebar-menu-hamburger-toggle"; diff --git a/dev-wiki/core/components/core/sidebar/progress-chart.tsx b/dev-wiki/core/components/core/sidebar/progress-chart.tsx new file mode 100644 index 0000000000..e6317eeb9f --- /dev/null +++ b/dev-wiki/core/components/core/sidebar/progress-chart.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { AreaChart } from "@plane/propel/charts/area-chart"; +import { TChartData, TModuleCompletionChartDistribution } from "@plane/types"; +import { renderFormattedDateWithoutYear } from "@/helpers/date-time.helper"; + +type Props = { + distribution: TModuleCompletionChartDistribution; + totalIssues: number; + className?: string; + plotTitle?: string; +}; + +const ProgressChart: React.FC = ({ distribution, totalIssues, className = "", plotTitle = "work items" }) => { + const chartData: TChartData[] = Object.keys(distribution ?? []).map((key, index) => ({ + name: renderFormattedDateWithoutYear(key), + current: distribution[key] ?? 0, + ideal: totalIssues * (1 - index / (Object.keys(distribution ?? []).length - 1)), + })); + + return ( +
    + +
    + ); +}; + +export default ProgressChart; diff --git a/dev-wiki/core/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx b/dev-wiki/core/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx new file mode 100644 index 0000000000..7dc3988129 --- /dev/null +++ b/dev-wiki/core/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { observer } from "mobx-react"; +import { Menu } from "lucide-react"; +import { useAppTheme } from "@/hooks/store"; + +export const SidebarHamburgerToggle = observer(() => { + // store hooks + const { toggleSidebar } = useAppTheme(); + + return ( + + ); +}); diff --git a/dev-wiki/core/components/core/sidebar/single-progress-stats.tsx b/dev-wiki/core/components/core/sidebar/single-progress-stats.tsx new file mode 100644 index 0000000000..047d8bc04d --- /dev/null +++ b/dev-wiki/core/components/core/sidebar/single-progress-stats.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +type TSingleProgressStatsProps = { + title: any; + completed: number; + total: number; + onClick?: () => void; + selected?: boolean; +}; + +export const SingleProgressStats: React.FC = ({ + title, + completed, + total, + onClick, + selected = false, +}) => ( +
    +
    {title}
    +
    +
    + + {isNaN(Math.round((completed / total) * 100)) ? "0" : Math.round((completed / total) * 100)}% + +
    + of {total} +
    +
    +); diff --git a/dev-wiki/core/components/core/theme/color-picker-input.tsx b/dev-wiki/core/components/core/theme/color-picker-input.tsx new file mode 100644 index 0000000000..dbaa13aacb --- /dev/null +++ b/dev-wiki/core/components/core/theme/color-picker-input.tsx @@ -0,0 +1,129 @@ +"use client"; +import { FC, Fragment } from "react"; +// react-form +import { ColorResult, SketchPicker } from "react-color"; +import { + Control, + Controller, + FieldError, + FieldErrorsImpl, + Merge, + UseFormRegister, + UseFormSetValue, + UseFormWatch, +} from "react-hook-form"; +// react-color +// component +import { Palette } from "lucide-react"; +import { Popover, Transition } from "@headlessui/react"; +import { IUserTheme } from "@plane/types"; +import { Input } from "@plane/ui"; +// icons +// types + +type Props = { + name: keyof IUserTheme; + position?: "left" | "right"; + watch: UseFormWatch; + setValue: UseFormSetValue; + control: Control; + error: FieldError | Merge> | undefined; + register: UseFormRegister; +}; + +export const ColorPickerInput: FC = (props) => { + const { name, position = "left", watch, setValue, error, control } = props; + + const handleColorChange = (newColor: ColorResult) => { + const { hex } = newColor; + setValue(name, hex); + }; + + const getColorText = (colorName: keyof IUserTheme) => { + switch (colorName) { + case "background": + return "Background"; + case "text": + return "Text"; + case "primary": + return "Primary(Theme)"; + case "sidebarBackground": + return "Sidebar Background"; + case "sidebarText": + return "Sidebar Text"; + default: + return "Color"; + } + }; + + return ( +
    + ( + + )} + /> +
    + + {({ open }) => ( + <> + + {watch(name) && watch(name) !== "" ? ( + + ) : ( + + )} + + + + + + + + + )} + +
    +
    + ); +}; diff --git a/dev-wiki/core/components/core/theme/custom-theme-selector.tsx b/dev-wiki/core/components/core/theme/custom-theme-selector.tsx new file mode 100644 index 0000000000..d66fbe82f8 --- /dev/null +++ b/dev-wiki/core/components/core/theme/custom-theme-selector.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { useMemo } from "react"; +import { observer } from "mobx-react"; +import { Controller, useForm } from "react-hook-form"; +// types +import { useTranslation } from "@plane/i18n"; +import { IUserTheme } from "@plane/types"; +// ui +import { Button, InputColorPicker, setPromiseToast } from "@plane/ui"; +// hooks +import { useUserProfile } from "@/hooks/store"; + +type TCustomThemeSelector = { + applyThemeChange: (theme: Partial) => void; +}; + +export const CustomThemeSelector: React.FC = observer((props) => { + const { applyThemeChange } = props; + // hooks + const { data: userProfile, updateUserTheme } = useUserProfile(); + const { t } = useTranslation(); + const { + control, + formState: { errors, isSubmitting }, + handleSubmit, + watch, + } = useForm({ + defaultValues: { + background: userProfile?.theme?.background !== "" ? userProfile?.theme?.background : "#0d101b", + text: userProfile?.theme?.text !== "" ? userProfile?.theme?.text : "#c5c5c5", + primary: userProfile?.theme?.primary !== "" ? userProfile?.theme?.primary : "#3f76ff", + sidebarBackground: + userProfile?.theme?.sidebarBackground !== "" ? userProfile?.theme?.sidebarBackground : "#0d101b", + sidebarText: userProfile?.theme?.sidebarText !== "" ? userProfile?.theme?.sidebarText : "#c5c5c5", + darkPalette: userProfile?.theme?.darkPalette || false, + palette: userProfile?.theme?.palette !== "" ? userProfile?.theme?.palette : "", + }, + }); + + const inputRules = useMemo( + () => ({ + minLength: { + value: 7, + message: t("enter_a_valid_hex_code_of_6_characters"), + }, + maxLength: { + value: 7, + message: t("enter_a_valid_hex_code_of_6_characters"), + }, + pattern: { + value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, + message: t("enter_a_valid_hex_code_of_6_characters"), + }, + }), + [t] // Empty dependency array since these rules never change + ); + + const handleUpdateTheme = async (formData: Partial) => { + const payload: IUserTheme = { + background: formData.background, + text: formData.text, + primary: formData.primary, + sidebarBackground: formData.sidebarBackground, + sidebarText: formData.sidebarText, + darkPalette: false, + palette: `${formData.background},${formData.text},${formData.primary},${formData.sidebarBackground},${formData.sidebarText}`, + theme: "custom", + }; + applyThemeChange(payload); + + const updateCurrentUserThemePromise = updateUserTheme(payload); + setPromiseToast(updateCurrentUserThemePromise, { + loading: t("updating_theme"), + success: { + title: t("success"), + message: () => t("theme_updated_successfully"), + }, + error: { + title: t("error"), + message: () => t("failed_to_update_the_theme"), + }, + }); + + return; + }; + + const handleValueChange = (val: string | undefined, onChange: any) => { + let hex = val; + // prepend a hashtag if it doesn't exist + if (val && val[0] !== "#") hex = `#${val}`; + + onChange(hex); + }; + + return ( +
    +
    +

    {t("customize_your_theme")}

    +
    +
    +
    +

    {t("background_color")}

    +
    + ( + handleValueChange(val, onChange)} + placeholder="#0d101b" + className="w-full placeholder:text-custom-text-400/60" + style={{ + backgroundColor: watch("background"), + color: watch("text"), + }} + hasError={Boolean(errors?.background)} + /> + )} + /> + {errors.background &&

    {errors.background.message}

    } +
    +
    + +
    +

    {t("text_color")}

    +
    + ( + handleValueChange(val, onChange)} + placeholder="#c5c5c5" + className="w-full placeholder:text-custom-text-400/60" + style={{ + backgroundColor: watch("text"), + color: watch("background"), + }} + hasError={Boolean(errors?.text)} + /> + )} + /> + {errors.text &&

    {errors.text.message}

    } +
    +
    + +
    +

    {t("primary_color")}

    +
    + ( + handleValueChange(val, onChange)} + placeholder="#3f76ff" + className="w-full placeholder:text-custom-text-400/60" + style={{ + backgroundColor: watch("primary"), + color: watch("text"), + }} + hasError={Boolean(errors?.primary)} + /> + )} + /> + {errors.primary &&

    {errors.primary.message}

    } +
    +
    + +
    +

    {t("sidebar_background_color")}

    +
    + ( + handleValueChange(val, onChange)} + placeholder="#0d101b" + className="w-full placeholder:text-custom-text-400/60" + style={{ + backgroundColor: watch("sidebarBackground"), + color: watch("sidebarText"), + }} + hasError={Boolean(errors?.sidebarBackground)} + /> + )} + /> + {errors.sidebarBackground && ( +

    {errors.sidebarBackground.message}

    + )} +
    +
    + +
    +

    {t("sidebar_text_color")}

    +
    + ( + handleValueChange(val, onChange)} + placeholder="#c5c5c5" + className="w-full placeholder:text-custom-text-400/60" + style={{ + backgroundColor: watch("sidebarText"), + color: watch("sidebarBackground"), + }} + hasError={Boolean(errors?.sidebarText)} + /> + )} + /> + {errors.sidebarText &&

    {errors.sidebarText.message}

    } +
    +
    +
    +
    +
    +
    + +
    +
    + ); +}); diff --git a/dev-wiki/core/components/core/theme/index.ts b/dev-wiki/core/components/core/theme/index.ts new file mode 100644 index 0000000000..145b3862a1 --- /dev/null +++ b/dev-wiki/core/components/core/theme/index.ts @@ -0,0 +1,3 @@ +export * from "./color-picker-input"; +export * from "./custom-theme-selector"; +export * from "./theme-switch"; diff --git a/dev-wiki/core/components/core/theme/theme-switch.tsx b/dev-wiki/core/components/core/theme/theme-switch.tsx new file mode 100644 index 0000000000..c6677ac293 --- /dev/null +++ b/dev-wiki/core/components/core/theme/theme-switch.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { FC } from "react"; +// plane imports +import { I_THEME_OPTION, THEME_OPTIONS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// constants +import { CustomSelect } from "@plane/ui"; +// ui + +type Props = { + value: I_THEME_OPTION | null; + onChange: (value: I_THEME_OPTION) => void; +}; + +export const ThemeSwitch: FC = (props) => { + const { value, onChange } = props; + const { t } = useTranslation(); + return ( + +
    +
    +
    +
    + {t(value.key)} +
    + ) : ( + t("select_your_theme") + ) + } + onChange={onChange} + input + > + {THEME_OPTIONS.map((themeOption) => ( + +
    +
    +
    +
    +
    + {t(themeOption.key)} +
    + + ))} + + ); +}; diff --git a/dev-wiki/core/components/editor/embeds/index.ts b/dev-wiki/core/components/editor/embeds/index.ts new file mode 100644 index 0000000000..8146e94d92 --- /dev/null +++ b/dev-wiki/core/components/editor/embeds/index.ts @@ -0,0 +1 @@ +export * from "./mentions"; diff --git a/dev-wiki/core/components/editor/embeds/mentions/index.ts b/dev-wiki/core/components/editor/embeds/mentions/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/dev-wiki/core/components/editor/embeds/mentions/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/dev-wiki/core/components/editor/embeds/mentions/root.tsx b/dev-wiki/core/components/editor/embeds/mentions/root.tsx new file mode 100644 index 0000000000..d1bc8e9b54 --- /dev/null +++ b/dev-wiki/core/components/editor/embeds/mentions/root.tsx @@ -0,0 +1,17 @@ +// plane editor +import { TMentionComponentProps } from "@plane/editor"; +// plane web components +// import { EditorAdditionalMentionsRoot } from "@/ce/components/editor"; +// local components +import { EditorUserMention } from "./user"; + +export const EditorMentionsRoot: React.FC = (props) => { + const { entity_identifier, entity_name } = props; + + switch (entity_name) { + case "user_mention": + return ; + // default: + // return ; + } +}; diff --git a/dev-wiki/core/components/editor/embeds/mentions/user.tsx b/dev-wiki/core/components/editor/embeds/mentions/user.tsx new file mode 100644 index 0000000000..f49d1792ca --- /dev/null +++ b/dev-wiki/core/components/editor/embeds/mentions/user.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { usePopper } from "react-popper"; +// plane imports +import { ROLE } from "@plane/constants"; +// plane ui +import { Avatar } from "@plane/ui"; +// constants +// helpers +import { cn } from "@/helpers/common.helper"; +import { getFileURL } from "@/helpers/file.helper"; +// hooks +import { useMember, useUser } from "@/hooks/store"; + +type Props = { + id: string; +}; + +export const EditorUserMention: React.FC = observer((props) => { + const { id } = props; + // router + const { projectId } = useParams(); + // states + const [popperElement, setPopperElement] = useState(null); + const [referenceElement, setReferenceElement] = useState(null); + // params + const { workspaceSlug } = useParams(); + // store hooks + const { data: currentUser } = useUser(); + const { + getUserDetails, + project: { getProjectMemberDetails }, + } = useMember(); + // popper-js refs + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + // derived values + const userDetails = getUserDetails(id); + const roleDetails = projectId ? getProjectMemberDetails(id, projectId.toString())?.role : null; + const profileLink = `/${workspaceSlug}/profile/${id}`; + + if (!userDetails) { + return ( +
    + @deactivated user +
    + ); + } + + return ( +
    + + @{userDetails?.display_name} + +
    +
    +
    + +
    +
    + + {userDetails?.first_name} {userDetails?.last_name} + + {roleDetails &&

    {ROLE[roleDetails as keyof typeof ROLE]}

    } +
    +
    +
    +
    + ); +}); diff --git a/dev-wiki/core/components/editor/index.ts b/dev-wiki/core/components/editor/index.ts new file mode 100644 index 0000000000..cf8352ae4a --- /dev/null +++ b/dev-wiki/core/components/editor/index.ts @@ -0,0 +1 @@ +export * from "./embeds"; diff --git a/dev-wiki/core/components/editor/index.tsx b/dev-wiki/core/components/editor/index.tsx new file mode 100644 index 0000000000..a891fa82b8 --- /dev/null +++ b/dev-wiki/core/components/editor/index.tsx @@ -0,0 +1,2 @@ +export const RichTextReadOnlyEditor = () => null; +export const EditorMentionsRoot = () => null; export const ProductUpdatesHeader = () => null; diff --git a/dev-wiki/core/components/empty-state/index.tsx b/dev-wiki/core/components/empty-state/index.tsx new file mode 100644 index 0000000000..4ae97e839c --- /dev/null +++ b/dev-wiki/core/components/empty-state/index.tsx @@ -0,0 +1,107 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +// ui +import { Button } from "@plane/ui/src/button"; +// utils +import { cn } from "@plane/utils"; + +type EmptyStateSize = "sm" | "md" | "lg"; + +type ButtonConfig = { + text: string; + prependIcon?: React.ReactNode; + appendIcon?: React.ReactNode; + onClick?: () => void; + disabled?: boolean; +}; + +type Props = { + title: string; + description?: string; + assetPath?: string; + size?: EmptyStateSize; + primaryButton?: ButtonConfig; + secondaryButton?: ButtonConfig; + customPrimaryButton?: React.ReactNode; + customSecondaryButton?: React.ReactNode; + className?: string; +}; + +const sizeClasses = { + sm: "md:min-w-[24rem] max-w-[45rem]", + md: "md:min-w-[28rem] max-w-[50rem]", + lg: "md:min-w-[30rem] max-w-[60rem]", +} as const; + +const CustomButton = ({ + config, + variant, + size, +}: { + config: ButtonConfig; + variant: "primary" | "neutral-primary"; + size: EmptyStateSize; +}) => ( + +); + +export const DetailedEmptyState: React.FC = observer((props) => { + const { + title, + description, + size = "lg", + primaryButton, + secondaryButton, + customPrimaryButton, + customSecondaryButton, + assetPath, + className, + } = props; + + const hasButtons = primaryButton || secondaryButton || customPrimaryButton || customSecondaryButton; + + return ( +
    +
    +
    +

    {title}

    + {description &&

    {description}

    } +
    + + {assetPath && ( + {title} + )} + + {hasButtons && ( +
    + {/* primary button */} + {customPrimaryButton ?? + (primaryButton?.text && )} + {/* secondary button */} + {customSecondaryButton ?? + (secondaryButton?.text && ( + + ))} +
    + )} +
    +
    + ); +}); diff --git a/dev-wiki/core/components/instance/index.ts b/dev-wiki/core/components/instance/index.ts new file mode 100644 index 0000000000..43e76eba72 --- /dev/null +++ b/dev-wiki/core/components/instance/index.ts @@ -0,0 +1,2 @@ +export * from "./not-ready-view"; +export * from "./maintenance-view"; diff --git a/dev-wiki/core/components/instance/maintenance-view.tsx b/dev-wiki/core/components/instance/maintenance-view.tsx new file mode 100644 index 0000000000..8dd4c34e78 --- /dev/null +++ b/dev-wiki/core/components/instance/maintenance-view.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { FC } from "react"; +import Image from "next/image"; +// ui +import { Button } from "@plane/ui"; +// layouts +import DefaultLayout from "@/layouts/default-layout"; +// components +import { MaintenanceMessage } from "@/plane-web/components/instance"; +// images +import maintenanceModeImage from "@/public/maintenance-mode.webp"; + +export const MaintenanceView: FC = () => ( + +
    +
    + ProjectSettingImg +
    +
    + + +
    +
    +
    +); diff --git a/dev-wiki/core/components/instance/not-ready-view.tsx b/dev-wiki/core/components/instance/not-ready-view.tsx new file mode 100644 index 0000000000..8d317634a8 --- /dev/null +++ b/dev-wiki/core/components/instance/not-ready-view.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { FC } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { useTheme } from "next-themes"; +import { Button } from "@plane/ui"; +// helpers +import { GOD_MODE_URL } from "@/helpers/common.helper"; +// images +// assets +import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg"; +import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg"; +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; +import PlaneTakeOffImage from "@/public/plane-takeoff.png"; + +export const InstanceNotReady: FC = () => { + const { resolvedTheme } = useTheme(); + const patternBackground = resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern; + + const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; + + return ( +
    +
    +
    +
    + + Plane logo + +
    +
    + +
    + Plane background pattern +
    + +
    +
    +
    +
    +

    Welcome aboard Plane!

    + Plane Logo +

    + Get started by setting up your instance and workspace +

    +
    + +
    +
    +
    +
    +
    + ); +}; diff --git a/dev-wiki/core/components/issues/issue-layouts/properties/all-properties.tsx b/dev-wiki/core/components/issues/issue-layouts/properties/all-properties.tsx new file mode 100644 index 0000000000..c6aeb549ea --- /dev/null +++ b/dev-wiki/core/components/issues/issue-layouts/properties/all-properties.tsx @@ -0,0 +1 @@ +export const IssueProperties = () => null; diff --git a/dev-wiki/core/components/issues/issue-layouts/utils.ts b/dev-wiki/core/components/issues/issue-layouts/utils.ts new file mode 100644 index 0000000000..1dadc78345 --- /dev/null +++ b/dev-wiki/core/components/issues/issue-layouts/utils.ts @@ -0,0 +1,12 @@ +// Minimal utils for dev-wiki - only what's needed for sidebar functionality +export const HIGHLIGHT_CLASS = "bg-yellow-50 border-yellow-200"; + +export const highlightIssueOnDrop = (elementId: string, timeout: number = 3000) => { + const element = document.getElementById(elementId); + if (element) { + element.classList.add(HIGHLIGHT_CLASS); + setTimeout(() => { + element.classList.remove(HIGHLIGHT_CLASS); + }, timeout); + } +}; diff --git a/dev-wiki/core/components/pages/dropdowns/actions.tsx b/dev-wiki/core/components/pages/dropdowns/actions.tsx new file mode 100644 index 0000000000..ffe8208d0a --- /dev/null +++ b/dev-wiki/core/components/pages/dropdowns/actions.tsx @@ -0,0 +1,194 @@ +"use client"; +import { useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import { Copy, ExternalLink, Globe2, Link, Lock, Trash2 } from "lucide-react"; +// constants +import { EPageAccess } from "@plane/constants"; +// plane editor +import { EditorRefApi } from "@plane/editor"; +// plane ui +import { ContextMenu, CustomMenu, TContextMenuItem } from "@plane/ui"; +// components +import { DeletePageModal } from "@/components/pages"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { usePageOperations } from "@/hooks/use-page-operations"; +// plane web hooks +import { EPageStoreType } from "@/plane-web/hooks/store/use-page-store"; +// Import the custom menu hook +import { usePageActionsMenu } from "@/plane-web/hooks/use-page-actions-menu"; +// store types +import { TPageInstance } from "@/store/pages/base-page"; + +export type TPageActions = + | "full-screen" + | "sticky-toolbar" + | "copy-markdown" + | "toggle-lock" + | "toggle-access" + | "open-in-new-tab" + | "copy-link" + | "make-a-copy" + | "archive-restore" + | "delete" + | "version-history" + | "export" + | "move"; + +type Props = { + editorRef?: EditorRefApi | null; + extraOptions?: (TContextMenuItem & { key: TPageActions })[]; + optionsOrder: TPageActions[]; + page: TPageInstance; + parentRef?: React.RefObject; + storeType: EPageStoreType; + realtimeEvents?: boolean; +}; + +export const PageActions: React.FC = observer((props) => { + const { editorRef, extraOptions, optionsOrder, page, parentRef, storeType, realtimeEvents = true } = props; + + // states for common modals + const [deletePageModal, setDeletePageModal] = useState(false); + + // page operations + const { pageOperations } = usePageOperations({ + editorRef, + page, + }); + + // Get custom menu items and modals from the environment-specific implementation + const { customMenuItems, ModalsComponent } = usePageActionsMenu({ + page, + storeType, + pageOperations, + }); + + // derived values + const { access, archived_at, canCurrentUserChangeAccess, canCurrentUserDeletePage, canCurrentUserDuplicatePage } = + page; + + // Base menu items that are common across all implementations + const baseMenuItems: (TContextMenuItem & { key: TPageActions })[] = useMemo( + () => [ + { + key: "toggle-access", + action: pageOperations.toggleAccess, + title: access === EPageAccess.PUBLIC ? "Make private" : "Make public", + icon: access === EPageAccess.PUBLIC ? Lock : Globe2, + shouldRender: canCurrentUserChangeAccess && !archived_at, + }, + { + key: "open-in-new-tab", + action: pageOperations.openInNewTab, + title: "Open in new tab", + icon: ExternalLink, + shouldRender: true, + }, + { + key: "copy-link", + action: pageOperations.copyLink, + title: "Copy link", + icon: Link, + shouldRender: true, + }, + { + key: "make-a-copy", + action: () => pageOperations.duplicate(realtimeEvents), + title: "Make a copy", + icon: Copy, + shouldRender: canCurrentUserDuplicatePage, + }, + { + key: "delete", + action: () => setDeletePageModal(true), + title: "Delete", + icon: Trash2, + shouldRender: canCurrentUserDeletePage && !!archived_at, + }, + ], + [ + access, + archived_at, + canCurrentUserChangeAccess, + canCurrentUserDeletePage, + canCurrentUserDuplicatePage, + pageOperations, + realtimeEvents, + ] + ); + + // Merge base menu items with custom menu items + const MENU_ITEMS: (TContextMenuItem & { key: TPageActions })[] = useMemo(() => { + // Start with base menu items + const menuItems = [...baseMenuItems]; + + // Add custom menu items + customMenuItems.forEach((customItem) => { + // Find if there's already an item with the same key + const existingIndex = menuItems.findIndex((item) => item.key === customItem.key); + + if (existingIndex >= 0) { + // Replace the existing item + menuItems[existingIndex] = customItem; + } else { + // Add as a new item + menuItems.push(customItem); + } + }); + + // Add extra options if provided + if (extraOptions) { + menuItems.push(...extraOptions); + } + + return menuItems; + }, [baseMenuItems, customMenuItems, extraOptions]); + + // arrange options + const arrangedOptions = useMemo( + () => + optionsOrder + .map((key) => MENU_ITEMS.find((item) => item.key === key)) + .filter((item) => !!item) as (TContextMenuItem & { key: TPageActions })[], + [optionsOrder, MENU_ITEMS] + ); + + return ( + <> + setDeletePageModal(false)} + page={page} + storeType={storeType} + /> + + {parentRef && } + + {arrangedOptions.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action?.(); + }} + className={cn("flex items-center gap-2", item.className)} + disabled={item.disabled} + > + {item.customContent ?? ( + <> + {item.icon && } + {item.title} + + )} + + ); + })} + + + ); +}); diff --git a/dev-wiki/core/components/pages/dropdowns/index.ts b/dev-wiki/core/components/pages/dropdowns/index.ts new file mode 100644 index 0000000000..8ff5a89aff --- /dev/null +++ b/dev-wiki/core/components/pages/dropdowns/index.ts @@ -0,0 +1 @@ +export * from "./actions"; diff --git a/dev-wiki/core/components/pages/editor/breadcrumb-page-item.tsx b/dev-wiki/core/components/pages/editor/breadcrumb-page-item.tsx new file mode 100644 index 0000000000..8ab5d0a4ee --- /dev/null +++ b/dev-wiki/core/components/pages/editor/breadcrumb-page-item.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { FileText } from "lucide-react"; +// types +// ui +import { EmojiIconPicker, EmojiIconPickerTypes, Tooltip } from "@plane/ui"; +// components +import { BreadcrumbLink, Logo } from "@/components/common"; +// helpers +import { getPageName } from "@/helpers/page.helper"; +// hooks +import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web hooks +import { usePage } from "@/plane-web/hooks/store/use-page"; +import { EPageStoreType } from "@/plane-web/hooks/store/use-page-store"; + +export interface IPageBreadcrumbProps { + pageId: string; + storeType: EPageStoreType; + href?: string; + showLogo?: boolean; + isEditable?: boolean; +} + +export const PageBreadcrumbItem: React.FC = observer( + ({ pageId, storeType, href, showLogo = true, isEditable }) => { + // states + const [isOpen, setIsOpen] = useState(false); + + // hooks + const { isMobile } = usePlatformOS(); + const page = usePage({ + pageId: pageId, + storeType: storeType, + }); + + const { name, logo_props, updatePageLogo, isContentEditable } = page ?? {}; + + // Show loading state when the page data isn't available yet + if (!page) { + const loadingItemClasses = href ? "" : "flex items-center space-x-2"; + + if (href) { + return ( +
    + {showLogo &&
    } +
    +
    + ); + } + + return ( +
  • +
    +
    + {showLogo &&
    } +
    +
    +
    +
  • + ); + } + + if (href) { + return ( + + {logo_props?.in_use ? ( + + ) : ( + + )} + + ) + } + /> + ); + } + + return ( +
  • +
    +
    + {showLogo && ( +
    + setIsOpen(val)} + className="flex items-center justify-center" + buttonClassName="flex items-center justify-center" + label={ + <> + {logo_props?.in_use ? ( + + ) : ( + + )} + + } + onChange={(val) => updatePageLogo?.(val)} + defaultIconColor={ + logo_props?.in_use && logo_props.in_use === "icon" ? logo_props?.icon?.color : undefined + } + defaultOpen={ + logo_props?.in_use && logo_props?.in_use === "emoji" + ? EmojiIconPickerTypes.EMOJI + : EmojiIconPickerTypes.ICON + } + disabled={!isEditable && !isContentEditable} + /> +
    + )} + +
    + {getPageName(name)} +
    +
    +
    +
    +
  • + ); + } +); diff --git a/dev-wiki/core/components/pages/editor/editor-body.tsx b/dev-wiki/core/components/pages/editor/editor-body.tsx new file mode 100644 index 0000000000..7fd736f1bf --- /dev/null +++ b/dev-wiki/core/components/pages/editor/editor-body.tsx @@ -0,0 +1,331 @@ +import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { observer } from "mobx-react"; +// document-editor +import { + CollaborativeDocumentEditorWithRef, + EditorRefApi, + EditorTitleRefApi, + TAIMenuProps, + TDisplayConfig, + TFileHandler, + TRealtimeConfig, + TServerHandler, +} from "@plane/editor"; +// plane types +import { TSearchEntityRequestPayload, TSearchResponse, TWebhookConnectionQueryParams } from "@plane/types"; +// plane ui +import { ERowVariant, Row } from "@plane/ui"; +import { cn, HSL, hslToHex, isCommentEmpty } from "@plane/utils"; +// components +import { EditorMentionsRoot } from "@/components/editor"; +import { PageContentBrowser, PageContentLoader } from "@/components/pages"; +// helpers +import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@/helpers/common.helper"; +// hooks +import { useEditorMention } from "@/hooks/editor"; +import { useMember, useUser, useUserProfile, useWorkspace } from "@/hooks/store"; +import { usePageFilters } from "@/hooks/use-page-filters"; +import { type TCustomEventHandlers, useRealtimePageEvents } from "@/hooks/use-realtime-page-events"; +// plane web components +import { EditorAIMenu } from "@/plane-web/components/pages"; +import { MultipleDeletePagesModal } from "@/plane-web/components/pages/modals/multiple-page-delete-modal"; +// plane web store +import { EPageStoreType } from "@/plane-web/hooks/store/use-page-store"; +// plane web hooks +import { useEditorEmbeds } from "@/plane-web/hooks/use-editor-embed"; +import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; +// store +import { TPageInstance } from "@/store/pages/base-page"; +import { PageEditorHeaderRoot } from "./header"; + +// Add a CSS keyframe animation +export type TEditorBodyConfig = { + fileHandler: TFileHandler; +}; + +// Define the structure of action-specific data +export type TEditorBodyHandlers = { + fetchEntity: (payload: TSearchEntityRequestPayload) => Promise; + getRedirectionLink: (pageId?: string) => string; +}; + +type Props = { + config: TEditorBodyConfig; + editorReady: boolean; + editorForwardRef: React.RefObject; + handleConnectionStatus: Dispatch>; + handleEditorReady: (status: boolean) => void; + handlers: TEditorBodyHandlers; + page: TPageInstance; + webhookConnectionParams: TWebhookConnectionQueryParams; + projectId?: string; + workspaceSlug: string; + storeType: EPageStoreType; + customRealtimeEventHandlers?: TCustomEventHandlers; +}; + +export const generateRandomColor = (input: string): HSL => { + // If input is falsy, generate a random seed string. + // The random seed is created by converting a random number to base-36 and taking a substring. + const seed = input || Math.random().toString(36).substring(2, 8); + + const uniqueId = seed.length.toString() + seed; // Unique identifier based on string length + const combinedString = uniqueId + seed; + + // Create a hash value from the combined string. + const hash = Array.from(combinedString).reduce((acc, char) => { + const charCode = char.charCodeAt(0); + return (acc << 5) - acc + charCode; + }, 0); + + // Derive the HSL values from the hash. + const hue = Math.abs(hash % 360); + const saturation = 70; // Maintains a good amount of color + const lightness = 70; // Increased lightness for a pastel look + + return { h: hue, s: saturation, l: lightness }; +}; + +export const PageEditorBody: React.FC = observer((props) => { + const { + config, + editorForwardRef, + handleConnectionStatus, + handleEditorReady, + handlers, + page, + storeType, + webhookConnectionParams, + projectId, + workspaceSlug, + customRealtimeEventHandlers, + } = props; + // states + const [isDescriptionEmpty, setIsDescriptionEmpty] = useState(false); + const [isVisible, setIsVisible] = useState(false); + const [deletePageModal, setDeletePageModal] = useState({ + visible: false, + pages: [page], + }); + // refs + const titleEditorRef = useRef(null); + // store hooks + const { data: currentUser } = useUser(); + const { + data: { is_smooth_cursor_enabled }, + } = useUserProfile(); + const { getWorkspaceBySlug } = useWorkspace(); + const { getUserDetails } = useMember(); + // derived values + const { id: pageId, isContentEditable, editorRef, setSyncingStatus } = page; + const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? ""; + const isTitleEmpty = !page.name || page.name.trim() === ""; + // Simple animation effect that triggers on mount + useEffect(() => { + const timer = setTimeout(() => { + setIsVisible(true); + }, 100); // Slightly longer delay for smoother coordination + + return () => clearTimeout(timer); + }, []); + + // all editor embeds + const { embedProps } = useEditorEmbeds({ + fetchEmbedSuggestions: handlers.fetchEntity, + getRedirectionLink: handlers.getRedirectionLink, + workspaceSlug, + page, + storeType, + setDeletePageModal, + }); + // use editor mention + const { fetchMentions } = useEditorMention({ + searchEntity: handlers.fetchEntity, + }); + // editor flaggings + const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString(), storeType); + // page filters + const { fontSize, fontStyle, isFullWidth } = usePageFilters(); + // derived values + const displayConfig: TDisplayConfig = useMemo( + () => ({ + fontSize, + fontStyle, + wideLayout: isFullWidth, + }), + [fontSize, fontStyle, isFullWidth] + ); + + // Use the new hook to handle page events + const { updatePageProperties } = useRealtimePageEvents({ + storeType, + page, + getUserDetails, + customRealtimeEventHandlers, + handlers, + }); + + // Set syncing status on initial render + useEffect(() => { + setSyncingStatus("syncing"); + }, [setSyncingStatus]); + + const getAIMenu = useCallback( + ({ isOpen, onClose }: TAIMenuProps) => ( + + ), + [editorRef, workspaceId, workspaceSlug] + ); + + const handleServerConnect = useCallback(() => { + handleConnectionStatus(false); + }, [handleConnectionStatus]); + + const handleServerError = useCallback(() => { + handleConnectionStatus(true); + setSyncingStatus("error"); + }, [handleConnectionStatus, setSyncingStatus]); + + const handleServerSynced = useCallback(() => { + setSyncingStatus("synced"); + }, [setSyncingStatus]); + + const serverHandler: TServerHandler = useMemo( + () => ({ + onConnect: handleServerConnect, + onServerError: handleServerError, + onServerSynced: handleServerSynced, + }), + [handleServerConnect, handleServerError, handleServerSynced] + ); + + const realtimeConfig: TRealtimeConfig | undefined = useMemo(() => { + // Construct the WebSocket Collaboration URL + try { + const LIVE_SERVER_BASE_URL = LIVE_BASE_URL?.trim() || window.location.origin; + const WS_LIVE_URL = new URL(LIVE_SERVER_BASE_URL); + const isSecureEnvironment = window.location.protocol === "https:"; + WS_LIVE_URL.protocol = isSecureEnvironment ? "wss" : "ws"; + WS_LIVE_URL.pathname = `${LIVE_BASE_PATH}/collaboration`; + // Construct realtime config + return { + url: WS_LIVE_URL.toString(), + queryParams: webhookConnectionParams, + }; + } catch (error) { + console.error("Error creating realtime config", error); + return undefined; + } + }, [webhookConnectionParams]); + + const userConfig = useMemo( + () => ({ + id: currentUser?.id ?? "", + name: currentUser?.display_name ?? "", + color: hslToHex(generateRandomColor(currentUser?.id ?? "")), + }), + [currentUser?.display_name, currentUser?.id] + ); + + const blockWidthClassName = cn( + "block bg-transparent w-full max-w-[720px] mx-auto transition-all duration-200 ease-in-out", + { + "max-w-[1152px]": isFullWidth, + } + ); + + const isPageLoading = pageId === undefined || !realtimeConfig; + if (isPageLoading) return ; + + return ( + +
    + {/* table of content */} +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    + +
    +
    + + { + const res = await fetchMentions(query); + if (!res) throw new Error("Failed in fetching mentions"); + return res; + }, + renderComponent: (props) => , + getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? "" }), + }} + onChange={(_json, html) => { + setIsDescriptionEmpty(isCommentEmpty(html)); + }} + updatePageProperties={updatePageProperties} + embedHandler={embedProps} + pageRestorationInProgress={page.restoration.inProgress} + realtimeConfig={realtimeConfig} + serverHandler={serverHandler} + user={userConfig} + disabledExtensions={disabledExtensions} + aiHandler={{ + menu: getAIMenu, + }} + /> +
    +
    + { + setDeletePageModal({ visible: false, pages: [] }); + }} + pages={deletePageModal.pages} + storeType={storeType} + /> +
    + ); +}); diff --git a/dev-wiki/core/components/pages/editor/header/index.ts b/dev-wiki/core/components/pages/editor/header/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/dev-wiki/core/components/pages/editor/header/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/dev-wiki/core/components/pages/editor/header/logo-picker.tsx b/dev-wiki/core/components/pages/editor/header/logo-picker.tsx new file mode 100644 index 0000000000..6770466750 --- /dev/null +++ b/dev-wiki/core/components/pages/editor/header/logo-picker.tsx @@ -0,0 +1,53 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { EmojiIconPicker, EmojiIconPickerTypes } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { Logo } from "@/components/common"; +// store +import { TPageInstance } from "@/store/pages/base-page"; + +type Props = { + className?: string; + page: TPageInstance; +}; + +export const PageEditorHeaderLogoPicker: React.FC = observer((props) => { + const { className, page } = props; + // states + const [isLogoPickerOpen, setIsLogoPickerOpen] = useState(false); + // derived values + const { logo_props, isContentEditable, updatePageLogo } = page; + const isLogoSelected = !!logo_props?.in_use; + + return ( +
    + setIsLogoPickerOpen(val)} + className="flex items-center justify-center" + buttonClassName="flex items-center justify-center" + label={ +
    + {isLogoSelected && } +
    + } + onChange={updatePageLogo} + defaultIconColor={logo_props?.in_use && logo_props.in_use === "icon" ? logo_props?.icon?.color : undefined} + defaultOpen={ + logo_props?.in_use && logo_props?.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON + } + disabled={!isContentEditable} + /> +
    + ); +}); diff --git a/dev-wiki/core/components/pages/editor/header/root.tsx b/dev-wiki/core/components/pages/editor/header/root.tsx new file mode 100644 index 0000000000..bc9d7fb8b0 --- /dev/null +++ b/dev-wiki/core/components/pages/editor/header/root.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +import { SmilePlus } from "lucide-react"; +// plane imports +import type { EditorTitleRefApi } from "@plane/editor"; +import { EmojiIconPicker, EmojiIconPickerTypes } from "@plane/ui"; +import { cn } from "@plane/utils"; +// plane web components +// import { PageTemplatePicker } from "@/plane-web/components/pages"; +// store +import { TPageInstance } from "@/store/pages/base-page"; +// local imports +import { PageEditorHeaderLogoPicker } from "./logo-picker"; + +type Props = { + isEditorContentEmpty: boolean; + isPageLoading: boolean; + page: TPageInstance; + projectId: string | undefined; + titleEditorRef: React.RefObject; + workspaceSlug: string; +}; + +export const PageEditorHeaderRoot: React.FC = observer((props) => { + const { isEditorContentEmpty, isPageLoading, page, projectId, titleEditorRef, workspaceSlug } = props; + // states + const [isLogoPickerOpen, setIsLogoPickerOpen] = useState(false); + // derived values + const { isContentEditable, logo_props, updatePageLogo } = page; + const isLogoSelected = !!logo_props?.in_use; + + return ( + <> +
    +
    + {!isLogoSelected && ( + setIsLogoPickerOpen(val)} + className="flex items-center justify-center" + buttonClassName="flex items-center justify-center" + label={ + + } + onChange={updatePageLogo} + defaultIconColor={ + logo_props?.in_use && logo_props.in_use === "icon" ? logo_props?.icon?.color : undefined + } + defaultOpen={ + logo_props?.in_use && logo_props?.in_use === "emoji" + ? EmojiIconPickerTypes.EMOJI + : EmojiIconPickerTypes.ICON + } + disabled={!isContentEditable} + /> + )} + {/* {isEditorContentEmpty && ( */} + {/* */} + {/* )} */} +
    +
    + + + ); +}); diff --git a/dev-wiki/core/components/pages/editor/index.ts b/dev-wiki/core/components/pages/editor/index.ts new file mode 100644 index 0000000000..83c2ed4e8d --- /dev/null +++ b/dev-wiki/core/components/pages/editor/index.ts @@ -0,0 +1,6 @@ +export * from "./editor-body"; +export * from "./page-root"; +export * from "./breadcrumb-page-item"; +export * from "./summary"; +export * from "./title"; +export * from "./toolbar"; diff --git a/dev-wiki/core/components/pages/editor/page-root.tsx b/dev-wiki/core/components/pages/editor/page-root.tsx new file mode 100644 index 0000000000..f7b9baa13c --- /dev/null +++ b/dev-wiki/core/components/pages/editor/page-root.tsx @@ -0,0 +1,173 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +// editor +import { EditorRefApi } from "@plane/editor"; +// types +import { TDocumentPayload, TPage, TPageVersion, TWebhookConnectionQueryParams } from "@plane/types"; +// components +import { setToast, TOAST_TYPE } from "@plane/ui"; +import { + PageEditorToolbarRoot, + PageEditorBody, + PageVersionsOverlay, + PagesVersionEditor, + TEditorBodyHandlers, + TEditorBodyConfig, +} from "@/components/pages"; +// hooks +import { useAppRouter } from "@/hooks/use-app-router"; +import { usePageFallback } from "@/hooks/use-page-fallback"; +import { useQueryParams } from "@/hooks/use-query-params"; +// types +import { TCustomEventHandlers } from "@/hooks/use-realtime-page-events"; +// plane web hooks +import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store/use-page-store"; +// services +import { WorkspacePageVersionService } from "@/plane-web/services/page"; +// store +import { TPageInstance } from "@/store/pages/base-page"; + +export type TPageRootHandlers = { + create: (payload: Partial) => Promise | undefined>; + fetchAllVersions: (pageId: string) => Promise; + fetchDescriptionBinary: () => Promise; + fetchVersionDetails: (pageId: string, versionId: string) => Promise; + updateDescription: (document: TDocumentPayload) => Promise; +} & TEditorBodyHandlers; + +export type TPageRootConfig = TEditorBodyConfig; + +type TPageRootProps = { + config: TPageRootConfig; + handlers: TPageRootHandlers; + page: TPageInstance; + storeType: EPageStoreType; + webhookConnectionParams: TWebhookConnectionQueryParams; + projectId?: string; + workspaceSlug: string; + customRealtimeEventHandlers?: TCustomEventHandlers; +}; + +const workspacePageVersionService = new WorkspacePageVersionService(); + +export const PageRoot = observer((props: TPageRootProps) => { + const { + config, + handlers, + page, + projectId, + storeType, + webhookConnectionParams, + workspaceSlug, + customRealtimeEventHandlers, + } = props; + // states + const [editorReady, setEditorReady] = useState(false); + const [hasConnectionFailed, setHasConnectionFailed] = useState(false); + const [isVersionsOverlayOpen, setIsVersionsOverlayOpen] = useState(false); + // refs + const editorRef = useRef(null); + // router + const router = useAppRouter(); + // search params + const searchParams = useSearchParams(); + // derived values + const { isNestedPagesEnabled } = usePageStore(storeType); + const { isContentEditable, setEditorRef } = page; + // page fallback + usePageFallback({ + editorRef, + fetchPageDescription: handlers.fetchDescriptionBinary, + hasConnectionFailed, + updatePageDescription: handlers.updateDescription, + }); + // update query params + const { updateQueryParams } = useQueryParams(); + + const handleEditorReady = useCallback( + (status: boolean) => { + setEditorReady(status); + if (editorRef.current && !page.editorRef) { + setEditorRef(editorRef.current); + } + }, + [page.editorRef, setEditorRef] + ); + + useEffect(() => { + setTimeout(() => { + setEditorRef(editorRef.current); + }, 0); + }, [isContentEditable, setEditorRef]); + + const version = searchParams.get("version"); + useEffect(() => { + if (!version) { + setIsVersionsOverlayOpen(false); + return; + } + setIsVersionsOverlayOpen(true); + }, [version]); + + const handleCloseVersionsOverlay = () => { + const updatedRoute = updateQueryParams({ + paramsToRemove: ["version"], + }); + router.push(updatedRoute); + }; + + const handleRestoreVersion = async (descriptionHTML: string) => { + if (version && isNestedPagesEnabled(workspaceSlug?.toString())) { + page.setVersionToBeRestored(version, descriptionHTML); + page.setRestorationStatus(true); + setToast({ id: "restoring-version", type: TOAST_TYPE.LOADING_TOAST, title: "Restoring version..." }); + await workspacePageVersionService.restoreVersion(workspaceSlug, page.id ?? "", version ?? ""); + } else { + editorRef.current?.clearEditor(); + editorRef.current?.setEditorValue(descriptionHTML); + } + }; + const currentVersionDescription = editorRef.current?.getDocument().html; + + // reset editor ref on unmount + useEffect( + () => () => { + setEditorRef(null); + }, + [setEditorRef] + ); + + return ( + <> + + + + + ); +}); diff --git a/dev-wiki/core/components/pages/editor/summary/content-browser.tsx b/dev-wiki/core/components/pages/editor/summary/content-browser.tsx new file mode 100644 index 0000000000..e0ef271168 --- /dev/null +++ b/dev-wiki/core/components/pages/editor/summary/content-browser.tsx @@ -0,0 +1,68 @@ +import { useState, useEffect, useCallback } from "react"; +// plane editor +import { EditorRefApi, IMarking } from "@plane/editor"; +// components +import { OutlineHeading1, OutlineHeading2, OutlineHeading3 } from "./heading-components"; + +type Props = { + editorRef: EditorRefApi | null; + setSidePeekVisible?: (sidePeekState: boolean) => void; + showOutline?: boolean; +}; + +export const PageContentBrowser: React.FC = (props) => { + const { editorRef, setSidePeekVisible, showOutline = false } = props; + // states + const [headings, setHeadings] = useState([]); + + useEffect(() => { + const unsubscribe = editorRef?.onHeadingChange(setHeadings); + // for initial render of this component to get the editor headings + setHeadings(editorRef?.getHeadings() ?? []); + return () => { + if (unsubscribe) unsubscribe(); + }; + }, [editorRef]); + + const handleOnClick = useCallback( + (marking: IMarking) => { + editorRef?.scrollSummary(marking); + setSidePeekVisible?.(false); + }, + [editorRef, setSidePeekVisible] + ); + + const HeadingComponent: { + [key: number]: React.FC<{ marking: IMarking; onClick: () => void }>; + } = { + 1: OutlineHeading1, + 2: OutlineHeading2, + 3: OutlineHeading3, + }; + + return ( +
    + {headings.map((marking) => { + const Component = HeadingComponent[marking.level]; + if (!Component) return null; + if (showOutline === true) + return ( +
    + ); + return ( + handleOnClick(marking)} + /> + ); + })} +
    + ); +}; diff --git a/dev-wiki/core/components/pages/editor/summary/heading-components.tsx b/dev-wiki/core/components/pages/editor/summary/heading-components.tsx new file mode 100644 index 0000000000..c2e78dd67f --- /dev/null +++ b/dev-wiki/core/components/pages/editor/summary/heading-components.tsx @@ -0,0 +1,37 @@ +// plane editor +import type { IMarking } from "@plane/editor"; + +export type THeadingComponentProps = { + marking: IMarking; + onClick: (event: React.MouseEvent) => void; +}; + +export const OutlineHeading1 = ({ marking, onClick }: THeadingComponentProps) => ( + +); + +export const OutlineHeading2 = ({ marking, onClick }: THeadingComponentProps) => ( + +); + +export const OutlineHeading3 = ({ marking, onClick }: THeadingComponentProps) => ( + +); diff --git a/dev-wiki/core/components/pages/editor/summary/index.ts b/dev-wiki/core/components/pages/editor/summary/index.ts new file mode 100644 index 0000000000..779b785483 --- /dev/null +++ b/dev-wiki/core/components/pages/editor/summary/index.ts @@ -0,0 +1 @@ +export * from "./content-browser"; diff --git a/dev-wiki/core/components/pages/editor/title.tsx b/dev-wiki/core/components/pages/editor/title.tsx new file mode 100644 index 0000000000..5864ac5d9b --- /dev/null +++ b/dev-wiki/core/components/pages/editor/title.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +// editor +import { EditorRefApi } from "@plane/editor"; +// ui +import { TextArea } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { getPageName } from "@/helpers/page.helper"; +// hooks +import { usePageFilters } from "@/hooks/use-page-filters"; + +type Props = { + editorRef: EditorRefApi | null; + readOnly: boolean; + title: string | undefined; + updateTitle: (title: string) => void; +}; + +export const PageEditorTitle: React.FC = observer((props) => { + const { editorRef, readOnly, title, updateTitle } = props; + // states + const [isLengthVisible, setIsLengthVisible] = useState(false); + // page filters + const { fontSize } = usePageFilters(); + // ui + const titleFontClassName = cn("tracking-[-2%] font-bold", { + "text-[1.6rem] leading-[1.9rem]": fontSize === "small-font", + "text-[2rem] leading-[2.375rem]": fontSize === "large-font", + }); + + return ( +
    + {readOnly ? ( +
    + {getPageName(title)} +
    + ) : ( +
    +