From 22ae10cf8802b8b526a4ece38d7044db0927c74d Mon Sep 17 00:00:00 2001
From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com>
Date: Wed, 18 Jun 2025 21:19:30 +0530
Subject: [PATCH] [WIKI-383] feat: dev wiki app (#3411)
* dev: 1
* fix: tubro name
* fix : floating sidebar
* --wip-- [skip ci]
* wip-2
* --wip-- [skip ci]
* wip
* fix: working wiki
---------
Co-authored-by: VipinDevelops
---
dev-wiki/.env.example | 12 +
dev-wiki/.eslintignore | 3 +
dev-wiki/.eslintrc.js | 6 +
dev-wiki/.prettierignore | 5 +
dev-wiki/.prettierrc | 5 +
dev-wiki/.yarnrc.yml | 4 +
dev-wiki/Dockerfile.dev | 12 +
dev-wiki/Dockerfile.web | 112 ++
.../pages/(pageType)/archived/layout.tsx | 17 +
.../pages/(pageType)/archived/page.tsx | 8 +
.../pages/(pageType)/filters-header.tsx | 55 +
.../(pages)/pages/(pageType)/header.tsx | 130 ++
.../pages/(pageType)/private/layout.tsx | 17 +
.../(pages)/pages/(pageType)/private/page.tsx | 8 +
.../pages/(pageType)/public/layout.tsx | 17 +
.../(pages)/pages/(pageType)/public/page.tsx | 8 +
.../(pages)/pages/[pageId]/header.tsx | 231 +++
.../(pages)/pages/[pageId]/layout.tsx | 15 +
.../(pages)/pages/[pageId]/page.tsx | 193 ++
.../[workspaceSlug]/(pages)/pages/header.tsx | 48 +
.../[workspaceSlug]/(pages)/pages/layout.tsx | 52 +
.../[workspaceSlug]/(pages)/pages/page.tsx | 41 +
.../[workspaceSlug]/(pages)/pages/sidebar.tsx | 112 ++
dev-wiki/app/(all)/[workspaceSlug]/layout.tsx | 3 +
dev-wiki/app/(all)/layout.preload.tsx | 28 +
dev-wiki/app/(all)/layout.tsx | 31 +
dev-wiki/app/error.tsx | 77 +
dev-wiki/app/global-error.tsx | 17 +
dev-wiki/app/layout.tsx | 105 +
dev-wiki/app/not-found.tsx | 46 +
dev-wiki/app/provider.tsx | 53 +
.../ce/components/pages/extra-actions.tsx | 8 +
dev-wiki/ce/layouts/workspace-wrapper.tsx | 15 +
.../core/components/auth-screens/index.ts | 2 +
.../auth-screens/not-authorized-view.tsx | 37 +
.../auth-screens/workspace/index.ts | 1 +
.../auth-screens/workspace/not-a-member.tsx | 35 +
.../core/components/common/access-field.tsx | 53 +
.../common/activity/activity-block.tsx | 56 +
.../common/activity/activity-item.tsx | 32 +
.../components/common/activity/helper.tsx | 281 +++
.../core/components/common/activity/index.ts | 1 +
.../core/components/common/activity/user.tsx | 37 +
.../common/applied-filters/date.tsx | 56 +
.../common/applied-filters/index.ts | 2 +
.../common/applied-filters/members.tsx | 55 +
.../components/common/breadcrumb-link.tsx | 44 +
.../core/components/common/count-chip.tsx | 25 +
.../core/components/common/custom-error.tsx | 43 +
.../core/components/common/empty-state.tsx | 50 +
.../components/common/filters/created-at.tsx | 76 +
.../components/common/filters/created-by.tsx | 110 ++
.../core/components/common/filters/index.ts | 2 +
dev-wiki/core/components/common/index.ts | 11 +
.../common/latest-feature-block.tsx | 40 +
.../core/components/common/logo-spinner.tsx | 17 +
dev-wiki/core/components/common/logo.tsx | 95 +
.../components/common/new-empty-state.tsx | 114 ++
.../components/common/page-access-icon.tsx | 15 +
dev-wiki/core/components/common/pro-icon.tsx | 16 +
.../core/components/common/switcher-label.tsx | 27 +
dev-wiki/core/components/core/app-header.tsx | 28 +
.../components/core/content-overflow-HOC.tsx | 156 ++
.../core/components/core/content-wrapper.tsx | 16 +
.../core/filters/date-filter-modal.tsx | 170 ++
.../core/filters/date-filter-select.tsx | 59 +
.../core/components/core/filters/index.ts | 2 +
.../components/core/image-picker-popover.tsx | 389 ++++
dev-wiki/core/components/core/index.ts | 7 +
dev-wiki/core/components/core/list/index.ts | 2 +
.../core/components/core/list/list-item.tsx | 110 ++
.../core/components/core/list/list-root.tsx | 18 +
dev-wiki/core/components/core/page-title.tsx | 14 +
.../components/core/render-if-visible-HOC.tsx | 86 +
.../core/components/core/sidebar/index.ts | 2 +
.../core/sidebar/progress-chart.tsx | 69 +
.../sidebar/sidebar-menu-hamburger-toggle.tsx | 20 +
.../core/sidebar/single-progress-stats.tsx | 34 +
.../core/theme/color-picker-input.tsx | 129 ++
.../core/theme/custom-theme-selector.tsx | 243 +++
dev-wiki/core/components/core/theme/index.ts | 3 +
.../components/core/theme/theme-switch.tsx | 83 +
.../core/components/editor/embeds/index.ts | 1 +
.../editor/embeds/mentions/index.ts | 1 +
.../editor/embeds/mentions/root.tsx | 17 +
.../editor/embeds/mentions/user.tsx | 99 +
dev-wiki/core/components/editor/index.ts | 1 +
dev-wiki/core/components/editor/index.tsx | 2 +
.../core/components/empty-state/index.tsx | 107 +
dev-wiki/core/components/instance/index.ts | 2 +
.../components/instance/maintenance-view.tsx | 34 +
.../components/instance/not-ready-view.tsx | 62 +
.../properties/all-properties.tsx | 1 +
.../components/issues/issue-layouts/utils.ts | 12 +
.../components/pages/dropdowns/actions.tsx | 194 ++
.../core/components/pages/dropdowns/index.ts | 1 +
.../pages/editor/breadcrumb-page-item.tsx | 129 ++
.../components/pages/editor/editor-body.tsx | 331 ++++
.../components/pages/editor/header/index.ts | 1 +
.../pages/editor/header/logo-picker.tsx | 53 +
.../components/pages/editor/header/root.tsx | 89 +
.../core/components/pages/editor/index.ts | 6 +
.../components/pages/editor/page-root.tsx | 173 ++
.../pages/editor/summary/content-browser.tsx | 68 +
.../editor/summary/heading-components.tsx | 37 +
.../components/pages/editor/summary/index.ts | 1 +
.../core/components/pages/editor/title.tsx | 87 +
.../pages/editor/toolbar/color-dropdown.tsx | 127 ++
.../components/pages/editor/toolbar/index.ts | 5 +
.../pages/editor/toolbar/info-popover.tsx | 141 ++
.../pages/editor/toolbar/options-dropdown.tsx | 134 ++
.../components/pages/editor/toolbar/root.tsx | 48 +
.../pages/editor/toolbar/toolbar.tsx | 167 ++
.../core/components/pages/header/actions.tsx | 37 +
.../pages/header/archived-badge.tsx | 21 +
.../pages/header/copy-link-control.tsx | 76 +
.../pages/header/favorite-control.tsx | 31 +
.../components/pages/header/offline-badge.tsx | 24 +
.../components/pages/header/syncing-badge.tsx | 72 +
dev-wiki/core/components/pages/index.ts | 6 +
.../pages/list/applied-filters/index.ts | 1 +
.../pages/list/applied-filters/root.tsx | 79 +
.../pages/list/block-item-action.tsx | 92 +
.../core/components/pages/list/block-root.tsx | 55 +
dev-wiki/core/components/pages/list/block.tsx | 153 ++
.../components/pages/list/filters/index.ts | 1 +
.../components/pages/list/filters/root.tsx | 100 +
dev-wiki/core/components/pages/list/index.ts | 7 +
.../core/components/pages/list/order-by.tsx | 86 +
.../components/pages/list/search-input.tsx | 109 +
.../components/pages/list/tab-navigation.tsx | 61 +
.../core/components/pages/loaders/index.ts | 2 +
.../pages/loaders/page-content-loader.tsx | 85 +
.../components/pages/loaders/page-loader.tsx | 35 +
.../pages/modals/create-page-modal.tsx | 84 +
.../pages/modals/delete-page-modal.tsx | 90 +
.../core/components/pages/modals/index.ts | 3 +
.../components/pages/modals/page-form.tsx | 158 ++
.../core/components/pages/version/editor.tsx | 148 ++
.../core/components/pages/version/index.ts | 6 +
.../components/pages/version/main-content.tsx | 130 ++
.../core/components/pages/version/root.tsx | 72 +
.../pages/version/sidebar-list-item.tsx | 51 +
.../components/pages/version/sidebar-list.tsx | 99 +
.../components/pages/version/sidebar-root.tsx | 38 +
dev-wiki/core/components/sidebar/index.ts | 1 +
.../components/sidebar/sidebar-navigation.tsx | 29 +
dev-wiki/core/components/ui/index.ts | 1 +
.../core/components/ui/view-list-loader.tsx | 20 +
dev-wiki/core/components/user/index.ts | 1 +
.../core/components/user/user-greetings.tsx | 52 +
.../components/workspace/billing/index.ts | 1 +
.../workspace/billing/subscription.ts | 213 ++
dev-wiki/core/components/workspace/index.ts | 3 +
dev-wiki/core/components/workspace/logo.tsx | 37 +
.../workspace/sidebar/dropdown-item.tsx | 120 ++
.../components/workspace/sidebar/dropdown.tsx | 259 +++
.../sidebar/favorites/favorite-folder.tsx | 320 +++
.../common/favorite-item-drag-handle.tsx | 41 +
.../common/favorite-item-quick-action.tsx | 54 +
.../common/favorite-item-title.tsx | 39 +
.../common/favorite-item-wrapper.tsx | 34 +
.../favorite-items/common/helper.tsx | 45 +
.../favorites/favorite-items/common/index.ts | 5 +
.../sidebar/favorites/favorite-items/index.ts | 2 +
.../sidebar/favorites/favorite-items/root.tsx | 149 ++
.../sidebar/favorites/favorites-menu.tsx | 329 +++
.../sidebar/favorites/favorites.helpers.ts | 68 +
.../workspace/sidebar/favorites/index.ts | 5 +
.../sidebar/favorites/new-fav-folder.tsx | 149 ++
.../components/workspace/sidebar/index.ts | 5 +
.../sidebar/workspace-menu-header.tsx | 116 ++
.../workspace/sidebar/workspace-menu-item.tsx | 83 +
.../workspace/sidebar/workspace-menu.tsx | 106 +
dev-wiki/core/constants/editor.ts | 390 ++++
dev-wiki/core/hooks/editor/index.ts | 2 +
.../core/hooks/editor/use-editor-config.ts | 100 +
.../core/hooks/editor/use-editor-mention.tsx | 76 +
dev-wiki/core/hooks/store/index.ts | 7 +
dev-wiki/core/hooks/store/use-app-theme.ts | 10 +
dev-wiki/core/hooks/store/use-editor-asset.ts | 10 +
dev-wiki/core/hooks/store/use-favorite.ts | 10 +
dev-wiki/core/hooks/store/use-instance.ts | 10 +
dev-wiki/core/hooks/store/use-member.ts | 11 +
.../core/hooks/store/use-router-params.ts | 10 +
dev-wiki/core/hooks/store/use-workspace.ts | 11 +
dev-wiki/core/hooks/store/user/index.ts | 4 +
.../core/hooks/store/user/user-permissions.ts | 12 +
.../hooks/store/user/user-user-profile.ts | 11 +
.../hooks/store/user/user-user-settings.ts | 11 +
dev-wiki/core/hooks/store/user/user-user.ts | 11 +
dev-wiki/core/hooks/use-app-router.tsx | 4 +
dev-wiki/core/hooks/use-auto-save.tsx | 73 +
.../hooks/use-collaborative-page-actions.tsx | 84 +
dev-wiki/core/hooks/use-current-time.tsx | 17 +
dev-wiki/core/hooks/use-dropdown-key-down.tsx | 36 +
dev-wiki/core/hooks/use-keypress.tsx | 19 +
dev-wiki/core/hooks/use-local-storage.tsx | 58 +
dev-wiki/core/hooks/use-online-status.ts | 22 +
dev-wiki/core/hooks/use-page-fallback.ts | 55 +
dev-wiki/core/hooks/use-page-filters.ts | 116 ++
dev-wiki/core/hooks/use-page-operations.tsx | 216 ++
dev-wiki/core/hooks/use-platform-os.tsx | 20 +
dev-wiki/core/hooks/use-query-params.ts | 39 +
.../core/hooks/use-realtime-page-events.tsx | 291 +++
.../core/hooks/use-reload-confirmation.tsx | 61 +
.../core/hooks/use-resolved-asset-path.tsx | 26 +
dev-wiki/core/layouts/access/index.ts | 1 +
.../core/layouts/access/workspace-wrapper.tsx | 26 +
dev-wiki/core/layouts/auth-layout/index.ts | 1 +
.../layouts/auth-layout/workspace-wrapper.tsx | 196 ++
.../core/layouts/default-layout/index.tsx | 16 +
.../core/lib/n-progress/AppProgressBar.tsx | 303 +++
dev-wiki/core/lib/n-progress/index.tsx | 68 +
.../lib/n-progress/utils/getAnchorProperty.ts | 59 +
dev-wiki/core/lib/n-progress/utils/sameURL.ts | 16 +
dev-wiki/core/lib/n-progress/withSuspense.tsx | 11 +
dev-wiki/core/lib/polyfills/index.ts | 1 +
.../core/lib/polyfills/requestIdleCallback.ts | 24 +
dev-wiki/core/lib/store-context.tsx | 20 +
.../lib/wrappers/authentication-wrapper.tsx | 139 ++
dev-wiki/core/lib/wrappers/index.ts | 2 +
.../core/lib/wrappers/instance-wrapper.tsx | 42 +
dev-wiki/core/lib/wrappers/store-wrapper.tsx | 67 +
dev-wiki/core/services/ai.service.ts | 44 +
dev-wiki/core/services/api.service.ts | 57 +
dev-wiki/core/services/api_token.service.ts | 41 +
dev-wiki/core/services/auth.service.ts | 78 +
.../services/favorite/favorite.service.ts | 56 +
dev-wiki/core/services/favorite/index.ts | 1 +
dev-wiki/core/services/file-upload.service.ts | 39 +
dev-wiki/core/services/file.service.ts | 342 ++++
dev-wiki/core/services/instance.service.ts | 37 +
dev-wiki/core/services/user.service.ts | 264 +++
dev-wiki/core/services/webhook.service.ts | 60 +
dev-wiki/core/services/workspace.service.ts | 386 ++++
dev-wiki/core/store/editor/asset.store.ts | 128 ++
dev-wiki/core/store/favorite.store.ts | 375 ++++
dev-wiki/core/store/instance.store.ts | 95 +
dev-wiki/core/store/member/index.ts | 47 +
.../store/member/workspace-member.store.ts | 324 +++
dev-wiki/core/store/pages/base-page.ts | 660 +++++++
.../core/store/pages/project-page.store.ts | 2 +
dev-wiki/core/store/root.store.ts | 52 +
dev-wiki/core/store/router.store.ts | 176 ++
dev-wiki/core/store/theme.store.ts | 162 ++
dev-wiki/core/store/user/account.store.ts | 46 +
.../core/store/user/base-permissions.store.ts | 334 ++++
dev-wiki/core/store/user/index.ts | 301 +++
dev-wiki/core/store/user/profile.store.ts | 239 +++
dev-wiki/core/store/user/settings.store.ts | 101 +
.../core/store/workspace/api-token.store.ts | 114 ++
dev-wiki/core/store/workspace/home.ts | 145 ++
dev-wiki/core/store/workspace/index.ts | 273 +++
dev-wiki/core/store/workspace/link.store.ts | 127 ++
.../core/store/workspace/webhook.store.ts | 196 ++
.../ee/components/feature-flags/index.tsx | 1 +
.../feature-flags/with-feature-flag-hoc.tsx | 20 +
dev-wiki/ee/components/global/index.ts | 1 +
dev-wiki/ee/components/global/index.tsx | 0
.../ee/components/global/version-number.tsx | 20 +
dev-wiki/ee/components/instance/index.ts | 1 +
.../instance/maintenance-message.tsx | 6 +
dev-wiki/ee/components/issues/index.tsx | 2 +
.../components/pages/badge/archived-badge.tsx | 6 +
dev-wiki/ee/components/pages/badge/badge.tsx | 6 +
dev-wiki/ee/components/pages/badge/index.ts | 3 +
.../ee/components/pages/badge/moved-badge.tsx | 3 +
.../components/pages/collaborators-list.tsx | 123 ++
.../pages/editor/ai/ask-pi-menu.tsx | 112 ++
.../ee/components/pages/editor/ai/index.ts | 2 +
.../ee/components/pages/editor/ai/menu.tsx | 309 +++
.../ee/components/pages/editor/embed/index.ts | 2 +
.../editor/embed/issue-embed-upgrade-card.tsx | 34 +
.../pages/editor/embed/issue-embed.tsx | 117 ++
dev-wiki/ee/components/pages/editor/index.ts | 2 +
.../ee/components/pages/embed/content.tsx | 362 ++++
dev-wiki/ee/components/pages/embed/index.ts | 3 +
.../ee/components/pages/embed/preview.tsx | 244 +++
dev-wiki/ee/components/pages/embed/root.tsx | 33 +
.../ee/components/pages/extra-actions.tsx | 79 +
.../pages/header/collaborators-list.tsx | 10 +
.../components/pages/header/lock-control.tsx | 271 +++
dev-wiki/ee/components/pages/index.ts | 11 +
.../pages/list/block-item-action.tsx | 89 +
dev-wiki/ee/components/pages/list/block.tsx | 79 +
dev-wiki/ee/components/pages/list/index.ts | 3 +
dev-wiki/ee/components/pages/list/root.tsx | 180 ++
.../pages/modals/confirmation-modal.tsx | 69 +
.../pages/modals/create-page-modal.tsx | 73 +
.../pages/modals/delete-page-modal.tsx | 79 +
dev-wiki/ee/components/pages/modals/index.ts | 5 +
.../pages/modals/lock-page-modal.tsx | 56 +
.../modals/multiple-page-delete-modal.tsx | 134 ++
.../pages/modals/publish-page-modal.tsx | 146 ++
.../ee/components/pages/quick-actions.tsx | 112 ++
dev-wiki/ee/components/pages/sidebar/index.ts | 3 +
.../pages/sidebar/list-item-root.tsx | 354 ++++
.../ee/components/pages/sidebar/list-item.tsx | 213 ++
dev-wiki/ee/components/pages/sidebar/list.tsx | 49 +
dev-wiki/ee/components/pages/sidebar/menu.tsx | 75 +
.../pages/sidebar/quick-actions.tsx | 43 +
.../pages/sidebar/section/components/index.ts | 2 +
.../section/components/section-content.tsx | 103 +
.../section/components/section-header.tsx | 78 +
.../pages/sidebar/section/constants.ts | 19 +
.../pages/sidebar/section/hooks/index.ts | 3 +
.../section/hooks/use-section-drag-drop.ts | 56 +
.../section/hooks/use-section-pages.ts | 25 +
.../components/pages/sidebar/section/index.ts | 5 +
.../pages/sidebar/section/section-root.tsx | 350 ++++
.../components/pages/sidebar/section/types.ts | 50 +
.../ee/components/pages/version/editor.tsx | 113 ++
dev-wiki/ee/components/pages/version/index.ts | 1 +
.../pages/workspace-pages-upgrade.tsx | 41 +
.../ee/components/sidebar/app-switcher.tsx | 84 +
dev-wiki/ee/components/sidebar/index.ts | 1 +
dev-wiki/ee/components/workspace-disabled.tsx | 97 +
.../ee/components/workspace/edition-badge.tsx | 23 +
dev-wiki/ee/components/workspace/index.ts | 1 +
dev-wiki/ee/components/workspace/index.tsx | 1 +
.../ee/components/workspace/sidebar/index.tsx | 1 +
dev-wiki/ee/constants/ai.ts | 8 +
dev-wiki/ee/constants/editor.ts | 11 +
dev-wiki/ee/constants/index.ts | 1 +
dev-wiki/ee/constants/sidebar-favorites.ts | 46 +
dev-wiki/ee/helpers/dashboard.helper.ts | 53 +
dev-wiki/ee/helpers/instance.helper.ts | 7 +
.../ee/hooks/context/use-project-creation.ts | 1 +
dev-wiki/ee/hooks/store/index.ts | 7 +
dev-wiki/ee/hooks/store/use-feature-flags.ts | 11 +
dev-wiki/ee/hooks/store/use-flag.ts | 15 +
dev-wiki/ee/hooks/store/use-page-store.ts | 25 +
dev-wiki/ee/hooks/store/use-page.ts | 23 +
dev-wiki/ee/hooks/store/use-publish-page.ts | 11 +
.../store/use-self-hosted-subscription.ts | 11 +
.../ee/hooks/store/use-workspace-features.ts | 11 +
.../hooks/use-additional-editor-mention.tsx | 41 +
.../use-additional-favorite-item-details.ts | 37 +
dev-wiki/ee/hooks/use-editor-embed.tsx | 197 ++
dev-wiki/ee/hooks/use-editor-flagging.ts | 36 +
dev-wiki/ee/hooks/use-file-size.ts | 34 +
dev-wiki/ee/hooks/use-issue-embed.tsx | 82 +
dev-wiki/ee/hooks/use-page-actions-menu.tsx | 128 ++
dev-wiki/ee/hooks/use-page-flag.ts | 20 +
.../ee/hooks/use-workspace-subscription.ts | 11 +
dev-wiki/ee/layouts/workspace-wrapper.tsx | 69 +
dev-wiki/ee/services/feature-flag.service.ts | 26 +
dev-wiki/ee/services/index.ts | 2 +
dev-wiki/ee/services/page/index.ts | 2 +
.../ee/services/page/publish-page.service.ts | 100 +
.../page/workspace-page-version.service.ts | 36 +
.../services/page/workspace-page.service.ts | 175 ++
dev-wiki/ee/services/payment.service.ts | 130 ++
.../self-hosted-subscription.service.ts | 88 +
.../ee/services/workspace-feature.service.ts | 50 +
dev-wiki/ee/services/workspace.service.ts | 63 +
.../feature-flags/feature-flags.store.ts | 64 +
dev-wiki/ee/store/pages/publish-page.store.ts | 130 ++
.../ee/store/pages/workspace-page.store.ts | 725 +++++++
dev-wiki/ee/store/pages/workspace-page.ts | 237 +++
dev-wiki/ee/store/root.store.ts | 45 +
.../self-hosted-subscription.store.ts | 159 ++
.../store/subscription/subscription.store.ts | 278 +++
dev-wiki/ee/store/user/permission.store.ts | 52 +
dev-wiki/ee/store/workspace-feature.store.ts | 133 ++
dev-wiki/ee/types/app.d.ts | 9 +
dev-wiki/ee/types/index.ts | 1 +
dev-wiki/ee/types/pages.d.ts | 8 +
dev-wiki/ee/types/self-hosted-subscription.ts | 14 +
dev-wiki/ee/types/workspace-feature.ts | 22 +
dev-wiki/google.d.ts | 91 +
dev-wiki/helpers/array.helper.ts | 119 ++
dev-wiki/helpers/authentication.helper.tsx | 450 +++++
dev-wiki/helpers/color.helper.ts | 144 ++
dev-wiki/helpers/common.helper.ts | 45 +
dev-wiki/helpers/date-time.helper.ts | 477 +++++
dev-wiki/helpers/editor.helper.ts | 128 ++
dev-wiki/helpers/emoji.helper.tsx | 77 +
dev-wiki/helpers/file.helper.ts | 96 +
dev-wiki/helpers/filter.helper.ts | 98 +
dev-wiki/helpers/page.helper.ts | 85 +
dev-wiki/helpers/string.helper.ts | 254 +++
dev-wiki/helpers/tab-indices.helper.ts | 10 +
dev-wiki/helpers/theme.helper.ts | 123 ++
dev-wiki/helpers/user.helper.ts | 12 +
dev-wiki/manifest.json | 32 +
dev-wiki/next-env.d.ts | 5 +
dev-wiki/next.config.js | 133 ++
dev-wiki/null | 0
dev-wiki/package.json | 109 +
dev-wiki/postcss.config.js | 2 +
.../.well-known/apple-app-site-association | 19 +
dev-wiki/public/.well-known/assetlinks.json | 32 +
dev-wiki/public/404.svg | 17 +
dev-wiki/public/animated-icons/uploading.json | 1 +
dev-wiki/public/attachment/audio-icon.png | Bin 0 -> 10369 bytes
dev-wiki/public/attachment/css-icon.png | Bin 0 -> 12703 bytes
dev-wiki/public/attachment/csv-icon.png | Bin 0 -> 9681 bytes
dev-wiki/public/attachment/default-icon.png | Bin 0 -> 6134 bytes
dev-wiki/public/attachment/doc-icon.png | Bin 0 -> 15175 bytes
dev-wiki/public/attachment/excel-icon.png | Bin 0 -> 6708 bytes
dev-wiki/public/attachment/figma-icon.png | Bin 0 -> 15614 bytes
dev-wiki/public/attachment/html-icon.png | Bin 0 -> 6665 bytes
dev-wiki/public/attachment/img-icon.png | Bin 0 -> 10447 bytes
dev-wiki/public/attachment/jpg-icon.png | Bin 0 -> 12088 bytes
dev-wiki/public/attachment/js-icon.png | Bin 0 -> 10992 bytes
dev-wiki/public/attachment/pdf-icon.png | Bin 0 -> 13038 bytes
dev-wiki/public/attachment/png-icon.png | Bin 0 -> 12486 bytes
dev-wiki/public/attachment/rar-icon.png | Bin 0 -> 10465 bytes
dev-wiki/public/attachment/svg-icon.png | Bin 0 -> 12899 bytes
dev-wiki/public/attachment/txt-icon.png | Bin 0 -> 11142 bytes
dev-wiki/public/attachment/video-icon.png | Bin 0 -> 11673 bytes
dev-wiki/public/attachment/zip-icon.png | Bin 0 -> 7217 bytes
dev-wiki/public/auth/access-denied.svg | 49 +
.../public/auth/background-pattern-dark.svg | 68 +
dev-wiki/public/auth/background-pattern.svg | 68 +
.../public/auth/project-not-authorized.svg | 44 +
dev-wiki/public/auth/unauthorized.svg | 44 +
.../public/auth/workspace-not-authorized.svg | 44 +
dev-wiki/public/emoji/project-emoji.svg | 4 +
.../active-cycle/assignee-dark.webp | Bin 0 -> 1188 bytes
.../active-cycle/assignee-light.webp | Bin 0 -> 1156 bytes
.../empty-state/active-cycle/chart-dark.webp | Bin 0 -> 1176 bytes
.../empty-state/active-cycle/chart-light.webp | Bin 0 -> 1154 bytes
.../empty-state/active-cycle/cycle-dark.webp | Bin 0 -> 1360 bytes
.../empty-state/active-cycle/cycle-light.webp | Bin 0 -> 1246 bytes
.../empty-state/active-cycle/label-dark.webp | Bin 0 -> 1138 bytes
.../empty-state/active-cycle/label-light.webp | Bin 0 -> 1186 bytes
.../active-cycle/priority-dark.webp | Bin 0 -> 1052 bytes
.../active-cycle/priority-light.webp | Bin 0 -> 1096 bytes
.../active-cycle/progress-dark.webp | Bin 0 -> 1094 bytes
.../active-cycle/progress-light.webp | Bin 0 -> 1172 bytes
.../all-issues/all-issues-dark.webp | Bin 0 -> 85642 bytes
.../all-issues/all-issues-light.webp | Bin 0 -> 71872 bytes
.../empty-state/all-issues/assigned-dark.webp | Bin 0 -> 70338 bytes
.../all-issues/assigned-light.webp | Bin 0 -> 72084 bytes
.../empty-state/all-issues/created-dark.webp | Bin 0 -> 70004 bytes
.../empty-state/all-issues/created-light.webp | Bin 0 -> 72328 bytes
.../all-issues/custom-view-dark.webp | Bin 0 -> 70366 bytes
.../all-issues/custom-view-light.webp | Bin 0 -> 72264 bytes
.../all-issues/no-project-dark.webp | Bin 0 -> 85516 bytes
.../all-issues/no-project-light.webp | Bin 0 -> 68070 bytes
.../all-issues/subscribed-dark.webp | Bin 0 -> 70354 bytes
.../all-issues/subscribed-light.webp | Bin 0 -> 72268 bytes
.../analytics/empty-chart-area-dark.webp | Bin 0 -> 2720 bytes
.../analytics/empty-chart-area-light.webp | Bin 0 -> 694 bytes
.../analytics/empty-chart-bar-dark.webp | Bin 0 -> 2508 bytes
.../analytics/empty-chart-bar-light.webp | Bin 0 -> 512 bytes
.../analytics/empty-chart-radar-dark.webp | Bin 0 -> 3076 bytes
.../analytics/empty-chart-radar-light.webp | Bin 0 -> 716 bytes
.../analytics/empty-grid-background-dark.webp | Bin 0 -> 35070 bytes
.../empty-grid-background-light.webp | Bin 0 -> 2576 bytes
.../analytics/empty-table-dark.webp | Bin 0 -> 3280 bytes
.../analytics/empty-table-light.webp | Bin 0 -> 862 bytes
dev-wiki/public/empty-state/api-token.svg | 49 +
.../archived/empty-cycles-dark.webp | Bin 0 -> 47726 bytes
.../archived/empty-cycles-light.webp | Bin 0 -> 58406 bytes
.../archived/empty-issues-dark.webp | Bin 0 -> 54934 bytes
.../archived/empty-issues-light.webp | Bin 0 -> 65014 bytes
.../archived/empty-modules-dark.webp | Bin 0 -> 43128 bytes
.../archived/empty-modules-light.webp | Bin 0 -> 45046 bytes
.../customers/customer-upgrade-dark.webp | Bin 0 -> 26688 bytes
.../customers/customer-upgrade-light.webp | Bin 0 -> 27060 bytes
.../public/empty-state/customers/customer.svg | 3 +
.../customers/customers-disabled-dark.webp | Bin 0 -> 24700 bytes
.../customers/customers-disabled-light.webp | Bin 0 -> 26142 bytes
.../empty-state/customers/detail-dark.svg | 42 +
.../empty-state/customers/detail-light.svg | 40 +
.../customers/request-search-dark.svg | 39 +
.../customers/request-search-light.svg | 39 +
.../customers/search-empty-dark.svg | 39 +
.../customers/search-empty-light.svg | 39 +
.../cycle-issues/calendar-dark-resp.webp | Bin 0 -> 37114 bytes
.../cycle-issues/calendar-dark.webp | Bin 0 -> 57398 bytes
.../cycle-issues/calendar-light-resp.webp | Bin 0 -> 39002 bytes
.../cycle-issues/calendar-light.webp | Bin 0 -> 60846 bytes
.../cycle-issues/gantt_chart-dark-resp.webp | Bin 0 -> 44260 bytes
.../cycle-issues/gantt_chart-dark.webp | Bin 0 -> 69388 bytes
.../cycle-issues/gantt_chart-light-resp.webp | Bin 0 -> 44350 bytes
.../cycle-issues/gantt_chart-light.webp | Bin 0 -> 75124 bytes
.../cycle-issues/kanban-dark-resp.webp | Bin 0 -> 58980 bytes
.../empty-state/cycle-issues/kanban-dark.webp | Bin 0 -> 93938 bytes
.../cycle-issues/kanban-light-resp.webp | Bin 0 -> 60980 bytes
.../cycle-issues/kanban-light.webp | Bin 0 -> 100208 bytes
.../cycle-issues/list-dark-resp.webp | Bin 0 -> 47436 bytes
.../empty-state/cycle-issues/list-dark.webp | Bin 0 -> 75056 bytes
.../cycle-issues/list-light-resp.webp | Bin 0 -> 48962 bytes
.../empty-state/cycle-issues/list-light.webp | Bin 0 -> 79588 bytes
.../cycle-issues/spreadsheet-dark-resp.webp | Bin 0 -> 50896 bytes
.../cycle-issues/spreadsheet-dark.webp | Bin 0 -> 83280 bytes
.../cycle-issues/spreadsheet-light-resp.webp | Bin 0 -> 52632 bytes
.../cycle-issues/spreadsheet-light.webp | Bin 0 -> 84346 bytes
dev-wiki/public/empty-state/cycle.svg | 21 +
.../public/empty-state/cycle/active-dark.webp | Bin 0 -> 70616 bytes
.../empty-state/cycle/active-light.webp | Bin 0 -> 71370 bytes
.../public/empty-state/cycle/all-filters.svg | 42 +
.../empty-state/cycle/completed-dark.webp | Bin 0 -> 79182 bytes
.../empty-state/cycle/completed-light.webp | Bin 0 -> 85086 bytes
.../cycle/completed-no-issues-dark.webp | Bin 0 -> 88100 bytes
.../cycle/completed-no-issues-light.webp | Bin 0 -> 90250 bytes
.../public/empty-state/cycle/draft-dark.webp | Bin 0 -> 67770 bytes
.../public/empty-state/cycle/draft-light.webp | Bin 0 -> 71682 bytes
.../public/empty-state/cycle/name-filter.svg | 41 +
.../empty-state/cycle/upcoming-dark.webp | Bin 0 -> 77842 bytes
.../empty-state/cycle/upcoming-light.webp | Bin 0 -> 83712 bytes
.../dashboard/dark/completed-issues.svg | 5 +
.../dashboard/dark/issues-by-priority.svg | 6 +
.../dashboard/dark/issues-by-state-group.svg | 24 +
.../dashboard/dark/overdue-issues.svg | 9 +
.../dashboard/dark/recent-activity.svg | 6 +
.../dashboard/dark/recent-collaborators-1.svg | 6 +
.../dashboard/dark/recent-collaborators-2.svg | 6 +
.../dashboard/dark/recent-collaborators-3.svg | 6 +
.../dashboard/dark/upcoming-issues.svg | 13 +
.../dashboard/light/completed-issues.svg | 5 +
.../dashboard/light/issues-by-priority.svg | 6 +
.../dashboard/light/issues-by-state-group.svg | 27 +
.../dashboard/light/overdue-issues.svg | 9 +
.../dashboard/light/recent-activity.svg | 6 +
.../light/recent-collaborators-1.svg | 7 +
.../light/recent-collaborators-2.svg | 7 +
.../light/recent-collaborators-3.svg | 7 +
.../dashboard/light/upcoming-issues.svg | 13 +
.../empty-state/dashboard/widgets-dark.webp | Bin 0 -> 78084 bytes
.../empty-state/dashboard/widgets-light.webp | Bin 0 -> 67128 bytes
.../empty-state/dashboard_empty_project.webp | Bin 0 -> 31886 bytes
.../dashboards/feature-flag/inside-dark.webp | Bin 0 -> 75944 bytes
.../dashboards/feature-flag/inside-light.webp | Bin 0 -> 76988 bytes
.../dashboards/feature-flag/outside-dark.webp | Bin 0 -> 35934 bytes
.../feature-flag/outside-light.webp | Bin 0 -> 36022 bytes
.../empty-state/dashboards/list-dark.webp | Bin 0 -> 92992 bytes
.../empty-state/dashboards/list-light.webp | Bin 0 -> 85602 bytes
.../dashboards/list-search-dark.webp | Bin 0 -> 64116 bytes
.../dashboards/list-search-light.webp | Bin 0 -> 77146 bytes
.../widgets/charts/area_chart-dark.webp | Bin 0 -> 2084 bytes
.../widgets/charts/area_chart-light.webp | Bin 0 -> 2388 bytes
.../widgets/charts/bar_chart-dark.webp | Bin 0 -> 2096 bytes
.../widgets/charts/bar_chart-light.webp | Bin 0 -> 2380 bytes
.../widgets/charts/donut_chart-dark.webp | Bin 0 -> 2528 bytes
.../widgets/charts/donut_chart-light.webp | Bin 0 -> 3138 bytes
.../widgets/charts/line_chart-dark.webp | Bin 0 -> 2126 bytes
.../widgets/charts/line_chart-light.webp | Bin 0 -> 2442 bytes
.../widgets/charts/number-dark.webp | Bin 0 -> 2682 bytes
.../widgets/charts/number-light.webp | Bin 0 -> 3594 bytes
.../widgets/charts/pie_chart-dark.webp | Bin 0 -> 2312 bytes
.../widgets/charts/pie_chart-light.webp | Bin 0 -> 2772 bytes
.../dashboards/widgets/list-dark.webp | Bin 0 -> 61964 bytes
.../dashboards/widgets/list-light.webp | Bin 0 -> 75226 bytes
.../disabled-feature/cycles-dark.webp | Bin 0 -> 71882 bytes
.../disabled-feature/cycles-light.webp | Bin 0 -> 74068 bytes
.../disabled-feature/intake-dark.webp | Bin 0 -> 83506 bytes
.../disabled-feature/intake-light.webp | Bin 0 -> 87120 bytes
.../disabled-feature/modules-dark.webp | Bin 0 -> 64292 bytes
.../disabled-feature/modules-light.webp | Bin 0 -> 67546 bytes
.../disabled-feature/pages-dark.webp | Bin 0 -> 83862 bytes
.../disabled-feature/pages-light.webp | Bin 0 -> 74942 bytes
.../disabled-feature/views-dark.webp | Bin 0 -> 55062 bytes
.../disabled-feature/views-light.webp | Bin 0 -> 55700 bytes
.../draft/draft-issues-empty-dark.webp | Bin 0 -> 53896 bytes
.../draft/draft-issues-empty-light.webp | Bin 0 -> 57020 bytes
.../empty-filters/calendar-dark.webp | Bin 0 -> 64996 bytes
.../empty-filters/calendar-light.webp | Bin 0 -> 66142 bytes
.../empty-filters/gantt_chart-dark.webp | Bin 0 -> 71714 bytes
.../empty-filters/gantt_chart-light.webp | Bin 0 -> 72198 bytes
.../empty-filters/kanban-dark.webp | Bin 0 -> 98618 bytes
.../empty-filters/kanban-light.webp | Bin 0 -> 100864 bytes
.../empty-state/empty-filters/list-dark.webp | Bin 0 -> 79200 bytes
.../empty-state/empty-filters/list-light.webp | Bin 0 -> 80098 bytes
.../empty-filters/spreadsheet-dark.webp | Bin 0 -> 87120 bytes
.../empty-filters/spreadsheet-light.webp | Bin 0 -> 87834 bytes
.../empty-state/empty-updates-light.png | Bin 0 -> 104151 bytes
.../public/empty-state/empty_analytics.webp | Bin 0 -> 12182 bytes
.../public/empty-state/empty_bar_graph.svg | 5 +
dev-wiki/public/empty-state/empty_cycles.webp | Bin 0 -> 27484 bytes
dev-wiki/public/empty-state/empty_graph.svg | 10 +
dev-wiki/public/empty-state/empty_issues.webp | Bin 0 -> 60102 bytes
dev-wiki/public/empty-state/empty_label.svg | 4 +
dev-wiki/public/empty-state/empty_members.svg | 13 +
.../public/empty-state/empty_modules.webp | Bin 0 -> 18618 bytes
dev-wiki/public/empty-state/empty_page.png | Bin 0 -> 62834 bytes
.../public/empty-state/empty_project.webp | Bin 0 -> 35082 bytes
dev-wiki/public/empty-state/empty_users.svg | 3 +
dev-wiki/public/empty-state/empty_view.webp | Bin 0 -> 30928 bytes
.../public/empty-state/epics/epics-dark.webp | Bin 0 -> 48054 bytes
.../public/empty-state/epics/epics-light.webp | Bin 0 -> 50384 bytes
.../empty-state/epics/settings-dark.png | Bin 0 -> 44713 bytes
.../empty-state/epics/settings-light.png | Bin 0 -> 42955 bytes
.../public/empty-state/estimates/dark.svg | 19 +
.../public/empty-state/estimates/light.svg | 19 +
.../initiatives/initiatives-dark.webp | Bin 0 -> 73388 bytes
.../initiatives/initiatives-light.webp | Bin 0 -> 64678 bytes
.../empty-state/initiatives/upgrade-dark.webp | Bin 0 -> 14740 bytes
.../initiatives/upgrade-light.webp | Bin 0 -> 15620 bytes
.../empty-state/intake/filter-issue-dark.webp | Bin 0 -> 46360 bytes
.../intake/filter-issue-light.webp | Bin 0 -> 44474 bytes
.../empty-state/intake/intake-dark-resp.webp | Bin 0 -> 54190 bytes
.../empty-state/intake/intake-dark.webp | Bin 0 -> 83506 bytes
.../empty-state/intake/intake-issue-dark.webp | Bin 0 -> 46760 bytes
.../intake/intake-issue-light.webp | Bin 0 -> 44138 bytes
.../empty-state/intake/intake-light-resp.webp | Bin 0 -> 58260 bytes
.../empty-state/intake/intake-light.webp | Bin 0 -> 87120 bytes
.../empty-state/intake/issue-detail-dark.webp | Bin 0 -> 2170 bytes
.../intake/issue-detail-light.webp | Bin 0 -> 2264 bytes
dev-wiki/public/empty-state/invitation.svg | 23 +
.../issue-types/issue-type-dark.png | Bin 0 -> 219294 bytes
.../issue-types/issue-type-dark.svg | 810 ++++++++
.../issue-types/issue-type-light.png | Bin 0 -> 244039 bytes
.../issue-types/issue-type-light.svg | 192 ++
dev-wiki/public/empty-state/issue.svg | 15 +
dev-wiki/public/empty-state/label.svg | 42 +
.../marketplace/empty-workspace-dark.png | Bin 0 -> 94046 bytes
.../marketplace/empty-workspace-light.png | Bin 0 -> 250735 bytes
.../module-issues/calendar-dark-resp.webp | Bin 0 -> 36396 bytes
.../module-issues/calendar-dark.webp | Bin 0 -> 56800 bytes
.../module-issues/calendar-light-resp.webp | Bin 0 -> 38550 bytes
.../module-issues/calendar-light.webp | Bin 0 -> 64242 bytes
.../module-issues/gantt_chart-dark-resp.webp | Bin 0 -> 43940 bytes
.../module-issues/gantt_chart-dark.webp | Bin 0 -> 68556 bytes
.../module-issues/gantt_chart-light-resp.webp | Bin 0 -> 43636 bytes
.../module-issues/gantt_chart-light.webp | Bin 0 -> 70102 bytes
.../module-issues/kanban-dark-resp.webp | Bin 0 -> 58420 bytes
.../module-issues/kanban-dark.webp | Bin 0 -> 93376 bytes
.../module-issues/kanban-light-resp.webp | Bin 0 -> 59474 bytes
.../module-issues/kanban-light.webp | Bin 0 -> 99462 bytes
.../module-issues/list-dark-resp.webp | Bin 0 -> 47000 bytes
.../empty-state/module-issues/list-dark.webp | Bin 0 -> 74544 bytes
.../module-issues/list-light-resp.webp | Bin 0 -> 48404 bytes
.../empty-state/module-issues/list-light.webp | Bin 0 -> 78682 bytes
.../module-issues/spreadsheet-dark-resp.webp | Bin 0 -> 50794 bytes
.../module-issues/spreadsheet-dark.webp | Bin 0 -> 78710 bytes
.../module-issues/spreadsheet-light-resp.webp | Bin 0 -> 51730 bytes
.../module-issues/spreadsheet-light.webp | Bin 0 -> 83500 bytes
dev-wiki/public/empty-state/module.svg | 31 +
.../public/empty-state/module/all-filters.svg | 45 +
.../public/empty-state/module/name-filter.svg | 44 +
dev-wiki/public/empty-state/notification.svg | 38 +
.../onboarding/analytics-dark.webp | Bin 0 -> 54378 bytes
.../onboarding/analytics-light.webp | Bin 0 -> 47910 bytes
.../empty-state/onboarding/archive-dark.png | Bin 0 -> 6690 bytes
.../empty-state/onboarding/archive-light.png | Bin 0 -> 6723 bytes
.../empty-state/onboarding/cycles-dark.webp | Bin 0 -> 71690 bytes
.../empty-state/onboarding/cycles-light.webp | Bin 0 -> 74002 bytes
.../onboarding/dashboard-dark.webp | Bin 0 -> 72264 bytes
.../onboarding/dashboard-light.webp | Bin 0 -> 73042 bytes
.../empty-state/onboarding/graph-dark.png | Bin 0 -> 5298 bytes
.../empty-state/onboarding/graph-light.png | Bin 0 -> 5318 bytes
.../onboarding/issues-closed-dark.png | Bin 0 -> 5846 bytes
.../onboarding/issues-closed-light.png | Bin 0 -> 5880 bytes
.../empty-state/onboarding/issues-dark.webp | Bin 0 -> 85274 bytes
.../empty-state/onboarding/issues-light.webp | Bin 0 -> 88902 bytes
.../empty-state/onboarding/members-dark.png | Bin 0 -> 7225 bytes
.../empty-state/onboarding/members-light.png | Bin 0 -> 7173 bytes
.../empty-state/onboarding/modules-dark.webp | Bin 0 -> 64280 bytes
.../empty-state/onboarding/modules-light.webp | Bin 0 -> 67462 bytes
.../onboarding/notification-dark.png | Bin 0 -> 7720 bytes
.../onboarding/notification-light.png | Bin 0 -> 7593 bytes
.../empty-state/onboarding/pages-dark.webp | Bin 0 -> 84448 bytes
.../empty-state/onboarding/pages-light.webp | Bin 0 -> 215178 bytes
.../empty-state/onboarding/projects-dark.webp | Bin 0 -> 100726 bytes
.../onboarding/projects-light.webp | Bin 0 -> 117956 bytes
.../empty-state/onboarding/search-dark.png | Bin 0 -> 8300 bytes
.../empty-state/onboarding/search-light.png | Bin 0 -> 8096 bytes
.../empty-state/onboarding/snooze-light.png | Bin 0 -> 8684 bytes
.../empty-state/onboarding/snoozed-dark.png | Bin 0 -> 9051 bytes
.../empty-state/onboarding/views-dark.webp | Bin 0 -> 55062 bytes
.../empty-state/onboarding/views-light.webp | Bin 0 -> 55700 bytes
.../workspace-active-cycles-dark.webp | Bin 0 -> 67144 bytes
.../workspace-active-cycles-light.webp | Bin 0 -> 75088 bytes
.../onboarding/workspace-invites-dark.webp | Bin 0 -> 53572 bytes
.../onboarding/workspace-invites-light.webp | Bin 0 -> 65348 bytes
.../public/empty-state/pages/all-dark.webp | Bin 0 -> 84604 bytes
.../public/empty-state/pages/all-filters.svg | 45 +
.../public/empty-state/pages/all-light.webp | Bin 0 -> 87890 bytes
.../empty-state/pages/archived-dark.webp | Bin 0 -> 52588 bytes
.../empty-state/pages/archived-light.webp | Bin 0 -> 54758 bytes
.../public/empty-state/pages/name-filter.svg | 44 +
.../empty-state/pages/private-dark.webp | Bin 0 -> 47460 bytes
.../empty-state/pages/private-light.webp | Bin 0 -> 49954 bytes
.../public/empty-state/pages/public-dark.webp | Bin 0 -> 47552 bytes
.../empty-state/pages/public-light.webp | Bin 0 -> 50602 bytes
dev-wiki/public/empty-state/pi/chat-dark.webp | Bin 0 -> 358912 bytes
.../public/empty-state/pi/chat-light.webp | Bin 0 -> 376336 bytes
.../empty-state/profile/activities-dark.webp | Bin 0 -> 602498 bytes
.../empty-state/profile/activities-light.webp | Bin 0 -> 605508 bytes
.../empty-state/profile/activity-dark.webp | Bin 0 -> 65728 bytes
.../empty-state/profile/activity-light.webp | Bin 0 -> 66542 bytes
.../empty-state/profile/assigned-dark.webp | Bin 0 -> 48858 bytes
.../empty-state/profile/assigned-light.webp | Bin 0 -> 52468 bytes
.../empty-state/profile/created-dark.webp | Bin 0 -> 48718 bytes
.../empty-state/profile/created-light.webp | Bin 0 -> 53188 bytes
.../profile/issues-by-priority-dark.webp | Bin 0 -> 204278 bytes
.../profile/issues-by-priority-light.webp | Bin 0 -> 204198 bytes
.../profile/issues-by-state-dark.webp | Bin 0 -> 217670 bytes
.../profile/issues-by-state-light.webp | Bin 0 -> 217914 bytes
.../empty-state/profile/subscribed-dark.webp | Bin 0 -> 48754 bytes
.../empty-state/profile/subscribed-light.webp | Bin 0 -> 53274 bytes
.../project-settings/estimates-dark-resp.webp | Bin 0 -> 28148 bytes
.../project-settings/estimates-dark.png | Bin 0 -> 1385116 bytes
.../project-settings/estimates-dark.webp | Bin 0 -> 50982 bytes
.../estimates-light-resp.webp | Bin 0 -> 33026 bytes
.../project-settings/estimates-light.png | Bin 0 -> 852149 bytes
.../project-settings/estimates-light.webp | Bin 0 -> 55100 bytes
.../integrations-dark-resp.webp | Bin 0 -> 37980 bytes
.../project-settings/integrations-dark.webp | Bin 0 -> 66530 bytes
.../integrations-light-resp.webp | Bin 0 -> 40660 bytes
.../project-settings/integrations-light.webp | Bin 0 -> 66340 bytes
.../project-settings/labels-dark-resp.webp | Bin 0 -> 33296 bytes
.../project-settings/labels-dark.webp | Bin 0 -> 42628 bytes
.../project-settings/labels-light-resp.webp | Bin 0 -> 28552 bytes
.../project-settings/labels-light.webp | Bin 0 -> 47320 bytes
.../project-settings/no-projects-dark.png | Bin 0 -> 299445 bytes
.../project-settings/no-projects-light.png | Bin 0 -> 261332 bytes
.../project-settings/updates-dark.png | Bin 0 -> 39599 bytes
.../project-settings/updates-light.png | Bin 0 -> 39630 bytes
dev-wiki/public/empty-state/project.svg | 23 +
.../empty-state/project/all-filters.svg | 42 +
.../empty-state/project/name-filter.svg | 41 +
.../public/empty-state/recent_activity.svg | 3 +
.../search/all-issue-view-dark.webp | Bin 0 -> 60420 bytes
.../search/all-issues-view-light.webp | Bin 0 -> 72366 bytes
.../empty-state/search/archive-dark.webp | Bin 0 -> 2292 bytes
.../empty-state/search/archive-light.webp | Bin 0 -> 2434 bytes
.../empty-state/search/comments-dark.webp | Bin 0 -> 2574 bytes
.../empty-state/search/comments-light.webp | Bin 0 -> 2744 bytes
.../empty-state/search/issues-dark.webp | Bin 0 -> 3118 bytes
.../empty-state/search/issues-light.webp | Bin 0 -> 2924 bytes
.../empty-state/search/member-dark.webp | Bin 0 -> 61754 bytes
.../empty-state/search/member-light.webp | Bin 0 -> 74852 bytes
.../empty-state/search/notification-dark.webp | Bin 0 -> 2286 bytes
.../search/notification-light.webp | Bin 0 -> 2524 bytes
.../empty-state/search/project-dark.webp | Bin 0 -> 52156 bytes
.../empty-state/search/project-light.webp | Bin 0 -> 66366 bytes
.../empty-state/search/search-dark.webp | Bin 0 -> 2330 bytes
.../empty-state/search/search-light.webp | Bin 0 -> 2644 bytes
.../empty-state/search/snooze-dark.webp | Bin 0 -> 2464 bytes
.../empty-state/search/snooze-light.webp | Bin 0 -> 2790 bytes
.../public/empty-state/search/views-dark.webp | Bin 0 -> 56238 bytes
.../empty-state/search/views-light.webp | Bin 0 -> 72318 bytes
dev-wiki/public/empty-state/state_graph.svg | 26 +
.../empty-state/stickies/stickies-dark.webp | Bin 0 -> 123458 bytes
.../empty-state/stickies/stickies-light.webp | Bin 0 -> 128460 bytes
.../stickies/stickies-search-dark.webp | Bin 0 -> 85006 bytes
.../stickies/stickies-search-light.webp | Bin 0 -> 72294 bytes
.../teams/completed-cycles-dark.webp | Bin 0 -> 73768 bytes
.../teams/completed-cycles-light.webp | Bin 0 -> 79098 bytes
.../teams/current-cycles-dark.webp | Bin 0 -> 80580 bytes
.../teams/current-cycles-light.webp | Bin 0 -> 86794 bytes
.../empty-state/teams/dark-upgrade.webp | Bin 0 -> 30980 bytes
.../public/empty-state/teams/issues-dark.webp | Bin 0 -> 128616 bytes
.../empty-state/teams/issues-light.webp | Bin 0 -> 127492 bytes
.../empty-state/teams/light-upgrade.webp | Bin 0 -> 33302 bytes
.../empty-state/teams/projects-dark.webp | Bin 0 -> 15858 bytes
.../empty-state/teams/projects-light.webp | Bin 0 -> 18454 bytes
.../public/empty-state/teams/teams-dark.webp | Bin 0 -> 95636 bytes
.../public/empty-state/teams/teams-light.webp | Bin 0 -> 98572 bytes
.../teams/upcoming-cycles-dark.webp | Bin 0 -> 77052 bytes
.../teams/upcoming-cycles-light.webp | Bin 0 -> 82030 bytes
.../public/empty-state/teams/views-dark.webp | Bin 0 -> 83482 bytes
.../public/empty-state/teams/views-light.webp | Bin 0 -> 85666 bytes
.../templates/no-templates-dark.webp | Bin 0 -> 36738 bytes
.../templates/no-templates-light.webp | Bin 0 -> 40580 bytes
.../empty-state/templates/upgrade-dark.webp | Bin 0 -> 35596 bytes
.../empty-state/templates/upgrade-light.webp | Bin 0 -> 37480 bytes
dev-wiki/public/empty-state/view.svg | 26 +
dev-wiki/public/empty-state/web-hook.svg | 49 +
.../empty-state/workflows/upgrade-dark.webp | Bin 0 -> 25648 bytes
.../empty-state/workflows/upgrade-light.webp | Bin 0 -> 28548 bytes
.../empty-state/worklogs/worklog-dark.png | Bin 0 -> 202444 bytes
.../empty-state/worklogs/worklog-light.png | Bin 0 -> 214607 bytes
.../workspace-draft/issue-dark.webp | Bin 0 -> 80938 bytes
.../workspace-draft/issue-light.webp | Bin 0 -> 91768 bytes
.../api-tokens-dark-resp.webp | Bin 0 -> 38362 bytes
.../workspace-settings/api-tokens-dark.webp | Bin 0 -> 47030 bytes
.../api-tokens-light-resp.webp | Bin 0 -> 32154 bytes
.../workspace-settings/api-tokens-light.webp | Bin 0 -> 51692 bytes
.../workspace-settings/exports-dark-resp.webp | Bin 0 -> 44288 bytes
.../workspace-settings/exports-dark.webp | Bin 0 -> 62236 bytes
.../exports-light-resp.webp | Bin 0 -> 42258 bytes
.../workspace-settings/exports-light.webp | Bin 0 -> 67094 bytes
.../workspace-settings/imports-dark-resp.webp | Bin 0 -> 46276 bytes
.../workspace-settings/imports-dark.webp | Bin 0 -> 78010 bytes
.../imports-light-resp.webp | Bin 0 -> 51614 bytes
.../workspace-settings/imports-light.webp | Bin 0 -> 81674 bytes
.../integrations-dark-resp.webp | Bin 0 -> 37980 bytes
.../workspace-settings/integrations-dark.webp | Bin 0 -> 66530 bytes
.../integrations-light-resp.webp | Bin 0 -> 40660 bytes
.../integrations-light.webp | Bin 0 -> 66340 bytes
.../webhooks-dark-resp.webp | Bin 0 -> 40072 bytes
.../workspace-settings/webhooks-dark.webp | Bin 0 -> 54446 bytes
.../webhooks-light-resp.webp | Bin 0 -> 35966 bytes
.../workspace-settings/webhooks-light.webp | Bin 0 -> 52944 bytes
.../public/favicon/android-chrome-192x192.png | Bin 0 -> 6932 bytes
.../public/favicon/android-chrome-512x512.png | Bin 0 -> 31206 bytes
dev-wiki/public/favicon/apple-touch-icon.png | Bin 0 -> 6156 bytes
dev-wiki/public/favicon/favicon-16x16.png | Bin 0 -> 260 bytes
dev-wiki/public/favicon/favicon-32x32.png | Bin 0 -> 477 bytes
dev-wiki/public/favicon/favicon.ico | Bin 0 -> 15086 bytes
dev-wiki/public/favicon/site.webmanifest | 11 +
dev-wiki/public/fonts/inter/bold-italic.ttf | Bin 0 -> 348180 bytes
dev-wiki/public/fonts/inter/bold.ttf | Bin 0 -> 344152 bytes
dev-wiki/public/fonts/inter/heavy-italic.ttf | Bin 0 -> 348712 bytes
dev-wiki/public/fonts/inter/heavy.ttf | Bin 0 -> 344820 bytes
dev-wiki/public/fonts/inter/light-italic.ttf | Bin 0 -> 347316 bytes
dev-wiki/public/fonts/inter/light.ttf | Bin 0 -> 343704 bytes
dev-wiki/public/fonts/inter/medium-italic.ttf | Bin 0 -> 346884 bytes
dev-wiki/public/fonts/inter/medium.ttf | Bin 0 -> 343200 bytes
.../public/fonts/inter/regular-italic.ttf | Bin 0 -> 346480 bytes
dev-wiki/public/fonts/inter/regular.ttf | Bin 0 -> 342680 bytes
.../public/fonts/inter/semibold-italic.ttf | Bin 0 -> 347760 bytes
dev-wiki/public/fonts/inter/semibold.ttf | Bin 0 -> 343828 bytes
dev-wiki/public/fonts/inter/thin-italic.ttf | Bin 0 -> 346916 bytes
dev-wiki/public/fonts/inter/thin.ttf | Bin 0 -> 343088 bytes
.../public/fonts/inter/ultrabold-italic.ttf | Bin 0 -> 349064 bytes
dev-wiki/public/fonts/inter/ultrabold.ttf | Bin 0 -> 345008 bytes
.../public/fonts/inter/ultralight-italic.ttf | Bin 0 -> 347452 bytes
dev-wiki/public/fonts/inter/ultralight.ttf | Bin 0 -> 343532 bytes
dev-wiki/public/icons/icon-128x128.png | Bin 0 -> 6992 bytes
dev-wiki/public/icons/icon-180x180.png | Bin 0 -> 8088 bytes
dev-wiki/public/icons/icon-192x192.png | Bin 0 -> 8605 bytes
dev-wiki/public/icons/icon-348x348.png | Bin 0 -> 15847 bytes
dev-wiki/public/icons/icon-512x512.png | Bin 0 -> 25169 bytes
dev-wiki/public/images/logo-spinner-dark.gif | Bin 0 -> 36427 bytes
dev-wiki/public/images/logo-spinner-light.gif | Bin 0 -> 35819 bytes
dev-wiki/public/instance-not-ready.webp | Bin 0 -> 23952 bytes
dev-wiki/public/instance-setup-done.webp | Bin 0 -> 90384 bytes
dev-wiki/public/logos/github-black.png | Bin 0 -> 14032 bytes
dev-wiki/public/logos/github-dark.svg | 3 +
dev-wiki/public/logos/github-square.png | Bin 0 -> 2352 bytes
dev-wiki/public/logos/github-white.png | Bin 0 -> 16559 bytes
dev-wiki/public/logos/gitlab-logo.svg | 1 +
dev-wiki/public/logos/google-logo.svg | 1 +
dev-wiki/public/logos/pi.png | Bin 0 -> 398 bytes
dev-wiki/public/mac-command.svg | 2 +
dev-wiki/public/maintenance-mode.webp | Bin 0 -> 119864 bytes
dev-wiki/public/manifest.json | 27 +
dev-wiki/public/marketplace/connect.svg | 3 +
dev-wiki/public/og-image.png | Bin 0 -> 158143 bytes
.../create-join-workspace-dark.webp | Bin 0 -> 27102 bytes
.../create-join-workspace-light.webp | Bin 0 -> 34658 bytes
dev-wiki/public/onboarding/cycles.webp | Bin 0 -> 154204 bytes
.../onboarding/invite-members-dark.webp | Bin 0 -> 20190 bytes
.../onboarding/invite-members-light.webp | Bin 0 -> 21910 bytes
dev-wiki/public/onboarding/issues.webp | Bin 0 -> 202198 bytes
dev-wiki/public/onboarding/modules.webp | Bin 0 -> 163952 bytes
.../public/onboarding/onboarding-pages.webp | Bin 0 -> 45508 bytes
dev-wiki/public/onboarding/pages.webp | Bin 0 -> 226948 bytes
.../public/onboarding/profile-setup-dark.webp | Bin 0 -> 43108 bytes
.../onboarding/profile-setup-light.webp | Bin 0 -> 56108 bytes
.../onboarding/user-personalization-dark.webp | Bin 0 -> 28146 bytes
.../user-personalization-light.webp | Bin 0 -> 30398 bytes
dev-wiki/public/onboarding/views.webp | Bin 0 -> 134924 bytes
.../black-horizontal-with-blue-logo.png | Bin 0 -> 2852 bytes
.../public/plane-logos/blue-without-text.png | Bin 0 -> 2460 bytes
.../plane-logos/plane-business-logo.svg | 12 +
.../public/plane-logos/plane-mobile-pwa.png | Bin 0 -> 73863 bytes
.../public/plane-logos/plane-one-silver.svg | 19 +
dev-wiki/public/plane-logos/plane-one.svg | 7 +
.../plane-logos/plane-pro-without-text.svg | 5 +
dev-wiki/public/plane-logos/plane-pro.svg | 8 +
.../white-horizontal-with-blue-logo.png | Bin 0 -> 2412 bytes
.../public/plane-logos/white-horizontal.svg | 17 +
dev-wiki/public/plane-takeoff.png | Bin 0 -> 47818 bytes
dev-wiki/public/projects/dark-upgrade.svg | 36 +
dev-wiki/public/projects/light-upgrade.svg | 42 +
.../public/projects/project-states-dark.webp | Bin 0 -> 53996 bytes
.../public/projects/project-states-light.webp | Bin 0 -> 47518 bytes
dev-wiki/public/projects/states-dark.svg | 46 +
dev-wiki/public/projects/states-light.svg | 46 +
dev-wiki/public/services/asana.svg | 1 +
dev-wiki/public/services/csv.svg | 12 +
dev-wiki/public/services/excel.svg | 11 +
dev-wiki/public/services/flatfile.svg | 1759 +++++++++++++++++
dev-wiki/public/services/github-dark.svg | 14 +
dev-wiki/public/services/github-light.svg | 14 +
dev-wiki/public/services/github.png | Bin 0 -> 2352 bytes
dev-wiki/public/services/github.svg | 15 +
dev-wiki/public/services/gitlab.svg | 1 +
dev-wiki/public/services/jira.svg | 15 +
dev-wiki/public/services/json.svg | 12 +
dev-wiki/public/services/linear.svg | 1 +
dev-wiki/public/services/slack.png | Bin 0 -> 19970 bytes
dev-wiki/public/site.webmanifest.json | 13 +
dev-wiki/public/sw.js | 1 +
.../upcoming-features/imports-cta-1-dark.png | Bin 0 -> 44084 bytes
.../upcoming-features/imports-cta-1-light.png | Bin 0 -> 42864 bytes
.../upcoming-features/imports-cta-2-dark.png | Bin 0 -> 9887 bytes
.../upcoming-features/imports-cta-2-light.png | Bin 0 -> 77522 bytes
.../integrations-cta-1-dark.png | Bin 0 -> 28340 bytes
.../integrations-cta-1-light.png | Bin 0 -> 29029 bytes
.../integrations-cta-2-dark.png | Bin 0 -> 59721 bytes
.../integrations-cta-2-light.png | Bin 0 -> 68848 bytes
dev-wiki/public/user.png | Bin 0 -> 99267 bytes
dev-wiki/public/users/user-1.png | Bin 0 -> 4193 bytes
dev-wiki/public/users/user-2.png | Bin 0 -> 3781 bytes
.../users/user-profile-cover-default-img.png | Bin 0 -> 76661 bytes
dev-wiki/public/users/user.png | Bin 0 -> 1419 bytes
dev-wiki/public/workbox-91d218dd.js | 1 +
.../workspace-active-cycles/cta-l-1-dark.webp | Bin 0 -> 5628 bytes
.../cta-l-1-light.webp | Bin 0 -> 28716 bytes
.../workspace-active-cycles/cta-r-1-dark.webp | Bin 0 -> 59460 bytes
.../cta-r-1-light.webp | Bin 0 -> 64318 bytes
.../workspace-active-cycles/cta-r-2-dark.webp | Bin 0 -> 207310 bytes
.../cta-r-2-light.webp | Bin 0 -> 392122 bytes
.../workspace/workspace-creation-disabled.png | Bin 0 -> 191452 bytes
.../workspace/workspace-not-available.png | Bin 0 -> 192990 bytes
dev-wiki/styles/command-pallette.css | 51 +
dev-wiki/styles/emoji.css | 52 +
dev-wiki/styles/globals.css | 1335 +++++++++++++
dev-wiki/styles/nprogress.css | 39 +
dev-wiki/styles/react-day-picker.css | 311 +++
dev-wiki/tailwind.config.js | 7 +
dev-wiki/tsconfig.json | 24 +
dev-wiki/use-font-face-observer.d.ts | 1 +
package.json | 3 +-
915 files changed, 36100 insertions(+), 1 deletion(-)
create mode 100644 dev-wiki/.env.example
create mode 100644 dev-wiki/.eslintignore
create mode 100644 dev-wiki/.eslintrc.js
create mode 100644 dev-wiki/.prettierignore
create mode 100644 dev-wiki/.prettierrc
create mode 100644 dev-wiki/.yarnrc.yml
create mode 100644 dev-wiki/Dockerfile.dev
create mode 100644 dev-wiki/Dockerfile.web
create mode 100644 dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/archived/layout.tsx
create mode 100644 dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/archived/page.tsx
create mode 100644 dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/filters-header.tsx
create mode 100644 dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/header.tsx
create mode 100644 dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/private/layout.tsx
create mode 100644 dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/private/page.tsx
create mode 100644 dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/public/layout.tsx
create mode 100644 dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/(pageType)/public/page.tsx
create mode 100644 dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/[pageId]/header.tsx
create mode 100644 dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/[pageId]/layout.tsx
create mode 100644 dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/[pageId]/page.tsx
create mode 100644 dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/header.tsx
create mode 100644 dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/layout.tsx
create mode 100644 dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/page.tsx
create mode 100644 dev-wiki/app/(all)/[workspaceSlug]/(pages)/pages/sidebar.tsx
create mode 100644 dev-wiki/app/(all)/[workspaceSlug]/layout.tsx
create mode 100644 dev-wiki/app/(all)/layout.preload.tsx
create mode 100644 dev-wiki/app/(all)/layout.tsx
create mode 100644 dev-wiki/app/error.tsx
create mode 100644 dev-wiki/app/global-error.tsx
create mode 100644 dev-wiki/app/layout.tsx
create mode 100644 dev-wiki/app/not-found.tsx
create mode 100644 dev-wiki/app/provider.tsx
create mode 100644 dev-wiki/ce/components/pages/extra-actions.tsx
create mode 100644 dev-wiki/ce/layouts/workspace-wrapper.tsx
create mode 100644 dev-wiki/core/components/auth-screens/index.ts
create mode 100644 dev-wiki/core/components/auth-screens/not-authorized-view.tsx
create mode 100644 dev-wiki/core/components/auth-screens/workspace/index.ts
create mode 100644 dev-wiki/core/components/auth-screens/workspace/not-a-member.tsx
create mode 100644 dev-wiki/core/components/common/access-field.tsx
create mode 100644 dev-wiki/core/components/common/activity/activity-block.tsx
create mode 100644 dev-wiki/core/components/common/activity/activity-item.tsx
create mode 100644 dev-wiki/core/components/common/activity/helper.tsx
create mode 100644 dev-wiki/core/components/common/activity/index.ts
create mode 100644 dev-wiki/core/components/common/activity/user.tsx
create mode 100644 dev-wiki/core/components/common/applied-filters/date.tsx
create mode 100644 dev-wiki/core/components/common/applied-filters/index.ts
create mode 100644 dev-wiki/core/components/common/applied-filters/members.tsx
create mode 100644 dev-wiki/core/components/common/breadcrumb-link.tsx
create mode 100644 dev-wiki/core/components/common/count-chip.tsx
create mode 100644 dev-wiki/core/components/common/custom-error.tsx
create mode 100644 dev-wiki/core/components/common/empty-state.tsx
create mode 100644 dev-wiki/core/components/common/filters/created-at.tsx
create mode 100644 dev-wiki/core/components/common/filters/created-by.tsx
create mode 100644 dev-wiki/core/components/common/filters/index.ts
create mode 100644 dev-wiki/core/components/common/index.ts
create mode 100644 dev-wiki/core/components/common/latest-feature-block.tsx
create mode 100644 dev-wiki/core/components/common/logo-spinner.tsx
create mode 100644 dev-wiki/core/components/common/logo.tsx
create mode 100644 dev-wiki/core/components/common/new-empty-state.tsx
create mode 100644 dev-wiki/core/components/common/page-access-icon.tsx
create mode 100644 dev-wiki/core/components/common/pro-icon.tsx
create mode 100644 dev-wiki/core/components/common/switcher-label.tsx
create mode 100644 dev-wiki/core/components/core/app-header.tsx
create mode 100644 dev-wiki/core/components/core/content-overflow-HOC.tsx
create mode 100644 dev-wiki/core/components/core/content-wrapper.tsx
create mode 100644 dev-wiki/core/components/core/filters/date-filter-modal.tsx
create mode 100644 dev-wiki/core/components/core/filters/date-filter-select.tsx
create mode 100644 dev-wiki/core/components/core/filters/index.ts
create mode 100644 dev-wiki/core/components/core/image-picker-popover.tsx
create mode 100644 dev-wiki/core/components/core/index.ts
create mode 100644 dev-wiki/core/components/core/list/index.ts
create mode 100644 dev-wiki/core/components/core/list/list-item.tsx
create mode 100644 dev-wiki/core/components/core/list/list-root.tsx
create mode 100644 dev-wiki/core/components/core/page-title.tsx
create mode 100644 dev-wiki/core/components/core/render-if-visible-HOC.tsx
create mode 100644 dev-wiki/core/components/core/sidebar/index.ts
create mode 100644 dev-wiki/core/components/core/sidebar/progress-chart.tsx
create mode 100644 dev-wiki/core/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx
create mode 100644 dev-wiki/core/components/core/sidebar/single-progress-stats.tsx
create mode 100644 dev-wiki/core/components/core/theme/color-picker-input.tsx
create mode 100644 dev-wiki/core/components/core/theme/custom-theme-selector.tsx
create mode 100644 dev-wiki/core/components/core/theme/index.ts
create mode 100644 dev-wiki/core/components/core/theme/theme-switch.tsx
create mode 100644 dev-wiki/core/components/editor/embeds/index.ts
create mode 100644 dev-wiki/core/components/editor/embeds/mentions/index.ts
create mode 100644 dev-wiki/core/components/editor/embeds/mentions/root.tsx
create mode 100644 dev-wiki/core/components/editor/embeds/mentions/user.tsx
create mode 100644 dev-wiki/core/components/editor/index.ts
create mode 100644 dev-wiki/core/components/editor/index.tsx
create mode 100644 dev-wiki/core/components/empty-state/index.tsx
create mode 100644 dev-wiki/core/components/instance/index.ts
create mode 100644 dev-wiki/core/components/instance/maintenance-view.tsx
create mode 100644 dev-wiki/core/components/instance/not-ready-view.tsx
create mode 100644 dev-wiki/core/components/issues/issue-layouts/properties/all-properties.tsx
create mode 100644 dev-wiki/core/components/issues/issue-layouts/utils.ts
create mode 100644 dev-wiki/core/components/pages/dropdowns/actions.tsx
create mode 100644 dev-wiki/core/components/pages/dropdowns/index.ts
create mode 100644 dev-wiki/core/components/pages/editor/breadcrumb-page-item.tsx
create mode 100644 dev-wiki/core/components/pages/editor/editor-body.tsx
create mode 100644 dev-wiki/core/components/pages/editor/header/index.ts
create mode 100644 dev-wiki/core/components/pages/editor/header/logo-picker.tsx
create mode 100644 dev-wiki/core/components/pages/editor/header/root.tsx
create mode 100644 dev-wiki/core/components/pages/editor/index.ts
create mode 100644 dev-wiki/core/components/pages/editor/page-root.tsx
create mode 100644 dev-wiki/core/components/pages/editor/summary/content-browser.tsx
create mode 100644 dev-wiki/core/components/pages/editor/summary/heading-components.tsx
create mode 100644 dev-wiki/core/components/pages/editor/summary/index.ts
create mode 100644 dev-wiki/core/components/pages/editor/title.tsx
create mode 100644 dev-wiki/core/components/pages/editor/toolbar/color-dropdown.tsx
create mode 100644 dev-wiki/core/components/pages/editor/toolbar/index.ts
create mode 100644 dev-wiki/core/components/pages/editor/toolbar/info-popover.tsx
create mode 100644 dev-wiki/core/components/pages/editor/toolbar/options-dropdown.tsx
create mode 100644 dev-wiki/core/components/pages/editor/toolbar/root.tsx
create mode 100644 dev-wiki/core/components/pages/editor/toolbar/toolbar.tsx
create mode 100644 dev-wiki/core/components/pages/header/actions.tsx
create mode 100644 dev-wiki/core/components/pages/header/archived-badge.tsx
create mode 100644 dev-wiki/core/components/pages/header/copy-link-control.tsx
create mode 100644 dev-wiki/core/components/pages/header/favorite-control.tsx
create mode 100644 dev-wiki/core/components/pages/header/offline-badge.tsx
create mode 100644 dev-wiki/core/components/pages/header/syncing-badge.tsx
create mode 100644 dev-wiki/core/components/pages/index.ts
create mode 100644 dev-wiki/core/components/pages/list/applied-filters/index.ts
create mode 100644 dev-wiki/core/components/pages/list/applied-filters/root.tsx
create mode 100644 dev-wiki/core/components/pages/list/block-item-action.tsx
create mode 100644 dev-wiki/core/components/pages/list/block-root.tsx
create mode 100644 dev-wiki/core/components/pages/list/block.tsx
create mode 100644 dev-wiki/core/components/pages/list/filters/index.ts
create mode 100644 dev-wiki/core/components/pages/list/filters/root.tsx
create mode 100644 dev-wiki/core/components/pages/list/index.ts
create mode 100644 dev-wiki/core/components/pages/list/order-by.tsx
create mode 100644 dev-wiki/core/components/pages/list/search-input.tsx
create mode 100644 dev-wiki/core/components/pages/list/tab-navigation.tsx
create mode 100644 dev-wiki/core/components/pages/loaders/index.ts
create mode 100644 dev-wiki/core/components/pages/loaders/page-content-loader.tsx
create mode 100644 dev-wiki/core/components/pages/loaders/page-loader.tsx
create mode 100644 dev-wiki/core/components/pages/modals/create-page-modal.tsx
create mode 100644 dev-wiki/core/components/pages/modals/delete-page-modal.tsx
create mode 100644 dev-wiki/core/components/pages/modals/index.ts
create mode 100644 dev-wiki/core/components/pages/modals/page-form.tsx
create mode 100644 dev-wiki/core/components/pages/version/editor.tsx
create mode 100644 dev-wiki/core/components/pages/version/index.ts
create mode 100644 dev-wiki/core/components/pages/version/main-content.tsx
create mode 100644 dev-wiki/core/components/pages/version/root.tsx
create mode 100644 dev-wiki/core/components/pages/version/sidebar-list-item.tsx
create mode 100644 dev-wiki/core/components/pages/version/sidebar-list.tsx
create mode 100644 dev-wiki/core/components/pages/version/sidebar-root.tsx
create mode 100644 dev-wiki/core/components/sidebar/index.ts
create mode 100644 dev-wiki/core/components/sidebar/sidebar-navigation.tsx
create mode 100644 dev-wiki/core/components/ui/index.ts
create mode 100644 dev-wiki/core/components/ui/view-list-loader.tsx
create mode 100644 dev-wiki/core/components/user/index.ts
create mode 100644 dev-wiki/core/components/user/user-greetings.tsx
create mode 100644 dev-wiki/core/components/workspace/billing/index.ts
create mode 100644 dev-wiki/core/components/workspace/billing/subscription.ts
create mode 100644 dev-wiki/core/components/workspace/index.ts
create mode 100644 dev-wiki/core/components/workspace/logo.tsx
create mode 100644 dev-wiki/core/components/workspace/sidebar/dropdown-item.tsx
create mode 100644 dev-wiki/core/components/workspace/sidebar/dropdown.tsx
create mode 100644 dev-wiki/core/components/workspace/sidebar/favorites/favorite-folder.tsx
create mode 100644 dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-drag-handle.tsx
create mode 100644 dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-quick-action.tsx
create mode 100644 dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-title.tsx
create mode 100644 dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-wrapper.tsx
create mode 100644 dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/helper.tsx
create mode 100644 dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/index.ts
create mode 100644 dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/index.ts
create mode 100644 dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/root.tsx
create mode 100644 dev-wiki/core/components/workspace/sidebar/favorites/favorites-menu.tsx
create mode 100644 dev-wiki/core/components/workspace/sidebar/favorites/favorites.helpers.ts
create mode 100644 dev-wiki/core/components/workspace/sidebar/favorites/index.ts
create mode 100644 dev-wiki/core/components/workspace/sidebar/favorites/new-fav-folder.tsx
create mode 100644 dev-wiki/core/components/workspace/sidebar/index.ts
create mode 100644 dev-wiki/core/components/workspace/sidebar/workspace-menu-header.tsx
create mode 100644 dev-wiki/core/components/workspace/sidebar/workspace-menu-item.tsx
create mode 100644 dev-wiki/core/components/workspace/sidebar/workspace-menu.tsx
create mode 100644 dev-wiki/core/constants/editor.ts
create mode 100644 dev-wiki/core/hooks/editor/index.ts
create mode 100644 dev-wiki/core/hooks/editor/use-editor-config.ts
create mode 100644 dev-wiki/core/hooks/editor/use-editor-mention.tsx
create mode 100644 dev-wiki/core/hooks/store/index.ts
create mode 100644 dev-wiki/core/hooks/store/use-app-theme.ts
create mode 100644 dev-wiki/core/hooks/store/use-editor-asset.ts
create mode 100644 dev-wiki/core/hooks/store/use-favorite.ts
create mode 100644 dev-wiki/core/hooks/store/use-instance.ts
create mode 100644 dev-wiki/core/hooks/store/use-member.ts
create mode 100644 dev-wiki/core/hooks/store/use-router-params.ts
create mode 100644 dev-wiki/core/hooks/store/use-workspace.ts
create mode 100644 dev-wiki/core/hooks/store/user/index.ts
create mode 100644 dev-wiki/core/hooks/store/user/user-permissions.ts
create mode 100644 dev-wiki/core/hooks/store/user/user-user-profile.ts
create mode 100644 dev-wiki/core/hooks/store/user/user-user-settings.ts
create mode 100644 dev-wiki/core/hooks/store/user/user-user.ts
create mode 100644 dev-wiki/core/hooks/use-app-router.tsx
create mode 100644 dev-wiki/core/hooks/use-auto-save.tsx
create mode 100644 dev-wiki/core/hooks/use-collaborative-page-actions.tsx
create mode 100644 dev-wiki/core/hooks/use-current-time.tsx
create mode 100644 dev-wiki/core/hooks/use-dropdown-key-down.tsx
create mode 100644 dev-wiki/core/hooks/use-keypress.tsx
create mode 100644 dev-wiki/core/hooks/use-local-storage.tsx
create mode 100644 dev-wiki/core/hooks/use-online-status.ts
create mode 100644 dev-wiki/core/hooks/use-page-fallback.ts
create mode 100644 dev-wiki/core/hooks/use-page-filters.ts
create mode 100644 dev-wiki/core/hooks/use-page-operations.tsx
create mode 100644 dev-wiki/core/hooks/use-platform-os.tsx
create mode 100644 dev-wiki/core/hooks/use-query-params.ts
create mode 100644 dev-wiki/core/hooks/use-realtime-page-events.tsx
create mode 100644 dev-wiki/core/hooks/use-reload-confirmation.tsx
create mode 100644 dev-wiki/core/hooks/use-resolved-asset-path.tsx
create mode 100644 dev-wiki/core/layouts/access/index.ts
create mode 100644 dev-wiki/core/layouts/access/workspace-wrapper.tsx
create mode 100644 dev-wiki/core/layouts/auth-layout/index.ts
create mode 100644 dev-wiki/core/layouts/auth-layout/workspace-wrapper.tsx
create mode 100644 dev-wiki/core/layouts/default-layout/index.tsx
create mode 100644 dev-wiki/core/lib/n-progress/AppProgressBar.tsx
create mode 100644 dev-wiki/core/lib/n-progress/index.tsx
create mode 100644 dev-wiki/core/lib/n-progress/utils/getAnchorProperty.ts
create mode 100644 dev-wiki/core/lib/n-progress/utils/sameURL.ts
create mode 100644 dev-wiki/core/lib/n-progress/withSuspense.tsx
create mode 100644 dev-wiki/core/lib/polyfills/index.ts
create mode 100644 dev-wiki/core/lib/polyfills/requestIdleCallback.ts
create mode 100644 dev-wiki/core/lib/store-context.tsx
create mode 100644 dev-wiki/core/lib/wrappers/authentication-wrapper.tsx
create mode 100644 dev-wiki/core/lib/wrappers/index.ts
create mode 100644 dev-wiki/core/lib/wrappers/instance-wrapper.tsx
create mode 100644 dev-wiki/core/lib/wrappers/store-wrapper.tsx
create mode 100644 dev-wiki/core/services/ai.service.ts
create mode 100644 dev-wiki/core/services/api.service.ts
create mode 100644 dev-wiki/core/services/api_token.service.ts
create mode 100644 dev-wiki/core/services/auth.service.ts
create mode 100644 dev-wiki/core/services/favorite/favorite.service.ts
create mode 100644 dev-wiki/core/services/favorite/index.ts
create mode 100644 dev-wiki/core/services/file-upload.service.ts
create mode 100644 dev-wiki/core/services/file.service.ts
create mode 100644 dev-wiki/core/services/instance.service.ts
create mode 100644 dev-wiki/core/services/user.service.ts
create mode 100644 dev-wiki/core/services/webhook.service.ts
create mode 100644 dev-wiki/core/services/workspace.service.ts
create mode 100644 dev-wiki/core/store/editor/asset.store.ts
create mode 100644 dev-wiki/core/store/favorite.store.ts
create mode 100644 dev-wiki/core/store/instance.store.ts
create mode 100644 dev-wiki/core/store/member/index.ts
create mode 100644 dev-wiki/core/store/member/workspace-member.store.ts
create mode 100644 dev-wiki/core/store/pages/base-page.ts
create mode 100644 dev-wiki/core/store/pages/project-page.store.ts
create mode 100644 dev-wiki/core/store/root.store.ts
create mode 100644 dev-wiki/core/store/router.store.ts
create mode 100644 dev-wiki/core/store/theme.store.ts
create mode 100644 dev-wiki/core/store/user/account.store.ts
create mode 100644 dev-wiki/core/store/user/base-permissions.store.ts
create mode 100644 dev-wiki/core/store/user/index.ts
create mode 100644 dev-wiki/core/store/user/profile.store.ts
create mode 100644 dev-wiki/core/store/user/settings.store.ts
create mode 100644 dev-wiki/core/store/workspace/api-token.store.ts
create mode 100644 dev-wiki/core/store/workspace/home.ts
create mode 100644 dev-wiki/core/store/workspace/index.ts
create mode 100644 dev-wiki/core/store/workspace/link.store.ts
create mode 100644 dev-wiki/core/store/workspace/webhook.store.ts
create mode 100644 dev-wiki/ee/components/feature-flags/index.tsx
create mode 100644 dev-wiki/ee/components/feature-flags/with-feature-flag-hoc.tsx
create mode 100644 dev-wiki/ee/components/global/index.ts
create mode 100644 dev-wiki/ee/components/global/index.tsx
create mode 100644 dev-wiki/ee/components/global/version-number.tsx
create mode 100644 dev-wiki/ee/components/instance/index.ts
create mode 100644 dev-wiki/ee/components/instance/maintenance-message.tsx
create mode 100644 dev-wiki/ee/components/issues/index.tsx
create mode 100644 dev-wiki/ee/components/pages/badge/archived-badge.tsx
create mode 100644 dev-wiki/ee/components/pages/badge/badge.tsx
create mode 100644 dev-wiki/ee/components/pages/badge/index.ts
create mode 100644 dev-wiki/ee/components/pages/badge/moved-badge.tsx
create mode 100644 dev-wiki/ee/components/pages/collaborators-list.tsx
create mode 100644 dev-wiki/ee/components/pages/editor/ai/ask-pi-menu.tsx
create mode 100644 dev-wiki/ee/components/pages/editor/ai/index.ts
create mode 100644 dev-wiki/ee/components/pages/editor/ai/menu.tsx
create mode 100644 dev-wiki/ee/components/pages/editor/embed/index.ts
create mode 100644 dev-wiki/ee/components/pages/editor/embed/issue-embed-upgrade-card.tsx
create mode 100644 dev-wiki/ee/components/pages/editor/embed/issue-embed.tsx
create mode 100644 dev-wiki/ee/components/pages/editor/index.ts
create mode 100644 dev-wiki/ee/components/pages/embed/content.tsx
create mode 100644 dev-wiki/ee/components/pages/embed/index.ts
create mode 100644 dev-wiki/ee/components/pages/embed/preview.tsx
create mode 100644 dev-wiki/ee/components/pages/embed/root.tsx
create mode 100644 dev-wiki/ee/components/pages/extra-actions.tsx
create mode 100644 dev-wiki/ee/components/pages/header/collaborators-list.tsx
create mode 100644 dev-wiki/ee/components/pages/header/lock-control.tsx
create mode 100644 dev-wiki/ee/components/pages/index.ts
create mode 100644 dev-wiki/ee/components/pages/list/block-item-action.tsx
create mode 100644 dev-wiki/ee/components/pages/list/block.tsx
create mode 100644 dev-wiki/ee/components/pages/list/index.ts
create mode 100644 dev-wiki/ee/components/pages/list/root.tsx
create mode 100644 dev-wiki/ee/components/pages/modals/confirmation-modal.tsx
create mode 100644 dev-wiki/ee/components/pages/modals/create-page-modal.tsx
create mode 100644 dev-wiki/ee/components/pages/modals/delete-page-modal.tsx
create mode 100644 dev-wiki/ee/components/pages/modals/index.ts
create mode 100644 dev-wiki/ee/components/pages/modals/lock-page-modal.tsx
create mode 100644 dev-wiki/ee/components/pages/modals/multiple-page-delete-modal.tsx
create mode 100644 dev-wiki/ee/components/pages/modals/publish-page-modal.tsx
create mode 100644 dev-wiki/ee/components/pages/quick-actions.tsx
create mode 100644 dev-wiki/ee/components/pages/sidebar/index.ts
create mode 100644 dev-wiki/ee/components/pages/sidebar/list-item-root.tsx
create mode 100644 dev-wiki/ee/components/pages/sidebar/list-item.tsx
create mode 100644 dev-wiki/ee/components/pages/sidebar/list.tsx
create mode 100644 dev-wiki/ee/components/pages/sidebar/menu.tsx
create mode 100644 dev-wiki/ee/components/pages/sidebar/quick-actions.tsx
create mode 100644 dev-wiki/ee/components/pages/sidebar/section/components/index.ts
create mode 100644 dev-wiki/ee/components/pages/sidebar/section/components/section-content.tsx
create mode 100644 dev-wiki/ee/components/pages/sidebar/section/components/section-header.tsx
create mode 100644 dev-wiki/ee/components/pages/sidebar/section/constants.ts
create mode 100644 dev-wiki/ee/components/pages/sidebar/section/hooks/index.ts
create mode 100644 dev-wiki/ee/components/pages/sidebar/section/hooks/use-section-drag-drop.ts
create mode 100644 dev-wiki/ee/components/pages/sidebar/section/hooks/use-section-pages.ts
create mode 100644 dev-wiki/ee/components/pages/sidebar/section/index.ts
create mode 100644 dev-wiki/ee/components/pages/sidebar/section/section-root.tsx
create mode 100644 dev-wiki/ee/components/pages/sidebar/section/types.ts
create mode 100644 dev-wiki/ee/components/pages/version/editor.tsx
create mode 100644 dev-wiki/ee/components/pages/version/index.ts
create mode 100644 dev-wiki/ee/components/pages/workspace-pages-upgrade.tsx
create mode 100644 dev-wiki/ee/components/sidebar/app-switcher.tsx
create mode 100644 dev-wiki/ee/components/sidebar/index.ts
create mode 100644 dev-wiki/ee/components/workspace-disabled.tsx
create mode 100644 dev-wiki/ee/components/workspace/edition-badge.tsx
create mode 100644 dev-wiki/ee/components/workspace/index.ts
create mode 100644 dev-wiki/ee/components/workspace/index.tsx
create mode 100644 dev-wiki/ee/components/workspace/sidebar/index.tsx
create mode 100644 dev-wiki/ee/constants/ai.ts
create mode 100644 dev-wiki/ee/constants/editor.ts
create mode 100644 dev-wiki/ee/constants/index.ts
create mode 100644 dev-wiki/ee/constants/sidebar-favorites.ts
create mode 100644 dev-wiki/ee/helpers/dashboard.helper.ts
create mode 100644 dev-wiki/ee/helpers/instance.helper.ts
create mode 100644 dev-wiki/ee/hooks/context/use-project-creation.ts
create mode 100644 dev-wiki/ee/hooks/store/index.ts
create mode 100644 dev-wiki/ee/hooks/store/use-feature-flags.ts
create mode 100644 dev-wiki/ee/hooks/store/use-flag.ts
create mode 100644 dev-wiki/ee/hooks/store/use-page-store.ts
create mode 100644 dev-wiki/ee/hooks/store/use-page.ts
create mode 100644 dev-wiki/ee/hooks/store/use-publish-page.ts
create mode 100644 dev-wiki/ee/hooks/store/use-self-hosted-subscription.ts
create mode 100644 dev-wiki/ee/hooks/store/use-workspace-features.ts
create mode 100644 dev-wiki/ee/hooks/use-additional-editor-mention.tsx
create mode 100644 dev-wiki/ee/hooks/use-additional-favorite-item-details.ts
create mode 100644 dev-wiki/ee/hooks/use-editor-embed.tsx
create mode 100644 dev-wiki/ee/hooks/use-editor-flagging.ts
create mode 100644 dev-wiki/ee/hooks/use-file-size.ts
create mode 100644 dev-wiki/ee/hooks/use-issue-embed.tsx
create mode 100644 dev-wiki/ee/hooks/use-page-actions-menu.tsx
create mode 100644 dev-wiki/ee/hooks/use-page-flag.ts
create mode 100644 dev-wiki/ee/hooks/use-workspace-subscription.ts
create mode 100644 dev-wiki/ee/layouts/workspace-wrapper.tsx
create mode 100644 dev-wiki/ee/services/feature-flag.service.ts
create mode 100644 dev-wiki/ee/services/index.ts
create mode 100644 dev-wiki/ee/services/page/index.ts
create mode 100644 dev-wiki/ee/services/page/publish-page.service.ts
create mode 100644 dev-wiki/ee/services/page/workspace-page-version.service.ts
create mode 100644 dev-wiki/ee/services/page/workspace-page.service.ts
create mode 100644 dev-wiki/ee/services/payment.service.ts
create mode 100644 dev-wiki/ee/services/self-hosted-subscription.service.ts
create mode 100644 dev-wiki/ee/services/workspace-feature.service.ts
create mode 100644 dev-wiki/ee/services/workspace.service.ts
create mode 100644 dev-wiki/ee/store/feature-flags/feature-flags.store.ts
create mode 100644 dev-wiki/ee/store/pages/publish-page.store.ts
create mode 100644 dev-wiki/ee/store/pages/workspace-page.store.ts
create mode 100644 dev-wiki/ee/store/pages/workspace-page.ts
create mode 100644 dev-wiki/ee/store/root.store.ts
create mode 100644 dev-wiki/ee/store/subscription/self-hosted-subscription.store.ts
create mode 100644 dev-wiki/ee/store/subscription/subscription.store.ts
create mode 100644 dev-wiki/ee/store/user/permission.store.ts
create mode 100644 dev-wiki/ee/store/workspace-feature.store.ts
create mode 100644 dev-wiki/ee/types/app.d.ts
create mode 100644 dev-wiki/ee/types/index.ts
create mode 100644 dev-wiki/ee/types/pages.d.ts
create mode 100644 dev-wiki/ee/types/self-hosted-subscription.ts
create mode 100644 dev-wiki/ee/types/workspace-feature.ts
create mode 100644 dev-wiki/google.d.ts
create mode 100644 dev-wiki/helpers/array.helper.ts
create mode 100644 dev-wiki/helpers/authentication.helper.tsx
create mode 100644 dev-wiki/helpers/color.helper.ts
create mode 100644 dev-wiki/helpers/common.helper.ts
create mode 100644 dev-wiki/helpers/date-time.helper.ts
create mode 100644 dev-wiki/helpers/editor.helper.ts
create mode 100644 dev-wiki/helpers/emoji.helper.tsx
create mode 100644 dev-wiki/helpers/file.helper.ts
create mode 100644 dev-wiki/helpers/filter.helper.ts
create mode 100644 dev-wiki/helpers/page.helper.ts
create mode 100644 dev-wiki/helpers/string.helper.ts
create mode 100644 dev-wiki/helpers/tab-indices.helper.ts
create mode 100644 dev-wiki/helpers/theme.helper.ts
create mode 100644 dev-wiki/helpers/user.helper.ts
create mode 100644 dev-wiki/manifest.json
create mode 100644 dev-wiki/next-env.d.ts
create mode 100644 dev-wiki/next.config.js
create mode 100644 dev-wiki/null
create mode 100644 dev-wiki/package.json
create mode 100644 dev-wiki/postcss.config.js
create mode 100644 dev-wiki/public/.well-known/apple-app-site-association
create mode 100644 dev-wiki/public/.well-known/assetlinks.json
create mode 100644 dev-wiki/public/404.svg
create mode 100644 dev-wiki/public/animated-icons/uploading.json
create mode 100644 dev-wiki/public/attachment/audio-icon.png
create mode 100644 dev-wiki/public/attachment/css-icon.png
create mode 100644 dev-wiki/public/attachment/csv-icon.png
create mode 100644 dev-wiki/public/attachment/default-icon.png
create mode 100644 dev-wiki/public/attachment/doc-icon.png
create mode 100644 dev-wiki/public/attachment/excel-icon.png
create mode 100644 dev-wiki/public/attachment/figma-icon.png
create mode 100644 dev-wiki/public/attachment/html-icon.png
create mode 100644 dev-wiki/public/attachment/img-icon.png
create mode 100644 dev-wiki/public/attachment/jpg-icon.png
create mode 100644 dev-wiki/public/attachment/js-icon.png
create mode 100644 dev-wiki/public/attachment/pdf-icon.png
create mode 100644 dev-wiki/public/attachment/png-icon.png
create mode 100644 dev-wiki/public/attachment/rar-icon.png
create mode 100644 dev-wiki/public/attachment/svg-icon.png
create mode 100644 dev-wiki/public/attachment/txt-icon.png
create mode 100644 dev-wiki/public/attachment/video-icon.png
create mode 100644 dev-wiki/public/attachment/zip-icon.png
create mode 100644 dev-wiki/public/auth/access-denied.svg
create mode 100644 dev-wiki/public/auth/background-pattern-dark.svg
create mode 100644 dev-wiki/public/auth/background-pattern.svg
create mode 100644 dev-wiki/public/auth/project-not-authorized.svg
create mode 100644 dev-wiki/public/auth/unauthorized.svg
create mode 100644 dev-wiki/public/auth/workspace-not-authorized.svg
create mode 100644 dev-wiki/public/emoji/project-emoji.svg
create mode 100644 dev-wiki/public/empty-state/active-cycle/assignee-dark.webp
create mode 100644 dev-wiki/public/empty-state/active-cycle/assignee-light.webp
create mode 100644 dev-wiki/public/empty-state/active-cycle/chart-dark.webp
create mode 100644 dev-wiki/public/empty-state/active-cycle/chart-light.webp
create mode 100644 dev-wiki/public/empty-state/active-cycle/cycle-dark.webp
create mode 100644 dev-wiki/public/empty-state/active-cycle/cycle-light.webp
create mode 100644 dev-wiki/public/empty-state/active-cycle/label-dark.webp
create mode 100644 dev-wiki/public/empty-state/active-cycle/label-light.webp
create mode 100644 dev-wiki/public/empty-state/active-cycle/priority-dark.webp
create mode 100644 dev-wiki/public/empty-state/active-cycle/priority-light.webp
create mode 100644 dev-wiki/public/empty-state/active-cycle/progress-dark.webp
create mode 100644 dev-wiki/public/empty-state/active-cycle/progress-light.webp
create mode 100644 dev-wiki/public/empty-state/all-issues/all-issues-dark.webp
create mode 100644 dev-wiki/public/empty-state/all-issues/all-issues-light.webp
create mode 100644 dev-wiki/public/empty-state/all-issues/assigned-dark.webp
create mode 100644 dev-wiki/public/empty-state/all-issues/assigned-light.webp
create mode 100644 dev-wiki/public/empty-state/all-issues/created-dark.webp
create mode 100644 dev-wiki/public/empty-state/all-issues/created-light.webp
create mode 100644 dev-wiki/public/empty-state/all-issues/custom-view-dark.webp
create mode 100644 dev-wiki/public/empty-state/all-issues/custom-view-light.webp
create mode 100644 dev-wiki/public/empty-state/all-issues/no-project-dark.webp
create mode 100644 dev-wiki/public/empty-state/all-issues/no-project-light.webp
create mode 100644 dev-wiki/public/empty-state/all-issues/subscribed-dark.webp
create mode 100644 dev-wiki/public/empty-state/all-issues/subscribed-light.webp
create mode 100644 dev-wiki/public/empty-state/analytics/empty-chart-area-dark.webp
create mode 100644 dev-wiki/public/empty-state/analytics/empty-chart-area-light.webp
create mode 100644 dev-wiki/public/empty-state/analytics/empty-chart-bar-dark.webp
create mode 100644 dev-wiki/public/empty-state/analytics/empty-chart-bar-light.webp
create mode 100644 dev-wiki/public/empty-state/analytics/empty-chart-radar-dark.webp
create mode 100644 dev-wiki/public/empty-state/analytics/empty-chart-radar-light.webp
create mode 100644 dev-wiki/public/empty-state/analytics/empty-grid-background-dark.webp
create mode 100644 dev-wiki/public/empty-state/analytics/empty-grid-background-light.webp
create mode 100644 dev-wiki/public/empty-state/analytics/empty-table-dark.webp
create mode 100644 dev-wiki/public/empty-state/analytics/empty-table-light.webp
create mode 100644 dev-wiki/public/empty-state/api-token.svg
create mode 100644 dev-wiki/public/empty-state/archived/empty-cycles-dark.webp
create mode 100644 dev-wiki/public/empty-state/archived/empty-cycles-light.webp
create mode 100644 dev-wiki/public/empty-state/archived/empty-issues-dark.webp
create mode 100644 dev-wiki/public/empty-state/archived/empty-issues-light.webp
create mode 100644 dev-wiki/public/empty-state/archived/empty-modules-dark.webp
create mode 100644 dev-wiki/public/empty-state/archived/empty-modules-light.webp
create mode 100644 dev-wiki/public/empty-state/customers/customer-upgrade-dark.webp
create mode 100644 dev-wiki/public/empty-state/customers/customer-upgrade-light.webp
create mode 100644 dev-wiki/public/empty-state/customers/customer.svg
create mode 100644 dev-wiki/public/empty-state/customers/customers-disabled-dark.webp
create mode 100644 dev-wiki/public/empty-state/customers/customers-disabled-light.webp
create mode 100644 dev-wiki/public/empty-state/customers/detail-dark.svg
create mode 100644 dev-wiki/public/empty-state/customers/detail-light.svg
create mode 100644 dev-wiki/public/empty-state/customers/request-search-dark.svg
create mode 100644 dev-wiki/public/empty-state/customers/request-search-light.svg
create mode 100644 dev-wiki/public/empty-state/customers/search-empty-dark.svg
create mode 100644 dev-wiki/public/empty-state/customers/search-empty-light.svg
create mode 100644 dev-wiki/public/empty-state/cycle-issues/calendar-dark-resp.webp
create mode 100644 dev-wiki/public/empty-state/cycle-issues/calendar-dark.webp
create mode 100644 dev-wiki/public/empty-state/cycle-issues/calendar-light-resp.webp
create mode 100644 dev-wiki/public/empty-state/cycle-issues/calendar-light.webp
create mode 100644 dev-wiki/public/empty-state/cycle-issues/gantt_chart-dark-resp.webp
create mode 100644 dev-wiki/public/empty-state/cycle-issues/gantt_chart-dark.webp
create mode 100644 dev-wiki/public/empty-state/cycle-issues/gantt_chart-light-resp.webp
create mode 100644 dev-wiki/public/empty-state/cycle-issues/gantt_chart-light.webp
create mode 100644 dev-wiki/public/empty-state/cycle-issues/kanban-dark-resp.webp
create mode 100644 dev-wiki/public/empty-state/cycle-issues/kanban-dark.webp
create mode 100644 dev-wiki/public/empty-state/cycle-issues/kanban-light-resp.webp
create mode 100644 dev-wiki/public/empty-state/cycle-issues/kanban-light.webp
create mode 100644 dev-wiki/public/empty-state/cycle-issues/list-dark-resp.webp
create mode 100644 dev-wiki/public/empty-state/cycle-issues/list-dark.webp
create mode 100644 dev-wiki/public/empty-state/cycle-issues/list-light-resp.webp
create mode 100644 dev-wiki/public/empty-state/cycle-issues/list-light.webp
create mode 100644 dev-wiki/public/empty-state/cycle-issues/spreadsheet-dark-resp.webp
create mode 100644 dev-wiki/public/empty-state/cycle-issues/spreadsheet-dark.webp
create mode 100644 dev-wiki/public/empty-state/cycle-issues/spreadsheet-light-resp.webp
create mode 100644 dev-wiki/public/empty-state/cycle-issues/spreadsheet-light.webp
create mode 100644 dev-wiki/public/empty-state/cycle.svg
create mode 100644 dev-wiki/public/empty-state/cycle/active-dark.webp
create mode 100644 dev-wiki/public/empty-state/cycle/active-light.webp
create mode 100644 dev-wiki/public/empty-state/cycle/all-filters.svg
create mode 100644 dev-wiki/public/empty-state/cycle/completed-dark.webp
create mode 100644 dev-wiki/public/empty-state/cycle/completed-light.webp
create mode 100644 dev-wiki/public/empty-state/cycle/completed-no-issues-dark.webp
create mode 100644 dev-wiki/public/empty-state/cycle/completed-no-issues-light.webp
create mode 100644 dev-wiki/public/empty-state/cycle/draft-dark.webp
create mode 100644 dev-wiki/public/empty-state/cycle/draft-light.webp
create mode 100644 dev-wiki/public/empty-state/cycle/name-filter.svg
create mode 100644 dev-wiki/public/empty-state/cycle/upcoming-dark.webp
create mode 100644 dev-wiki/public/empty-state/cycle/upcoming-light.webp
create mode 100644 dev-wiki/public/empty-state/dashboard/dark/completed-issues.svg
create mode 100644 dev-wiki/public/empty-state/dashboard/dark/issues-by-priority.svg
create mode 100644 dev-wiki/public/empty-state/dashboard/dark/issues-by-state-group.svg
create mode 100644 dev-wiki/public/empty-state/dashboard/dark/overdue-issues.svg
create mode 100644 dev-wiki/public/empty-state/dashboard/dark/recent-activity.svg
create mode 100644 dev-wiki/public/empty-state/dashboard/dark/recent-collaborators-1.svg
create mode 100644 dev-wiki/public/empty-state/dashboard/dark/recent-collaborators-2.svg
create mode 100644 dev-wiki/public/empty-state/dashboard/dark/recent-collaborators-3.svg
create mode 100644 dev-wiki/public/empty-state/dashboard/dark/upcoming-issues.svg
create mode 100644 dev-wiki/public/empty-state/dashboard/light/completed-issues.svg
create mode 100644 dev-wiki/public/empty-state/dashboard/light/issues-by-priority.svg
create mode 100644 dev-wiki/public/empty-state/dashboard/light/issues-by-state-group.svg
create mode 100644 dev-wiki/public/empty-state/dashboard/light/overdue-issues.svg
create mode 100644 dev-wiki/public/empty-state/dashboard/light/recent-activity.svg
create mode 100644 dev-wiki/public/empty-state/dashboard/light/recent-collaborators-1.svg
create mode 100644 dev-wiki/public/empty-state/dashboard/light/recent-collaborators-2.svg
create mode 100644 dev-wiki/public/empty-state/dashboard/light/recent-collaborators-3.svg
create mode 100644 dev-wiki/public/empty-state/dashboard/light/upcoming-issues.svg
create mode 100644 dev-wiki/public/empty-state/dashboard/widgets-dark.webp
create mode 100644 dev-wiki/public/empty-state/dashboard/widgets-light.webp
create mode 100644 dev-wiki/public/empty-state/dashboard_empty_project.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/feature-flag/inside-dark.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/feature-flag/inside-light.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/feature-flag/outside-dark.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/feature-flag/outside-light.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/list-dark.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/list-light.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/list-search-dark.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/list-search-light.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/widgets/charts/area_chart-dark.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/widgets/charts/area_chart-light.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/widgets/charts/bar_chart-dark.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/widgets/charts/bar_chart-light.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/widgets/charts/donut_chart-dark.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/widgets/charts/donut_chart-light.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/widgets/charts/line_chart-dark.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/widgets/charts/line_chart-light.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/widgets/charts/number-dark.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/widgets/charts/number-light.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/widgets/charts/pie_chart-dark.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/widgets/charts/pie_chart-light.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/widgets/list-dark.webp
create mode 100644 dev-wiki/public/empty-state/dashboards/widgets/list-light.webp
create mode 100644 dev-wiki/public/empty-state/disabled-feature/cycles-dark.webp
create mode 100644 dev-wiki/public/empty-state/disabled-feature/cycles-light.webp
create mode 100644 dev-wiki/public/empty-state/disabled-feature/intake-dark.webp
create mode 100644 dev-wiki/public/empty-state/disabled-feature/intake-light.webp
create mode 100644 dev-wiki/public/empty-state/disabled-feature/modules-dark.webp
create mode 100644 dev-wiki/public/empty-state/disabled-feature/modules-light.webp
create mode 100644 dev-wiki/public/empty-state/disabled-feature/pages-dark.webp
create mode 100644 dev-wiki/public/empty-state/disabled-feature/pages-light.webp
create mode 100644 dev-wiki/public/empty-state/disabled-feature/views-dark.webp
create mode 100644 dev-wiki/public/empty-state/disabled-feature/views-light.webp
create mode 100644 dev-wiki/public/empty-state/draft/draft-issues-empty-dark.webp
create mode 100644 dev-wiki/public/empty-state/draft/draft-issues-empty-light.webp
create mode 100644 dev-wiki/public/empty-state/empty-filters/calendar-dark.webp
create mode 100644 dev-wiki/public/empty-state/empty-filters/calendar-light.webp
create mode 100644 dev-wiki/public/empty-state/empty-filters/gantt_chart-dark.webp
create mode 100644 dev-wiki/public/empty-state/empty-filters/gantt_chart-light.webp
create mode 100644 dev-wiki/public/empty-state/empty-filters/kanban-dark.webp
create mode 100644 dev-wiki/public/empty-state/empty-filters/kanban-light.webp
create mode 100644 dev-wiki/public/empty-state/empty-filters/list-dark.webp
create mode 100644 dev-wiki/public/empty-state/empty-filters/list-light.webp
create mode 100644 dev-wiki/public/empty-state/empty-filters/spreadsheet-dark.webp
create mode 100644 dev-wiki/public/empty-state/empty-filters/spreadsheet-light.webp
create mode 100644 dev-wiki/public/empty-state/empty-updates-light.png
create mode 100644 dev-wiki/public/empty-state/empty_analytics.webp
create mode 100644 dev-wiki/public/empty-state/empty_bar_graph.svg
create mode 100644 dev-wiki/public/empty-state/empty_cycles.webp
create mode 100644 dev-wiki/public/empty-state/empty_graph.svg
create mode 100644 dev-wiki/public/empty-state/empty_issues.webp
create mode 100644 dev-wiki/public/empty-state/empty_label.svg
create mode 100644 dev-wiki/public/empty-state/empty_members.svg
create mode 100644 dev-wiki/public/empty-state/empty_modules.webp
create mode 100644 dev-wiki/public/empty-state/empty_page.png
create mode 100644 dev-wiki/public/empty-state/empty_project.webp
create mode 100644 dev-wiki/public/empty-state/empty_users.svg
create mode 100644 dev-wiki/public/empty-state/empty_view.webp
create mode 100644 dev-wiki/public/empty-state/epics/epics-dark.webp
create mode 100644 dev-wiki/public/empty-state/epics/epics-light.webp
create mode 100644 dev-wiki/public/empty-state/epics/settings-dark.png
create mode 100644 dev-wiki/public/empty-state/epics/settings-light.png
create mode 100644 dev-wiki/public/empty-state/estimates/dark.svg
create mode 100644 dev-wiki/public/empty-state/estimates/light.svg
create mode 100644 dev-wiki/public/empty-state/initiatives/initiatives-dark.webp
create mode 100644 dev-wiki/public/empty-state/initiatives/initiatives-light.webp
create mode 100644 dev-wiki/public/empty-state/initiatives/upgrade-dark.webp
create mode 100644 dev-wiki/public/empty-state/initiatives/upgrade-light.webp
create mode 100644 dev-wiki/public/empty-state/intake/filter-issue-dark.webp
create mode 100644 dev-wiki/public/empty-state/intake/filter-issue-light.webp
create mode 100644 dev-wiki/public/empty-state/intake/intake-dark-resp.webp
create mode 100644 dev-wiki/public/empty-state/intake/intake-dark.webp
create mode 100644 dev-wiki/public/empty-state/intake/intake-issue-dark.webp
create mode 100644 dev-wiki/public/empty-state/intake/intake-issue-light.webp
create mode 100644 dev-wiki/public/empty-state/intake/intake-light-resp.webp
create mode 100644 dev-wiki/public/empty-state/intake/intake-light.webp
create mode 100644 dev-wiki/public/empty-state/intake/issue-detail-dark.webp
create mode 100644 dev-wiki/public/empty-state/intake/issue-detail-light.webp
create mode 100644 dev-wiki/public/empty-state/invitation.svg
create mode 100644 dev-wiki/public/empty-state/issue-types/issue-type-dark.png
create mode 100644 dev-wiki/public/empty-state/issue-types/issue-type-dark.svg
create mode 100644 dev-wiki/public/empty-state/issue-types/issue-type-light.png
create mode 100644 dev-wiki/public/empty-state/issue-types/issue-type-light.svg
create mode 100644 dev-wiki/public/empty-state/issue.svg
create mode 100644 dev-wiki/public/empty-state/label.svg
create mode 100644 dev-wiki/public/empty-state/marketplace/empty-workspace-dark.png
create mode 100644 dev-wiki/public/empty-state/marketplace/empty-workspace-light.png
create mode 100644 dev-wiki/public/empty-state/module-issues/calendar-dark-resp.webp
create mode 100644 dev-wiki/public/empty-state/module-issues/calendar-dark.webp
create mode 100644 dev-wiki/public/empty-state/module-issues/calendar-light-resp.webp
create mode 100644 dev-wiki/public/empty-state/module-issues/calendar-light.webp
create mode 100644 dev-wiki/public/empty-state/module-issues/gantt_chart-dark-resp.webp
create mode 100644 dev-wiki/public/empty-state/module-issues/gantt_chart-dark.webp
create mode 100644 dev-wiki/public/empty-state/module-issues/gantt_chart-light-resp.webp
create mode 100644 dev-wiki/public/empty-state/module-issues/gantt_chart-light.webp
create mode 100644 dev-wiki/public/empty-state/module-issues/kanban-dark-resp.webp
create mode 100644 dev-wiki/public/empty-state/module-issues/kanban-dark.webp
create mode 100644 dev-wiki/public/empty-state/module-issues/kanban-light-resp.webp
create mode 100644 dev-wiki/public/empty-state/module-issues/kanban-light.webp
create mode 100644 dev-wiki/public/empty-state/module-issues/list-dark-resp.webp
create mode 100644 dev-wiki/public/empty-state/module-issues/list-dark.webp
create mode 100644 dev-wiki/public/empty-state/module-issues/list-light-resp.webp
create mode 100644 dev-wiki/public/empty-state/module-issues/list-light.webp
create mode 100644 dev-wiki/public/empty-state/module-issues/spreadsheet-dark-resp.webp
create mode 100644 dev-wiki/public/empty-state/module-issues/spreadsheet-dark.webp
create mode 100644 dev-wiki/public/empty-state/module-issues/spreadsheet-light-resp.webp
create mode 100644 dev-wiki/public/empty-state/module-issues/spreadsheet-light.webp
create mode 100644 dev-wiki/public/empty-state/module.svg
create mode 100644 dev-wiki/public/empty-state/module/all-filters.svg
create mode 100644 dev-wiki/public/empty-state/module/name-filter.svg
create mode 100644 dev-wiki/public/empty-state/notification.svg
create mode 100644 dev-wiki/public/empty-state/onboarding/analytics-dark.webp
create mode 100644 dev-wiki/public/empty-state/onboarding/analytics-light.webp
create mode 100644 dev-wiki/public/empty-state/onboarding/archive-dark.png
create mode 100644 dev-wiki/public/empty-state/onboarding/archive-light.png
create mode 100644 dev-wiki/public/empty-state/onboarding/cycles-dark.webp
create mode 100644 dev-wiki/public/empty-state/onboarding/cycles-light.webp
create mode 100644 dev-wiki/public/empty-state/onboarding/dashboard-dark.webp
create mode 100644 dev-wiki/public/empty-state/onboarding/dashboard-light.webp
create mode 100644 dev-wiki/public/empty-state/onboarding/graph-dark.png
create mode 100644 dev-wiki/public/empty-state/onboarding/graph-light.png
create mode 100644 dev-wiki/public/empty-state/onboarding/issues-closed-dark.png
create mode 100644 dev-wiki/public/empty-state/onboarding/issues-closed-light.png
create mode 100644 dev-wiki/public/empty-state/onboarding/issues-dark.webp
create mode 100644 dev-wiki/public/empty-state/onboarding/issues-light.webp
create mode 100644 dev-wiki/public/empty-state/onboarding/members-dark.png
create mode 100644 dev-wiki/public/empty-state/onboarding/members-light.png
create mode 100644 dev-wiki/public/empty-state/onboarding/modules-dark.webp
create mode 100644 dev-wiki/public/empty-state/onboarding/modules-light.webp
create mode 100644 dev-wiki/public/empty-state/onboarding/notification-dark.png
create mode 100644 dev-wiki/public/empty-state/onboarding/notification-light.png
create mode 100644 dev-wiki/public/empty-state/onboarding/pages-dark.webp
create mode 100644 dev-wiki/public/empty-state/onboarding/pages-light.webp
create mode 100644 dev-wiki/public/empty-state/onboarding/projects-dark.webp
create mode 100644 dev-wiki/public/empty-state/onboarding/projects-light.webp
create mode 100644 dev-wiki/public/empty-state/onboarding/search-dark.png
create mode 100644 dev-wiki/public/empty-state/onboarding/search-light.png
create mode 100644 dev-wiki/public/empty-state/onboarding/snooze-light.png
create mode 100644 dev-wiki/public/empty-state/onboarding/snoozed-dark.png
create mode 100644 dev-wiki/public/empty-state/onboarding/views-dark.webp
create mode 100644 dev-wiki/public/empty-state/onboarding/views-light.webp
create mode 100644 dev-wiki/public/empty-state/onboarding/workspace-active-cycles-dark.webp
create mode 100644 dev-wiki/public/empty-state/onboarding/workspace-active-cycles-light.webp
create mode 100644 dev-wiki/public/empty-state/onboarding/workspace-invites-dark.webp
create mode 100644 dev-wiki/public/empty-state/onboarding/workspace-invites-light.webp
create mode 100644 dev-wiki/public/empty-state/pages/all-dark.webp
create mode 100644 dev-wiki/public/empty-state/pages/all-filters.svg
create mode 100644 dev-wiki/public/empty-state/pages/all-light.webp
create mode 100644 dev-wiki/public/empty-state/pages/archived-dark.webp
create mode 100644 dev-wiki/public/empty-state/pages/archived-light.webp
create mode 100644 dev-wiki/public/empty-state/pages/name-filter.svg
create mode 100644 dev-wiki/public/empty-state/pages/private-dark.webp
create mode 100644 dev-wiki/public/empty-state/pages/private-light.webp
create mode 100644 dev-wiki/public/empty-state/pages/public-dark.webp
create mode 100644 dev-wiki/public/empty-state/pages/public-light.webp
create mode 100644 dev-wiki/public/empty-state/pi/chat-dark.webp
create mode 100644 dev-wiki/public/empty-state/pi/chat-light.webp
create mode 100644 dev-wiki/public/empty-state/profile/activities-dark.webp
create mode 100644 dev-wiki/public/empty-state/profile/activities-light.webp
create mode 100644 dev-wiki/public/empty-state/profile/activity-dark.webp
create mode 100644 dev-wiki/public/empty-state/profile/activity-light.webp
create mode 100644 dev-wiki/public/empty-state/profile/assigned-dark.webp
create mode 100644 dev-wiki/public/empty-state/profile/assigned-light.webp
create mode 100644 dev-wiki/public/empty-state/profile/created-dark.webp
create mode 100644 dev-wiki/public/empty-state/profile/created-light.webp
create mode 100644 dev-wiki/public/empty-state/profile/issues-by-priority-dark.webp
create mode 100644 dev-wiki/public/empty-state/profile/issues-by-priority-light.webp
create mode 100644 dev-wiki/public/empty-state/profile/issues-by-state-dark.webp
create mode 100644 dev-wiki/public/empty-state/profile/issues-by-state-light.webp
create mode 100644 dev-wiki/public/empty-state/profile/subscribed-dark.webp
create mode 100644 dev-wiki/public/empty-state/profile/subscribed-light.webp
create mode 100644 dev-wiki/public/empty-state/project-settings/estimates-dark-resp.webp
create mode 100644 dev-wiki/public/empty-state/project-settings/estimates-dark.png
create mode 100644 dev-wiki/public/empty-state/project-settings/estimates-dark.webp
create mode 100644 dev-wiki/public/empty-state/project-settings/estimates-light-resp.webp
create mode 100644 dev-wiki/public/empty-state/project-settings/estimates-light.png
create mode 100644 dev-wiki/public/empty-state/project-settings/estimates-light.webp
create mode 100644 dev-wiki/public/empty-state/project-settings/integrations-dark-resp.webp
create mode 100644 dev-wiki/public/empty-state/project-settings/integrations-dark.webp
create mode 100644 dev-wiki/public/empty-state/project-settings/integrations-light-resp.webp
create mode 100644 dev-wiki/public/empty-state/project-settings/integrations-light.webp
create mode 100644 dev-wiki/public/empty-state/project-settings/labels-dark-resp.webp
create mode 100644 dev-wiki/public/empty-state/project-settings/labels-dark.webp
create mode 100644 dev-wiki/public/empty-state/project-settings/labels-light-resp.webp
create mode 100644 dev-wiki/public/empty-state/project-settings/labels-light.webp
create mode 100644 dev-wiki/public/empty-state/project-settings/no-projects-dark.png
create mode 100644 dev-wiki/public/empty-state/project-settings/no-projects-light.png
create mode 100644 dev-wiki/public/empty-state/project-settings/updates-dark.png
create mode 100644 dev-wiki/public/empty-state/project-settings/updates-light.png
create mode 100644 dev-wiki/public/empty-state/project.svg
create mode 100644 dev-wiki/public/empty-state/project/all-filters.svg
create mode 100644 dev-wiki/public/empty-state/project/name-filter.svg
create mode 100644 dev-wiki/public/empty-state/recent_activity.svg
create mode 100644 dev-wiki/public/empty-state/search/all-issue-view-dark.webp
create mode 100644 dev-wiki/public/empty-state/search/all-issues-view-light.webp
create mode 100644 dev-wiki/public/empty-state/search/archive-dark.webp
create mode 100644 dev-wiki/public/empty-state/search/archive-light.webp
create mode 100644 dev-wiki/public/empty-state/search/comments-dark.webp
create mode 100644 dev-wiki/public/empty-state/search/comments-light.webp
create mode 100644 dev-wiki/public/empty-state/search/issues-dark.webp
create mode 100644 dev-wiki/public/empty-state/search/issues-light.webp
create mode 100644 dev-wiki/public/empty-state/search/member-dark.webp
create mode 100644 dev-wiki/public/empty-state/search/member-light.webp
create mode 100644 dev-wiki/public/empty-state/search/notification-dark.webp
create mode 100644 dev-wiki/public/empty-state/search/notification-light.webp
create mode 100644 dev-wiki/public/empty-state/search/project-dark.webp
create mode 100644 dev-wiki/public/empty-state/search/project-light.webp
create mode 100644 dev-wiki/public/empty-state/search/search-dark.webp
create mode 100644 dev-wiki/public/empty-state/search/search-light.webp
create mode 100644 dev-wiki/public/empty-state/search/snooze-dark.webp
create mode 100644 dev-wiki/public/empty-state/search/snooze-light.webp
create mode 100644 dev-wiki/public/empty-state/search/views-dark.webp
create mode 100644 dev-wiki/public/empty-state/search/views-light.webp
create mode 100644 dev-wiki/public/empty-state/state_graph.svg
create mode 100644 dev-wiki/public/empty-state/stickies/stickies-dark.webp
create mode 100644 dev-wiki/public/empty-state/stickies/stickies-light.webp
create mode 100644 dev-wiki/public/empty-state/stickies/stickies-search-dark.webp
create mode 100644 dev-wiki/public/empty-state/stickies/stickies-search-light.webp
create mode 100644 dev-wiki/public/empty-state/teams/completed-cycles-dark.webp
create mode 100644 dev-wiki/public/empty-state/teams/completed-cycles-light.webp
create mode 100644 dev-wiki/public/empty-state/teams/current-cycles-dark.webp
create mode 100644 dev-wiki/public/empty-state/teams/current-cycles-light.webp
create mode 100644 dev-wiki/public/empty-state/teams/dark-upgrade.webp
create mode 100644 dev-wiki/public/empty-state/teams/issues-dark.webp
create mode 100644 dev-wiki/public/empty-state/teams/issues-light.webp
create mode 100644 dev-wiki/public/empty-state/teams/light-upgrade.webp
create mode 100644 dev-wiki/public/empty-state/teams/projects-dark.webp
create mode 100644 dev-wiki/public/empty-state/teams/projects-light.webp
create mode 100644 dev-wiki/public/empty-state/teams/teams-dark.webp
create mode 100644 dev-wiki/public/empty-state/teams/teams-light.webp
create mode 100644 dev-wiki/public/empty-state/teams/upcoming-cycles-dark.webp
create mode 100644 dev-wiki/public/empty-state/teams/upcoming-cycles-light.webp
create mode 100644 dev-wiki/public/empty-state/teams/views-dark.webp
create mode 100644 dev-wiki/public/empty-state/teams/views-light.webp
create mode 100644 dev-wiki/public/empty-state/templates/no-templates-dark.webp
create mode 100644 dev-wiki/public/empty-state/templates/no-templates-light.webp
create mode 100644 dev-wiki/public/empty-state/templates/upgrade-dark.webp
create mode 100644 dev-wiki/public/empty-state/templates/upgrade-light.webp
create mode 100644 dev-wiki/public/empty-state/view.svg
create mode 100644 dev-wiki/public/empty-state/web-hook.svg
create mode 100644 dev-wiki/public/empty-state/workflows/upgrade-dark.webp
create mode 100644 dev-wiki/public/empty-state/workflows/upgrade-light.webp
create mode 100644 dev-wiki/public/empty-state/worklogs/worklog-dark.png
create mode 100644 dev-wiki/public/empty-state/worklogs/worklog-light.png
create mode 100644 dev-wiki/public/empty-state/workspace-draft/issue-dark.webp
create mode 100644 dev-wiki/public/empty-state/workspace-draft/issue-light.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/api-tokens-dark-resp.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/api-tokens-dark.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/api-tokens-light-resp.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/api-tokens-light.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/exports-dark-resp.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/exports-dark.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/exports-light-resp.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/exports-light.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/imports-dark-resp.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/imports-dark.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/imports-light-resp.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/imports-light.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/integrations-dark-resp.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/integrations-dark.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/integrations-light-resp.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/integrations-light.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/webhooks-dark-resp.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/webhooks-dark.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/webhooks-light-resp.webp
create mode 100644 dev-wiki/public/empty-state/workspace-settings/webhooks-light.webp
create mode 100644 dev-wiki/public/favicon/android-chrome-192x192.png
create mode 100644 dev-wiki/public/favicon/android-chrome-512x512.png
create mode 100644 dev-wiki/public/favicon/apple-touch-icon.png
create mode 100644 dev-wiki/public/favicon/favicon-16x16.png
create mode 100644 dev-wiki/public/favicon/favicon-32x32.png
create mode 100644 dev-wiki/public/favicon/favicon.ico
create mode 100644 dev-wiki/public/favicon/site.webmanifest
create mode 100644 dev-wiki/public/fonts/inter/bold-italic.ttf
create mode 100644 dev-wiki/public/fonts/inter/bold.ttf
create mode 100644 dev-wiki/public/fonts/inter/heavy-italic.ttf
create mode 100644 dev-wiki/public/fonts/inter/heavy.ttf
create mode 100644 dev-wiki/public/fonts/inter/light-italic.ttf
create mode 100644 dev-wiki/public/fonts/inter/light.ttf
create mode 100644 dev-wiki/public/fonts/inter/medium-italic.ttf
create mode 100644 dev-wiki/public/fonts/inter/medium.ttf
create mode 100644 dev-wiki/public/fonts/inter/regular-italic.ttf
create mode 100644 dev-wiki/public/fonts/inter/regular.ttf
create mode 100644 dev-wiki/public/fonts/inter/semibold-italic.ttf
create mode 100644 dev-wiki/public/fonts/inter/semibold.ttf
create mode 100644 dev-wiki/public/fonts/inter/thin-italic.ttf
create mode 100644 dev-wiki/public/fonts/inter/thin.ttf
create mode 100644 dev-wiki/public/fonts/inter/ultrabold-italic.ttf
create mode 100644 dev-wiki/public/fonts/inter/ultrabold.ttf
create mode 100644 dev-wiki/public/fonts/inter/ultralight-italic.ttf
create mode 100644 dev-wiki/public/fonts/inter/ultralight.ttf
create mode 100644 dev-wiki/public/icons/icon-128x128.png
create mode 100644 dev-wiki/public/icons/icon-180x180.png
create mode 100644 dev-wiki/public/icons/icon-192x192.png
create mode 100644 dev-wiki/public/icons/icon-348x348.png
create mode 100644 dev-wiki/public/icons/icon-512x512.png
create mode 100644 dev-wiki/public/images/logo-spinner-dark.gif
create mode 100644 dev-wiki/public/images/logo-spinner-light.gif
create mode 100644 dev-wiki/public/instance-not-ready.webp
create mode 100644 dev-wiki/public/instance-setup-done.webp
create mode 100644 dev-wiki/public/logos/github-black.png
create mode 100644 dev-wiki/public/logos/github-dark.svg
create mode 100644 dev-wiki/public/logos/github-square.png
create mode 100644 dev-wiki/public/logos/github-white.png
create mode 100644 dev-wiki/public/logos/gitlab-logo.svg
create mode 100644 dev-wiki/public/logos/google-logo.svg
create mode 100644 dev-wiki/public/logos/pi.png
create mode 100644 dev-wiki/public/mac-command.svg
create mode 100644 dev-wiki/public/maintenance-mode.webp
create mode 100644 dev-wiki/public/manifest.json
create mode 100644 dev-wiki/public/marketplace/connect.svg
create mode 100644 dev-wiki/public/og-image.png
create mode 100644 dev-wiki/public/onboarding/create-join-workspace-dark.webp
create mode 100644 dev-wiki/public/onboarding/create-join-workspace-light.webp
create mode 100644 dev-wiki/public/onboarding/cycles.webp
create mode 100644 dev-wiki/public/onboarding/invite-members-dark.webp
create mode 100644 dev-wiki/public/onboarding/invite-members-light.webp
create mode 100644 dev-wiki/public/onboarding/issues.webp
create mode 100644 dev-wiki/public/onboarding/modules.webp
create mode 100644 dev-wiki/public/onboarding/onboarding-pages.webp
create mode 100644 dev-wiki/public/onboarding/pages.webp
create mode 100644 dev-wiki/public/onboarding/profile-setup-dark.webp
create mode 100644 dev-wiki/public/onboarding/profile-setup-light.webp
create mode 100644 dev-wiki/public/onboarding/user-personalization-dark.webp
create mode 100644 dev-wiki/public/onboarding/user-personalization-light.webp
create mode 100644 dev-wiki/public/onboarding/views.webp
create mode 100644 dev-wiki/public/plane-logos/black-horizontal-with-blue-logo.png
create mode 100644 dev-wiki/public/plane-logos/blue-without-text.png
create mode 100644 dev-wiki/public/plane-logos/plane-business-logo.svg
create mode 100644 dev-wiki/public/plane-logos/plane-mobile-pwa.png
create mode 100644 dev-wiki/public/plane-logos/plane-one-silver.svg
create mode 100644 dev-wiki/public/plane-logos/plane-one.svg
create mode 100644 dev-wiki/public/plane-logos/plane-pro-without-text.svg
create mode 100644 dev-wiki/public/plane-logos/plane-pro.svg
create mode 100644 dev-wiki/public/plane-logos/white-horizontal-with-blue-logo.png
create mode 100644 dev-wiki/public/plane-logos/white-horizontal.svg
create mode 100644 dev-wiki/public/plane-takeoff.png
create mode 100644 dev-wiki/public/projects/dark-upgrade.svg
create mode 100644 dev-wiki/public/projects/light-upgrade.svg
create mode 100644 dev-wiki/public/projects/project-states-dark.webp
create mode 100644 dev-wiki/public/projects/project-states-light.webp
create mode 100644 dev-wiki/public/projects/states-dark.svg
create mode 100644 dev-wiki/public/projects/states-light.svg
create mode 100644 dev-wiki/public/services/asana.svg
create mode 100644 dev-wiki/public/services/csv.svg
create mode 100644 dev-wiki/public/services/excel.svg
create mode 100644 dev-wiki/public/services/flatfile.svg
create mode 100644 dev-wiki/public/services/github-dark.svg
create mode 100644 dev-wiki/public/services/github-light.svg
create mode 100644 dev-wiki/public/services/github.png
create mode 100644 dev-wiki/public/services/github.svg
create mode 100644 dev-wiki/public/services/gitlab.svg
create mode 100644 dev-wiki/public/services/jira.svg
create mode 100644 dev-wiki/public/services/json.svg
create mode 100644 dev-wiki/public/services/linear.svg
create mode 100644 dev-wiki/public/services/slack.png
create mode 100644 dev-wiki/public/site.webmanifest.json
create mode 100644 dev-wiki/public/sw.js
create mode 100644 dev-wiki/public/upcoming-features/imports-cta-1-dark.png
create mode 100644 dev-wiki/public/upcoming-features/imports-cta-1-light.png
create mode 100644 dev-wiki/public/upcoming-features/imports-cta-2-dark.png
create mode 100644 dev-wiki/public/upcoming-features/imports-cta-2-light.png
create mode 100644 dev-wiki/public/upcoming-features/integrations-cta-1-dark.png
create mode 100644 dev-wiki/public/upcoming-features/integrations-cta-1-light.png
create mode 100644 dev-wiki/public/upcoming-features/integrations-cta-2-dark.png
create mode 100644 dev-wiki/public/upcoming-features/integrations-cta-2-light.png
create mode 100644 dev-wiki/public/user.png
create mode 100644 dev-wiki/public/users/user-1.png
create mode 100644 dev-wiki/public/users/user-2.png
create mode 100644 dev-wiki/public/users/user-profile-cover-default-img.png
create mode 100644 dev-wiki/public/users/user.png
create mode 100644 dev-wiki/public/workbox-91d218dd.js
create mode 100644 dev-wiki/public/workspace-active-cycles/cta-l-1-dark.webp
create mode 100644 dev-wiki/public/workspace-active-cycles/cta-l-1-light.webp
create mode 100644 dev-wiki/public/workspace-active-cycles/cta-r-1-dark.webp
create mode 100644 dev-wiki/public/workspace-active-cycles/cta-r-1-light.webp
create mode 100644 dev-wiki/public/workspace-active-cycles/cta-r-2-dark.webp
create mode 100644 dev-wiki/public/workspace-active-cycles/cta-r-2-light.webp
create mode 100644 dev-wiki/public/workspace/workspace-creation-disabled.png
create mode 100644 dev-wiki/public/workspace/workspace-not-available.png
create mode 100644 dev-wiki/styles/command-pallette.css
create mode 100644 dev-wiki/styles/emoji.css
create mode 100644 dev-wiki/styles/globals.css
create mode 100644 dev-wiki/styles/nprogress.css
create mode 100644 dev-wiki/styles/react-day-picker.css
create mode 100644 dev-wiki/tailwind.config.js
create mode 100644 dev-wiki/tsconfig.json
create mode 100644 dev-wiki/use-font-face-observer.d.ts
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 (
+
+ );
+});
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 && (
+
setIsPublishModalOpen(true)} className="h-6">
+ {isDeployed ? "Unpublish" : "Publish"}
+
+ )}
+
+
+
+
+ >
+ );
+});
+
+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 (
+
+ );
+};
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
+
+
+ Sign out
+
+
+
+
+
+
+ );
+}
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 && (
+
+ )}
+ {!!isSessionRecorderEnabled && process.env.NEXT_PUBLIC_SESSION_RECORDER_KEY && (
+
+ )}
+
+ );
+}
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 = () => (
+
+
+
+
+
+
+
+
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.
+
+
+
+
+
+ Go to Home
+
+
+
+
+
+
+);
+
+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 (
+
+
+
+
+
+
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.
+
+
+
+
+
+ Check pending invites
+
+
+
+
+ Create new workspace
+
+
+
+
+
+
+);
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 (
+
+ onChange(access.key)}
+ className={cn(
+ "flex-shrink-0 relative flex justify-center items-center w-5 h-5 rounded-sm p-1 transition-all",
+ value === access.key ? "bg-custom-background-80" : "hover:bg-custom-background-80"
+ )}
+ tabIndex={2 + index}
+ >
+
+
+
+ );
+ })}
+
+ );
+};
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 && (
+ handleRemove(date)}
+ >
+
+
+ )}
+
+ ))}
+ >
+ );
+});
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 && (
+
handleRemove(memberId)}
+ >
+
+
+ )}
+
+ );
+ })}
+ >
+ );
+});
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
+
+ .
+
+
+
+
+ Refresh
+
+
+
+
+
+ );
+};
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,
+}) => (
+
+
+
+
{title}
+ {description &&
{description}
}
+
+ {primaryButton && (
+
+ {primaryButton.text}
+
+ )}
+ {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 && ( */}
+ {/* */}
+ {/* {itemsToRender === sortedOptions.length ? "View less" : "View all"} */}
+ {/* */}
+ {/* )} */}
+ {/* > */}
+ {/* ) : ( */}
+ {/* 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
+
+
+
+
+ >
+ );
+};
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 (
+
+
+
+ );
+};
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 && (
+
+ {primaryButton.text}
+
+
+
+ )}
+ {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 ? (
+
+ ) : (
+
+ )}
+ {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 || (
+
+ {showAll ? t("show_less") : t("show_all")}
+
+ )}
+
+ )}
+
+ );
+});
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) => (
+
+);
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 (
+
+
+
+
+
+
+
+
+ );
+};
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"
+ />
+ )}
+ />
+ setSearchParams(formData.search)} size="sm">
+ Search
+
+
+ {unsplashImages ? (
+ unsplashImages.length > 0 ? (
+
+ {unsplashImages.map((image) => (
+
{
+ setIsOpen(false);
+ onChange(image.urls.regular);
+ }}
+ >
+
+
+ ))}
+
+ ) : (
+ No images found.
+ )
+ ) : (
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ )}
+ {(!projectCoverImages || projectCoverImages.length !== 0) && (
+
+ {projectCoverImages ? (
+ projectCoverImages.length > 0 ? (
+
+ {projectCoverImages.map((image, index) => (
+
{
+ setIsOpen(false);
+ onChange(image);
+ }}
+ >
+
+
+ ))}
+
+ ) : (
+ No images found.
+ )
+ ) : (
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ )}
+
+
+
+
+
+ Edit
+
+ {image !== null || (value && value !== "") ? (
+ <>
+
+ >
+ ) : (
+
+
+ {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
+
+
+ {
+ setIsOpen(false);
+ setImage(null);
+ }}
+ >
+ Cancel
+
+
+ {isImageUploading ? "Uploading..." : "Upload & Save"}
+
+
+
+
+
+
+
+
+ )}
+
+ );
+});
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 (
+ toggleSidebar()}
+ >
+
+
+ );
+});
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 (
+
+ );
+});
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;
+}) => (
+
+ {config.text}
+
+);
+
+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 && (
+
+ )}
+
+ {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 = () => (
+
+
+
+
+
+
+
+ window.location.reload()}>
+ Reload
+
+
+
+
+);
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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
Welcome aboard Plane!
+
+
+ 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 (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+ }
+
+ 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={
+
+
+ Icon
+
+ }
+ 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) => (
+
+ {marking.text}
+
+);
+
+export const OutlineHeading2 = ({ marking, onClick }: THeadingComponentProps) => (
+
+ {marking.text}
+
+);
+
+export const OutlineHeading3 = ({ marking, onClick }: THeadingComponentProps) => (
+
+ {marking.text}
+
+);
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)}
+
+ ) : (
+
+ )}
+
+ );
+});
diff --git a/dev-wiki/core/components/pages/editor/toolbar/color-dropdown.tsx b/dev-wiki/core/components/pages/editor/toolbar/color-dropdown.tsx
new file mode 100644
index 0000000000..2809336c17
--- /dev/null
+++ b/dev-wiki/core/components/pages/editor/toolbar/color-dropdown.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { memo } from "react";
+import { ALargeSmall, Ban } from "lucide-react";
+import { Popover } from "@headlessui/react";
+// plane editor
+import { COLORS_LIST, TEditorCommands } from "@plane/editor";
+// helpers
+import { cn } from "@/helpers/common.helper";
+
+type Props = {
+ handleColorSelect: (
+ key: Extract,
+ color: string | undefined
+ ) => void;
+ isColorActive: (
+ key: Extract,
+ color: string | undefined
+ ) => boolean;
+};
+
+export const ColorDropdown: React.FC = memo((props) => {
+ const { handleColorSelect, isColorActive } = props;
+
+ const activeTextColor = COLORS_LIST.find((c) => isColorActive("text-color", c.key));
+ const activeBackgroundColor = COLORS_LIST.find((c) => isColorActive("background-color", c.key));
+
+ return (
+
+
+ cn("h-full", {
+ "outline-none": open,
+ })
+ }
+ >
+ {({ open }) => (
+
+ Color
+
+
+
+
+ )}
+
+
+
+
Text colors
+
+ {COLORS_LIST.map((color) => (
+ handleColorSelect("text-color", color.key)}
+ />
+ ))}
+ handleColorSelect("text-color", undefined)}
+ >
+
+
+
+
+
+
Background colors
+
+ {COLORS_LIST.map((color) => (
+ handleColorSelect("background-color", color.key)}
+ />
+ ))}
+ handleColorSelect("background-color", undefined)}
+ >
+
+
+
+
+
+
+ );
+});
+
+ColorDropdown.displayName = "ColorDropdown";
diff --git a/dev-wiki/core/components/pages/editor/toolbar/index.ts b/dev-wiki/core/components/pages/editor/toolbar/index.ts
new file mode 100644
index 0000000000..66652b2dbd
--- /dev/null
+++ b/dev-wiki/core/components/pages/editor/toolbar/index.ts
@@ -0,0 +1,5 @@
+export * from "./color-dropdown";
+export * from "./info-popover";
+export * from "./options-dropdown";
+export * from "./root";
+export * from "./toolbar";
diff --git a/dev-wiki/core/components/pages/editor/toolbar/info-popover.tsx b/dev-wiki/core/components/pages/editor/toolbar/info-popover.tsx
new file mode 100644
index 0000000000..02f75674d5
--- /dev/null
+++ b/dev-wiki/core/components/pages/editor/toolbar/info-popover.tsx
@@ -0,0 +1,141 @@
+import { useState } from "react";
+import { observer } from "mobx-react";
+import Link from "next/link";
+import { useParams } from "next/navigation";
+import { usePopper } from "react-popper";
+import { Info } from "lucide-react";
+// plane imports
+import { Avatar } from "@plane/ui";
+import { getFileURL, renderFormattedDate } from "@plane/utils";
+// helpers
+import { calculateTimeAgoShort, getReadTimeFromWordsCount } from "@/helpers/date-time.helper";
+// hooks
+import { useMember } from "@/hooks/store";
+// store types
+import { TPageInstance } from "@/store/pages/base-page";
+
+type Props = {
+ page: TPageInstance;
+};
+
+export const PageInfoPopover: React.FC = observer((props) => {
+ const { page } = props;
+ // states
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+ // refs
+ const [referenceElement, setReferenceElement] = useState(null);
+ const [popperElement, setPopperElement] = useState(null);
+ // router
+ const { workspaceSlug } = useParams();
+ // popper-js
+ const { styles: infoPopoverStyles, attributes: infoPopoverAttributes } = usePopper(referenceElement, popperElement, {
+ placement: "bottom-start",
+ });
+ // store hooks
+ const { getUserDetails } = useMember();
+ // derived values
+ const editorInformation = page.updated_by ? getUserDetails(page.updated_by) : undefined;
+ const creatorInformation = page.owned_by ? getUserDetails(page.owned_by) : undefined;
+
+ const documentsInfo = page.editorRef?.getDocumentInfo() || { words: 0, characters: 0, paragraphs: 0 };
+
+ const secondsToReadableTime = () => {
+ const wordsCount = documentsInfo.words;
+ const readTimeInSeconds = Number(getReadTimeFromWordsCount(wordsCount).toFixed(0));
+ return readTimeInSeconds < 60 ? `${readTimeInSeconds}s` : `${Math.ceil(readTimeInSeconds / 60)}m`;
+ };
+
+ const documentInfoCards = [
+ {
+ key: "words-count",
+ title: "Words",
+ info: documentsInfo.words,
+ },
+ {
+ key: "characters-count",
+ title: "Characters",
+ info: documentsInfo.characters,
+ },
+ {
+ key: "paragraphs-count",
+ title: "Paragraphs",
+ info: documentsInfo.paragraphs,
+ },
+ {
+ key: "read-time",
+ title: "Read time",
+ info: secondsToReadableTime(),
+ },
+ ];
+
+ return (
+ setIsPopoverOpen(true)}
+ onMouseLeave={() => setIsPopoverOpen(false)}
+ >
+
+
+
+ {isPopoverOpen && (
+
+
+ {documentInfoCards.map((card) => (
+
+
{card.info}
+
{card.title}
+
+ ))}
+
+
+
+
Edited by
+
+
+
+ {editorInformation?.display_name}{" "}
+ {calculateTimeAgoShort(page.updated_at ?? "")} ago
+
+
+
+
+
Created by
+
+
+
+ {creatorInformation?.display_name} {" "}
+ {renderFormattedDate(page.created_at)}
+
+
+
+
+
+ )}
+
+ );
+});
diff --git a/dev-wiki/core/components/pages/editor/toolbar/options-dropdown.tsx b/dev-wiki/core/components/pages/editor/toolbar/options-dropdown.tsx
new file mode 100644
index 0000000000..f868e68533
--- /dev/null
+++ b/dev-wiki/core/components/pages/editor/toolbar/options-dropdown.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { observer } from "mobx-react";
+import { useRouter } from "next/navigation";
+import { ArrowUpToLine, Clipboard, History } from "lucide-react";
+// plane imports
+import { TContextMenuItem, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
+// components
+import { PageActions, TPageActions } from "@/components/pages";
+// helpers
+import { copyTextToClipboard } from "@/helpers/string.helper";
+// hooks
+import { usePageFilters } from "@/hooks/use-page-filters";
+import { useQueryParams } from "@/hooks/use-query-params";
+// plane web hooks
+import { EPageStoreType } from "@/plane-web/hooks/store/use-page-store";
+// store
+import { TPageInstance } from "@/store/pages/base-page";
+
+type Props = {
+ page: TPageInstance;
+ storeType: EPageStoreType;
+};
+
+export const PageOptionsDropdown: React.FC = observer((props) => {
+ const { page, storeType } = props;
+ // states
+ const [isExportModalOpen, setIsExportModalOpen] = useState(false);
+ // router
+ const router = useRouter();
+ // store values
+ const { name, isContentEditable, editorRef } = page;
+ // page filters
+ const { isFullWidth, handleFullWidth, isStickyToolbarEnabled, handleStickyToolbar } = usePageFilters();
+ // update query params
+ const { updateQueryParams } = useQueryParams();
+ // menu items list
+ const EXTRA_MENU_OPTIONS: (TContextMenuItem & { key: TPageActions })[] = useMemo(
+ () => [
+ {
+ key: "full-screen",
+ action: () => handleFullWidth(!isFullWidth),
+ customContent: (
+ <>
+ Full width
+ {}} />
+ >
+ ),
+ className: "flex items-center justify-between gap-2",
+ },
+ {
+ key: "sticky-toolbar",
+ action: () => handleStickyToolbar(!isStickyToolbarEnabled),
+ customContent: (
+ <>
+ Sticky toolbar
+ {}} />
+ >
+ ),
+ className: "flex items-center justify-between gap-2",
+ shouldRender: isContentEditable,
+ },
+ {
+ key: "copy-markdown",
+ action: () => {
+ if (!editorRef) return;
+ copyTextToClipboard(editorRef.getMarkDown()).then(() =>
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success!",
+ message: "Markdown copied to clipboard.",
+ })
+ );
+ },
+ title: "Copy markdown",
+ icon: Clipboard,
+ shouldRender: true,
+ },
+ {
+ key: "version-history",
+ action: () => {
+ // add query param, version=current to the route
+ const updatedRoute = updateQueryParams({
+ paramsToAdd: { version: "current" },
+ });
+ router.push(updatedRoute);
+ },
+ title: "Version history",
+ icon: History,
+ shouldRender: true,
+ },
+ {
+ key: "export",
+ action: () => setIsExportModalOpen(true),
+ title: "Export",
+ icon: ArrowUpToLine,
+ shouldRender: true,
+ },
+ ],
+ [
+ editorRef,
+ handleFullWidth,
+ handleStickyToolbar,
+ isContentEditable,
+ isFullWidth,
+ isStickyToolbarEnabled,
+ router,
+ updateQueryParams,
+ ]
+ );
+
+ return (
+ <>
+
+ >
+ );
+});
diff --git a/dev-wiki/core/components/pages/editor/toolbar/root.tsx b/dev-wiki/core/components/pages/editor/toolbar/root.tsx
new file mode 100644
index 0000000000..e1e301c924
--- /dev/null
+++ b/dev-wiki/core/components/pages/editor/toolbar/root.tsx
@@ -0,0 +1,48 @@
+import { observer } from "mobx-react";
+// components
+import { PageToolbar } from "@/components/pages";
+// helpers
+import { cn } from "@/helpers/common.helper";
+// hooks
+import { usePageFilters } from "@/hooks/use-page-filters";
+// plane web hooks
+import { EPageStoreType } from "@/plane-web/hooks/store/use-page-store";
+// store
+import { TPageInstance } from "@/store/pages/base-page";
+
+type Props = {
+ page: TPageInstance;
+ storeType: EPageStoreType;
+};
+
+export const PageEditorToolbarRoot: React.FC = observer((props) => {
+ const { page } = props;
+ // derived values
+ const { isContentEditable, editorRef } = page;
+ // page filters
+ const { isFullWidth, isStickyToolbarEnabled } = usePageFilters();
+ // derived values
+ const shouldHideToolbar = !isStickyToolbarEnabled || !isContentEditable;
+
+ return (
+
+ );
+});
diff --git a/dev-wiki/core/components/pages/editor/toolbar/toolbar.tsx b/dev-wiki/core/components/pages/editor/toolbar/toolbar.tsx
new file mode 100644
index 0000000000..a3077f7593
--- /dev/null
+++ b/dev-wiki/core/components/pages/editor/toolbar/toolbar.tsx
@@ -0,0 +1,167 @@
+"use client";
+
+import React, { useEffect, useState, useCallback } from "react";
+import { Check, ChevronDown } from "lucide-react";
+// editor
+import { EditorRefApi } from "@plane/editor";
+// ui
+import { CustomMenu, Tooltip } from "@plane/ui";
+// components
+import { ColorDropdown } from "@/components/pages";
+// constants
+import { TOOLBAR_ITEMS, TYPOGRAPHY_ITEMS, ToolbarMenuItem } from "@/constants/editor";
+// helpers
+import { cn } from "@/helpers/common.helper";
+
+type Props = {
+ editorRef: EditorRefApi;
+};
+
+type ToolbarButtonProps = {
+ item: ToolbarMenuItem;
+ isActive: boolean;
+ executeCommand: EditorRefApi["executeMenuItemCommand"];
+};
+
+const ToolbarButton: React.FC = React.memo((props) => {
+ const { item, isActive, executeCommand } = props;
+
+ return (
+
+ {item.name}
+ {item.shortcut && {item.shortcut.join(" + ")} }
+
+ }
+ >
+
+ // TODO: update this while toolbar homogenization
+ // @ts-expect-error type mismatch here
+ executeCommand({
+ itemKey: item.itemKey,
+ ...item.extraProps,
+ })
+ }
+ className={cn(
+ "grid size-7 place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 transition-all duration-200 ease-in-out",
+ {
+ "bg-custom-background-80 text-custom-text-100": isActive,
+ }
+ )}
+ >
+
+
+
+ );
+});
+
+ToolbarButton.displayName = "ToolbarButton";
+
+const toolbarItems = TOOLBAR_ITEMS.document;
+
+export const PageToolbar: React.FC = (props) => {
+ const { editorRef } = props;
+ // states
+ const [activeStates, setActiveStates] = useState>({});
+
+ const updateActiveStates = useCallback(() => {
+ const newActiveStates: Record = {};
+ Object.values(toolbarItems)
+ .flat()
+ .forEach((item) => {
+ // TODO: update this while toolbar homogenization
+ // @ts-expect-error type mismatch here
+ newActiveStates[item.renderKey] = editorRef.isMenuItemActive({
+ itemKey: item.itemKey,
+ ...item.extraProps,
+ });
+ });
+ setActiveStates(newActiveStates);
+ }, [editorRef]);
+
+ useEffect(() => {
+ const unsubscribe = editorRef.onStateChange(updateActiveStates);
+ updateActiveStates();
+ return () => unsubscribe();
+ }, [editorRef, updateActiveStates]);
+
+ const activeTypography = TYPOGRAPHY_ITEMS.find((item) =>
+ editorRef.isMenuItemActive({
+ itemKey: item.itemKey,
+ ...item.extraProps,
+ })
+ );
+
+ return (
+
+
+ {activeTypography?.name || "Text"}
+
+
+ }
+ className="pr-2"
+ placement="bottom-start"
+ closeOnSelect
+ maxHeight="lg"
+ >
+ {TYPOGRAPHY_ITEMS.map((item) => (
+
+ editorRef.executeMenuItemCommand({
+ itemKey: item.itemKey,
+ ...item.extraProps,
+ })
+ }
+ >
+
+
+ {item.name}
+
+ {activeTypography?.itemKey === item.itemKey && (
+
+ )}
+
+ ))}
+
+
+
+ editorRef.executeMenuItemCommand({
+ itemKey: key,
+ color,
+ })
+ }
+ isColorActive={(key, color) =>
+ editorRef.isMenuItemActive({
+ itemKey: key,
+ color,
+ })
+ }
+ />
+
+ {Object.keys(toolbarItems).map((key) => (
+
+ {toolbarItems[key].map((item) => (
+
+ ))}
+
+ ))}
+
+ );
+};
diff --git a/dev-wiki/core/components/pages/header/actions.tsx b/dev-wiki/core/components/pages/header/actions.tsx
new file mode 100644
index 0000000000..f49cd4fd91
--- /dev/null
+++ b/dev-wiki/core/components/pages/header/actions.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { observer } from "mobx-react";
+// components
+import { PageInfoPopover, PageOptionsDropdown } from "@/components/pages";
+// plane web components
+import { PageLockControl } from "@/plane-web/components/pages/header/lock-control";
+// plane web hooks
+import { EPageStoreType } from "@/plane-web/hooks/store";
+// store
+import { TPageInstance } from "@/store/pages/base-page";
+// local imports
+import { PageArchivedBadge } from "./archived-badge";
+import { PageCopyLinkControl } from "./copy-link-control";
+import { PageFavoriteControl } from "./favorite-control";
+import { PageOfflineBadge } from "./offline-badge";
+
+type Props = {
+ page: TPageInstance;
+ storeType: EPageStoreType;
+};
+
+export const PageHeaderActions: React.FC = observer((props) => {
+ const { page, storeType } = props;
+
+ return (
+
+ );
+});
diff --git a/dev-wiki/core/components/pages/header/archived-badge.tsx b/dev-wiki/core/components/pages/header/archived-badge.tsx
new file mode 100644
index 0000000000..24f239c875
--- /dev/null
+++ b/dev-wiki/core/components/pages/header/archived-badge.tsx
@@ -0,0 +1,21 @@
+import { observer } from "mobx-react";
+// plane imports
+import { ArchiveIcon } from "@plane/ui";
+import { renderFormattedDate } from "@plane/utils";
+// store
+import { TPageInstance } from "@/store/pages/base-page";
+
+type Props = {
+ page: TPageInstance;
+};
+
+export const PageArchivedBadge = observer(({ page }: Props) => {
+ if (!page.archived_at) return null;
+
+ return (
+
+
+
Archived at {renderFormattedDate(page.archived_at)}
+
+ );
+});
diff --git a/dev-wiki/core/components/pages/header/copy-link-control.tsx b/dev-wiki/core/components/pages/header/copy-link-control.tsx
new file mode 100644
index 0000000000..b7827d901a
--- /dev/null
+++ b/dev-wiki/core/components/pages/header/copy-link-control.tsx
@@ -0,0 +1,76 @@
+import { useState, useRef, useEffect, useCallback } from "react";
+import { observer } from "mobx-react";
+import { Link, Check } from "lucide-react";
+// plane imports
+import { Tooltip } from "@plane/ui";
+import { cn } from "@plane/utils";
+// hooks
+import { usePageOperations } from "@/hooks/use-page-operations";
+// store
+import { TPageInstance } from "@/store/pages/base-page";
+
+type Props = {
+ page: TPageInstance;
+};
+
+export const PageCopyLinkControl = observer(({ page }: Props) => {
+ const [isCopied, setIsCopied] = useState(false);
+ const timerRef = useRef | null>(null);
+
+ // page operations
+ const { pageOperations } = usePageOperations({
+ page,
+ });
+
+ // Cleanup timer on unmount
+ useEffect(
+ () => () => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ }
+ },
+ []
+ );
+
+ const handleCopy = useCallback(() => {
+ pageOperations.copyLink();
+ setIsCopied(true);
+
+ // Clear previous timer if it exists
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ }
+
+ // Set new timer
+ timerRef.current = setTimeout(() => {
+ setIsCopied(false);
+ timerRef.current = null;
+ }, 1000);
+ }, [pageOperations]);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+});
diff --git a/dev-wiki/core/components/pages/header/favorite-control.tsx b/dev-wiki/core/components/pages/header/favorite-control.tsx
new file mode 100644
index 0000000000..948ce4f522
--- /dev/null
+++ b/dev-wiki/core/components/pages/header/favorite-control.tsx
@@ -0,0 +1,31 @@
+import { observer } from "mobx-react";
+// plane imports
+import { FavoriteStar } from "@plane/ui";
+// hooks
+import { usePageOperations } from "@/hooks/use-page-operations";
+// store
+import { TPageInstance } from "@/store/pages/base-page";
+
+type Props = {
+ page: TPageInstance;
+};
+
+export const PageFavoriteControl = observer(({ page }: Props) => {
+ // derived values
+ const { is_favorite, canCurrentUserFavoritePage } = page;
+ // page operations
+ const { pageOperations } = usePageOperations({
+ page,
+ });
+
+ if (!canCurrentUserFavoritePage) return null;
+
+ return (
+
+ );
+});
diff --git a/dev-wiki/core/components/pages/header/offline-badge.tsx b/dev-wiki/core/components/pages/header/offline-badge.tsx
new file mode 100644
index 0000000000..caea21960a
--- /dev/null
+++ b/dev-wiki/core/components/pages/header/offline-badge.tsx
@@ -0,0 +1,24 @@
+import { WifiOff } from "lucide-react";
+// plane imports
+import { Tooltip } from "@plane/ui";
+// hooks
+import useOnlineStatus from "@/hooks/use-online-status";
+
+export const PageOfflineBadge = () => {
+ // use online status
+ const { isOnline } = useOnlineStatus();
+
+ if (isOnline) return null;
+
+ return (
+
+
+
+ Offline
+
+
+ );
+};
diff --git a/dev-wiki/core/components/pages/header/syncing-badge.tsx b/dev-wiki/core/components/pages/header/syncing-badge.tsx
new file mode 100644
index 0000000000..864846ca9a
--- /dev/null
+++ b/dev-wiki/core/components/pages/header/syncing-badge.tsx
@@ -0,0 +1,72 @@
+import { CloudOff } from "lucide-react";
+import { Tooltip } from "@plane/ui";
+import { useState, useEffect } from "react";
+
+type Props = {
+ syncStatus: "syncing" | "synced" | "error";
+};
+
+export const PageSyncingBadge = ({ syncStatus }: Props) => {
+ const [prevSyncStatus, setPrevSyncStatus] = useState<"syncing" | "synced" | "error" | null>(null);
+ const [isVisible, setIsVisible] = useState(syncStatus !== "synced");
+
+ useEffect(() => {
+ // Only handle transitions when there's a change
+ if (prevSyncStatus !== syncStatus) {
+ if (syncStatus === "synced") {
+ // Delay hiding to allow exit animation to complete
+ setTimeout(() => {
+ setIsVisible(false);
+ }, 300); // match animation duration
+ } else {
+ setIsVisible(true);
+ }
+ setPrevSyncStatus(syncStatus);
+ }
+ }, [syncStatus, prevSyncStatus]);
+
+ if (!isVisible || syncStatus === "synced") return null;
+
+ const badgeContent = {
+ syncing: {
+ label: "Syncing...",
+ tooltipHeading: "Syncing...",
+ tooltipContent: "Your changes are being synced with the server. You can continue making changes.",
+ bgColor: "bg-custom-primary-100/20",
+ textColor: "text-custom-primary-100",
+ pulseColor: "bg-custom-primary-100",
+ pulseBgColor: "bg-custom-primary-100/30",
+ icon: null,
+ },
+ error: {
+ label: "Connection lost",
+ tooltipHeading: "Connection lost",
+ tooltipContent:
+ "We're having trouble connecting to the websocket server. Your changes will be synced and saved every 10 seconds.",
+ bgColor: "bg-red-500/20",
+ textColor: "text-red-500",
+ icon: ,
+ },
+ };
+
+ // This way we guarantee badgeContent is defined
+ const content = badgeContent[syncStatus];
+
+ return (
+
+
+ {syncStatus === "syncing" ? (
+
+ ) : (
+ content.icon
+ )}
+
{content.label}
+
+
+ );
+};
diff --git a/dev-wiki/core/components/pages/index.ts b/dev-wiki/core/components/pages/index.ts
new file mode 100644
index 0000000000..3bd60db528
--- /dev/null
+++ b/dev-wiki/core/components/pages/index.ts
@@ -0,0 +1,6 @@
+export * from "./dropdowns";
+export * from "./editor";
+export * from "./list";
+export * from "./loaders";
+export * from "./modals";
+export * from "./version";
diff --git a/dev-wiki/core/components/pages/list/applied-filters/index.ts b/dev-wiki/core/components/pages/list/applied-filters/index.ts
new file mode 100644
index 0000000000..1efe34c51e
--- /dev/null
+++ b/dev-wiki/core/components/pages/list/applied-filters/index.ts
@@ -0,0 +1 @@
+export * from "./root";
diff --git a/dev-wiki/core/components/pages/list/applied-filters/root.tsx b/dev-wiki/core/components/pages/list/applied-filters/root.tsx
new file mode 100644
index 0000000000..df8063b281
--- /dev/null
+++ b/dev-wiki/core/components/pages/list/applied-filters/root.tsx
@@ -0,0 +1,79 @@
+import { X } from "lucide-react";
+import { useTranslation } from "@plane/i18n";
+import { TPageFilterProps } from "@plane/types";
+// components
+import { Tag } from "@plane/ui";
+import { AppliedDateFilters, AppliedMembersFilters } from "@/components/common/applied-filters";
+// helpers
+import { replaceUnderscoreIfSnakeCase } from "@/helpers/string.helper";
+// types
+
+type Props = {
+ appliedFilters: TPageFilterProps;
+ handleClearAllFilters: () => void;
+ handleRemoveFilter: (key: keyof TPageFilterProps, value: string | null) => void;
+ alwaysAllowEditing?: boolean;
+};
+
+const MEMBERS_FILTERS = ["created_by"];
+const DATE_FILTERS = ["created_at"];
+
+export const PageAppliedFiltersList: React.FC = (props) => {
+ const { appliedFilters, handleClearAllFilters, handleRemoveFilter, alwaysAllowEditing } = props;
+ const { t } = useTranslation();
+
+ if (!appliedFilters) return null;
+ if (Object.keys(appliedFilters).length === 0) return null;
+
+ const isEditingAllowed = alwaysAllowEditing;
+
+ return (
+
+ {Object.entries(appliedFilters).map(([key, value]) => {
+ const filterKey = key as keyof TPageFilterProps;
+
+ if (!value) return;
+ if (Array.isArray(value) && value.length === 0) return;
+
+ return (
+
+
+
{replaceUnderscoreIfSnakeCase(filterKey)}
+ {DATE_FILTERS.includes(filterKey) && (
+
handleRemoveFilter(filterKey, val)}
+ values={Array.isArray(value) ? value : []}
+ />
+ )}
+ {MEMBERS_FILTERS.includes(filterKey) && (
+ handleRemoveFilter(filterKey, val)}
+ values={Array.isArray(value) ? value : []}
+ />
+ )}
+ {isEditingAllowed && (
+ handleRemoveFilter(filterKey, null)}
+ >
+
+
+ )}
+
+
+ );
+ })}
+ {isEditingAllowed && (
+
+
+ {t("common.clear_all")}
+
+
+
+ )}
+
+ );
+};
diff --git a/dev-wiki/core/components/pages/list/block-item-action.tsx b/dev-wiki/core/components/pages/list/block-item-action.tsx
new file mode 100644
index 0000000000..326923d063
--- /dev/null
+++ b/dev-wiki/core/components/pages/list/block-item-action.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import React, { FC } from "react";
+import { observer } from "mobx-react";
+import { Earth, Info, Lock, Minus } from "lucide-react";
+// ui
+import { Avatar, FavoriteStar, Tooltip } from "@plane/ui";
+// components
+import { PageActions } from "@/components/pages";
+// helpers
+import { renderFormattedDate } from "@/helpers/date-time.helper";
+import { getFileURL } from "@/helpers/file.helper";
+// hooks
+import { useMember } from "@/hooks/store";
+import { usePageOperations } from "@/hooks/use-page-operations";
+// plane web hooks
+import { EPageStoreType } from "@/plane-web/hooks/store";
+// store
+import { TPageInstance } from "@/store/pages/base-page";
+
+type Props = {
+ page: TPageInstance;
+ parentRef: React.RefObject;
+ storeType: EPageStoreType;
+};
+
+export const BlockItemAction: FC = observer((props) => {
+ const { page, parentRef, storeType } = props;
+ // store hooks
+ const { getUserDetails } = useMember();
+ // page operations
+ const { pageOperations } = usePageOperations({
+ page,
+ });
+ // derived values
+ const { access, created_at, is_favorite, owned_by, canCurrentUserFavoritePage } = page;
+ const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined;
+
+ return (
+ <>
+ {/* page details */}
+
+
+
+ {access === 0 ? : }
+
+
+ {/* vertical divider */}
+
+
+ {/* page info */}
+
+
+
+
+
+
+ {/* favorite/unfavorite */}
+ {canCurrentUserFavoritePage && (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ pageOperations.toggleFavorite();
+ }}
+ selected={is_favorite}
+ />
+ )}
+
+ {/* quick actions dropdown */}
+
+ >
+ );
+});
diff --git a/dev-wiki/core/components/pages/list/block-root.tsx b/dev-wiki/core/components/pages/list/block-root.tsx
new file mode 100644
index 0000000000..7b1192951c
--- /dev/null
+++ b/dev-wiki/core/components/pages/list/block-root.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { useState } from "react";
+import { observer } from "mobx-react";
+// plane types
+import { TPageNavigationTabs } from "@plane/types";
+// plane web hooks
+import { usePage } from "@/plane-web/hooks/store/use-page";
+import { EPageStoreType } from "@/plane-web/hooks/store/use-page-store";
+// local components
+import { PageListBlock } from "./block";
+
+type TPageListBlock = {
+ paddingLeft: number;
+ pageId: string;
+ storeType: EPageStoreType;
+ pageType?: TPageNavigationTabs;
+};
+
+export const PageListBlockRoot: React.FC = observer((props) => {
+ const { paddingLeft, pageId, storeType, pageType } = props;
+ // states
+ const [isExpanded, setIsExpanded] = useState(false);
+ // store hooks
+ const page = usePage({
+ pageId,
+ storeType,
+ });
+ // derived values
+ const { sub_pages_count, subPageIds } = page ?? {};
+ const shouldShowSubPages = isExpanded && sub_pages_count !== undefined && sub_pages_count > 0;
+
+ return (
+ <>
+ setIsExpanded((prev) => !prev)}
+ isExpanded={isExpanded}
+ paddingLeft={paddingLeft}
+ pageId={pageId}
+ storeType={storeType}
+ pageType={pageType}
+ />
+ {shouldShowSubPages &&
+ subPageIds?.map((pageId) => (
+
+ ))}
+ >
+ );
+});
diff --git a/dev-wiki/core/components/pages/list/block.tsx b/dev-wiki/core/components/pages/list/block.tsx
new file mode 100644
index 0000000000..ff9d3914ae
--- /dev/null
+++ b/dev-wiki/core/components/pages/list/block.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+import { FC, useCallback, useMemo, useRef, useState } from "react";
+import { observer } from "mobx-react";
+import { useParams } from "next/navigation";
+import { ChevronRight, FileText, Loader } from "lucide-react";
+import { TPageNavigationTabs } from "@plane/types";
+// plane imports
+import { Logo, RestrictedPageIcon, setToast, TOAST_TYPE } from "@plane/ui";
+import { cn } from "@plane/utils";
+import { ListItem } from "@/components/core/list";
+// components
+import { BlockItemAction } from "@/components/pages/list";
+// helpers
+import { getPageName } from "@/helpers/page.helper";
+// hooks
+import { useAppRouter } from "@/hooks/use-app-router";
+import { usePlatformOS } from "@/hooks/use-platform-os";
+import { usePage } from "@/plane-web/hooks/store/use-page";
+// plane web hooks
+import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store/use-page-store";
+
+type TPageListBlock = {
+ handleToggleExpanded: () => void;
+ isExpanded: boolean;
+ paddingLeft: number;
+ pageId: string;
+ storeType: EPageStoreType;
+ pageType?: TPageNavigationTabs;
+};
+
+export const PageListBlock: FC = observer((props) => {
+ const { handleToggleExpanded, isExpanded, paddingLeft, pageId, storeType, pageType } = props;
+ // states
+ const [isFetchingSubPages, setIsFetchingSubPages] = useState(false);
+ // refs
+ const parentRef = useRef(null);
+ // hooks
+ const router = useAppRouter();
+ const page = usePage({
+ pageId,
+ storeType,
+ });
+ const { isMobile } = usePlatformOS();
+ // handle page check
+ // derived values
+ const { workspaceSlug } = useParams();
+ const { isNestedPagesEnabled } = usePageStore(storeType);
+ const isNestedPagesDisabledForPage = useMemo(
+ () => !isNestedPagesEnabled(workspaceSlug?.toString()) && page?.parent_id,
+ [isNestedPagesEnabled, workspaceSlug, page?.parent_id]
+ );
+
+ const handleSubPagesToggle = useCallback(async () => {
+ handleToggleExpanded();
+ setIsFetchingSubPages(true);
+ try {
+ if (!isExpanded) {
+ await page?.fetchSubPages?.();
+ }
+ } catch {
+ handleToggleExpanded();
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: "Failed to fetch sub-pages. Please try again.",
+ });
+ } finally {
+ setIsFetchingSubPages(false);
+ }
+ }, [isExpanded, page, handleToggleExpanded]);
+
+ if (!page) return null;
+
+ const { name, logo_props, getRedirectionLink, sub_pages_count, deleted_at, canCurrentUserAccessPage } = page;
+
+ const shouldShowSubPagesButton = sub_pages_count !== undefined && sub_pages_count > 0;
+ const pageTitle = !isNestedPagesDisabledForPage ? getPageName(name) : `Page upgrade to view page`;
+
+ if (deleted_at) {
+ return null;
+ }
+
+ return (
+
+
router.push(getRedirectionLink?.() ?? "")}
+ leftElementClassName="gap-2"
+ prependTitleElement={
+
+ {shouldShowSubPagesButton ? (
+
{
+ e.stopPropagation();
+ e.preventDefault();
+ handleSubPagesToggle();
+ }}
+ disabled={isFetchingSubPages}
+ data-prevent-NProgress
+ >
+ {isFetchingSubPages ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+
+ )}
+
+ {canCurrentUserAccessPage && !isNestedPagesDisabledForPage ? (
+ logo_props?.in_use ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+
+ }
+ actionableItems={
+ !isNestedPagesDisabledForPage ? (
+
+ ) : undefined
+ }
+ isMobile={isMobile}
+ parentRef={parentRef}
+ />
+
+ );
+});
diff --git a/dev-wiki/core/components/pages/list/filters/index.ts b/dev-wiki/core/components/pages/list/filters/index.ts
new file mode 100644
index 0000000000..1efe34c51e
--- /dev/null
+++ b/dev-wiki/core/components/pages/list/filters/index.ts
@@ -0,0 +1 @@
+export * from "./root";
diff --git a/dev-wiki/core/components/pages/list/filters/root.tsx b/dev-wiki/core/components/pages/list/filters/root.tsx
new file mode 100644
index 0000000000..91ddd194eb
--- /dev/null
+++ b/dev-wiki/core/components/pages/list/filters/root.tsx
@@ -0,0 +1,100 @@
+import { useState } from "react";
+import { observer } from "mobx-react";
+import { Search, X } from "lucide-react";
+import { TPageFilterProps, TPageFilters } from "@plane/types";
+// components
+import { FilterCreatedBy, FilterCreatedDate } from "@/components/common/filters";
+import { usePlatformOS } from "@/hooks/use-platform-os";
+
+type Props = {
+ filters: TPageFilters;
+ handleFiltersUpdate: (filterKey: T, filterValue: TPageFilters[T]) => void;
+ memberIds?: string[] | undefined;
+};
+
+export const PageFiltersSelection: React.FC = observer((props) => {
+ const { filters, handleFiltersUpdate, memberIds } = props;
+ // states
+ const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
+ // store
+ const { isMobile } = usePlatformOS();
+
+ const handleFilters = (key: keyof TPageFilterProps, value: boolean | string | string[]) => {
+ const newValues = filters.filters?.[key] ?? [];
+
+ if (typeof newValues === "boolean" && typeof value === "boolean") return;
+
+ if (Array.isArray(newValues)) {
+ if (Array.isArray(value))
+ value.forEach((val) => {
+ if (!newValues.includes(val)) newValues.push(val);
+ else newValues.splice(newValues.indexOf(val), 1);
+ });
+ else if (typeof value === "string") {
+ if (newValues?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
+ else newValues.push(value);
+ }
+ }
+
+ handleFiltersUpdate("filters", {
+ ...filters.filters,
+ [key]: newValues,
+ });
+ };
+
+ return (
+
+
+
+
+ setFiltersSearchQuery(e.target.value)}
+ autoFocus={!isMobile}
+ />
+ {filtersSearchQuery !== "" && (
+ setFiltersSearchQuery("")}>
+
+
+ )}
+
+
+
+ {/*
*/}
+ {/* */}
+ {/* handleFiltersUpdate("filters", { */}
+ {/* ...filters.filters, */}
+ {/* favorites: !filters.filters?.favorites, */}
+ {/* }) */}
+ {/* } */}
+ {/* title="Favorites" */}
+ {/* /> */}
+ {/*
*/}
+
+ {/* created date */}
+
+ handleFilters("created_at", val)}
+ searchQuery={filtersSearchQuery}
+ />
+
+
+ {/* created by */}
+
+ handleFilters("created_by", val)}
+ searchQuery={filtersSearchQuery}
+ memberIds={memberIds}
+ />
+
+
+
+ );
+});
diff --git a/dev-wiki/core/components/pages/list/index.ts b/dev-wiki/core/components/pages/list/index.ts
new file mode 100644
index 0000000000..fc071cff8f
--- /dev/null
+++ b/dev-wiki/core/components/pages/list/index.ts
@@ -0,0 +1,7 @@
+export * from "./applied-filters";
+export * from "./filters";
+export * from "./block-item-action";
+export * from "./block-root";
+export * from "./order-by";
+export * from "./search-input";
+export * from "./tab-navigation";
diff --git a/dev-wiki/core/components/pages/list/order-by.tsx b/dev-wiki/core/components/pages/list/order-by.tsx
new file mode 100644
index 0000000000..5385ee5ae9
--- /dev/null
+++ b/dev-wiki/core/components/pages/list/order-by.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { ArrowDownWideNarrow, ArrowUpWideNarrow, Check, ChevronDown } from "lucide-react";
+// types
+import { TPageFiltersSortBy, TPageFiltersSortKey } from "@plane/types";
+// ui
+import { CustomMenu, getButtonStyling } from "@plane/ui";
+// helpers
+import { cn } from "@/helpers/common.helper";
+
+type Props = {
+ onChange: (value: { key?: TPageFiltersSortKey; order?: TPageFiltersSortBy }) => void;
+ sortBy: TPageFiltersSortBy;
+ sortKey: TPageFiltersSortKey;
+};
+
+const PAGE_SORTING_KEY_OPTIONS: {
+ key: TPageFiltersSortKey;
+ label: string;
+}[] = [
+ { key: "name", label: "Name" },
+ { key: "created_at", label: "Date created" },
+ { key: "updated_at", label: "Date modified" },
+];
+
+export const PageOrderByDropdown: React.FC = (props) => {
+ const { onChange, sortBy, sortKey } = props;
+
+ const orderByDetails = PAGE_SORTING_KEY_OPTIONS.find((option) => sortKey === option.key);
+ const isDescending = sortBy === "desc";
+
+ return (
+
+ {!isDescending ? : }
+ {orderByDetails?.label}
+
+
+ }
+ placement="bottom-end"
+ maxHeight="lg"
+ closeOnSelect
+ >
+ {PAGE_SORTING_KEY_OPTIONS.map((option) => (
+
+ onChange({
+ key: option.key,
+ })
+ }
+ >
+ {option.label}
+ {sortKey === option.key && }
+
+ ))}
+
+ {
+ if (isDescending)
+ onChange({
+ order: "asc",
+ });
+ }}
+ >
+ Ascending
+ {!isDescending && }
+
+ {
+ if (!isDescending)
+ onChange({
+ order: "desc",
+ });
+ }}
+ >
+ Descending
+ {isDescending && }
+
+
+ );
+};
diff --git a/dev-wiki/core/components/pages/list/search-input.tsx b/dev-wiki/core/components/pages/list/search-input.tsx
new file mode 100644
index 0000000000..85915c148a
--- /dev/null
+++ b/dev-wiki/core/components/pages/list/search-input.tsx
@@ -0,0 +1,109 @@
+import { FC, useState, useRef, useEffect, useCallback } from "react";
+import debounce from "lodash/debounce";
+import { Search, X } from "lucide-react";
+// plane helpers
+import { useOutsideClickDetector } from "@plane/hooks";
+// helpers
+import { cn } from "@/helpers/common.helper";
+
+type Props = {
+ searchQuery: string;
+ updateSearchQuery: (val: string) => void;
+};
+
+export const PageSearchInput: FC = (props) => {
+ const { searchQuery, updateSearchQuery } = props;
+ // states
+ const [isSearchOpen, setIsSearchOpen] = useState(false);
+ const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
+ // refs
+ const inputRef = useRef(null);
+
+ // outside click detector hook
+ useOutsideClickDetector(inputRef, () => {
+ if (isSearchOpen && localSearchQuery.trim() === "") setIsSearchOpen(false);
+ });
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const debouncedUpdateSearchQuery = useCallback(
+ debounce((value: string) => {
+ updateSearchQuery(value);
+ }, 300),
+ [updateSearchQuery]
+ );
+
+ // Update local state immediately and trigger debounced store update
+ const handleQueryChange = (value: string) => {
+ setLocalSearchQuery(value);
+ debouncedUpdateSearchQuery(value);
+
+ // Auto-open search when there's content
+ if (value.trim() !== "") setIsSearchOpen(true);
+ };
+
+ // Clean up the debounced function on unmount
+ useEffect(() => () => debouncedUpdateSearchQuery.cancel(), [debouncedUpdateSearchQuery]);
+
+ // Set initial local state from props
+ useEffect(() => {
+ setLocalSearchQuery(searchQuery);
+ }, [searchQuery]);
+
+ const handleInputKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Escape") {
+ if (localSearchQuery && localSearchQuery.trim() !== "") {
+ handleQueryChange("");
+ } else {
+ setIsSearchOpen(false);
+ inputRef.current?.blur();
+ }
+ }
+ };
+
+ return (
+
+ {!isSearchOpen && (
+
{
+ setIsSearchOpen(true);
+ inputRef.current?.focus();
+ }}
+ >
+
+
+ )}
+
+
+ handleQueryChange(e.target.value)}
+ onKeyDown={handleInputKeyDown}
+ />
+ {isSearchOpen && (
+ {
+ handleQueryChange("");
+ setIsSearchOpen(false);
+ }}
+ >
+
+
+ )}
+
+
+ );
+};
diff --git a/dev-wiki/core/components/pages/list/tab-navigation.tsx b/dev-wiki/core/components/pages/list/tab-navigation.tsx
new file mode 100644
index 0000000000..fc28d50a23
--- /dev/null
+++ b/dev-wiki/core/components/pages/list/tab-navigation.tsx
@@ -0,0 +1,61 @@
+import { FC } from "react";
+import Link from "next/link";
+// types
+import { TPageNavigationTabs } from "@plane/types";
+// helpers
+import { cn } from "@/helpers/common.helper";
+
+type TPageTabNavigation = {
+ workspaceSlug: string;
+ projectId: string;
+ pageType: TPageNavigationTabs;
+};
+
+// pages tab options
+const pageTabs: { key: TPageNavigationTabs; label: string }[] = [
+ {
+ key: "public",
+ label: "Public",
+ },
+ {
+ key: "private",
+ label: "Private",
+ },
+ {
+ key: "archived",
+ label: "Archived",
+ },
+];
+
+export const PageTabNavigation: FC = (props) => {
+ const { workspaceSlug, projectId, pageType } = props;
+
+ const handleTabClick = (e: React.MouseEvent, tabKey: TPageNavigationTabs) => {
+ if (tabKey === pageType) e.preventDefault();
+ };
+
+ return (
+
+ {pageTabs.map((tab) => (
+
handleTabClick(e, tab.key)}
+ >
+
+ {tab.label}
+
+
+
+ ))}
+
+ );
+};
diff --git a/dev-wiki/core/components/pages/loaders/index.ts b/dev-wiki/core/components/pages/loaders/index.ts
new file mode 100644
index 0000000000..8760eede6c
--- /dev/null
+++ b/dev-wiki/core/components/pages/loaders/index.ts
@@ -0,0 +1,2 @@
+export * from "./page-content-loader";
+export * from "./page-loader";
diff --git a/dev-wiki/core/components/pages/loaders/page-content-loader.tsx b/dev-wiki/core/components/pages/loaders/page-content-loader.tsx
new file mode 100644
index 0000000000..2e00bdc6aa
--- /dev/null
+++ b/dev-wiki/core/components/pages/loaders/page-content-loader.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+// plane imports
+import { Loader } from "@plane/ui";
+import { cn } from "@plane/utils";
+
+type Props = {
+ className?: string;
+};
+
+export const PageContentLoader = (props: Props) => {
+ const { className } = props;
+
+ return (
+
+ {/* header */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* content */}
+
+ {/* editor loader */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/dev-wiki/core/components/pages/loaders/page-loader.tsx b/dev-wiki/core/components/pages/loaders/page-loader.tsx
new file mode 100644
index 0000000000..974c28f6b8
--- /dev/null
+++ b/dev-wiki/core/components/pages/loaders/page-loader.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import range from "lodash/range";
+import { Loader } from "@plane/ui";
+
+export const PageLoader: React.FC = (props) => {
+ const {} = props;
+
+ return (
+
+
+
+ {range(10).map((i) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/dev-wiki/core/components/pages/modals/create-page-modal.tsx b/dev-wiki/core/components/pages/modals/create-page-modal.tsx
new file mode 100644
index 0000000000..c5d956aa65
--- /dev/null
+++ b/dev-wiki/core/components/pages/modals/create-page-modal.tsx
@@ -0,0 +1,84 @@
+import { FC, useEffect, useState } from "react";
+// constants
+import { EPageAccess, PAGE_CREATED } from "@plane/constants";
+import { TPage } from "@plane/types";
+// ui
+import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
+// components
+import { PageForm } from "@/components/pages";
+// hooks
+import { useAppRouter } from "@/hooks/use-app-router";
+// plane web hooks
+import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store/use-page-store";
+
+type Props = {
+ workspaceSlug: string;
+ projectId: string;
+ isModalOpen: boolean;
+ pageAccess?: EPageAccess;
+ handleModalClose: () => void;
+ redirectionEnabled?: boolean;
+ storeType: EPageStoreType;
+};
+
+export const CreatePageModal: FC = (props) => {
+ const {
+ workspaceSlug,
+ projectId,
+ isModalOpen,
+ pageAccess,
+ handleModalClose,
+ redirectionEnabled = false,
+ storeType,
+ } = props;
+ // states
+ const [pageFormData, setPageFormData] = useState>({
+ id: undefined,
+ name: "",
+ logo_props: undefined,
+ });
+ // router
+ const router = useAppRouter();
+ // store hooks
+ const { createPage } = usePageStore(storeType);
+ const handlePageFormData = (key: T, value: TPage[T]) =>
+ setPageFormData((prev) => ({ ...prev, [key]: value }));
+
+ // update page access in form data when page access from the store changes
+ useEffect(() => {
+ setPageFormData((prev) => ({ ...prev, access: pageAccess }));
+ }, [pageAccess]);
+
+ const handleStateClear = () => {
+ setPageFormData({ id: undefined, name: "", access: pageAccess });
+ handleModalClose();
+ };
+
+ const handleFormSubmit = async () => {
+ if (!workspaceSlug || !projectId) return;
+
+ try {
+ const pageData = await createPage?.(pageFormData);
+ if (pageData) {
+ handleStateClear();
+ if (redirectionEnabled) router.push(`/${workspaceSlug}/projects/${projectId}/pages/${pageData.id}`);
+ }
+ } catch {}
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/dev-wiki/core/components/pages/modals/delete-page-modal.tsx b/dev-wiki/core/components/pages/modals/delete-page-modal.tsx
new file mode 100644
index 0000000000..b9fe8ec245
--- /dev/null
+++ b/dev-wiki/core/components/pages/modals/delete-page-modal.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import React, { useState } from "react";
+import { observer } from "mobx-react";
+import { useParams, useRouter } from "next/navigation";
+// ui
+import { EditorRefApi } from "@plane/editor";
+import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui";
+// constants
+import { getPageName } from "@/helpers/page.helper";
+// plane web hooks
+import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store/use-page-store";
+// store
+import { TPageInstance } from "@/store/pages/base-page";
+
+type TConfirmPageDeletionProps = {
+ isOpen: boolean;
+ onClose: () => void;
+ page: TPageInstance;
+ storeType: EPageStoreType;
+ editorRef?: React.MutableRefObject;
+};
+
+export const DeletePageModal: React.FC = observer((props) => {
+ const { isOpen, onClose, page, storeType, editorRef } = props;
+ const { workspaceSlug } = useParams();
+ // states
+ const [isDeleting, setIsDeleting] = useState(false);
+ // store hooks
+ const { removePage } = usePageStore(storeType);
+ const router = useRouter();
+ if (!page || !page.id) return null;
+ // derived values
+ const { id: pageId, name } = page;
+
+ const handleClose = () => {
+ setIsDeleting(false);
+ onClose();
+ };
+
+ const { pageId: routePageId } = useParams();
+
+ const handleDelete = async () => {
+ setIsDeleting(true);
+ await removePage({ pageId })
+ .then(() => {
+ editorRef?.current?.findAndDeleteNode(
+ { attribute: "entity_identifier", value: page.id ?? "" },
+ "pageEmbedComponent"
+ );
+
+ handleClose();
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success!",
+ message: "Page deleted successfully.",
+ });
+
+ if (routePageId) {
+ router.back();
+ }
+ })
+ .catch(() => {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: "Page could not be deleted. Please try again.",
+ });
+ });
+
+ setIsDeleting(false);
+ };
+
+ return (
+
+ Are you sure you want to delete page -{" "}
+ {getPageName(name)} ? The Page
+ will be deleted permanently. This action cannot be undone.
+ >
+ }
+ />
+ );
+});
diff --git a/dev-wiki/core/components/pages/modals/index.ts b/dev-wiki/core/components/pages/modals/index.ts
new file mode 100644
index 0000000000..8f52e3adde
--- /dev/null
+++ b/dev-wiki/core/components/pages/modals/index.ts
@@ -0,0 +1,3 @@
+export * from "./create-page-modal";
+export * from "./delete-page-modal";
+export * from "./page-form";
diff --git a/dev-wiki/core/components/pages/modals/page-form.tsx b/dev-wiki/core/components/pages/modals/page-form.tsx
new file mode 100644
index 0000000000..4bc2a84285
--- /dev/null
+++ b/dev-wiki/core/components/pages/modals/page-form.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+import { FormEvent, useState } from "react";
+// types
+import { FileText, Globe2, Lock, LucideIcon } from "lucide-react";
+// plane imports
+import { ETabIndices, EPageAccess } from "@plane/constants";
+// i18n
+import { useTranslation } from "@plane/i18n";
+import { TPage } from "@plane/types";
+// ui
+import { Button, EmojiIconPicker, EmojiIconPickerTypes, Input } from "@plane/ui";
+import { Logo } from "@/components/common";
+// constants
+import { AccessField } from "@/components/common/access-field";
+// helpers
+import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
+import { getTabIndex } from "@/helpers/tab-indices.helper";
+// hooks
+import { usePlatformOS } from "@/hooks/use-platform-os";
+
+type Props = {
+ formData: Partial;
+ handleFormData: (key: T, value: TPage[T]) => void;
+ handleModalClose: () => void;
+ handleFormSubmit: () => Promise;
+};
+
+const PAGE_ACCESS_SPECIFIERS: {
+ key: EPageAccess;
+ i18n_label: string;
+ icon: LucideIcon;
+}[] = [
+ { key: EPageAccess.PUBLIC, i18n_label: "common.access.public", icon: Globe2 },
+ { key: EPageAccess.PRIVATE, i18n_label: "common.access.private", icon: Lock },
+];
+
+export const PageForm: React.FC = (props) => {
+ const { formData, handleFormData, handleModalClose, handleFormSubmit } = props;
+ // hooks
+ const { isMobile } = usePlatformOS();
+ const { t } = useTranslation();
+ // state
+ const [isOpen, setIsOpen] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const i18n_access_label = PAGE_ACCESS_SPECIFIERS.find((access) => access.key === formData.access)?.i18n_label;
+
+ const { getIndex } = getTabIndex(ETabIndices.PROJECT_PAGE, isMobile);
+
+ const handlePageFormSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ try {
+ setIsSubmitting(true);
+ await handleFormSubmit();
+ setIsSubmitting(false);
+ } catch {
+ setIsSubmitting(false);
+ }
+ };
+
+ const isTitleLengthMoreThan255Character = formData.name ? formData.name.length > 255 : false;
+
+ return (
+
+ );
+};
diff --git a/dev-wiki/core/components/pages/version/editor.tsx b/dev-wiki/core/components/pages/version/editor.tsx
new file mode 100644
index 0000000000..cfaeb4fd28
--- /dev/null
+++ b/dev-wiki/core/components/pages/version/editor.tsx
@@ -0,0 +1,148 @@
+import { useMemo } from "react";
+import { observer } from "mobx-react";
+import { useParams } from "next/navigation";
+// plane editor
+import { DocumentReadOnlyEditorWithRef, TDisplayConfig } from "@plane/editor";
+// plane types
+import { TPage, TPageVersion } from "@plane/types";
+// plane ui
+import { Loader } from "@plane/ui";
+// components
+import { EditorMentionsRoot } from "@/components/editor";
+// hooks
+import { useEditorConfig } from "@/hooks/editor";
+import { useMember, useWorkspace } from "@/hooks/store";
+import { usePageFilters } from "@/hooks/use-page-filters";
+import { EPageStoreType } from "@/plane-web/hooks/store/use-page-store";
+// plane web hooks
+import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
+import { PageEmbedCardRoot } from "@/plane-web/components/pages";
+// import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed";
+
+export type TVersionEditorProps = {
+ activeVersion: string | null;
+ currentVersionDescription: string | null;
+ isCurrentVersionActive: boolean;
+ versionDetails: TPageVersion | undefined;
+ storeType: EPageStoreType;
+};
+
+export const PagesVersionEditor: React.FC = observer((props) => {
+ const { activeVersion, currentVersionDescription, isCurrentVersionActive, versionDetails, storeType } = props;
+ // store hooks
+ const { getUserDetails } = useMember();
+ // params
+ const { workspaceSlug, projectId } = useParams();
+ // store hooks
+ const { getWorkspaceBySlug } = useWorkspace();
+ // derived values
+ const workspaceDetails = getWorkspaceBySlug(workspaceSlug?.toString() ?? "");
+ // editor flaggings
+ const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString() ?? "");
+ // editor config
+ const { getReadOnlyEditorFileHandlers } = useEditorConfig();
+ // issue-embed
+ // const { issueEmbedProps } = useIssueEmbed({
+ // projectId: projectId?.toString() ?? "",
+ // workspaceSlug: workspaceSlug?.toString() ?? "",
+ // });
+ // page filters
+ const { fontSize, fontStyle } = usePageFilters();
+
+ const displayConfig: TDisplayConfig = {
+ fontSize,
+ fontStyle,
+ wideLayout: true,
+ };
+
+ const subPagesDetails = useMemo(
+ () => (versionDetails?.sub_pages_data ? (versionDetails.sub_pages_data as TPage[]) : []),
+ [versionDetails?.sub_pages_data]
+ );
+
+ if (!isCurrentVersionActive && !versionDetails)
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ const description = isCurrentVersionActive ? currentVersionDescription : versionDetails?.description_html;
+ if (description === undefined || description?.trim() === "") return null;
+
+ return (
+ "}
+ containerClassName="p-0 pb-64 border-none"
+ disabledExtensions={disabledExtensions}
+ displayConfig={displayConfig}
+ editorClassName="pl-10"
+ fileHandler={getReadOnlyEditorFileHandlers({
+ projectId: projectId?.toString() ?? "",
+ workspaceId: workspaceDetails?.id ?? "",
+ workspaceSlug: workspaceSlug?.toString() ?? "",
+ })}
+ mentionHandler={{
+ renderComponent: (props) => ,
+ getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? "" }),
+ }}
+ embedHandler={{
+ // issue: {
+ // widgetCallback: issueEmbedProps.widgetCallback,
+ // },
+ page: {
+ widgetCallback: ({ pageId: pageIdFromNode }) => {
+ const pageDetails = subPagesDetails.find((page) => page.id === pageIdFromNode);
+ return (
+
+ );
+ },
+ workspaceSlug: workspaceSlug.toString(),
+ },
+ }}
+ />
+ );
+});
diff --git a/dev-wiki/core/components/pages/version/index.ts b/dev-wiki/core/components/pages/version/index.ts
new file mode 100644
index 0000000000..8e04e4de9e
--- /dev/null
+++ b/dev-wiki/core/components/pages/version/index.ts
@@ -0,0 +1,6 @@
+export * from "./editor";
+export * from "./main-content";
+export * from "./root";
+export * from "./sidebar-list-item";
+export * from "./sidebar-list";
+export * from "./sidebar-root";
diff --git a/dev-wiki/core/components/pages/version/main-content.tsx b/dev-wiki/core/components/pages/version/main-content.tsx
new file mode 100644
index 0000000000..7e423b77c8
--- /dev/null
+++ b/dev-wiki/core/components/pages/version/main-content.tsx
@@ -0,0 +1,130 @@
+import { useState } from "react";
+import { observer } from "mobx-react";
+import useSWR from "swr";
+import { TriangleAlert } from "lucide-react";
+// plane types
+import { TPageVersion } from "@plane/types";
+// plane ui
+import { Button, setToast, TOAST_TYPE } from "@plane/ui";
+// components
+import { TVersionEditorProps } from "@/components/pages";
+// helpers
+import { renderFormattedDate, renderFormattedTime } from "@/helpers/date-time.helper";
+import { EPageStoreType } from "@/plane-web/hooks/store";
+
+type Props = {
+ activeVersion: string | null;
+ currentVersionDescription: string | null;
+ editorComponent: React.FC;
+ fetchVersionDetails: (pageId: string, versionId: string) => Promise;
+ handleClose: () => void;
+ handleRestore: (descriptionHTML: string) => Promise;
+ pageId: string;
+ restoreEnabled: boolean;
+ storeType: EPageStoreType;
+};
+
+export const PageVersionsMainContent: React.FC = observer((props) => {
+ const {
+ activeVersion,
+ currentVersionDescription,
+ editorComponent,
+ fetchVersionDetails,
+ handleClose,
+ handleRestore,
+ pageId,
+ restoreEnabled,
+ storeType,
+ } = props;
+ // states
+ const [isRestoring, setIsRestoring] = useState(false);
+ const [isRetrying, setIsRetrying] = useState(false);
+
+ const {
+ data: versionDetails,
+ error: versionDetailsError,
+ mutate: mutateVersionDetails,
+ } = useSWR(
+ pageId && activeVersion && activeVersion !== "current" ? `PAGE_VERSION_${activeVersion}` : null,
+ pageId && activeVersion && activeVersion !== "current" ? () => fetchVersionDetails(pageId, activeVersion) : null
+ );
+
+ const isCurrentVersionActive = activeVersion === "current";
+
+ const handleRestoreVersion = async () => {
+ if (!restoreEnabled) return;
+ setIsRestoring(true);
+ await handleRestore(versionDetails?.description_html ?? "
")
+ .then(() => {
+ handleClose();
+ })
+ .catch(() =>
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Failed to restore page version.",
+ })
+ )
+ .finally(() => setIsRestoring(false));
+ };
+
+ const handleRetry = async () => {
+ setIsRetrying(true);
+ await mutateVersionDetails();
+ setIsRetrying(false);
+ };
+
+ const VersionEditor = editorComponent;
+
+ return (
+
+ {versionDetailsError ? (
+
+
+
+
+
+
+
Something went wrong!
+
The version could not be loaded, please try again.
+
+
+ Try again
+
+
+
+ ) : (
+ <>
+
+
+ {isCurrentVersionActive
+ ? "Current version"
+ : versionDetails
+ ? `${renderFormattedDate(versionDetails.last_saved_at)} ${renderFormattedTime(versionDetails.last_saved_at)}`
+ : "Loading version details"}
+
+ {!isCurrentVersionActive && restoreEnabled && (
+
+ {isRestoring ? "Restoring" : "Restore"}
+
+ )}
+
+
+
+
+ >
+ )}
+
+ );
+});
diff --git a/dev-wiki/core/components/pages/version/root.tsx b/dev-wiki/core/components/pages/version/root.tsx
new file mode 100644
index 0000000000..6a56df0c39
--- /dev/null
+++ b/dev-wiki/core/components/pages/version/root.tsx
@@ -0,0 +1,72 @@
+import { observer } from "mobx-react";
+// plane types
+import { TPageVersion } from "@plane/types";
+// components
+import { PageVersionsMainContent, PageVersionsSidebarRoot, TVersionEditorProps } from "@/components/pages";
+// helpers
+import { cn } from "@/helpers/common.helper";
+import { EPageStoreType } from "@/plane-web/hooks/store";
+
+type Props = {
+ activeVersion: string | null;
+ currentVersionDescription: string | null;
+ editorComponent: React.FC;
+ fetchAllVersions: (pageId: string) => Promise;
+ fetchVersionDetails: (pageId: string, versionId: string) => Promise;
+ handleRestore: (descriptionHTML: string) => Promise;
+ isOpen: boolean;
+ onClose: () => void;
+ pageId: string;
+ restoreEnabled: boolean;
+ storeType: EPageStoreType;
+};
+
+export const PageVersionsOverlay: React.FC = observer((props) => {
+ const {
+ activeVersion,
+ currentVersionDescription,
+ editorComponent,
+ fetchAllVersions,
+ fetchVersionDetails,
+ handleRestore,
+ isOpen,
+ onClose,
+ pageId,
+ restoreEnabled,
+ storeType,
+ } = props;
+
+ const handleClose = () => {
+ onClose();
+ };
+
+ return (
+
+ );
+});
diff --git a/dev-wiki/core/components/pages/version/sidebar-list-item.tsx b/dev-wiki/core/components/pages/version/sidebar-list-item.tsx
new file mode 100644
index 0000000000..5459bc1663
--- /dev/null
+++ b/dev-wiki/core/components/pages/version/sidebar-list-item.tsx
@@ -0,0 +1,51 @@
+import { observer } from "mobx-react";
+import Link from "next/link";
+// plane imports
+import { useTranslation } from "@plane/i18n";
+import { TPageVersion } from "@plane/types";
+import { Avatar } from "@plane/ui";
+// helpers
+import { cn } from "@/helpers/common.helper";
+import { renderFormattedDate, renderFormattedTime } from "@/helpers/date-time.helper";
+import { getFileURL } from "@/helpers/file.helper";
+// hooks
+import { useMember } from "@/hooks/store";
+
+type Props = {
+ href: string;
+ isActive: boolean;
+ version: TPageVersion;
+};
+
+export const PlaneVersionsSidebarListItem: React.FC = observer((props) => {
+ const { href, isActive, version } = props;
+ // store hooks
+ const { getUserDetails } = useMember();
+ // derived values
+ const ownerDetails = getUserDetails(version.owned_by);
+ // translation
+ const { t } = useTranslation();
+
+ return (
+
+
+ {renderFormattedDate(version.last_saved_at)} {renderFormattedTime(version.last_saved_at)}
+
+
+
+ {ownerDetails?.display_name ?? t("common.deactivated_user")}
+
+
+ );
+});
diff --git a/dev-wiki/core/components/pages/version/sidebar-list.tsx b/dev-wiki/core/components/pages/version/sidebar-list.tsx
new file mode 100644
index 0000000000..cf276742b0
--- /dev/null
+++ b/dev-wiki/core/components/pages/version/sidebar-list.tsx
@@ -0,0 +1,99 @@
+import { useState } from "react";
+import Link from "next/link";
+import useSWR from "swr";
+import { TriangleAlert } from "lucide-react";
+// plane types
+import { TPageVersion } from "@plane/types";
+// plane ui
+import { Button, Loader } from "@plane/ui";
+// components
+import { PlaneVersionsSidebarListItem } from "@/components/pages";
+// helpers
+import { cn } from "@/helpers/common.helper";
+// hooks
+import { useQueryParams } from "@/hooks/use-query-params";
+
+type Props = {
+ activeVersion: string | null;
+ fetchAllVersions: (pageId: string) => Promise;
+ isOpen: boolean;
+ pageId: string;
+};
+
+export const PageVersionsSidebarList: React.FC = (props) => {
+ const { activeVersion, fetchAllVersions, isOpen, pageId } = props;
+ // states
+ const [isRetrying, setIsRetrying] = useState(false);
+ // update query params
+ const { updateQueryParams } = useQueryParams();
+
+ const {
+ data: versionsList,
+ error: versionsListError,
+ mutate: mutateVersionsList,
+ } = useSWR(
+ pageId && isOpen ? `PAGE_VERSIONS_LIST_${pageId}` : null,
+ pageId && isOpen ? () => fetchAllVersions(pageId) : null
+ );
+
+ const handleRetry = async () => {
+ setIsRetrying(true);
+ await mutateVersionsList();
+ setIsRetrying(false);
+ };
+
+ const getVersionLink = (versionID: string) =>
+ updateQueryParams({
+ paramsToAdd: { version: versionID },
+ });
+
+ return (
+
+
+
Current version
+
+ {versionsListError ? (
+
+
+
+
+
+
+
Something went wrong!
+
+ There was a problem while loading previous
+
+ versions, please try again.
+
+
+
+ Try again
+
+
+
+ ) : versionsList ? (
+ versionsList.map((version) => (
+
+ ))
+ ) : (
+
+
+
+
+
+
+
+ )}
+
+ );
+};
diff --git a/dev-wiki/core/components/pages/version/sidebar-root.tsx b/dev-wiki/core/components/pages/version/sidebar-root.tsx
new file mode 100644
index 0000000000..793d7fed90
--- /dev/null
+++ b/dev-wiki/core/components/pages/version/sidebar-root.tsx
@@ -0,0 +1,38 @@
+import { X } from "lucide-react";
+// plane types
+import { TPageVersion } from "@plane/types";
+// components
+import { PageVersionsSidebarList } from "@/components/pages";
+
+type Props = {
+ activeVersion: string | null;
+ fetchAllVersions: (pageId: string) => Promise;
+ handleClose: () => void;
+ isOpen: boolean;
+ pageId: string;
+};
+
+export const PageVersionsSidebarRoot: React.FC = (props) => {
+ const { activeVersion, fetchAllVersions, handleClose, isOpen, pageId } = props;
+
+ return (
+
+
+
Version history
+
+
+
+
+
+
+ );
+};
diff --git a/dev-wiki/core/components/sidebar/index.ts b/dev-wiki/core/components/sidebar/index.ts
new file mode 100644
index 0000000000..b2e6f7602d
--- /dev/null
+++ b/dev-wiki/core/components/sidebar/index.ts
@@ -0,0 +1 @@
+export * from "./sidebar-navigation";
\ No newline at end of file
diff --git a/dev-wiki/core/components/sidebar/sidebar-navigation.tsx b/dev-wiki/core/components/sidebar/sidebar-navigation.tsx
new file mode 100644
index 0000000000..f31a93c9c3
--- /dev/null
+++ b/dev-wiki/core/components/sidebar/sidebar-navigation.tsx
@@ -0,0 +1,29 @@
+"use client";
+import React, { FC } from "react";
+// helpers
+import { cn } from "@/helpers/common.helper";
+
+type TSidebarNavItem = {
+ className?: string;
+ isActive?: boolean;
+ children?: React.ReactNode;
+};
+
+export const SidebarNavItem: FC = (props) => {
+ const { className, isActive, children } = props;
+ return (
+
+ {children}
+
+ );
+};
diff --git a/dev-wiki/core/components/ui/index.ts b/dev-wiki/core/components/ui/index.ts
new file mode 100644
index 0000000000..65abe08bb3
--- /dev/null
+++ b/dev-wiki/core/components/ui/index.ts
@@ -0,0 +1 @@
+export { ViewListLoader } from "./view-list-loader";
diff --git a/dev-wiki/core/components/ui/view-list-loader.tsx b/dev-wiki/core/components/ui/view-list-loader.tsx
new file mode 100644
index 0000000000..71432a5433
--- /dev/null
+++ b/dev-wiki/core/components/ui/view-list-loader.tsx
@@ -0,0 +1,20 @@
+import range from "lodash/range";
+
+export const ViewListLoader = () => (
+
+ {range(8).map((i) => (
+
+ ))}
+
+);
diff --git a/dev-wiki/core/components/user/index.ts b/dev-wiki/core/components/user/index.ts
new file mode 100644
index 0000000000..9c2663b18c
--- /dev/null
+++ b/dev-wiki/core/components/user/index.ts
@@ -0,0 +1 @@
+export * from "./user-greetings";
diff --git a/dev-wiki/core/components/user/user-greetings.tsx b/dev-wiki/core/components/user/user-greetings.tsx
new file mode 100644
index 0000000000..216da7a529
--- /dev/null
+++ b/dev-wiki/core/components/user/user-greetings.tsx
@@ -0,0 +1,52 @@
+import { FC } from "react";
+// hooks
+import { IUser } from "@plane/types";
+import { useCurrentTime } from "@/hooks/use-current-time";
+// types
+
+export interface IUserGreetingsView {
+ user: IUser;
+}
+
+export const UserGreetingsView: FC = (props) => {
+ const { user } = props;
+ // current time hook
+ const { currentTime } = useCurrentTime();
+
+ const hour = new Intl.DateTimeFormat("en-US", {
+ hour12: false,
+ hour: "numeric",
+ }).format(currentTime);
+
+ const date = new Intl.DateTimeFormat("en-US", {
+ month: "short",
+ day: "numeric",
+ }).format(currentTime);
+
+ const weekDay = new Intl.DateTimeFormat("en-US", {
+ weekday: "long",
+ }).format(currentTime);
+
+ const timeString = new Intl.DateTimeFormat("en-US", {
+ timeZone: user?.user_timezone,
+ hour12: false, // Use 24-hour format
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(currentTime);
+
+ const greeting = parseInt(hour, 10) < 12 ? "morning" : parseInt(hour, 10) < 18 ? "afternoon" : "evening";
+
+ return (
+
+
+ Good {greeting}, {user?.first_name} {user?.last_name}
+
+
+ {greeting === "morning" ? "🌤️" : greeting === "afternoon" ? "🌥️" : "🌙️"}
+
+ {weekDay}, {date} {timeString}
+
+
+
+ );
+};
diff --git a/dev-wiki/core/components/workspace/billing/index.ts b/dev-wiki/core/components/workspace/billing/index.ts
new file mode 100644
index 0000000000..75c2cf410a
--- /dev/null
+++ b/dev-wiki/core/components/workspace/billing/index.ts
@@ -0,0 +1 @@
+export * from "./subscription";
diff --git a/dev-wiki/core/components/workspace/billing/subscription.ts b/dev-wiki/core/components/workspace/billing/subscription.ts
new file mode 100644
index 0000000000..e895d7c983
--- /dev/null
+++ b/dev-wiki/core/components/workspace/billing/subscription.ts
@@ -0,0 +1,213 @@
+// plane imports
+import { EProductSubscriptionEnum } from "@plane/constants";
+import { cn } from "@plane/utils";
+
+// --------------- NOTE ----------------
+// This has to be in web application as tailwind won't be able to resolve the colors
+// ------------------------------------
+
+export const getSubscriptionTextColor = (
+ planVariant: EProductSubscriptionEnum,
+ shade: "200" | "400" = "200"
+): string => {
+ const subscriptionColors = {
+ [EProductSubscriptionEnum.ONE]: {
+ "200": "text-custom-subscription-one-200",
+ "400": "text-custom-subscription-one-400",
+ },
+ [EProductSubscriptionEnum.PRO]: {
+ "200": "text-custom-subscription-pro-200",
+ "400": "text-custom-subscription-pro-400",
+ },
+ [EProductSubscriptionEnum.BUSINESS]: {
+ "200": "text-custom-subscription-business-200",
+ "400": "text-custom-subscription-business-400",
+ },
+ [EProductSubscriptionEnum.ENTERPRISE]: {
+ "200": "text-custom-subscription-enterprise-200",
+ "400": "text-custom-subscription-enterprise-400",
+ },
+ [EProductSubscriptionEnum.FREE]: {
+ "200": "text-custom-subscription-free-200",
+ "400": "text-custom-subscription-free-400",
+ },
+ };
+
+ return subscriptionColors[planVariant]?.[shade] ?? subscriptionColors[EProductSubscriptionEnum.FREE][shade];
+};
+
+export const getSubscriptionBackgroundColor = (
+ planVariant: EProductSubscriptionEnum,
+ shade: "50" | "100" | "200" | "400" = "100"
+): string => {
+ const subscriptionColors = {
+ [EProductSubscriptionEnum.ONE]: {
+ "50": "bg-custom-subscription-one-200/10",
+ "100": "bg-custom-subscription-one-200/20",
+ "200": "bg-custom-subscription-one-200",
+ "400": "bg-custom-subscription-one-400",
+ },
+ [EProductSubscriptionEnum.PRO]: {
+ "50": "bg-custom-subscription-pro-200/10",
+ "100": "bg-custom-subscription-pro-200/20",
+ "200": "bg-custom-subscription-pro-200",
+ "400": "bg-custom-subscription-pro-400",
+ },
+ [EProductSubscriptionEnum.BUSINESS]: {
+ "50": "bg-custom-subscription-business-200/10",
+ "100": "bg-custom-subscription-business-200/20",
+ "200": "bg-custom-subscription-business-200",
+ "400": "bg-custom-subscription-business-400",
+ },
+ [EProductSubscriptionEnum.ENTERPRISE]: {
+ "50": "bg-custom-subscription-enterprise-200/10",
+ "100": "bg-custom-subscription-enterprise-200/20",
+ "200": "bg-custom-subscription-enterprise-200",
+ "400": "bg-custom-subscription-enterprise-400",
+ },
+ [EProductSubscriptionEnum.FREE]: {
+ "50": "bg-custom-subscription-free-200/10",
+ "100": "bg-custom-subscription-free-200/20",
+ "200": "bg-custom-subscription-free-200",
+ "400": "bg-custom-subscription-free-400",
+ },
+ };
+
+ return subscriptionColors[planVariant]?.[shade] ?? subscriptionColors[EProductSubscriptionEnum.FREE][shade];
+};
+
+export const getSubscriptionBorderColor = (
+ planVariant: EProductSubscriptionEnum,
+ shade: "200" | "400" = "200"
+): string => {
+ const subscriptionColors = {
+ [EProductSubscriptionEnum.ONE]: {
+ "200": "border-custom-subscription-one-200",
+ "400": "border-custom-subscription-one-400",
+ },
+ [EProductSubscriptionEnum.PRO]: {
+ "200": "border-custom-subscription-pro-200",
+ "400": "border-custom-subscription-pro-400",
+ },
+ [EProductSubscriptionEnum.BUSINESS]: {
+ "200": "border-custom-subscription-business-200",
+ "400": "border-custom-subscription-business-400",
+ },
+ [EProductSubscriptionEnum.ENTERPRISE]: {
+ "200": "border-custom-subscription-enterprise-200",
+ "400": "border-custom-subscription-enterprise-400",
+ },
+ [EProductSubscriptionEnum.FREE]: {
+ "200": "border-custom-subscription-free-200",
+ "400": "border-custom-subscription-free-400",
+ },
+ default: "border-custom-subscription-free-400",
+ };
+
+ return subscriptionColors[planVariant]?.[shade] ?? subscriptionColors.default;
+};
+
+export const getUpgradeButtonStyle = (
+ planVariant: EProductSubscriptionEnum,
+ isDisabled: boolean
+): string | undefined => {
+ const baseClassNames = "border bg-custom-background-100";
+ const hoverClassNames = !isDisabled ? "hover:text-white hover:bg-gradient-to-br" : "";
+ const disabledClassNames = isDisabled ? "opacity-70 cursor-not-allowed" : "";
+
+ const COMMON_CLASSNAME = cn(baseClassNames, hoverClassNames, disabledClassNames);
+
+ switch (planVariant) {
+ case EProductSubscriptionEnum.ENTERPRISE:
+ return cn(
+ "text-custom-subscription-enterprise-200 from-custom-subscription-enterprise-200 to-custom-subscription-enterprise-400",
+ getSubscriptionBorderColor(planVariant, "200"),
+ COMMON_CLASSNAME
+ );
+ case EProductSubscriptionEnum.BUSINESS:
+ return cn(
+ "text-custom-subscription-business-200 from-custom-subscription-business-200 to-custom-subscription-business-400",
+ getSubscriptionBorderColor(planVariant, "200"),
+ COMMON_CLASSNAME
+ );
+ case EProductSubscriptionEnum.PRO:
+ return cn(
+ "text-custom-subscription-pro-200 from-custom-subscription-pro-200 to-custom-subscription-pro-400",
+ getSubscriptionBorderColor(planVariant, "200"),
+ COMMON_CLASSNAME
+ );
+ case EProductSubscriptionEnum.ONE:
+ return cn(
+ "text-custom-subscription-one-200 from-custom-subscription-one-200 to-custom-subscription-one-400",
+ getSubscriptionBorderColor(planVariant, "200"),
+ COMMON_CLASSNAME
+ );
+ case EProductSubscriptionEnum.FREE:
+ default:
+ return cn(
+ "text-custom-subscription-free-200 from-custom-subscription-free-200 to-custom-subscription-free-400",
+ getSubscriptionBorderColor(planVariant, "200"),
+ COMMON_CLASSNAME
+ );
+ }
+};
+
+export const getUpgradeCardVariantStyle = (planVariant: EProductSubscriptionEnum): string | undefined => {
+ const COMMON_CLASSNAME = cn("bg-gradient-to-b from-0% to-40% border border-custom-border-200 rounded-xl");
+
+ switch (planVariant) {
+ case EProductSubscriptionEnum.ENTERPRISE:
+ return cn("from-custom-subscription-enterprise-200/[0.14] to-transparent", COMMON_CLASSNAME);
+ case EProductSubscriptionEnum.BUSINESS:
+ return cn("from-custom-subscription-business-200/[0.14] to-transparent", COMMON_CLASSNAME);
+ case EProductSubscriptionEnum.PRO:
+ return cn("from-custom-subscription-pro-200/[0.14] to-transparent", COMMON_CLASSNAME);
+ case EProductSubscriptionEnum.ONE:
+ return cn("from-custom-subscription-one-200/[0.14] to-transparent", COMMON_CLASSNAME);
+ case EProductSubscriptionEnum.FREE:
+ default:
+ return cn("from-custom-subscription-free-200/[0.14] to-transparent", COMMON_CLASSNAME);
+ }
+};
+
+export const getSuccessModalVariantStyle = (planVariant: EProductSubscriptionEnum) => {
+ const COMMON_CLASSNAME = cn("bg-gradient-to-b from-0% to-30% rounded-2xl");
+
+ switch (planVariant) {
+ case EProductSubscriptionEnum.ENTERPRISE:
+ return cn("from-custom-subscription-enterprise-200/[0.14] to-transparent", COMMON_CLASSNAME);
+ case EProductSubscriptionEnum.BUSINESS:
+ return cn("from-custom-subscription-business-200/[0.14] to-transparent", COMMON_CLASSNAME);
+ case EProductSubscriptionEnum.PRO:
+ return cn("from-custom-subscription-pro-200/[0.14] to-transparent", COMMON_CLASSNAME);
+ case EProductSubscriptionEnum.ONE:
+ return cn("from-custom-subscription-one-200/[0.14] to-transparent", COMMON_CLASSNAME);
+ case EProductSubscriptionEnum.FREE:
+ default:
+ return cn("from-custom-subscription-free-200/[0.14] to-transparent", COMMON_CLASSNAME);
+ }
+};
+
+export const getBillingAndPlansCardVariantStyle = (planVariant: EProductSubscriptionEnum) => {
+ const COMMON_CLASSNAME = cn("bg-gradient-to-b from-0% to-50%");
+
+ switch (planVariant) {
+ case EProductSubscriptionEnum.ENTERPRISE:
+ return cn("from-custom-subscription-enterprise-200/[0.14] to-transparent", COMMON_CLASSNAME);
+ case EProductSubscriptionEnum.BUSINESS:
+ return cn("from-custom-subscription-business-200/[0.14] to-transparent", COMMON_CLASSNAME);
+ case EProductSubscriptionEnum.PRO:
+ return cn("from-custom-subscription-pro-200/[0.14] to-transparent", COMMON_CLASSNAME);
+ case EProductSubscriptionEnum.ONE:
+ return cn("from-custom-subscription-one-200/[0.14] to-transparent", COMMON_CLASSNAME);
+ case EProductSubscriptionEnum.FREE:
+ default:
+ return cn("from-custom-subscription-free-200/[0.14] to-transparent", COMMON_CLASSNAME);
+ }
+};
+
+export const getSubscriptionTextAndBackgroundColor = (planVariant: EProductSubscriptionEnum) =>
+ cn(getSubscriptionTextColor(planVariant), getSubscriptionBackgroundColor(planVariant));
+
+export const getDiscountPillStyle = (planVariant: EProductSubscriptionEnum): string =>
+ cn(getSubscriptionBackgroundColor(planVariant, "200"), "text-white");
diff --git a/dev-wiki/core/components/workspace/index.ts b/dev-wiki/core/components/workspace/index.ts
new file mode 100644
index 0000000000..1198654401
--- /dev/null
+++ b/dev-wiki/core/components/workspace/index.ts
@@ -0,0 +1,3 @@
+export * from "./sidebar";
+export * from "./logo";
+export * from "./billing";
diff --git a/dev-wiki/core/components/workspace/logo.tsx b/dev-wiki/core/components/workspace/logo.tsx
new file mode 100644
index 0000000000..f25615dfc2
--- /dev/null
+++ b/dev-wiki/core/components/workspace/logo.tsx
@@ -0,0 +1,37 @@
+import { observer } from "mobx-react";
+// plane imports
+import { useTranslation } from "@plane/i18n";
+import { cn } from "@plane/utils";
+// helpers
+import { getFileURL } from "@/helpers/file.helper";
+
+type Props = {
+ logo: string | null | undefined;
+ name: string | undefined;
+ classNames?: string;
+};
+
+export const WorkspaceLogo = observer((props: Props) => {
+ // translation
+ const { t } = useTranslation();
+
+ return (
+
+ {props.logo && props.logo !== "" ? (
+
+ ) : (
+ (props.name?.[0] ?? "...")
+ )}
+
+ );
+});
diff --git a/dev-wiki/core/components/workspace/sidebar/dropdown-item.tsx b/dev-wiki/core/components/workspace/sidebar/dropdown-item.tsx
new file mode 100644
index 0000000000..1ced9ac078
--- /dev/null
+++ b/dev-wiki/core/components/workspace/sidebar/dropdown-item.tsx
@@ -0,0 +1,120 @@
+"use client";
+import { observer } from "mobx-react";
+import Link from "next/link";
+import { useParams } from "next/navigation";
+import { Check, Settings, UserPlus } from "lucide-react";
+// plane imports
+import { Menu } from "@headlessui/react";
+import { EUserPermissions } from "@plane/constants";
+import { useTranslation } from "@plane/i18n";
+import { IWorkspace } from "@plane/types";
+import { cn, getFileURL } from "@plane/utils";
+// helpers
+import { getUserRole } from "@/helpers/user.helper";
+// plane web imports
+// import { SubscriptionPill } from "@/plane-web/components/common/subscription";
+
+type TProps = {
+ workspace: IWorkspace;
+ activeWorkspace: IWorkspace | null;
+ handleItemClick: () => void;
+ handleWorkspaceNavigation: (workspace: IWorkspace) => void;
+ handleClose: () => void;
+};
+const SidebarDropdownItem = observer((props: TProps) => {
+ const { workspace, activeWorkspace, handleItemClick, handleWorkspaceNavigation, handleClose } = props;
+ // router
+ const { workspaceSlug } = useParams();
+ // hooks
+ const { t } = useTranslation();
+
+ return (
+ {
+ handleWorkspaceNavigation(workspace);
+ handleItemClick();
+ }}
+ className="w-full"
+ id={workspace.id}
+ >
+
+
+
+
+ {workspace?.logo_url && workspace.logo_url !== "" ? (
+
+ ) : (
+ (workspace?.name?.[0] ?? "...")
+ )}
+
+
+
+ {workspace.name}
+
+
+
{getUserRole(workspace.role)?.toLowerCase() || "guest"}
+
+
{t("member", { count: workspace.total_members || 0 })}
+
+
+
+ {/* {workspace.id === activeWorkspace?.id ? ( */}
+ {/*
*/}
+ {/* */}
+ {/* */}
+ {/* ) : ( */}
+ {/*
*/}
+ {/* )} */}
+
+ {workspace.id === activeWorkspace?.id && (
+ <>
+
+ {[EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role) && (
+
+
+ {t("settings")}
+
+ )}
+ {[EUserPermissions.ADMIN].includes(workspace?.role) && (
+
+
+
+ {t("project_settings.members.invite_members.title")}
+
+
+ )}
+
+ >
+ )}
+
+
+ );
+});
+
+export default SidebarDropdownItem;
diff --git a/dev-wiki/core/components/workspace/sidebar/dropdown.tsx b/dev-wiki/core/components/workspace/sidebar/dropdown.tsx
new file mode 100644
index 0000000000..d24fb369a2
--- /dev/null
+++ b/dev-wiki/core/components/workspace/sidebar/dropdown.tsx
@@ -0,0 +1,259 @@
+"use client";
+
+import { Fragment, Ref, useState } from "react";
+import { observer } from "mobx-react";
+import Link from "next/link";
+import { useParams } from "next/navigation";
+import { usePopper } from "react-popper";
+// icons
+import { ChevronDown, CirclePlus, LogOut, Mails, Settings } from "lucide-react";
+// ui
+import { Menu, Transition } from "@headlessui/react";
+// plane imports
+import { useTranslation } from "@plane/i18n";
+import { IWorkspace } from "@plane/types";
+import { Avatar, Loader, TOAST_TYPE, setToast } from "@plane/ui";
+import { orderWorkspacesList } from "@plane/utils";
+// helpers
+import { GOD_MODE_URL, cn } from "@/helpers/common.helper";
+import { getFileURL } from "@/helpers/file.helper";
+// hooks
+import { useAppTheme, useUser, useUserProfile, useWorkspace } from "@/hooks/store";
+// plane web helpers
+import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper";
+// components
+import { WorkspaceLogo } from "../logo";
+import SidebarDropdownItem from "./dropdown-item";
+
+export const SidebarDropdown = observer(() => {
+ const { workspaceSlug } = useParams();
+ // store hooks
+ const { sidebarCollapsed, toggleSidebar } = useAppTheme();
+ const { data: currentUser } = useUser();
+ const { signOut } = useUser();
+ const { updateUserProfile } = useUserProfile();
+ const { currentWorkspace: activeWorkspace, workspaces } = useWorkspace();
+ // derived values
+ const isWorkspaceCreationEnabled = getIsWorkspaceCreationDisabled() === false;
+ const isUserInstanceAdmin = false;
+ // translation
+ const { t } = useTranslation();
+ // popper-js refs
+ const [referenceElement, setReferenceElement] = useState(null);
+ const [popperElement, setPopperElement] = useState(null);
+ // popper-js init
+ const { styles, attributes } = usePopper(referenceElement, popperElement, {
+ placement: "right",
+ modifiers: [{ name: "preventOverflow", options: { padding: 12 } }],
+ });
+
+ const handleWorkspaceNavigation = (workspace: IWorkspace) => updateUserProfile({ last_workspace_id: workspace?.id });
+
+ const handleSignOut = async () => {
+ await signOut().catch(() =>
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: t("sign_out.toast.error.title"),
+ message: t("sign_out.toast.error.message"),
+ })
+ );
+ };
+
+ const handleItemClick = () => {
+ if (window.innerWidth < 768) {
+ toggleSidebar();
+ }
+ };
+ const workspacesList = orderWorkspacesList(Object.values(workspaces ?? {}));
+ // TODO: fix workspaces list scroll
+ return (
+
+
+ {({ open, close }) => (
+ <>
+
+
+
+ {!sidebarCollapsed && (
+
+ {activeWorkspace?.name ?? t("loading")}
+
+ )}
+
+ {!sidebarCollapsed && (
+
+ )}
+
+
+
+
+
+
+ {currentUser?.email}
+
+ {workspacesList ? (
+
+ {(activeWorkspace
+ ? [
+ activeWorkspace,
+ ...workspacesList.filter((workspace) => workspace.id !== activeWorkspace?.id),
+ ]
+ : workspacesList
+ ).map((workspace) => (
+
+ ))}
+
+ ) : (
+
+
+
+
+
+
+ )}
+
+
+ {isWorkspaceCreationEnabled && (
+
+
+
+ {t("create_workspace")}
+
+
+ )}
+
+
+
+
+ {t("workspace_invites")}
+
+
+
+
+
+
+ {t("sign_out")}
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+ }
+ style={styles.popper}
+ {...attributes.popper}
+ >
+
+ {currentUser?.email}
+
+
+
+
+ {t("settings")}
+
+
+
+
+
+
+
+ {t("sign_out")}
+
+
+ {isUserInstanceAdmin && (
+
+
+
+
+ {t("enter_god_mode")}
+
+
+
+
+ )}
+
+
+
+
+ );
+});
diff --git a/dev-wiki/core/components/workspace/sidebar/favorites/favorite-folder.tsx b/dev-wiki/core/components/workspace/sidebar/favorites/favorite-folder.tsx
new file mode 100644
index 0000000000..9f81deeaf6
--- /dev/null
+++ b/dev-wiki/core/components/workspace/sidebar/favorites/favorite-folder.tsx
@@ -0,0 +1,320 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
+import {
+ DragLocationHistory,
+ ElementDragPayload,
+ DropTargetRecord,
+} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
+import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
+import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
+import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
+import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
+
+import orderBy from "lodash/orderBy";
+import { useParams } from "next/navigation";
+import { createRoot } from "react-dom/client";
+import { PenSquare, Star, MoreHorizontal, ChevronRight, GripVertical } from "lucide-react";
+import { Disclosure, Transition } from "@headlessui/react";
+// plane imports
+import { useOutsideClickDetector } from "@plane/hooks";
+import { useTranslation } from "@plane/i18n";
+import { IFavorite, InstructionType } from "@plane/types";
+import { CustomMenu, Tooltip, DropIndicator, FavoriteFolderIcon, DragHandle } from "@plane/ui";
+// helpers
+import { cn } from "@/helpers/common.helper";
+// hooks
+import { useAppTheme } from "@/hooks/store";
+import { useFavorite } from "@/hooks/store/use-favorite";
+import { usePlatformOS } from "@/hooks/use-platform-os";
+// local imports
+import { FavoriteRoot } from "./favorite-items";
+import { getCanDrop, getInstructionFromPayload } from "./favorites.helpers";
+import { NewFavoriteFolder } from "./new-fav-folder";
+
+type Props = {
+ isLastChild: boolean;
+ favorite: IFavorite;
+ handleRemoveFromFavorites: (favorite: IFavorite) => void;
+ handleRemoveFromFavoritesFolder: (favoriteId: string) => void;
+ handleDrop: (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => void;
+};
+
+export const FavoriteFolder: React.FC = (props) => {
+ const { favorite, handleRemoveFromFavorites, isLastChild, handleDrop } = props;
+ // store hooks
+ const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme();
+ const { getGroupedFavorites } = useFavorite();
+ const { isMobile } = usePlatformOS();
+ const { workspaceSlug } = useParams();
+ // states
+ const [isMenuActive, setIsMenuActive] = useState(false);
+ const [isDragging, setIsDragging] = useState(false);
+ const [folderToRename, setFolderToRename] = useState(null);
+ const [instruction, setInstruction] = useState(undefined);
+ // refs
+ const actionSectionRef = useRef(null);
+ const elementRef = useRef(null);
+ // translation
+ const { t } = useTranslation();
+
+ useEffect(() => {
+ if (favorite.children === undefined && workspaceSlug) {
+ getGroupedFavorites(workspaceSlug.toString(), favorite.id);
+ }
+ }, [favorite.id, favorite.children, workspaceSlug, getGroupedFavorites]);
+
+ useEffect(() => {
+ const element = elementRef.current;
+
+ if (!element) return;
+ const initialData = { id: favorite.id, isGroup: true, isChild: false };
+
+ return combine(
+ draggable({
+ element,
+ getInitialData: () => initialData,
+ onDragStart: () => setIsDragging(true),
+ onGenerateDragPreview: ({ nativeSetDragImage }) => {
+ setCustomNativeDragPreview({
+ getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }),
+ render: ({ container }) => {
+ const root = createRoot(container);
+ root.render(
+
+
+
+
+
{favorite.name}
+
+ );
+ return () => root.unmount();
+ },
+ nativeSetDragImage,
+ });
+ },
+ onDrop: () => {
+ setIsDragging(false);
+ }, // canDrag: () => isDraggable,
+ }),
+ dropTargetForElements({
+ element,
+ canDrop: ({ source }) => getCanDrop(source, favorite, false),
+ getData: ({ input, element }) => {
+ const blockedStates: InstructionType[] = [];
+ if (!isLastChild) {
+ blockedStates.push("reorder-below");
+ }
+
+ return attachInstruction(initialData, {
+ input,
+ element,
+ currentLevel: 0,
+ indentPerLevel: 0,
+ mode: isLastChild ? "last-in-group" : "standard",
+ block: blockedStates,
+ });
+ },
+ onDrag: ({ source, self, location }) => {
+ const instruction = getInstructionFromPayload(self, source, location);
+ setInstruction(instruction);
+ },
+ onDragLeave: () => {
+ setInstruction(undefined);
+ },
+ onDrop: ({ self, source, location }) => {
+ setInstruction(undefined);
+ handleDrop(self, source, location);
+ },
+ })
+ );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isDragging, favorite.id, isLastChild, favorite.id]);
+
+ useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
+
+ return folderToRename ? (
+
+ ) : (
+ <>
+
+ {({ open }) => (
+
+ )}
+
+ >
+ );
+};
diff --git a/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-drag-handle.tsx b/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-drag-handle.tsx
new file mode 100644
index 0000000000..6c8e8666ea
--- /dev/null
+++ b/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-drag-handle.tsx
@@ -0,0 +1,41 @@
+"use client";
+import React, { FC } from "react";
+import { observer } from "mobx-react";
+// ui
+import { DragHandle, Tooltip } from "@plane/ui";
+// helper
+import { cn } from "@/helpers/common.helper";
+// hooks
+import { usePlatformOS } from "@/hooks/use-platform-os";
+
+type Props = {
+ sort_order: number | null;
+ isDragging: boolean;
+};
+
+export const FavoriteItemDragHandle: FC = observer((props) => {
+ const { sort_order, isDragging } = props;
+ // store hooks
+ const { isMobile } = usePlatformOS();
+
+ return (
+
+
+
+
+
+ );
+});
diff --git a/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-quick-action.tsx b/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-quick-action.tsx
new file mode 100644
index 0000000000..cf6436733f
--- /dev/null
+++ b/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-quick-action.tsx
@@ -0,0 +1,54 @@
+"use client";
+import React, { FC } from "react";
+import { observer } from "mobx-react";
+import { MoreHorizontal, Star } from "lucide-react";
+// plane imports
+import { useTranslation } from "@plane/i18n";
+import { IFavorite } from "@plane/types";
+import { CustomMenu } from "@plane/ui";
+// helpers
+import { cn } from "@/helpers/common.helper";
+
+type Props = {
+ ref: React.MutableRefObject;
+ isMenuActive: boolean;
+ favorite: IFavorite;
+ onChange: (value: boolean) => void;
+ handleRemoveFromFavorites: (favorite: IFavorite) => void;
+};
+
+export const FavoriteItemQuickAction: FC = observer((props) => {
+ const { ref, isMenuActive, onChange, handleRemoveFromFavorites, favorite } = props;
+ // translation
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ }
+ menuButtonOnClick={() => onChange(!isMenuActive)}
+ className={cn(
+ "opacity-0 pointer-events-none flex-shrink-0 group-hover/project-item:opacity-100 group-hover/project-item:pointer-events-auto",
+ {
+ "opacity-100 pointer-events-auto": isMenuActive,
+ }
+ )}
+ customButtonClassName="grid place-items-center"
+ placement="bottom-start"
+ ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
+ >
+ handleRemoveFromFavorites(favorite)}>
+
+
+ Remove from favorites
+
+
+
+ );
+});
diff --git a/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-title.tsx b/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-title.tsx
new file mode 100644
index 0000000000..847c45f7a0
--- /dev/null
+++ b/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-title.tsx
@@ -0,0 +1,39 @@
+"use client";
+import React, { FC } from "react";
+import { observer } from "mobx-react";
+import Link from "next/link";
+import { Tooltip } from "@plane/ui";
+import { cn } from "@plane/utils";
+import { useAppTheme } from "@/hooks/store";
+import { usePlatformOS } from "@/hooks/use-platform-os";
+
+type Props = {
+ href: string;
+ title: string;
+ icon: JSX.Element;
+ isSidebarCollapsed: boolean;
+};
+
+export const FavoriteItemTitle: FC = observer((props) => {
+ const { href, title, icon, isSidebarCollapsed } = props;
+ // store hooks
+ const { toggleSidebar } = useAppTheme();
+ const { isMobile } = usePlatformOS();
+
+ const linkClass = "flex items-center gap-1.5 truncate w-full";
+ const collapsedClass =
+ "group/project-item cursor-pointer relative group w-full flex items-center justify-center gap-1.5 rounded px-2 py-1 outline-none text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-90 active:bg-custom-sidebar-background-90 truncate p-0 size-8 aspect-square mx-auto";
+
+ const handleOnClick = () => {
+ if (isMobile) toggleSidebar();
+ };
+
+ return (
+
+
+ {icon}
+ {!isSidebarCollapsed && {title} }
+
+
+ );
+});
diff --git a/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-wrapper.tsx b/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-wrapper.tsx
new file mode 100644
index 0000000000..f1ffde408c
--- /dev/null
+++ b/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-wrapper.tsx
@@ -0,0 +1,34 @@
+"use client";
+import React, { FC } from "react";
+// helpers
+import { cn } from "@/helpers/common.helper";
+
+type Props = {
+ children: React.ReactNode;
+ elementRef: React.RefObject;
+ isMenuActive?: boolean;
+ sidebarCollapsed?: boolean;
+};
+
+export const FavoriteItemWrapper: FC = (props) => {
+ const { children, elementRef, isMenuActive = false, sidebarCollapsed = false } = props;
+ return (
+ <>
+ {sidebarCollapsed ? (
+ {children}
+ ) : (
+
+ {children}
+
+ )}
+ >
+ );
+};
diff --git a/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/helper.tsx b/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/helper.tsx
new file mode 100644
index 0000000000..670f9ba116
--- /dev/null
+++ b/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/helper.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { FileText } from "lucide-react";
+// plane imports
+import { IFavorite, TLogoProps } from "@plane/types";
+// components
+import { Logo } from "@/components/common";
+// plane web constants
+import { FAVORITE_ITEM_ICONS, FAVORITE_ITEM_LINKS } from "@/plane-web/constants";
+
+export const getFavoriteItemIcon = (type: string, logo?: TLogoProps | undefined) => {
+ const Icon = FAVORITE_ITEM_ICONS[type] || FileText;
+
+ return (
+ <>
+
+
+
+
+ {logo?.in_use ? (
+
+ ) : (
+
+ )}
+
+ >
+ );
+};
+
+export const generateFavoriteItemLink = (workspaceSlug: string, favorite: IFavorite) => {
+ const entityLinkDetails = FAVORITE_ITEM_LINKS[favorite.entity_type];
+
+ if (!entityLinkDetails) {
+ console.error(`Unrecognized favorite entity type: ${favorite.entity_type}`);
+ return `/${workspaceSlug}`;
+ }
+
+ if (entityLinkDetails.itemLevel === "workspace") {
+ return `/${workspaceSlug}/${entityLinkDetails.getLink(favorite)}`;
+ } else if (entityLinkDetails.itemLevel === "project") {
+ return `/${workspaceSlug}/projects/${favorite.project_id}/${entityLinkDetails.getLink(favorite)}`;
+ } else {
+ return `/${workspaceSlug}`;
+ }
+};
diff --git a/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/index.ts b/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/index.ts
new file mode 100644
index 0000000000..5e03ce1c74
--- /dev/null
+++ b/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/common/index.ts
@@ -0,0 +1,5 @@
+export * from "./favorite-item-drag-handle";
+export * from "./favorite-item-quick-action";
+export * from "./favorite-item-wrapper";
+export * from "./favorite-item-title";
+export * from "./helper";
diff --git a/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/index.ts b/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/index.ts
new file mode 100644
index 0000000000..1037372f37
--- /dev/null
+++ b/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/index.ts
@@ -0,0 +1,2 @@
+export * from "./common";
+export * from "./root";
diff --git a/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/root.tsx b/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/root.tsx
new file mode 100644
index 0000000000..83fb1a5a41
--- /dev/null
+++ b/dev-wiki/core/components/workspace/sidebar/favorites/favorite-items/root.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+import { FC, useEffect, useRef, useState } from "react";
+import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
+import { DropTargetRecord, DragLocationHistory } from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
+import {
+ draggable,
+ dropTargetForElements,
+ ElementDragPayload,
+} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
+import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
+import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
+import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
+
+import { observer } from "mobx-react";
+// plane helpers
+import { createRoot } from "react-dom/client";
+import { useOutsideClickDetector } from "@plane/hooks";
+// ui
+import { IFavorite, InstructionType } from "@plane/types";
+// components
+import { DropIndicator } from "@plane/ui";
+// hooks
+import { useAppTheme } from "@/hooks/store";
+//helpers
+import { getCanDrop, getInstructionFromPayload } from "../favorites.helpers";
+
+type Props = {
+ isLastChild: boolean;
+ parentId: string | undefined;
+ workspaceSlug: string;
+ favorite: IFavorite;
+ handleRemoveFromFavorites: (favorite: IFavorite) => void;
+ handleDrop: (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => void;
+};
+
+export const FavoriteRoot: FC = observer((props) => {
+ // props
+ const { isLastChild, parentId, workspaceSlug, favorite, handleRemoveFromFavorites, handleDrop } = props;
+ // store hooks
+ const { sidebarCollapsed } = useAppTheme();
+ //state
+ const [isDragging, setIsDragging] = useState(false);
+ const [isMenuActive, setIsMenuActive] = useState(false);
+ const [instruction, setInstruction] = useState(undefined);
+
+ //ref
+ const elementRef = useRef(null);
+ const actionSectionRef = useRef(null);
+
+ const handleQuickAction = (value: boolean) => setIsMenuActive(value);
+
+ // drag and drop
+ useEffect(() => {
+ const element = elementRef.current;
+
+ if (!element) return;
+ const initialData = { id: favorite.id, isGroup: false, isChild: !!parentId, parentId };
+ return combine(
+ draggable({
+ element,
+ dragHandle: elementRef.current,
+ getInitialData: () => initialData,
+ onDragStart: () => {
+ setIsDragging(true);
+ },
+ onDrop: () => {
+ setIsDragging(false);
+ },
+ onGenerateDragPreview: ({ nativeSetDragImage }) => {
+ setCustomNativeDragPreview({
+ getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }),
+ render: ({ container }) => {
+ const root = createRoot(container);
+ root.render(
+
+ {/* */}
+
+ );
+ return () => root.unmount();
+ },
+ nativeSetDragImage,
+ });
+ },
+ }),
+ dropTargetForElements({
+ element,
+ canDrop: ({ source }) => getCanDrop(source, favorite, !!parentId),
+ onDragStart: () => {
+ setIsDragging(true);
+ },
+ getData: ({ input, element }) => {
+ const blockedStates: InstructionType[] = ["make-child"];
+ if (!isLastChild) {
+ blockedStates.push("reorder-below");
+ }
+
+ return attachInstruction(initialData, {
+ input,
+ element,
+ currentLevel: 1,
+ indentPerLevel: 0,
+ mode: isLastChild ? "last-in-group" : "standard",
+ block: blockedStates,
+ });
+ },
+ onDrag: ({ self, source, location }) => {
+ const instruction = getInstructionFromPayload(self, source, location);
+ setInstruction(instruction);
+ },
+ onDragLeave: () => {
+ setInstruction(undefined);
+ },
+ onDrop: ({ self, source, location }) => {
+ setInstruction(undefined);
+ handleDrop(self, source, location);
+ },
+ })
+ );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [elementRef?.current, isDragging, isLastChild, favorite.id]);
+
+ useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
+
+ return (
+ <>
+
+ {/* */}
+ {/* {!sidebarCollapsed && } */}
+ {/* */}
+ {/* {!sidebarCollapsed && ( */}
+ {/* */}
+ {/* )} */}
+ {/* */}
+ {/* {isLastChild && } */}
+ >
+ );
+});
diff --git a/dev-wiki/core/components/workspace/sidebar/favorites/favorites-menu.tsx b/dev-wiki/core/components/workspace/sidebar/favorites/favorites-menu.tsx
new file mode 100644
index 0000000000..f83f0ed061
--- /dev/null
+++ b/dev-wiki/core/components/workspace/sidebar/favorites/favorites-menu.tsx
@@ -0,0 +1,329 @@
+"use client";
+
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
+import {
+ DragLocationHistory,
+ DropTargetRecord,
+ ElementDragPayload,
+} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
+import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
+import orderBy from "lodash/orderBy";
+import { observer } from "mobx-react";
+import { useParams } from "next/navigation";
+import { ChevronRight, FolderPlus } from "lucide-react";
+import { Disclosure, Transition } from "@headlessui/react";
+import { IS_FAVORITE_MENU_OPEN } from "@plane/constants";
+import { useTranslation } from "@plane/i18n";
+// ui
+import { IFavorite } from "@plane/types";
+import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
+// constants
+
+// helpers
+import { cn } from "@/helpers/common.helper";
+// hooks
+import { useAppTheme } from "@/hooks/store";
+import { useFavorite } from "@/hooks/store/use-favorite";
+import useLocalStorage from "@/hooks/use-local-storage";
+import { usePlatformOS } from "@/hooks/use-platform-os";
+// plane web components
+import { FavoriteFolder } from "./favorite-folder";
+import { FavoriteRoot } from "./favorite-items";
+import { getInstructionFromPayload, TargetData } from "./favorites.helpers";
+import { NewFavoriteFolder } from "./new-fav-folder";
+
+export const SidebarFavoritesMenu = observer(() => {
+ // states
+ const [createNewFolder, setCreateNewFolder] = useState(null);
+ const [isDragging, setIsDragging] = useState(false);
+ // navigation
+ const { workspaceSlug } = useParams();
+ // store hooks
+ const { sidebarCollapsed } = useAppTheme();
+ const {
+ favoriteIds,
+ groupedFavorites,
+ deleteFavorite,
+ removeFromFavoriteFolder,
+ reOrderFavorite,
+ moveFavoriteToFolder,
+ } = useFavorite();
+ // translation
+ const { t } = useTranslation();
+ // platform hooks
+ const { isMobile } = usePlatformOS();
+ // local storage
+ const { setValue: toggleFavoriteMenu, storedValue } = useLocalStorage(IS_FAVORITE_MENU_OPEN, false);
+ // derived values
+ const isFavoriteMenuOpen = !!storedValue;
+ // refs
+ const containerRef = useRef(null);
+ const elementRef = useRef(null);
+
+ const handleMoveToFolder = (sourceId: string, destinationId: string) => {
+ moveFavoriteToFolder(workspaceSlug.toString(), sourceId, {
+ parent: destinationId,
+ }).catch(() => {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: t("error"),
+ message: t("failed_to_move_favorite"),
+ });
+ });
+ };
+
+ const handleDrop = (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => {
+ const isFolder = self.data?.isGroup;
+ const dropTargets = location?.current?.dropTargets ?? [];
+ if (!dropTargets || dropTargets.length <= 0) return;
+ const dropTarget =
+ dropTargets.length > 1 ? dropTargets.find((target: DropTargetRecord) => target?.data?.isChild) : dropTargets[0];
+
+ const dropTargetData = dropTarget?.data as TargetData;
+
+ if (!dropTarget || !dropTargetData) return;
+ const instruction = getInstructionFromPayload(dropTarget, source, location);
+ const parentId = instruction === "make-child" ? dropTargetData.id : dropTargetData.parentId;
+ const droppedFavId = instruction !== "make-child" ? dropTargetData.id : undefined;
+ const sourceData = source.data as TargetData;
+
+ if (!sourceData.id) return;
+ if (isFolder) {
+ // handle move to a new parent folder if dropped on a folder
+ if (parentId && parentId !== sourceData.parentId) {
+ handleMoveToFolder(sourceData.id, parentId); /**parent id */
+ }
+ // handle reordering at root level
+ if (droppedFavId) {
+ if (instruction != "make-child") {
+ handleReorder(sourceData.id, droppedFavId, instruction); /** sequence */
+ }
+ }
+ } else {
+ //handling reordering for favorites
+ if (droppedFavId) {
+ handleReorder(sourceData.id, droppedFavId, instruction); /** sequence */
+ }
+ }
+
+ /**remove if dropped outside and source is a child */
+ if (!parentId && sourceData.isChild) {
+ handleRemoveFromFavoritesFolder(sourceData.id); /**parent null */
+ }
+ };
+
+ const handleRemoveFromFavorites = (favorite: IFavorite) => {
+ deleteFavorite(workspaceSlug.toString(), favorite.id)
+ .then(() => {
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: t("success"),
+ message: t("favorite_removed_successfully"),
+ });
+ })
+ .catch(() => {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: t("error"),
+ message: t("something_went_wrong"),
+ });
+ });
+ };
+
+ const handleRemoveFromFavoritesFolder = (favoriteId: string) => {
+ removeFromFavoriteFolder(workspaceSlug.toString(), favoriteId).catch(() => {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: t("error"),
+ message: t("failed_to_move_favorite"),
+ });
+ });
+ };
+
+ const handleReorder = useCallback(
+ (favoriteId: string, droppedFavId: string, edge: string | undefined) => {
+ reOrderFavorite(workspaceSlug.toString(), favoriteId, droppedFavId, edge).catch(() => {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: t("error"),
+ message: t("failed_to_reorder_favorite"),
+ });
+ });
+ },
+ [workspaceSlug, reOrderFavorite, t]
+ );
+
+ useEffect(() => {
+ if (sidebarCollapsed) toggleFavoriteMenu(true);
+ }, [sidebarCollapsed, toggleFavoriteMenu]);
+
+ useEffect(() => {
+ const element = elementRef.current;
+
+ if (!element) return;
+
+ return combine(
+ dropTargetForElements({
+ element,
+ onDragEnter: () => {
+ setIsDragging(true);
+ },
+ onDragLeave: () => {
+ setIsDragging(false);
+ },
+ onDragStart: () => {
+ setIsDragging(true);
+ },
+ onDrop: ({ source }) => {
+ setIsDragging(false);
+ const sourceId = source?.data?.id as string | undefined;
+ console.log({ sourceId });
+ if (!sourceId || !groupedFavorites[sourceId].parent) return;
+ },
+ })
+ );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [elementRef.current, isDragging]);
+
+ return (
+ <>
+
+ {!sidebarCollapsed && (
+
+
toggleFavoriteMenu(!isFavoriteMenuOpen)}
+ aria-label={t(
+ isFavoriteMenuOpen
+ ? "aria_labels.projects_sidebar.close_favorites_menu"
+ : "aria_labels.projects_sidebar.open_favorites_menu"
+ )}
+ >
+ {t("favorites")}
+
+
+
+ {
+ setCreateNewFolder(true);
+ if (!isFavoriteMenuOpen) toggleFavoriteMenu(!isFavoriteMenuOpen);
+ }}
+ aria-label={t("aria_labels.projects_sidebar.create_favorites_folder")}
+ >
+
+
+
+ toggleFavoriteMenu(!isFavoriteMenuOpen)}
+ aria-label={t(
+ isFavoriteMenuOpen
+ ? "aria_labels.projects_sidebar.close_favorites_menu"
+ : "aria_labels.projects_sidebar.open_favorites_menu"
+ )}
+ >
+
+
+
+
+ )}
+
+ {isFavoriteMenuOpen && (
+
+ {createNewFolder && }
+ {Object.keys(groupedFavorites).length === 0 ? (
+ <>
+ {!sidebarCollapsed && (
+
+ {t("no_favorites_yet")}
+
+ )}
+ >
+ ) : (
+ orderBy(Object.values(groupedFavorites), "sequence", "desc")
+ .filter((fav) => !fav.parent)
+ .map((fav, index, { length }) => (
+ <>
+ {fav?.id && (
+
+ {fav?.is_folder ? (
+
+ ) : (
+
+ )}
+
+ )}
+ >
+ ))
+ )}
+
+ )}
+
+
+
+ {sidebarCollapsed && favoriteIds.length > 0 && (
+
+ )}
+ >
+ );
+});
diff --git a/dev-wiki/core/components/workspace/sidebar/favorites/favorites.helpers.ts b/dev-wiki/core/components/workspace/sidebar/favorites/favorites.helpers.ts
new file mode 100644
index 0000000000..a7364c9ed9
--- /dev/null
+++ b/dev-wiki/core/components/workspace/sidebar/favorites/favorites.helpers.ts
@@ -0,0 +1,68 @@
+import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
+import orderBy from "lodash/orderBy";
+import { IFavorite, InstructionType, IPragmaticPayloadLocation, TDropTarget } from "@plane/types";
+
+export type TargetData = {
+ id: string;
+ parentId: string | null;
+ isGroup: boolean;
+ isChild: boolean;
+}
+
+/**
+ * extracts the Payload and translates the instruction for the current dropTarget based on drag and drop payload
+ * @param dropTarget dropTarget for which the instruction is required
+ * @param source the dragging favorite data that is being dragged on the dropTarget
+ * @param location location includes the data of all the dropTargets the source is being dragged on
+ * @returns Instruction for dropTarget
+ */
+export const getInstructionFromPayload = (
+ dropTarget: TDropTarget,
+ source: TDropTarget,
+ location: IPragmaticPayloadLocation
+): InstructionType | undefined => {
+ const dropTargetData = dropTarget?.data as TargetData;
+ const sourceData = source?.data as TargetData;
+ const allDropTargets = location?.current?.dropTargets;
+
+ // if all the dropTargets are greater than 1 meaning the source is being dragged on a group and its child at the same time
+ // and also if the dropTarget in question is also a group then, it should be a child of the current Droptarget
+ if (allDropTargets?.length > 1 && dropTargetData?.isGroup) return "make-child";
+
+ if (!dropTargetData || !sourceData) return undefined;
+
+ let instruction = extractInstruction(dropTargetData)?.type;
+
+ // If the instruction is blocked then set an instruction based on if dropTarget it is a child or not
+ if (instruction === "instruction-blocked") {
+ instruction = dropTargetData.isChild ? "reorder-above" : "make-child";
+ }
+
+ // if source that is being dragged is a group. A group cannon be a child of any other favorite,
+ // hence if current instruction is to be a child of dropTarget then reorder-above instead
+ if (instruction === "make-child" && sourceData.isGroup) instruction = "reorder-above";
+
+ return instruction;
+};
+
+/**
+ * This provides a boolean to indicate if the favorite can be dropped onto the droptarget
+ * @param source
+ * @param favorite
+ * @param isCurrentChild if the dropTarget is a child
+ * @returns
+ */
+export const getCanDrop = (source: TDropTarget, favorite: IFavorite | undefined, isCurrentChild: boolean) => {
+ const sourceData = source?.data;
+
+ if (!sourceData) return false;
+
+ // a favorite cannot be dropped on to itself
+ if (sourceData.id === favorite?.id ) return false;
+
+
+ // if current dropTarget is a child and the favorite being dropped is a group then don't enable drop
+ if (isCurrentChild && sourceData.isGroup) return false;
+
+ return true;
+};
\ No newline at end of file
diff --git a/dev-wiki/core/components/workspace/sidebar/favorites/index.ts b/dev-wiki/core/components/workspace/sidebar/favorites/index.ts
new file mode 100644
index 0000000000..7998acef82
--- /dev/null
+++ b/dev-wiki/core/components/workspace/sidebar/favorites/index.ts
@@ -0,0 +1,5 @@
+export * from "./favorite-folder";
+export * from "./favorite-items";
+export * from "./favorites-menu";
+export * from "./favorites.helpers";
+export * from "./new-fav-folder";
\ No newline at end of file
diff --git a/dev-wiki/core/components/workspace/sidebar/favorites/new-fav-folder.tsx b/dev-wiki/core/components/workspace/sidebar/favorites/new-fav-folder.tsx
new file mode 100644
index 0000000000..5412196b55
--- /dev/null
+++ b/dev-wiki/core/components/workspace/sidebar/favorites/new-fav-folder.tsx
@@ -0,0 +1,149 @@
+import { useEffect, useRef } from "react";
+import { observer } from "mobx-react";
+import { useParams } from "next/navigation";
+import { Controller, SubmitHandler, useForm } from "react-hook-form";
+import { useOutsideClickDetector } from "@plane/hooks";
+import { useTranslation } from "@plane/i18n";
+// plane helpers
+// plane ui
+import { FavoriteFolderIcon, Input, setToast, TOAST_TYPE } from "@plane/ui";
+// hooks
+import { useFavorite } from "@/hooks/store/use-favorite";
+
+type TForm = {
+ name: string;
+ entity_type: string;
+ parent: string | null;
+ project_id: string | null;
+ is_folder: boolean;
+};
+type TProps = {
+ setCreateNewFolder: (value: boolean | string | null) => void;
+ actionType: "create" | "rename";
+ defaultName?: string;
+ favoriteId?: string;
+};
+export const NewFavoriteFolder = observer((props: TProps) => {
+ const { setCreateNewFolder, actionType, defaultName, favoriteId } = props;
+ const { t } = useTranslation();
+ const { workspaceSlug } = useParams();
+ const { addFavorite, updateFavorite, existingFolders } = useFavorite();
+
+ // ref
+ const ref = useRef(null);
+
+ // form info
+ const { handleSubmit, control, setValue, setFocus } = useForm({
+ reValidateMode: "onChange",
+ defaultValues: {
+ name: defaultName,
+ },
+ });
+
+ const handleAddNewFolder: SubmitHandler = (formData) => {
+ if (existingFolders.includes(formData.name))
+ return setToast({
+ type: TOAST_TYPE.ERROR,
+ title: t("error"),
+ message: t("folder_already_exists"),
+ });
+ formData = {
+ entity_type: "folder",
+ is_folder: true,
+ name: formData.name.trim(),
+ parent: null,
+ project_id: null,
+ };
+
+ if (formData.name === "")
+ return setToast({
+ type: TOAST_TYPE.ERROR,
+ title: t("error"),
+ message: t("folder_name_cannot_be_empty"),
+ });
+
+ addFavorite(workspaceSlug.toString(), formData)
+ .then(() => {
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: t("success"),
+ message: t("favorite_created_successfully"),
+ });
+ })
+ .catch(() => {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: t("error"),
+ message: t("something_went_wrong"),
+ });
+ });
+ setCreateNewFolder(false);
+ setValue("name", "");
+ };
+
+ const handleRenameFolder: SubmitHandler = (formData) => {
+ if (!favoriteId) return;
+ if (existingFolders.includes(formData.name))
+ return setToast({
+ type: TOAST_TYPE.ERROR,
+ title: t("error"),
+ message: t("folder_already_exists"),
+ });
+ const payload = {
+ name: formData.name.trim(),
+ };
+
+ if (formData.name.trim() === "")
+ return setToast({
+ type: TOAST_TYPE.ERROR,
+ title: t("error"),
+ message: t("folder_name_cannot_be_empty"),
+ });
+
+ updateFavorite(workspaceSlug.toString(), favoriteId, payload)
+ .then(() => {
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: t("success"),
+ message: t("favorite_updated_successfully"),
+ });
+ })
+ .catch(() => {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: t("error"),
+ message: t("something_went_wrong"),
+ });
+ });
+ setCreateNewFolder(false);
+ setValue("name", "");
+ };
+
+ useEffect(() => {
+ setFocus("name");
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+ useOutsideClickDetector(ref, () => {
+ setCreateNewFolder(false);
+ });
+ return (
+
+
+
+
+ );
+});
diff --git a/dev-wiki/core/components/workspace/sidebar/index.ts b/dev-wiki/core/components/workspace/sidebar/index.ts
new file mode 100644
index 0000000000..698c905c6d
--- /dev/null
+++ b/dev-wiki/core/components/workspace/sidebar/index.ts
@@ -0,0 +1,5 @@
+export * from "./dropdown";
+export * from "./favorites";
+export * from "./workspace-menu";
+export * from "./workspace-menu-item";
+export * from "./workspace-menu-header";
diff --git a/dev-wiki/core/components/workspace/sidebar/workspace-menu-header.tsx b/dev-wiki/core/components/workspace/sidebar/workspace-menu-header.tsx
new file mode 100644
index 0000000000..3367a2b347
--- /dev/null
+++ b/dev-wiki/core/components/workspace/sidebar/workspace-menu-header.tsx
@@ -0,0 +1,116 @@
+import { FC, useState, useRef } from "react";
+import { observer } from "mobx-react";
+import Link from "next/link";
+import { useParams } from "next/navigation";
+import { MoreHorizontal, ArchiveIcon, ChevronRight, Settings } from "lucide-react";
+import { Disclosure } from "@headlessui/react";
+// plane imports
+import { EUserWorkspaceRoles, EUserPermissionsLevel } from "@plane/constants";
+import { useOutsideClickDetector } from "@plane/hooks";
+import { useTranslation } from "@plane/i18n";
+import { CustomMenu } from "@plane/ui";
+import { cn } from "@plane/utils";
+// store hooks
+import { useAppTheme, useUserPermissions } from "@/hooks/store";
+
+export type SidebarWorkspaceMenuHeaderProps = {
+ isWorkspaceMenuOpen: boolean;
+ toggleWorkspaceMenu: (value: boolean) => void;
+};
+
+export const SidebarWorkspaceMenuHeader: FC = observer((props) => {
+ const { isWorkspaceMenuOpen, toggleWorkspaceMenu } = props;
+ // state
+ const [isMenuActive, setIsMenuActive] = useState(false);
+ // refs
+ const actionSectionRef = useRef(null);
+ // hooks
+ const { workspaceSlug } = useParams();
+ const { sidebarCollapsed } = useAppTheme();
+ const { allowPermissions } = useUserPermissions();
+ const { t } = useTranslation();
+
+ useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
+
+ // TODO: fix types
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const isAdmin = allowPermissions([EUserWorkspaceRoles.ADMIN] as any, EUserPermissionsLevel.WORKSPACE);
+
+ if (sidebarCollapsed) {
+ return <>>;
+ }
+
+ return (
+
+
toggleWorkspaceMenu(!isWorkspaceMenuOpen)}
+ >
+ {t("workspace")}
+
+
{
+ setIsMenuActive(!isMenuActive);
+ }}
+ >
+
+
+ }
+ className={cn(
+ "h-full flex items-center opacity-0 z-20 pointer-events-none flex-shrink-0 group-hover/workspace-button:opacity-100 group-hover/workspace-button:pointer-events-auto my-auto",
+ {
+ "opacity-100 pointer-events-auto": isMenuActive,
+ }
+ )}
+ customButtonClassName="grid place-items-center"
+ placement="bottom-start"
+ >
+
+
+
+
+
+
+ {isAdmin && (
+
+
+
+
+ {t("settings")}
+
+
+
+ )}
+
+
toggleWorkspaceMenu(!isWorkspaceMenuOpen)}
+ >
+ {" "}
+
+
+
+
+
+ );
+});
diff --git a/dev-wiki/core/components/workspace/sidebar/workspace-menu-item.tsx b/dev-wiki/core/components/workspace/sidebar/workspace-menu-item.tsx
new file mode 100644
index 0000000000..f16556a381
--- /dev/null
+++ b/dev-wiki/core/components/workspace/sidebar/workspace-menu-item.tsx
@@ -0,0 +1,83 @@
+import { FC } from "react";
+import { observer } from "mobx-react";
+import Link from "next/link";
+import { useParams, usePathname } from "next/navigation";
+// plane imports
+import { EUserWorkspaceRoles, EUserPermissionsLevel } from "@plane/constants";
+import { usePlatformOS } from "@plane/hooks";
+import { useTranslation } from "@plane/i18n";
+import { Tooltip } from "@plane/ui";
+import { cn } from "@plane/utils";
+// components
+import { SidebarNavItem } from "@/components/sidebar";
+// hooks
+import { useAppTheme, useUserPermissions } from "@/hooks/store";
+// plane web imports
+// import { UpgradeBadge } from "@/plane-web/components/workspace";
+import { isSidebarFeatureEnabled } from "@/plane-web/helpers/dashboard.helper";
+
+export type SidebarWorkspaceMenuItemProps = {
+ item: {
+ labelTranslationKey: string;
+ key: string;
+ href: string;
+ Icon: any;
+ access: EUserWorkspaceRoles[];
+ };
+};
+
+export const SidebarWorkspaceMenuItem: FC = observer((props) => {
+ const { item } = props;
+
+ const { t } = useTranslation();
+ // nextjs hooks
+ const pathname = usePathname();
+ const { workspaceSlug } = useParams();
+ const { allowPermissions } = useUserPermissions();
+ // store hooks
+ const { toggleSidebar, sidebarCollapsed } = useAppTheme();
+ const { isMobile } = usePlatformOS();
+
+ const handleLinkClick = () => {
+ if (window.innerWidth < 768) {
+ toggleSidebar();
+ }
+ };
+
+ if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) {
+ return null;
+ }
+
+ if (!isSidebarFeatureEnabled(item.key, workspaceSlug.toString())) return null;
+
+ const isActive = item.href === pathname;
+
+ return (
+
+ handleLinkClick()}>
+
+
+
+ {!sidebarCollapsed &&
{t(item.labelTranslationKey)}
}
+
+ {!sidebarCollapsed && item.key === "active_cycles" && (
+ {/* */}
+ )}
+
+
+
+ );
+});
diff --git a/dev-wiki/core/components/workspace/sidebar/workspace-menu.tsx b/dev-wiki/core/components/workspace/sidebar/workspace-menu.tsx
new file mode 100644
index 0000000000..3c1ed9a048
--- /dev/null
+++ b/dev-wiki/core/components/workspace/sidebar/workspace-menu.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import React, { useEffect } from "react";
+import { observer } from "mobx-react";
+import { useParams } from "next/navigation";
+import { BarChart2, Briefcase, Layers } from "lucide-react";
+import { Disclosure, Transition } from "@headlessui/react";
+// ui
+import { EUserWorkspaceRoles } from "@plane/constants";
+import { ContrastIcon, InitiativeIcon, TeamsIcon } from "@plane/ui";
+// components
+import { SidebarWorkspaceMenuHeader, SidebarWorkspaceMenuItem } from "@/components/workspace/sidebar";
+// helpers
+import { cn } from "@/helpers/common.helper";
+// hooks
+import { useAppTheme } from "@/hooks/store";
+import useLocalStorage from "@/hooks/use-local-storage";
+
+export const SidebarWorkspaceMenu = observer(() => {
+ // router params
+ const { workspaceSlug } = useParams();
+ // store hooks
+ const { sidebarCollapsed } = useAppTheme();
+ // local storage
+ const { setValue: toggleWorkspaceMenu, storedValue } = useLocalStorage("is_workspace_menu_open", true);
+ // derived values
+ const isWorkspaceMenuOpen = !!storedValue;
+
+ useEffect(() => {
+ if (sidebarCollapsed) toggleWorkspaceMenu(true);
+ }, [sidebarCollapsed, toggleWorkspaceMenu]);
+
+ const SIDEBAR_WORKSPACE_MENU_ITEMS = [
+ {
+ key: "initiatives",
+ labelTranslationKey: "sidebar.initiatives",
+ href: `/${workspaceSlug}/initiatives/`,
+ access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
+ Icon: InitiativeIcon,
+ },
+ {
+ key: "teamspaces",
+ labelTranslationKey: "sidebar.teamspaces",
+ href: `/${workspaceSlug}/teamspaces/`,
+ access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
+ Icon: TeamsIcon,
+ },
+ {
+ key: "projects",
+ labelTranslationKey: "sidebar.projects",
+ href: `/${workspaceSlug}/projects/`,
+ access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
+ Icon: Briefcase,
+ },
+ {
+ key: "views",
+ labelTranslationKey: "sidebar.views",
+ href: `/${workspaceSlug}/workspace-views/all-issues/`,
+ access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
+ Icon: Layers,
+ },
+ {
+ key: "active-cycles",
+ labelTranslationKey: "sidebar.cycles",
+ href: `/${workspaceSlug}/active-cycles/`,
+ access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
+ Icon: ContrastIcon,
+ },
+ {
+ key: "analytics",
+ labelTranslationKey: "sidebar.analytics",
+ href: `/${workspaceSlug}/analytics/`,
+ access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
+ Icon: BarChart2,
+ },
+ ];
+
+ return (
+
+
+
+ {isWorkspaceMenuOpen && (
+
+ {SIDEBAR_WORKSPACE_MENU_ITEMS.map((item) => (
+
+ ))}
+
+ )}
+
+
+ );
+});
diff --git a/dev-wiki/core/constants/editor.ts b/dev-wiki/core/constants/editor.ts
new file mode 100644
index 0000000000..16fe46e5dc
--- /dev/null
+++ b/dev-wiki/core/constants/editor.ts
@@ -0,0 +1,390 @@
+import { Styles, StyleSheet } from "@react-pdf/renderer";
+import {
+ AlignCenter,
+ AlignLeft,
+ AlignRight,
+ Bold,
+ CaseSensitive,
+ Code2,
+ Heading1,
+ Heading2,
+ Heading3,
+ Heading4,
+ Heading5,
+ Heading6,
+ Image,
+ Italic,
+ List,
+ ListCollapse,
+ ListOrdered,
+ ListTodo,
+ LucideIcon,
+ Strikethrough,
+ Table,
+ TextQuote,
+ Underline,
+} from "lucide-react";
+// editor
+import { TCommandExtraProps, TEditorCommands, TEditorFontStyle } from "@plane/editor";
+// ui
+import { MonospaceIcon, SansSerifIcon, SerifIcon } from "@plane/ui";
+// helpers
+import { convertRemToPixel } from "@/helpers/common.helper";
+
+type TEditorTypes = "lite" | "document" | "sticky";
+
+// Utility type to enforce the necessary extra props or make extraProps optional
+type ExtraPropsForCommand = T extends keyof TCommandExtraProps
+ ? TCommandExtraProps[T]
+ : object; // Default to empty object for commands without extra props
+
+export type ToolbarMenuItem = {
+ itemKey: T;
+ renderKey: string;
+ name: string;
+ icon: LucideIcon;
+ shortcut?: string[];
+ editors: TEditorTypes[];
+ extraProps?: ExtraPropsForCommand;
+};
+
+export const TYPOGRAPHY_ITEMS: ToolbarMenuItem<"text" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6">[] = [
+ { itemKey: "text", renderKey: "text", name: "Text", icon: CaseSensitive, editors: ["document"] },
+ { itemKey: "h1", renderKey: "h1", name: "Heading 1", icon: Heading1, editors: ["document"] },
+ { itemKey: "h2", renderKey: "h2", name: "Heading 2", icon: Heading2, editors: ["document"] },
+ { itemKey: "h3", renderKey: "h3", name: "Heading 3", icon: Heading3, editors: ["document"] },
+ { itemKey: "h4", renderKey: "h4", name: "Heading 4", icon: Heading4, editors: ["document"] },
+ { itemKey: "h5", renderKey: "h5", name: "Heading 5", icon: Heading5, editors: ["document"] },
+ { itemKey: "h6", renderKey: "h6", name: "Heading 6", icon: Heading6, editors: ["document"] },
+];
+
+export const TEXT_ALIGNMENT_ITEMS: ToolbarMenuItem<"text-align">[] = [
+ {
+ itemKey: "text-align",
+ renderKey: "text-align-left",
+ name: "Left align",
+ icon: AlignLeft,
+ shortcut: ["Cmd", "Shift", "L"],
+ editors: ["lite", "document"],
+ extraProps: {
+ alignment: "left",
+ },
+ },
+ {
+ itemKey: "text-align",
+ renderKey: "text-align-center",
+ name: "Center align",
+ icon: AlignCenter,
+ shortcut: ["Cmd", "Shift", "E"],
+ editors: ["lite", "document"],
+ extraProps: {
+ alignment: "center",
+ },
+ },
+ {
+ itemKey: "text-align",
+ renderKey: "text-align-right",
+ name: "Right align",
+ icon: AlignRight,
+ shortcut: ["Cmd", "Shift", "R"],
+ editors: ["lite", "document"],
+ extraProps: {
+ alignment: "right",
+ },
+ },
+];
+
+const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strikethrough">[] = [
+ {
+ itemKey: "bold",
+ renderKey: "bold",
+ name: "Bold",
+ icon: Bold,
+ shortcut: ["Cmd", "B"],
+ editors: ["lite", "document"],
+ },
+ {
+ itemKey: "italic",
+ renderKey: "italic",
+ name: "Italic",
+ icon: Italic,
+ shortcut: ["Cmd", "I"],
+ editors: ["lite", "document"],
+ },
+ {
+ itemKey: "underline",
+ renderKey: "underline",
+ name: "Underline",
+ icon: Underline,
+ shortcut: ["Cmd", "U"],
+ editors: ["lite", "document"],
+ },
+ {
+ itemKey: "strikethrough",
+ renderKey: "strikethrough",
+ name: "Strikethrough",
+ icon: Strikethrough,
+ shortcut: ["Cmd", "Shift", "S"],
+ editors: ["lite", "document"],
+ },
+];
+
+const LIST_ITEMS: ToolbarMenuItem<"bulleted-list" | "numbered-list" | "to-do-list">[] = [
+ {
+ itemKey: "bulleted-list",
+ renderKey: "bulleted-list",
+ name: "Bulleted list",
+ icon: List,
+ shortcut: ["Cmd", "Shift", "7"],
+ editors: ["lite", "document"],
+ },
+ {
+ itemKey: "numbered-list",
+ renderKey: "numbered-list",
+ name: "Numbered list",
+ icon: ListOrdered,
+ shortcut: ["Cmd", "Shift", "8"],
+ editors: ["lite", "document"],
+ },
+ {
+ itemKey: "to-do-list",
+ renderKey: "to-do-list",
+ name: "To-do list",
+ icon: ListTodo,
+ shortcut: ["Cmd", "Shift", "9"],
+ editors: ["lite", "document"],
+ },
+];
+
+const USER_ACTION_ITEMS: ToolbarMenuItem<"quote" | "code">[] = [
+ { itemKey: "quote", renderKey: "quote", name: "Quote", icon: TextQuote, editors: ["lite", "document"] },
+ { itemKey: "code", renderKey: "code", name: "Code", icon: Code2, editors: ["lite", "document"] },
+];
+
+const COMPLEX_ITEMS: ToolbarMenuItem<"table" | "image">[] = [
+ { itemKey: "table", renderKey: "table", name: "Table", icon: Table, editors: ["document"] },
+ { itemKey: "image", renderKey: "image", name: "Image", icon: Image, editors: ["lite", "document"], extraProps: {} },
+];
+
+export const TOOLBAR_ITEMS: {
+ [editorType in TEditorTypes]: {
+ [key: string]: ToolbarMenuItem[];
+ };
+} = {
+ lite: {
+ basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("lite")),
+ alignment: TEXT_ALIGNMENT_ITEMS.filter((item) => item.editors.includes("lite")),
+ list: LIST_ITEMS.filter((item) => item.editors.includes("lite")),
+ userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("lite")),
+ complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("lite")),
+ },
+ document: {
+ basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("document")),
+ alignment: TEXT_ALIGNMENT_ITEMS.filter((item) => item.editors.includes("document")),
+ list: LIST_ITEMS.filter((item) => item.editors.includes("document")),
+ userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("document")),
+ complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("document")),
+ },
+ sticky: {
+ basic: BASIC_MARK_ITEMS.filter((item) => ["Bold", "Italic"].includes(item.name)),
+ list: LIST_ITEMS.filter((item) => ["To-do list"].includes(item.name)),
+ },
+};
+
+export const EDITOR_FONT_STYLES: {
+ key: TEditorFontStyle;
+ label: string;
+ icon: any;
+}[] = [
+ {
+ key: "sans-serif",
+ label: "Sans serif",
+ icon: SansSerifIcon,
+ },
+ {
+ key: "serif",
+ label: "Serif",
+ icon: SerifIcon,
+ },
+ {
+ key: "monospace",
+ label: "Mono",
+ icon: MonospaceIcon,
+ },
+];
+
+const EDITOR_PDF_FONT_FAMILY_STYLES: Styles = {
+ "*:not(.courier, .courier-bold)": {
+ fontFamily: "Inter",
+ },
+ ".courier": {
+ fontFamily: "Courier",
+ },
+ ".courier-bold": {
+ fontFamily: "Courier-Bold",
+ },
+};
+
+const EDITOR_PDF_TYPOGRAPHY_STYLES: Styles = {
+ // page title
+ "h1.page-title": {
+ fontSize: convertRemToPixel(1.6),
+ fontWeight: "bold",
+ marginTop: 0,
+ marginBottom: convertRemToPixel(2),
+ },
+ // headings
+ "h1:not(.page-title)": {
+ fontSize: convertRemToPixel(1.4),
+ fontWeight: "semibold",
+ marginTop: convertRemToPixel(2),
+ marginBottom: convertRemToPixel(0.25),
+ },
+ h2: {
+ fontSize: convertRemToPixel(1.2),
+ fontWeight: "semibold",
+ marginTop: convertRemToPixel(1.4),
+ marginBottom: convertRemToPixel(0.0625),
+ },
+ h3: {
+ fontSize: convertRemToPixel(1.1),
+ fontWeight: "semibold",
+ marginTop: convertRemToPixel(1),
+ marginBottom: convertRemToPixel(0.0625),
+ },
+ h4: {
+ fontSize: convertRemToPixel(1),
+ fontWeight: "semibold",
+ marginTop: convertRemToPixel(1),
+ marginBottom: convertRemToPixel(0.0625),
+ },
+ h5: {
+ fontSize: convertRemToPixel(0.9),
+ fontWeight: "semibold",
+ marginTop: convertRemToPixel(1),
+ marginBottom: convertRemToPixel(0.0625),
+ },
+ h6: {
+ fontSize: convertRemToPixel(0.8),
+ fontWeight: "semibold",
+ marginTop: convertRemToPixel(1),
+ marginBottom: convertRemToPixel(0.0625),
+ },
+ // paragraph
+ "p:not(table p)": {
+ fontSize: convertRemToPixel(0.8),
+ },
+ "p:not(ol p, ul p)": {
+ marginTop: convertRemToPixel(0.25),
+ marginBottom: convertRemToPixel(0.0625),
+ },
+};
+
+const EDITOR_PDF_LIST_STYLES: Styles = {
+ "ul, ol": {
+ fontSize: convertRemToPixel(0.8),
+ marginHorizontal: -20,
+ },
+ "ol p, ul p": {
+ marginVertical: 0,
+ },
+ "ol li, ul li": {
+ marginTop: convertRemToPixel(0.45),
+ },
+ "ul ul, ul ol, ol ol, ol ul": {
+ marginVertical: 0,
+ },
+ "ul[data-type='taskList']": {
+ position: "relative",
+ },
+ "div.input-checkbox": {
+ position: "absolute",
+ top: convertRemToPixel(0.15),
+ left: -convertRemToPixel(1.2),
+ height: convertRemToPixel(0.75),
+ width: convertRemToPixel(0.75),
+ borderWidth: "1.5px",
+ borderStyle: "solid",
+ borderRadius: convertRemToPixel(0.125),
+ },
+ "div.input-checkbox:not(.checked)": {
+ backgroundColor: "#ffffff",
+ borderColor: "#171717",
+ },
+ "div.input-checkbox.checked": {
+ backgroundColor: "#3f76ff",
+ borderColor: "#3f76ff",
+ },
+ "ul li[data-checked='true'] p": {
+ color: "#a3a3a3",
+ },
+};
+
+const EDITOR_PDF_CODE_STYLES: Styles = {
+ // code block
+ "[data-node-type='code-block']": {
+ marginVertical: convertRemToPixel(0.5),
+ padding: convertRemToPixel(1),
+ borderRadius: convertRemToPixel(0.5),
+ backgroundColor: "#f7f7f7",
+ fontSize: convertRemToPixel(0.7),
+ },
+ // inline code block
+ "[data-node-type='inline-code-block']": {
+ margin: 0,
+ paddingVertical: convertRemToPixel(0.25 / 4 + 0.25 / 8),
+ paddingHorizontal: convertRemToPixel(0.375),
+ border: "0.5px solid #e5e5e5",
+ borderRadius: convertRemToPixel(0.25),
+ backgroundColor: "#e8e8e8",
+ color: "#f97316",
+ fontSize: convertRemToPixel(0.7),
+ },
+};
+
+export const EDITOR_PDF_DOCUMENT_STYLESHEET = StyleSheet.create({
+ ...EDITOR_PDF_FONT_FAMILY_STYLES,
+ ...EDITOR_PDF_TYPOGRAPHY_STYLES,
+ ...EDITOR_PDF_LIST_STYLES,
+ ...EDITOR_PDF_CODE_STYLES,
+ // quote block
+ blockquote: {
+ borderLeft: "3px solid gray",
+ paddingLeft: convertRemToPixel(1),
+ marginTop: convertRemToPixel(0.625),
+ marginBottom: 0,
+ marginHorizontal: 0,
+ },
+ // image
+ img: {
+ marginVertical: 0,
+ borderRadius: convertRemToPixel(0.375),
+ },
+ // divider
+ "div[data-type='horizontalRule']": {
+ marginVertical: convertRemToPixel(1),
+ height: 1,
+ width: "100%",
+ backgroundColor: "gray",
+ },
+ // mention block
+ "[data-node-type='mention-block']": {
+ margin: 0,
+ color: "#3f76ff",
+ backgroundColor: "#3f76ff33",
+ paddingHorizontal: convertRemToPixel(0.375),
+ },
+ // table
+ table: {
+ marginTop: convertRemToPixel(0.5),
+ marginBottom: convertRemToPixel(1),
+ marginHorizontal: 0,
+ },
+ "table td": {
+ padding: convertRemToPixel(0.625),
+ border: "1px solid #e5e5e5",
+ },
+ "table p": {
+ fontSize: convertRemToPixel(0.7),
+ },
+});
diff --git a/dev-wiki/core/hooks/editor/index.ts b/dev-wiki/core/hooks/editor/index.ts
new file mode 100644
index 0000000000..532916cd48
--- /dev/null
+++ b/dev-wiki/core/hooks/editor/index.ts
@@ -0,0 +1,2 @@
+export * from "./use-editor-config";
+export * from "./use-editor-mention";
diff --git a/dev-wiki/core/hooks/editor/use-editor-config.ts b/dev-wiki/core/hooks/editor/use-editor-config.ts
new file mode 100644
index 0000000000..00eff17527
--- /dev/null
+++ b/dev-wiki/core/hooks/editor/use-editor-config.ts
@@ -0,0 +1,100 @@
+import { useCallback } from "react";
+// plane editor
+import { TFileHandler, TReadOnlyFileHandler } from "@plane/editor";
+// helpers
+import { getEditorAssetSrc } from "@/helpers/editor.helper";
+// hooks
+import { useEditorAsset } from "@/hooks/store";
+// plane web hooks
+import { useFileSize } from "@/plane-web/hooks/use-file-size";
+// services
+import { FileService } from "@/services/file.service";
+const fileService = new FileService();
+
+type TArgs = {
+ projectId?: string;
+ uploadFile: TFileHandler["upload"];
+ workspaceId: string;
+ workspaceSlug: string;
+};
+
+export const useEditorConfig = () => {
+ // store hooks
+ const { assetsUploadPercentage } = useEditorAsset();
+ // file size
+ const { maxFileSize } = useFileSize();
+
+ const getReadOnlyEditorFileHandlers = useCallback(
+ (args: Pick): TReadOnlyFileHandler => {
+ const { projectId, workspaceId, workspaceSlug } = args;
+
+ return {
+ checkIfAssetExists: async (assetId: string) => {
+ const res = await fileService.checkIfAssetExists(workspaceSlug, assetId);
+ return res?.exists ?? false;
+ },
+ getAssetSrc: async (path) => {
+ if (!path) return "";
+ if (path?.startsWith("http")) {
+ return path;
+ } else {
+ return (
+ getEditorAssetSrc({
+ assetId: path,
+ projectId,
+ workspaceSlug,
+ }) ?? ""
+ );
+ }
+ },
+ restore: async (src: string) => {
+ if (src?.startsWith("http")) {
+ await fileService.restoreOldEditorAsset(workspaceId, src);
+ } else {
+ await fileService.restoreNewAsset(workspaceSlug, src);
+ }
+ },
+ };
+ },
+ []
+ );
+
+ const getEditorFileHandlers = useCallback(
+ (args: TArgs): TFileHandler => {
+ const { projectId, uploadFile, workspaceId, workspaceSlug } = args;
+
+ return {
+ ...getReadOnlyEditorFileHandlers({
+ projectId,
+ workspaceId,
+ workspaceSlug,
+ }),
+ assetsUploadStatus: assetsUploadPercentage,
+ upload: uploadFile,
+ delete: async (src: string) => {
+ if (src?.startsWith("http")) {
+ await fileService.deleteOldWorkspaceAsset(workspaceId, src);
+ } else {
+ await fileService.deleteNewAsset(
+ getEditorAssetSrc({
+ assetId: src,
+ projectId,
+ workspaceSlug,
+ }) ?? ""
+ );
+ }
+ },
+ cancel: fileService.cancelUpload,
+ validation: {
+ maxFileSize,
+ },
+ };
+ },
+ [assetsUploadPercentage, getReadOnlyEditorFileHandlers, maxFileSize]
+ );
+
+ return {
+ getEditorFileHandlers,
+ getReadOnlyEditorFileHandlers,
+ };
+};
diff --git a/dev-wiki/core/hooks/editor/use-editor-mention.tsx b/dev-wiki/core/hooks/editor/use-editor-mention.tsx
new file mode 100644
index 0000000000..183cc1ec9d
--- /dev/null
+++ b/dev-wiki/core/hooks/editor/use-editor-mention.tsx
@@ -0,0 +1,76 @@
+import { useCallback } from "react";
+// plane editor
+import { TMentionSection, TMentionSuggestion } from "@plane/editor";
+// plane types
+import { TSearchEntities, TSearchEntityRequestPayload, TSearchResponse, TUserSearchResponse } from "@plane/types";
+// plane ui
+import { Avatar } from "@plane/ui";
+// helpers
+import { getFileURL } from "@/helpers/file.helper";
+// plane web constants
+import { EDITOR_MENTION_TYPES } from "@/plane-web/constants/editor";
+// plane web hooks
+import { useAdditionalEditorMention } from "@/plane-web/hooks/use-additional-editor-mention";
+
+type TArgs = {
+ searchEntity: (payload: TSearchEntityRequestPayload) => Promise;
+};
+
+export const useEditorMention = (args: TArgs) => {
+ const { searchEntity } = args;
+ // additional mentions
+ const { updateAdditionalSections } = useAdditionalEditorMention();
+ // fetch mentions handler
+ const fetchMentions = useCallback(
+ async (query: string): Promise => {
+ try {
+ const res = await searchEntity({
+ count: 5,
+ query_type: EDITOR_MENTION_TYPES,
+ query,
+ });
+ const suggestionSections: TMentionSection[] = [];
+ if (!res) {
+ throw new Error("No response found");
+ }
+ Object.keys(res).map((key) => {
+ const responseKey = key as TSearchEntities;
+ const response = res[responseKey];
+ if (responseKey === "user_mention" && response && response.length > 0) {
+ const items: TMentionSuggestion[] = (response as TUserSearchResponse[]).map((user) => ({
+ icon: (
+
+ ),
+ id: user.member__id,
+ entity_identifier: user.member__id,
+ entity_name: "user_mention",
+ title: user.member__display_name,
+ }));
+ suggestionSections.push({
+ key: "users",
+ title: "Users",
+ items,
+ });
+ }
+ });
+ updateAdditionalSections({
+ response: res,
+ sections: suggestionSections,
+ });
+ return suggestionSections;
+ } catch (error) {
+ console.error("Error in fetching mentions for project pages:", error);
+ throw error;
+ }
+ },
+ [searchEntity, updateAdditionalSections]
+ );
+
+ return {
+ fetchMentions,
+ };
+};
diff --git a/dev-wiki/core/hooks/store/index.ts b/dev-wiki/core/hooks/store/index.ts
new file mode 100644
index 0000000000..3326ce3b22
--- /dev/null
+++ b/dev-wiki/core/hooks/store/index.ts
@@ -0,0 +1,7 @@
+export * from "./use-member";
+export * from "./use-router-params";
+export * from "./use-app-theme";
+export * from "./use-instance";
+export * from "./user";
+export * from "./use-workspace";
+export * from "./use-editor-asset";
diff --git a/dev-wiki/core/hooks/store/use-app-theme.ts b/dev-wiki/core/hooks/store/use-app-theme.ts
new file mode 100644
index 0000000000..0a1406a394
--- /dev/null
+++ b/dev-wiki/core/hooks/store/use-app-theme.ts
@@ -0,0 +1,10 @@
+import { useContext } from "react";
+// store
+import { StoreContext } from "@/lib/store-context";
+import { IThemeStore } from "@/store/theme.store";
+
+export const useAppTheme = (): IThemeStore => {
+ const context = useContext(StoreContext);
+ if (context === undefined) throw new Error("useAppTheme must be used within StoreProvider");
+ return context.theme;
+};
diff --git a/dev-wiki/core/hooks/store/use-editor-asset.ts b/dev-wiki/core/hooks/store/use-editor-asset.ts
new file mode 100644
index 0000000000..7c5af36960
--- /dev/null
+++ b/dev-wiki/core/hooks/store/use-editor-asset.ts
@@ -0,0 +1,10 @@
+import { useContext } from "react";
+// store
+import { StoreContext } from "@/lib/store-context";
+import { IEditorAssetStore } from "@/store/editor/asset.store";
+
+export const useEditorAsset = (): IEditorAssetStore => {
+ const context = useContext(StoreContext);
+ if (context === undefined) throw new Error("useEditorAsset must be used within StoreProvider");
+ return context.editorAssetStore;
+};
diff --git a/dev-wiki/core/hooks/store/use-favorite.ts b/dev-wiki/core/hooks/store/use-favorite.ts
new file mode 100644
index 0000000000..6a677520fe
--- /dev/null
+++ b/dev-wiki/core/hooks/store/use-favorite.ts
@@ -0,0 +1,10 @@
+import { useContext } from "react";
+// mobx store
+import { StoreContext } from "@/lib/store-context";
+import { IFavoriteStore } from "@/store/favorite.store";
+
+export const useFavorite = (): IFavoriteStore => {
+ const context = useContext(StoreContext);
+ if (context === undefined) throw new Error("useFavorites must be used within StoreProvider");
+ return context.favorite;
+};
diff --git a/dev-wiki/core/hooks/store/use-instance.ts b/dev-wiki/core/hooks/store/use-instance.ts
new file mode 100644
index 0000000000..92165e2bbe
--- /dev/null
+++ b/dev-wiki/core/hooks/store/use-instance.ts
@@ -0,0 +1,10 @@
+import { useContext } from "react";
+// store
+import { StoreContext } from "@/lib/store-context";
+import { IInstanceStore } from "@/store/instance.store";
+
+export const useInstance = (): IInstanceStore => {
+ const context = useContext(StoreContext);
+ if (context === undefined) throw new Error("useInstance must be used within StoreProvider");
+ return context.instance;
+};
diff --git a/dev-wiki/core/hooks/store/use-member.ts b/dev-wiki/core/hooks/store/use-member.ts
new file mode 100644
index 0000000000..a5a93926d0
--- /dev/null
+++ b/dev-wiki/core/hooks/store/use-member.ts
@@ -0,0 +1,11 @@
+import { useContext } from "react";
+// mobx store
+import { StoreContext } from "@/lib/store-context";
+// types;
+import { IMemberRootStore } from "@/store/member";
+
+export const useMember = (): IMemberRootStore => {
+ const context = useContext(StoreContext);
+ if (context === undefined) throw new Error("useMember must be used within StoreProvider");
+ return context.memberRoot;
+};
diff --git a/dev-wiki/core/hooks/store/use-router-params.ts b/dev-wiki/core/hooks/store/use-router-params.ts
new file mode 100644
index 0000000000..062134ace4
--- /dev/null
+++ b/dev-wiki/core/hooks/store/use-router-params.ts
@@ -0,0 +1,10 @@
+import { useContext } from "react";
+// store
+import { StoreContext } from "@/lib/store-context";
+import { IRouterStore } from "@/store/router.store";
+
+export const useRouterParams = (): IRouterStore => {
+ const context = useContext(StoreContext);
+ if (context === undefined) throw new Error("useRouterParams must be used within StoreProvider");
+ return context.router;
+};
diff --git a/dev-wiki/core/hooks/store/use-workspace.ts b/dev-wiki/core/hooks/store/use-workspace.ts
new file mode 100644
index 0000000000..8d422a343a
--- /dev/null
+++ b/dev-wiki/core/hooks/store/use-workspace.ts
@@ -0,0 +1,11 @@
+import { useContext } from "react";
+// mobx store
+import { StoreContext } from "@/lib/store-context";
+// types
+import { IWorkspaceRootStore } from "@/store/workspace";
+
+export const useWorkspace = (): IWorkspaceRootStore => {
+ const context = useContext(StoreContext);
+ if (context === undefined) throw new Error("useWorkspace must be used within StoreProvider");
+ return context.workspaceRoot;
+};
diff --git a/dev-wiki/core/hooks/store/user/index.ts b/dev-wiki/core/hooks/store/user/index.ts
new file mode 100644
index 0000000000..8b00f82b04
--- /dev/null
+++ b/dev-wiki/core/hooks/store/user/index.ts
@@ -0,0 +1,4 @@
+export * from "./user-user";
+export * from "./user-user-profile";
+export * from "./user-user-settings";
+export * from "./user-permissions";
diff --git a/dev-wiki/core/hooks/store/user/user-permissions.ts b/dev-wiki/core/hooks/store/user/user-permissions.ts
new file mode 100644
index 0000000000..12be981355
--- /dev/null
+++ b/dev-wiki/core/hooks/store/user/user-permissions.ts
@@ -0,0 +1,12 @@
+import { useContext } from "react";
+// mobx store
+import { StoreContext } from "@/lib/store-context";
+// plane web imports
+import { IUserPermissionStore } from "@/plane-web/store/user/permission.store";
+
+export const useUserPermissions = (): IUserPermissionStore => {
+ const context = useContext(StoreContext);
+ if (context === undefined) throw new Error("useUserPermissions must be used within StoreProvider");
+
+ return context.user.permission;
+};
diff --git a/dev-wiki/core/hooks/store/user/user-user-profile.ts b/dev-wiki/core/hooks/store/user/user-user-profile.ts
new file mode 100644
index 0000000000..d56378c781
--- /dev/null
+++ b/dev-wiki/core/hooks/store/user/user-user-profile.ts
@@ -0,0 +1,11 @@
+import { useContext } from "react";
+// mobx store
+import { StoreContext } from "@/lib/store-context";
+// types
+import { IUserProfileStore } from "@/store/user/profile.store";
+
+export const useUserProfile = (): IUserProfileStore => {
+ const context = useContext(StoreContext);
+ if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider");
+ return context.user.userProfile;
+};
diff --git a/dev-wiki/core/hooks/store/user/user-user-settings.ts b/dev-wiki/core/hooks/store/user/user-user-settings.ts
new file mode 100644
index 0000000000..103188e11d
--- /dev/null
+++ b/dev-wiki/core/hooks/store/user/user-user-settings.ts
@@ -0,0 +1,11 @@
+import { useContext } from "react";
+// mobx store
+import { StoreContext } from "@/lib/store-context";
+// types
+import { IUserSettingsStore } from "@/store/user/settings.store";
+
+export const useUserSettings = (): IUserSettingsStore => {
+ const context = useContext(StoreContext);
+ if (context === undefined) throw new Error("useUserSettings must be used within StoreProvider");
+ return context.user.userSettings;
+};
diff --git a/dev-wiki/core/hooks/store/user/user-user.ts b/dev-wiki/core/hooks/store/user/user-user.ts
new file mode 100644
index 0000000000..7f3f52c24e
--- /dev/null
+++ b/dev-wiki/core/hooks/store/user/user-user.ts
@@ -0,0 +1,11 @@
+import { useContext } from "react";
+// mobx store
+import { StoreContext } from "@/lib/store-context";
+// types
+import { IUserStore } from "@/store/user";
+
+export const useUser = (): IUserStore => {
+ const context = useContext(StoreContext);
+ if (context === undefined) throw new Error("useUser must be used within StoreProvider");
+ return context.user;
+};
diff --git a/dev-wiki/core/hooks/use-app-router.tsx b/dev-wiki/core/hooks/use-app-router.tsx
new file mode 100644
index 0000000000..6de0049a65
--- /dev/null
+++ b/dev-wiki/core/hooks/use-app-router.tsx
@@ -0,0 +1,4 @@
+// router from n-progress-bar
+import { useRouter } from "@/lib/n-progress";
+
+export const useAppRouter = () => useRouter();
diff --git a/dev-wiki/core/hooks/use-auto-save.tsx b/dev-wiki/core/hooks/use-auto-save.tsx
new file mode 100644
index 0000000000..8f4dd39f43
--- /dev/null
+++ b/dev-wiki/core/hooks/use-auto-save.tsx
@@ -0,0 +1,73 @@
+import { useEffect, useRef } from "react";
+import { debounce } from "lodash";
+
+const AUTO_SAVE_TIME = 30000;
+
+const useAutoSave = (handleSaveDescription: () => void) => {
+ const intervalIdRef = useRef(null);
+ const handleSaveDescriptionRef = useRef(handleSaveDescription);
+
+ // Update the ref to always point to the latest handleSaveDescription
+ useEffect(() => {
+ handleSaveDescriptionRef.current = handleSaveDescription;
+ }, [handleSaveDescription]);
+
+ // Set up the interval to run every 10 seconds
+ useEffect(() => {
+ intervalIdRef.current = setInterval(() => {
+ try {
+ handleSaveDescriptionRef.current();
+ } catch (error) {
+ console.error("Autosave before manual save failed:", error);
+ }
+ }, AUTO_SAVE_TIME);
+
+ return () => {
+ if (intervalIdRef.current) {
+ clearInterval(intervalIdRef.current);
+ }
+ };
+ }, []);
+
+ // Debounced save function for manual save (Ctrl+S or Cmd+S) and clearing the
+ // interval for auto save and setting up the interval again
+ useEffect(() => {
+ const debouncedSave = debounce(() => {
+ try {
+ handleSaveDescriptionRef.current();
+ } catch (error) {
+ console.error("Manual save failed:", error);
+ }
+
+ if (intervalIdRef.current) {
+ clearInterval(intervalIdRef.current);
+ intervalIdRef.current = setInterval(() => {
+ try {
+ handleSaveDescriptionRef.current();
+ } catch (error) {
+ console.error("Autosave after manual save failed:", error);
+ }
+ }, AUTO_SAVE_TIME);
+ }
+ }, 500);
+
+ const handleSave = (e: KeyboardEvent) => {
+ const { ctrlKey, metaKey, key } = e;
+ const cmdClicked = ctrlKey || metaKey;
+
+ if (cmdClicked && key.toLowerCase() === "s") {
+ e.preventDefault();
+ e.stopPropagation();
+ debouncedSave();
+ }
+ };
+
+ window.addEventListener("keydown", handleSave);
+
+ return () => {
+ window.removeEventListener("keydown", handleSave);
+ };
+ }, []);
+};
+
+export default useAutoSave;
diff --git a/dev-wiki/core/hooks/use-collaborative-page-actions.tsx b/dev-wiki/core/hooks/use-collaborative-page-actions.tsx
new file mode 100644
index 0000000000..309fff0a1f
--- /dev/null
+++ b/dev-wiki/core/hooks/use-collaborative-page-actions.tsx
@@ -0,0 +1,84 @@
+import { useCallback, useMemo } from "react";
+import { EditorRefApi, TDocumentEventsServer } from "@plane/editor";
+import { DocumentCollaborativeEvents, TDocumentEventsClient, getServerEventName } from "@plane/editor/lib";
+// plane ui
+import { TOAST_TYPE, setToast } from "@plane/ui";
+// store
+import { TPageInstance } from "@/store/pages/base-page";
+
+export type CollaborativeAction = {
+ execute: (shouldSync?: boolean, recursive?: boolean) => Promise;
+ errorMessage: string;
+};
+
+type CollaborativeActionEvent =
+ | { type: "sendMessageToServer"; message: TDocumentEventsServer; recursive?: boolean }
+ | { type: "receivedMessageFromServer"; message: TDocumentEventsClient };
+
+type Props = {
+ editorRef?: EditorRefApi | null;
+ page: TPageInstance;
+};
+
+export const useCollaborativePageActions = (props: Props) => {
+ const { editorRef, page } = props;
+
+ // @ts-expect-error - TODO: fix this
+ const actionHandlerMap: Record = useMemo(
+ () => ({
+ [DocumentCollaborativeEvents.lock.client]: {
+ execute: (shouldSync?: boolean, recursive?: boolean) => page.lock({ shouldSync, recursive }),
+ errorMessage: "Page could not be locked. Please try again later.",
+ },
+ [DocumentCollaborativeEvents.unlock.client]: {
+ execute: (shouldSync?: boolean, recursive?: boolean) => page.unlock({ shouldSync, recursive }),
+ errorMessage: "Page could not be unlocked. Please try again later.",
+ },
+ [DocumentCollaborativeEvents.archive.client]: {
+ execute: (shouldSync?: boolean) => page.archive({ shouldSync }),
+ errorMessage: "Page could not be archived. Please try again later.",
+ },
+ [DocumentCollaborativeEvents.unarchive.client]: {
+ execute: (shouldSync?: boolean) => page.restore({ shouldSync }),
+ errorMessage: "Page could not be restored. Please try again later.",
+ },
+ [DocumentCollaborativeEvents["make-public"].client]: {
+ execute: (shouldSync?: boolean) => page.makePublic({ shouldSync }),
+ errorMessage: "Page could not be made public. Please try again later.",
+ },
+ [DocumentCollaborativeEvents["make-private"].client]: {
+ execute: (shouldSync?: boolean) => page.makePrivate({ shouldSync }),
+ errorMessage: "Page could not be made private. Please try again later.",
+ },
+ }),
+ [page]
+ );
+
+ const executeCollaborativeAction = useCallback(
+ async (event: CollaborativeActionEvent) => {
+ const isPerformedByCurrentUser = event.type === "sendMessageToServer";
+ const clientAction = isPerformedByCurrentUser ? DocumentCollaborativeEvents[event.message].client : event.message;
+ const actionDetails = actionHandlerMap[clientAction];
+ try {
+ await actionDetails.execute(isPerformedByCurrentUser, isPerformedByCurrentUser ? event?.recursive : undefined);
+ if (isPerformedByCurrentUser) {
+ const serverEventName = getServerEventName(clientAction);
+ if (serverEventName) {
+ editorRef?.emitRealTimeUpdate(serverEventName);
+ }
+ }
+ } catch {
+ if (actionDetails?.errorMessage) {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: actionDetails.errorMessage,
+ });
+ }
+ }
+ },
+ [actionHandlerMap, editorRef]
+ );
+
+ return { executeCollaborativeAction, actionHandlerMap };
+};
diff --git a/dev-wiki/core/hooks/use-current-time.tsx b/dev-wiki/core/hooks/use-current-time.tsx
new file mode 100644
index 0000000000..fc37129c6d
--- /dev/null
+++ b/dev-wiki/core/hooks/use-current-time.tsx
@@ -0,0 +1,17 @@
+import { useEffect, useState } from "react";
+
+export const useCurrentTime = () => {
+ const [currentTime, setCurrentTime] = useState(new Date());
+ // update the current time every second
+ useEffect(() => {
+ const intervalId = setInterval(() => {
+ setCurrentTime(new Date());
+ }, 1000);
+
+ return () => clearInterval(intervalId);
+ }, []);
+
+ return {
+ currentTime,
+ };
+};
diff --git a/dev-wiki/core/hooks/use-dropdown-key-down.tsx b/dev-wiki/core/hooks/use-dropdown-key-down.tsx
new file mode 100644
index 0000000000..fdb925dc23
--- /dev/null
+++ b/dev-wiki/core/hooks/use-dropdown-key-down.tsx
@@ -0,0 +1,36 @@
+import { useCallback } from "react";
+
+type TUseDropdownKeyDown = {
+ (
+ onEnterKeyDown: () => void,
+ onEscKeyDown: () => void,
+ stopPropagation?: boolean
+ ): (event: React.KeyboardEvent) => void;
+};
+
+export const useDropdownKeyDown: TUseDropdownKeyDown = (onEnterKeyDown, onEscKeyDown, stopPropagation = true) => {
+ const stopEventPropagation = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (stopPropagation) {
+ event.stopPropagation();
+ event.preventDefault();
+ }
+ },
+ [stopPropagation]
+ );
+
+ const handleKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "Enter" && !event.nativeEvent.isComposing) {
+ stopEventPropagation(event);
+ onEnterKeyDown();
+ } else if (event.key === "Escape") {
+ stopEventPropagation(event);
+ onEscKeyDown();
+ } else if (event.key === "Tab") onEscKeyDown();
+ },
+ [onEnterKeyDown, onEscKeyDown, stopEventPropagation]
+ );
+
+ return handleKeyDown;
+};
diff --git a/dev-wiki/core/hooks/use-keypress.tsx b/dev-wiki/core/hooks/use-keypress.tsx
new file mode 100644
index 0000000000..c7243348d7
--- /dev/null
+++ b/dev-wiki/core/hooks/use-keypress.tsx
@@ -0,0 +1,19 @@
+import { useEffect } from "react";
+
+const useKeypress = (key: string, callback: (event: KeyboardEvent) => void) => {
+ useEffect(() => {
+ const handleKeydown = (event: KeyboardEvent) => {
+ if (event.key === key) {
+ callback(event);
+ }
+ };
+
+ document.addEventListener("keydown", handleKeydown);
+
+ return () => {
+ document.removeEventListener("keydown", handleKeydown);
+ };
+ }, [key, callback]);
+};
+
+export default useKeypress;
diff --git a/dev-wiki/core/hooks/use-local-storage.tsx b/dev-wiki/core/hooks/use-local-storage.tsx
new file mode 100644
index 0000000000..6ba44662a9
--- /dev/null
+++ b/dev-wiki/core/hooks/use-local-storage.tsx
@@ -0,0 +1,58 @@
+import { useState, useEffect, useCallback } from "react";
+
+export const getValueFromLocalStorage = (key: string, defaultValue: any) => {
+ if (typeof window === undefined || typeof window === "undefined") return defaultValue;
+ try {
+ const item = window.localStorage.getItem(key);
+ return item ? JSON.parse(item) : defaultValue;
+ } catch (error) {
+ window.localStorage.removeItem(key);
+ return defaultValue;
+ }
+};
+
+export const setValueIntoLocalStorage = (key: string, value: any) => {
+ if (typeof window === undefined || typeof window === "undefined") return false;
+ try {
+ window.localStorage.setItem(key, JSON.stringify(value));
+ return true;
+ } catch (error) {
+ return false;
+ }
+};
+
+// TODO: Remove this once we migrate to the new hooks from plane/helpers
+const useLocalStorage = (key: string, initialValue: T) => {
+ const [storedValue, setStoredValue] = useState(() => getValueFromLocalStorage(key, initialValue));
+
+ const setValue = useCallback(
+ (value: T) => {
+ window.localStorage.setItem(key, JSON.stringify(value));
+ setStoredValue(value);
+ window.dispatchEvent(new Event(`local-storage:${key}`));
+ },
+ [key]
+ );
+
+ const clearValue = useCallback(() => {
+ window.localStorage.removeItem(key);
+ setStoredValue(null);
+ window.dispatchEvent(new Event(`local-storage:${key}`));
+ }, [key]);
+
+ const reHydrate = useCallback(() => {
+ const data = getValueFromLocalStorage(key, initialValue);
+ setStoredValue(data);
+ }, [key, initialValue]);
+
+ useEffect(() => {
+ window.addEventListener(`local-storage:${key}`, reHydrate);
+ return () => {
+ window.removeEventListener(`local-storage:${key}`, reHydrate);
+ };
+ }, [key, reHydrate]);
+
+ return { storedValue, setValue, clearValue } as const;
+};
+
+export default useLocalStorage;
diff --git a/dev-wiki/core/hooks/use-online-status.ts b/dev-wiki/core/hooks/use-online-status.ts
new file mode 100644
index 0000000000..610fe1d5d2
--- /dev/null
+++ b/dev-wiki/core/hooks/use-online-status.ts
@@ -0,0 +1,22 @@
+import { useState, useEffect } from "react";
+
+const useOnlineStatus = () => {
+ // states
+ const [isOnline, setIsOnline] = useState(typeof navigator !== "undefined" ? navigator.onLine : true);
+
+ const updateOnlineStatus = () => setIsOnline(navigator.onLine);
+
+ useEffect(() => {
+ window.addEventListener("online", updateOnlineStatus);
+ window.addEventListener("offline", updateOnlineStatus);
+
+ return () => {
+ window.removeEventListener("online", updateOnlineStatus);
+ window.removeEventListener("offline", updateOnlineStatus);
+ };
+ }, []);
+
+ return { isOnline };
+};
+
+export default useOnlineStatus;
diff --git a/dev-wiki/core/hooks/use-page-fallback.ts b/dev-wiki/core/hooks/use-page-fallback.ts
new file mode 100644
index 0000000000..52b54fe501
--- /dev/null
+++ b/dev-wiki/core/hooks/use-page-fallback.ts
@@ -0,0 +1,55 @@
+import { useCallback, useEffect } from "react";
+// plane editor
+import { EditorRefApi, getBinaryDataFromDocumentEditorHTMLString } from "@plane/editor";
+// plane types
+import { TDocumentPayload } from "@plane/types";
+// hooks
+import useAutoSave from "@/hooks/use-auto-save";
+
+type TArgs = {
+ editorRef: React.RefObject;
+ fetchPageDescription: () => Promise;
+ hasConnectionFailed: boolean;
+ updatePageDescription: (data: TDocumentPayload) => Promise;
+};
+
+export const usePageFallback = (args: TArgs) => {
+ const { editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription } = args;
+
+ const handleUpdateDescription = useCallback(async () => {
+ if (!hasConnectionFailed) return;
+ const editor = editorRef.current;
+ if (!editor) return;
+
+ try {
+ const latestEncodedDescription = await fetchPageDescription();
+ let latestDecodedDescription: Uint8Array;
+ if (latestEncodedDescription && latestEncodedDescription.byteLength > 0) {
+ latestDecodedDescription = new Uint8Array(latestEncodedDescription);
+ } else {
+ latestDecodedDescription = getBinaryDataFromDocumentEditorHTMLString("
");
+ }
+
+ editor.setProviderDocument(latestDecodedDescription);
+ const { binary, html, json } = editor.getDocument();
+ if (!binary || !json) return;
+ const encodedBinary = Buffer.from(binary).toString("base64");
+
+ await updatePageDescription({
+ description_binary: encodedBinary,
+ description_html: html,
+ description: json,
+ });
+ } catch (error) {
+ console.error("Error in updating description using fallback logic:", error);
+ }
+ }, [editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription]);
+
+ useEffect(() => {
+ if (hasConnectionFailed) {
+ handleUpdateDescription();
+ }
+ }, [handleUpdateDescription, hasConnectionFailed]);
+
+ useAutoSave(handleUpdateDescription);
+};
diff --git a/dev-wiki/core/hooks/use-page-filters.ts b/dev-wiki/core/hooks/use-page-filters.ts
new file mode 100644
index 0000000000..448c589777
--- /dev/null
+++ b/dev-wiki/core/hooks/use-page-filters.ts
@@ -0,0 +1,116 @@
+import { useCallback, useMemo } from "react";
+// plane editor
+import { TEditorFontSize, TEditorFontStyle } from "@plane/editor";
+// hooks
+import useLocalStorage from "@/hooks/use-local-storage";
+
+export type TPagesPersonalizationConfig = {
+ full_width: boolean;
+ font_size: TEditorFontSize;
+ font_style: TEditorFontStyle;
+ sticky_toolbar: boolean;
+};
+
+const DEFAULT_PERSONALIZATION_VALUES: TPagesPersonalizationConfig = {
+ full_width: false,
+ font_size: "large-font",
+ font_style: "sans-serif",
+ sticky_toolbar: true,
+};
+
+export const usePageFilters = () => {
+ // local storage
+ const { storedValue: pagesConfig, setValue: setPagesConfig } = useLocalStorage(
+ "pages_config",
+ DEFAULT_PERSONALIZATION_VALUES
+ );
+ // stored values
+ const isFullWidth = useMemo(
+ () => (pagesConfig?.full_width === undefined ? DEFAULT_PERSONALIZATION_VALUES.full_width : pagesConfig?.full_width),
+ [pagesConfig?.full_width]
+ );
+ const isStickyToolbarEnabled = useMemo(
+ () =>
+ pagesConfig?.sticky_toolbar === undefined
+ ? DEFAULT_PERSONALIZATION_VALUES.sticky_toolbar
+ : pagesConfig?.sticky_toolbar,
+ [pagesConfig?.sticky_toolbar]
+ );
+ const fontSize = useMemo(
+ () => pagesConfig?.font_size ?? DEFAULT_PERSONALIZATION_VALUES.font_size,
+ [pagesConfig?.font_size]
+ );
+ const fontStyle = useMemo(
+ () => pagesConfig?.font_style ?? DEFAULT_PERSONALIZATION_VALUES.font_style,
+ [pagesConfig?.font_style]
+ );
+ // update action
+ const handleUpdateConfig = useCallback(
+ (payload: Partial) => {
+ setPagesConfig({
+ ...(pagesConfig ?? DEFAULT_PERSONALIZATION_VALUES),
+ ...payload,
+ });
+ },
+ [pagesConfig, setPagesConfig]
+ );
+ /**
+ * @description action to update full_width value
+ * @param {boolean} value
+ */
+ const handleFullWidth = useCallback(
+ (value: boolean) => {
+ handleUpdateConfig({
+ full_width: value,
+ });
+ },
+ [handleUpdateConfig]
+ );
+ /**
+ * @description action to update font_size value
+ * @param {TEditorFontSize} value
+ */
+ const handleFontSize = useCallback(
+ (value: TEditorFontSize) => {
+ handleUpdateConfig({
+ font_size: value,
+ });
+ },
+ [handleUpdateConfig]
+ );
+ /**
+ * @description action to update font_size value
+ * @param {TEditorFontSize} value
+ */
+ const handleFontStyle = useCallback(
+ (value: TEditorFontStyle) => {
+ handleUpdateConfig({
+ font_style: value,
+ });
+ },
+ [handleUpdateConfig]
+ );
+ /**
+ * @description action to update full_width value
+ * @param {boolean} value
+ */
+ const handleStickyToolbar = useCallback(
+ (value: boolean) => {
+ handleUpdateConfig({
+ sticky_toolbar: value,
+ });
+ },
+ [handleUpdateConfig]
+ );
+
+ return {
+ fontSize,
+ handleFontSize,
+ fontStyle,
+ handleFontStyle,
+ isFullWidth,
+ handleFullWidth,
+ isStickyToolbarEnabled,
+ handleStickyToolbar,
+ };
+};
diff --git a/dev-wiki/core/hooks/use-page-operations.tsx b/dev-wiki/core/hooks/use-page-operations.tsx
new file mode 100644
index 0000000000..5006e0c0a8
--- /dev/null
+++ b/dev-wiki/core/hooks/use-page-operations.tsx
@@ -0,0 +1,216 @@
+import { useMemo } from "react";
+// plane imports
+import { IS_FAVORITE_MENU_OPEN } from "@plane/constants";
+import { EditorRefApi } from "@plane/editor";
+import { useLocalStorage } from "@plane/hooks";
+import { EPageAccess } from "@plane/types/src/enums";
+import { setToast, TOAST_TYPE } from "@plane/ui";
+import { copyUrlToClipboard } from "@plane/utils";
+// hooks
+import { useCollaborativePageActions } from "@/hooks/use-collaborative-page-actions";
+// store types
+import { TPageInstance } from "@/store/pages/base-page";
+
+export type TPageOperations = {
+ toggleLock: ({ recursive }: { recursive?: boolean }) => void;
+ toggleAccess: () => void;
+ toggleFavorite: () => void;
+ openInNewTab: () => void;
+ copyLink: () => void;
+ duplicate: (realtimeEvents?: boolean) => void;
+ toggleArchive: () => void;
+};
+
+type Props = {
+ editorRef?: EditorRefApi | null;
+ page: TPageInstance;
+};
+
+export const usePageOperations = (
+ props: Props
+): {
+ pageOperations: TPageOperations;
+} => {
+ const { page } = props;
+ // derived values
+ const {
+ access,
+ addToFavorites,
+ archived_at,
+ duplicate,
+ is_favorite,
+ is_locked,
+ getRedirectionLink,
+ removePageFromFavorites,
+ } = page;
+ // collaborative actions
+ const { executeCollaborativeAction } = useCollaborativePageActions(props);
+ // local storage
+ const { setValue: toggleFavoriteMenu, storedValue: isFavoriteMenuOpen } = useLocalStorage(
+ IS_FAVORITE_MENU_OPEN,
+ false
+ );
+ // page operations
+ const pageOperations: TPageOperations = useMemo(() => {
+ const pageLink = getRedirectionLink();
+
+ return {
+ copyLink: () => {
+ copyUrlToClipboard(pageLink).then(() => {
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Link Copied!",
+ message: "Page link copied to clipboard.",
+ });
+ });
+ },
+ duplicate: async (realtimeEvents = true) => {
+ try {
+ setToast({
+ id: "duplicating-page",
+ type: TOAST_TYPE.LOADING_TOAST,
+ title: "Duplicating page...",
+ });
+ await duplicate();
+ if (realtimeEvents) return;
+ setTimeout(() => {
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success!",
+ message: "Page duplicated successfully.",
+ });
+ }, 3000);
+ } catch {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: "Page could not be duplicated. Please try again later.",
+ });
+ }
+ },
+ move: async () => {},
+ openInNewTab: () => window.open(pageLink, "_blank"),
+ toggleAccess: async () => {
+ const changedPageType = access === EPageAccess.PUBLIC ? "private" : "public";
+ try {
+ if (access === EPageAccess.PUBLIC)
+ await executeCollaborativeAction({ type: "sendMessageToServer", message: "make-private" });
+ else await executeCollaborativeAction({ type: "sendMessageToServer", message: "make-public" });
+
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success!",
+ message: `The page has been marked ${changedPageType} and moved to the ${changedPageType} section.`,
+ });
+ } catch {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: `The page couldn't be marked ${changedPageType}. Please try again.`,
+ });
+ }
+ },
+ toggleArchive: async () => {
+ if (archived_at) {
+ try {
+ await executeCollaborativeAction({ type: "sendMessageToServer", message: "unarchive" });
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success!",
+ message: "Page restored successfully.",
+ });
+ } catch {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: "Page could not be restored. Please try again later.",
+ });
+ }
+ } else {
+ try {
+ await executeCollaborativeAction({ type: "sendMessageToServer", message: "archive" });
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success!",
+ message: "Page archived successfully.",
+ });
+ } catch {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: "Page could not be archived. Please try again later.",
+ });
+ }
+ }
+ },
+ toggleFavorite: () => {
+ if (is_favorite) {
+ removePageFromFavorites().then(() =>
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success!",
+ message: "Page removed from favorites.",
+ })
+ );
+ } else {
+ addToFavorites().then(() => {
+ if (!isFavoriteMenuOpen) toggleFavoriteMenu(true);
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success!",
+ message: "Page added to favorites.",
+ });
+ });
+ }
+ },
+ toggleLock: async ({ recursive = false }: { recursive?: boolean } = {}) => {
+ if (is_locked) {
+ try {
+ await executeCollaborativeAction({ type: "sendMessageToServer", message: "unlock", recursive });
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success!",
+ message: "Page unlocked successfully.",
+ });
+ } catch {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: "Page could not be unlocked. Please try again later.",
+ });
+ }
+ } else {
+ try {
+ await executeCollaborativeAction({ type: "sendMessageToServer", message: "lock", recursive });
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success!",
+ message: "Page locked successfully.",
+ });
+ } catch {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: "Page could not be locked. Please try again later.",
+ });
+ }
+ }
+ },
+ };
+ }, [
+ access,
+ addToFavorites,
+ archived_at,
+ duplicate,
+ executeCollaborativeAction,
+ getRedirectionLink,
+ is_favorite,
+ is_locked,
+ isFavoriteMenuOpen,
+ removePageFromFavorites,
+ toggleFavoriteMenu,
+ ]);
+ return {
+ pageOperations,
+ };
+};
diff --git a/dev-wiki/core/hooks/use-platform-os.tsx b/dev-wiki/core/hooks/use-platform-os.tsx
new file mode 100644
index 0000000000..cef4e785cf
--- /dev/null
+++ b/dev-wiki/core/hooks/use-platform-os.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+export const usePlatformOS = () => {
+ const userAgent = window.navigator.userAgent;
+ const isMobile = /iPhone|iPad|iPod|Android/i.test(userAgent);
+ let platform = "";
+
+ if (!isMobile) {
+ if (userAgent.indexOf("Win") !== -1) {
+ platform = "Windows";
+ } else if (userAgent.indexOf("Mac") !== -1) {
+ platform = "MacOS";
+ } else if (userAgent.indexOf("Linux") !== -1) {
+ platform = "Linux";
+ } else {
+ platform = "Unknown";
+ }
+ }
+ return { isMobile, platform };
+};
diff --git a/dev-wiki/core/hooks/use-query-params.ts b/dev-wiki/core/hooks/use-query-params.ts
new file mode 100644
index 0000000000..8b689f0cbe
--- /dev/null
+++ b/dev-wiki/core/hooks/use-query-params.ts
@@ -0,0 +1,39 @@
+import { useSearchParams, usePathname } from "next/navigation";
+
+type TParamsToAdd = {
+ [key: string]: string;
+};
+
+export const useQueryParams = () => {
+ // next navigation
+ const searchParams = useSearchParams();
+ const pathname = usePathname();
+
+ const updateQueryParams = ({
+ paramsToAdd = {},
+ paramsToRemove = [],
+ }: {
+ paramsToAdd?: TParamsToAdd;
+ paramsToRemove?: string[];
+ }) => {
+ const currentParams = new URLSearchParams(searchParams.toString());
+
+ // add or update query parameters
+ Object.keys(paramsToAdd).forEach((key) => {
+ currentParams.set(key, paramsToAdd[key]);
+ });
+
+ // remove specified query parameters
+ paramsToRemove.forEach((key) => {
+ currentParams.delete(key);
+ });
+
+ // construct the new route with the updated query parameters
+ const newRoute = `${pathname}?${currentParams.toString()}`;
+ return newRoute;
+ };
+
+ return {
+ updateQueryParams,
+ };
+};
diff --git a/dev-wiki/core/hooks/use-realtime-page-events.tsx b/dev-wiki/core/hooks/use-realtime-page-events.tsx
new file mode 100644
index 0000000000..84527760a0
--- /dev/null
+++ b/dev-wiki/core/hooks/use-realtime-page-events.tsx
@@ -0,0 +1,291 @@
+import { useCallback, useMemo } from "react";
+import { EventToPayloadMap } from "@plane/editor";
+import { IUserLite, TCollaborator } from "@plane/types";
+import { dismissToast, setToast, TOAST_TYPE } from "@plane/ui";
+// components
+import { TEditorBodyHandlers } from "@/components/pages";
+// hooks
+import { useUser } from "@/hooks/store";
+import { useAppRouter } from "@/hooks/use-app-router";
+import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store/use-page-store";
+// store
+import { TPageInstance } from "@/store/pages/base-page";
+
+// Type for page update handlers with proper typing for action data
+export type PageUpdateHandler = (params: {
+ pageIds: string[];
+ data: EventToPayloadMap[T];
+ performAction: boolean;
+}) => void;
+
+// Type for custom event handlers that can be provided to override default behavior
+export type TCustomEventHandlers = {
+ [K in keyof EventToPayloadMap]?: PageUpdateHandler;
+};
+
+interface UsePageEventsProps {
+ page: TPageInstance;
+ storeType: EPageStoreType;
+ getUserDetails: (userId: string) => IUserLite | undefined;
+ customRealtimeEventHandlers?: TCustomEventHandlers;
+ handlers: TEditorBodyHandlers;
+}
+
+export const useRealtimePageEvents = ({
+ page,
+ storeType,
+ getUserDetails,
+ customRealtimeEventHandlers,
+ handlers,
+}: UsePageEventsProps) => {
+ const router = useAppRouter();
+ const { removePage, getPageById, getOrFetchPageInstance } = usePageStore(storeType);
+ const { data: currentUser } = useUser();
+ // derived values
+ const editorRef = page.editorRef;
+
+ // Helper function to safely get user display text
+ const getUserDisplayText = useCallback(
+ (userId: string | undefined) => {
+ if (!userId) return "";
+ try {
+ const userDetails = getUserDetails(userId as string);
+ return userDetails?.display_name ? ` by ${userDetails.display_name}` : "";
+ } catch {
+ return "";
+ }
+ },
+ [getUserDetails]
+ );
+
+ const ACTION_HANDLERS = useMemo<
+ Partial<{
+ [K in keyof EventToPayloadMap]: PageUpdateHandler;
+ }>
+ >(
+ () => ({
+ archived: ({ pageIds, data }) => {
+ pageIds.forEach((pageId) => {
+ const pageItem = getPageById(pageId);
+ if (pageItem) pageItem.archive({ archived_at: data.archived_at, shouldSync: false });
+ });
+ },
+ unarchived: ({ pageIds }) => {
+ pageIds.forEach((pageId) => {
+ const pageItem = getPageById(pageId);
+ if (pageItem) pageItem.restore({ shouldSync: false });
+ });
+ },
+ locked: ({ pageIds }) => {
+ pageIds.forEach((pageId) => {
+ const pageItem = getPageById(pageId);
+ if (pageItem) pageItem.lock({ shouldSync: false, recursive: false });
+ });
+ },
+ unlocked: ({ pageIds }) => {
+ pageIds.forEach((pageId) => {
+ const pageItem = getPageById(pageId);
+ if (pageItem) pageItem.unlock({ shouldSync: false, recursive: false });
+ });
+ },
+ "made-public": ({ pageIds }) => {
+ pageIds.forEach((pageId) => {
+ const pageItem = getPageById(pageId);
+ if (pageItem) pageItem.makePublic({ shouldSync: false });
+ });
+ },
+ "made-private": ({ pageIds }) => {
+ pageIds.forEach((pageId) => {
+ const pageItem = getPageById(pageId);
+ if (pageItem) pageItem.makePrivate({ shouldSync: false });
+ });
+ },
+ deleted: ({ pageIds, data }) => {
+ pageIds.forEach((pageId) => {
+ const pageItem = getPageById(pageId);
+ if (pageItem) {
+ removePage({ pageId, shouldSync: false });
+ if (page.id === pageId && data?.user_id !== currentUser?.id) {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Page deleted",
+ message: `Page deleted${getUserDisplayText(data.user_id)}`,
+ });
+ router.push(handlers.getRedirectionLink());
+ } else if (page.id === pageId) {
+ router.push(handlers.getRedirectionLink());
+ }
+ }
+ });
+ },
+ title_updated: ({ pageIds, data }) => {
+ pageIds.forEach((pageId) => {
+ const pageItem = getPageById(pageId);
+ if (pageItem && data.title != null) pageItem.updateTitle(data.title);
+ });
+ },
+ moved_internally: ({ pageIds, data }) => {
+ pageIds.forEach((pageId) => {
+ const pageItem = getPageById(pageId);
+ if (data.sub_pages_count !== undefined) {
+ // Handle parent page sub-pages count update
+ const currentSubPageCount = pageItem?.sub_pages_count ?? (data.sub_pages_count === -1 ? 1 : -1);
+ pageItem?.mutateProperties({
+ sub_pages_count: currentSubPageCount + data.sub_pages_count,
+ });
+ } else if (data.parent_id !== undefined) {
+ // Handle page parent change
+ pageItem?.mutateProperties({ parent_id: data.parent_id });
+ if (pageItem?.id === page.id) {
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "This page has been moved",
+ message: "This page has been moved to a new parent page",
+ });
+ }
+ }
+ });
+ },
+ published: ({ data }) => {
+ const pagesToPublish = data.published_pages;
+ pagesToPublish?.forEach(({ page_id: pageId, anchor }) => {
+ const pageItem = getPageById(pageId);
+ pageItem?.mutateProperties({ anchor: anchor });
+ });
+ },
+ unpublished: ({ pageIds }) => {
+ pageIds.forEach((pageId) => {
+ const pageItem = getPageById(pageId);
+ pageItem?.mutateProperties({ anchor: null });
+ });
+ },
+ "collaborators-updated": ({ pageIds, data }) => {
+ pageIds.forEach((pageId: string) => {
+ const pageItem = getPageById(pageId);
+ const collaborators = data.users;
+ if (pageItem && collaborators) {
+ const collaboratorsForPageStore: TCollaborator[] = collaborators.map((col) => ({
+ name: col.name,
+ color: col.color,
+ id: col.id,
+ }));
+ pageItem.updateCollaborators(collaboratorsForPageStore);
+ }
+ });
+ },
+ restored: ({ data }) => {
+ if (page.id) {
+ let descriptionHTML: string | null = null;
+ if (page?.restoration.versionId) {
+ descriptionHTML = page.restoration.descriptionHTML;
+ if (!editorRef) {
+ page?.setVersionToBeRestored(null, null);
+ page?.setRestorationStatus(false);
+ dismissToast("restoring-version");
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Page version failed to restore.",
+ });
+ return;
+ }
+ editorRef?.clearEditor(true);
+
+ page?.setVersionToBeRestored(null, null);
+ page?.setRestorationStatus(false);
+ if (descriptionHTML) {
+ editorRef?.setEditorValue(descriptionHTML);
+ }
+
+ dismissToast("restoring-version");
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Page version restored.",
+ });
+
+ return;
+ }
+ }
+ // delete the pages from the store
+ const { deleted_page_ids } = data;
+
+ deleted_page_ids?.forEach((pageId) => {
+ const pageItem = getPageById(pageId);
+ if (pageItem)
+ Promise.resolve(removePage({ pageId, shouldSync: false })).then(() => {
+ if (page.id === pageId) {
+ router.push(handlers.getRedirectionLink());
+ }
+ });
+ else if (page.id === pageId) router.push(handlers.getRedirectionLink());
+ });
+ },
+ duplicated: async ({ pageIds, data }) => {
+ const duplicatedPage = data.new_page_id;
+ dismissToast("duplicating-page");
+
+ // create a new page instace of the duplicatedPage in the store
+ await getOrFetchPageInstance({ pageId: duplicatedPage });
+
+ if (page.id === pageIds[0] && data.user_id === currentUser?.id) {
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: `Page duplicated successfully.`,
+ actionItems: (
+
+ ),
+ });
+ }
+ },
+ ...customRealtimeEventHandlers,
+ }),
+ [
+ getPageById,
+ getOrFetchPageInstance,
+ page,
+ editorRef,
+ router,
+ getUserDisplayText,
+ removePage,
+ currentUser,
+ customRealtimeEventHandlers,
+ handlers,
+ ]
+ );
+
+ // The main function that will be returned from this hook
+ const updatePageProperties = useCallback(
+ (
+ pageIds: string | string[],
+ actionType: T,
+ data: EventToPayloadMap[T],
+ performAction = false
+ ) => {
+ // Convert to array if single string is passed
+ const normalizedPageIds = Array.isArray(pageIds) ? pageIds : [pageIds];
+
+ if (normalizedPageIds.length === 0) return;
+
+ // Get the handler for this message type
+ const handler = ACTION_HANDLERS[actionType];
+
+ if (handler) {
+ // Now TypeScript knows that handler and data match in type
+ handler({ pageIds: normalizedPageIds, data, performAction });
+ } else {
+ console.warn(`No handler for message type: ${actionType}`);
+ }
+ },
+ [ACTION_HANDLERS]
+ );
+
+ return { updatePageProperties };
+};
diff --git a/dev-wiki/core/hooks/use-reload-confirmation.tsx b/dev-wiki/core/hooks/use-reload-confirmation.tsx
new file mode 100644
index 0000000000..6ba324d148
--- /dev/null
+++ b/dev-wiki/core/hooks/use-reload-confirmation.tsx
@@ -0,0 +1,61 @@
+import { useCallback, useEffect, useState } from "react";
+
+//TODO: remove temp flag isActive later and use showAlert as the source of truth
+const useReloadConfirmations = (isActive = true, message?: string, defaultShowAlert = false, onLeave?: () => void) => {
+ const [showAlert, setShowAlert] = useState(defaultShowAlert);
+
+ const alertMessage = message ?? "Are you sure you want to leave? Changes you made may not be saved.";
+
+ const handleBeforeUnload = useCallback(
+ (event: BeforeUnloadEvent) => {
+ if (!isActive || !showAlert) return;
+ event.preventDefault();
+ event.returnValue = "";
+ },
+ [isActive, showAlert]
+ );
+
+ const handleAnchorClick = useCallback(
+ (event: MouseEvent) => {
+ if (!isActive || !showAlert) return;
+ // Skip if event target is not available or defaultPrevented
+ if (!event.target || event.defaultPrevented) return;
+ // Skip control/command/option/alt+click
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
+ // check if the event target is an anchor or a child of an anchor tag
+ const eventTarget = event.target as HTMLElement;
+ if (!eventTarget.closest("a")) return; // This is intentionally not type safe
+ // check if anchor target is _blank
+ const anchorElement = eventTarget.closest("a") as HTMLAnchorElement;
+ const isAnchorTargetBlank = anchorElement.getAttribute("target") === "_blank";
+ if (isAnchorTargetBlank) return;
+ // show confirm dialog
+ const isLeaving = confirm(alertMessage);
+ if (isLeaving) {
+ onLeave && onLeave();
+ } else {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ },
+ [isActive, showAlert]
+ );
+
+ useEffect(() => {
+ // handle browser refresh
+ window.addEventListener("beforeunload", handleBeforeUnload, true);
+ // handle anchor tag click
+ window.addEventListener("click", handleAnchorClick, true);
+ // TODO: handle back / forward button click
+
+ return () => {
+ // cleanup
+ window.removeEventListener("beforeunload", handleBeforeUnload, true);
+ window.removeEventListener("click", handleAnchorClick, true);
+ };
+ }, [handleAnchorClick, handleBeforeUnload]);
+
+ return { setShowAlert };
+};
+
+export default useReloadConfirmations;
diff --git a/dev-wiki/core/hooks/use-resolved-asset-path.tsx b/dev-wiki/core/hooks/use-resolved-asset-path.tsx
new file mode 100644
index 0000000000..4233dc6469
--- /dev/null
+++ b/dev-wiki/core/hooks/use-resolved-asset-path.tsx
@@ -0,0 +1,26 @@
+import { useTheme } from "next-themes";
+
+type AssetPathConfig = {
+ basePath: string;
+ additionalPath?: string;
+ extension?: string;
+ includeThemeInPath?: boolean;
+};
+
+export const useResolvedAssetPath = ({
+ basePath,
+ additionalPath = "",
+ extension = "webp",
+ includeThemeInPath = true,
+}: AssetPathConfig) => {
+ // hooks
+ const { resolvedTheme } = useTheme();
+ // resolved theme
+ const theme = resolvedTheme === "light" ? "light" : "dark";
+
+ if (!includeThemeInPath) {
+ return `${additionalPath && additionalPath !== "" ? `${basePath}${additionalPath}` : basePath}.${extension}`;
+ }
+
+ return `${additionalPath && additionalPath !== "" ? `${basePath}${additionalPath}` : basePath}-${theme}.${extension}`;
+};
diff --git a/dev-wiki/core/layouts/access/index.ts b/dev-wiki/core/layouts/access/index.ts
new file mode 100644
index 0000000000..ab2a1137f1
--- /dev/null
+++ b/dev-wiki/core/layouts/access/index.ts
@@ -0,0 +1 @@
+export * from "./workspace-wrapper";
diff --git a/dev-wiki/core/layouts/access/workspace-wrapper.tsx b/dev-wiki/core/layouts/access/workspace-wrapper.tsx
new file mode 100644
index 0000000000..32ddd6e9fa
--- /dev/null
+++ b/dev-wiki/core/layouts/access/workspace-wrapper.tsx
@@ -0,0 +1,26 @@
+import { useParams } from "next/navigation";
+import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
+import { NotAuthorizedView } from "@/components/auth-screens";
+import { useUserPermissions } from "@/hooks/store";
+interface IWorkspaceAuthWrapper {
+ children: React.ReactNode;
+ pageKey: string;
+ allowedPermissions?: EUserPermissions[];
+}
+
+const WorkspaceAccessWrapper = ({ children, ...props }: IWorkspaceAuthWrapper) => {
+ const { pageKey, allowedPermissions } = props;
+ // router
+ const { workspaceSlug } = useParams();
+ // store
+ const { hasPageAccess, allowPermissions } = useUserPermissions();
+ // derived values
+ const isAuthorized = allowedPermissions
+ ? allowPermissions(allowedPermissions, EUserPermissionsLevel.WORKSPACE, workspaceSlug?.toString())
+ : hasPageAccess(workspaceSlug?.toString() ?? "", pageKey);
+ // render
+ if (!isAuthorized) return ;
+ return <>{children}>;
+};
+
+export default WorkspaceAccessWrapper;
diff --git a/dev-wiki/core/layouts/auth-layout/index.ts b/dev-wiki/core/layouts/auth-layout/index.ts
new file mode 100644
index 0000000000..ab2a1137f1
--- /dev/null
+++ b/dev-wiki/core/layouts/auth-layout/index.ts
@@ -0,0 +1 @@
+export * from "./workspace-wrapper";
diff --git a/dev-wiki/core/layouts/auth-layout/workspace-wrapper.tsx b/dev-wiki/core/layouts/auth-layout/workspace-wrapper.tsx
new file mode 100644
index 0000000000..49183a504e
--- /dev/null
+++ b/dev-wiki/core/layouts/auth-layout/workspace-wrapper.tsx
@@ -0,0 +1,196 @@
+"use client";
+
+import { FC, ReactNode } from "react";
+import { observer } from "mobx-react";
+import Image from "next/image";
+import Link from "next/link";
+import { useParams } from "next/navigation";
+import { useTheme } from "next-themes";
+import useSWR from "swr";
+// ui
+import { LogOut } from "lucide-react";
+import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
+import { Button, getButtonStyling, setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
+// components
+import { cn } from "@plane/utils";
+import { LogoSpinner } from "@/components/common";
+// hooks
+import { useMember, useUser, useUserPermissions, useWorkspace } from "@/hooks/store";
+import { usePlatformOS } from "@/hooks/use-platform-os";
+// images
+import PlaneBlackLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png";
+import PlaneWhiteLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png";
+import WorkSpaceNotAvailable from "@/public/workspace/workspace-not-available.png";
+
+interface IWorkspaceAuthWrapper {
+ children: ReactNode;
+ isLoading?: boolean;
+}
+
+export const WorkspaceAuthWrapper: FC = observer((props) => {
+ const { children, isLoading: isParentLoading = false } = props;
+ // router params
+ const { workspaceSlug } = useParams();
+ // next themes
+ const { resolvedTheme } = useTheme();
+ // store hooks
+ const { signOut, data: currentUser } = useUser();
+ const {
+ workspace: { fetchWorkspaceMembers },
+ } = useMember();
+ const { workspaces, fetchSidebarNavigationPreferences } = useWorkspace();
+ const { isMobile } = usePlatformOS();
+ const { loader, workspaceInfoBySlug, fetchUserWorkspaceInfo, fetchUserProjectPermissions, allowPermissions } =
+ useUserPermissions();
+ // derived values
+ const canPerformWorkspaceMemberActions = allowPermissions(
+ [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
+ EUserPermissionsLevel.WORKSPACE
+ );
+ const planeLogo = resolvedTheme === "dark" ? PlaneWhiteLogo : PlaneBlackLogo;
+ const allWorkspaces = workspaces ? Object.values(workspaces) : undefined;
+ const currentWorkspace =
+ (allWorkspaces && allWorkspaces.find((workspace) => workspace?.slug === workspaceSlug)) || undefined;
+ const currentWorkspaceInfo = workspaceSlug && workspaceInfoBySlug(workspaceSlug.toString());
+
+ // fetching user workspace information
+ useSWR(
+ workspaceSlug && currentWorkspace ? `WORKSPACE_MEMBER_ME_INFORMATION_${workspaceSlug}` : null,
+ workspaceSlug && currentWorkspace ? () => fetchUserWorkspaceInfo(workspaceSlug.toString()) : null,
+ { revalidateIfStale: false, revalidateOnFocus: false }
+ );
+ useSWR(
+ workspaceSlug && currentWorkspace ? `WORKSPACE_PROJECTS_ROLES_INFORMATION_${workspaceSlug}` : null,
+ workspaceSlug && currentWorkspace ? () => fetchUserProjectPermissions(workspaceSlug.toString()) : null,
+ { revalidateIfStale: false, revalidateOnFocus: false }
+ );
+
+ // fetch workspace members
+ useSWR(
+ workspaceSlug && currentWorkspace ? `WORKSPACE_MEMBERS_${workspaceSlug}` : null,
+ workspaceSlug && currentWorkspace ? () => fetchWorkspaceMembers(workspaceSlug.toString()) : null,
+ { revalidateIfStale: false, revalidateOnFocus: false }
+ );
+
+ // fetch workspace sidebar preferences
+ useSWR(
+ workspaceSlug ? `WORKSPACE_SIDEBAR_PREFERENCES_${workspaceSlug}` : null,
+ workspaceSlug ? () => fetchSidebarNavigationPreferences(workspaceSlug.toString()) : null,
+ { revalidateIfStale: false, revalidateOnFocus: false }
+ );
+
+ const handleSignOut = async () => {
+ await signOut().catch(() =>
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: "Failed to sign out. Please try again.",
+ })
+ );
+ };
+
+ // if list of workspaces are not there then we have to render the spinner
+ if (isParentLoading || allWorkspaces === undefined || loader) {
+ return (
+
+ );
+ }
+
+ // if workspaces are there and we are trying to access the workspace that we are not part of then show the existing workspaces
+ if (currentWorkspace === undefined && !currentWorkspaceInfo) {
+ return (
+
+
+
+
+
+
+
+
{currentUser?.email}
+
+
+
+
+
+
+
+
+
+
+
+
Workspace not found
+
+ No workspace found with the URL. It may not exist or you lack authorization to view it.
+
+
+ {allWorkspaces && allWorkspaces.length > 0 && (
+
+ Go Home
+
+ )}
+ {allWorkspaces?.length > 0 && (
+
+ Visit Profile
+
+ )}
+ {allWorkspaces && allWorkspaces.length === 0 && (
+
+ Create new workspace
+
+ )}
+
+
+
+
+
+
+ );
+ }
+
+ // while user does not have access to view that workspace
+ if (currentWorkspaceInfo === undefined) {
+ return (
+
+
+
+
+
Not Authorized!
+
+ You{"'"}re not a member of this workspace. Please contact the workspace admin to get an invitation or
+ check your pending invitations.
+
+
+
+
+
+
+ Check pending invites
+
+
+
+
+
+
+ Create new workspace
+
+
+
+
+
+
+
+ );
+ }
+
+ return <>{children}>;
+});
diff --git a/dev-wiki/core/layouts/default-layout/index.tsx b/dev-wiki/core/layouts/default-layout/index.tsx
new file mode 100644
index 0000000000..4282f40a8b
--- /dev/null
+++ b/dev-wiki/core/layouts/default-layout/index.tsx
@@ -0,0 +1,16 @@
+import { FC, ReactNode } from "react";
+import { cn } from "@plane/utils";
+
+type Props = {
+ children: ReactNode;
+ gradient?: boolean;
+ className?: string;
+};
+
+const DefaultLayout: FC = ({ children, gradient = false, className }) => (
+
+ {children}
+
+);
+
+export default DefaultLayout;
diff --git a/dev-wiki/core/lib/n-progress/AppProgressBar.tsx b/dev-wiki/core/lib/n-progress/AppProgressBar.tsx
new file mode 100644
index 0000000000..41698bbd5f
--- /dev/null
+++ b/dev-wiki/core/lib/n-progress/AppProgressBar.tsx
@@ -0,0 +1,303 @@
+import React, { useCallback, useEffect, useMemo, useRef } from "react";
+import { NavigateOptions } from "next/dist/shared/lib/app-router-context.shared-runtime";
+import { usePathname, useSearchParams, useRouter as useNextRouter } from "next/navigation";
+import NProgress from "nprogress";
+import { getAnchorProperty, hasPreventProgressAttribute } from "./utils/getAnchorProperty";
+import { isSameURL, isSameURLWithoutSearch } from "./utils/sameURL";
+import { ProgressBarProps, RouterNProgressOptions } from ".";
+
+type PushStateInput = [data: any, unused: string, url?: string | URL | null | undefined];
+
+export const AppProgressBar = React.memo(
+ ({
+ color = "#0A2FFF",
+ height = "2px",
+ options,
+ shallowRouting = false,
+ disableSameURL = true,
+ startPosition = 0,
+ delay = 0,
+ stopDelay = 0,
+ style,
+ nonce,
+ targetPreprocessor,
+ disableAnchorClick = false,
+ }: ProgressBarProps) => {
+ const styles = (
+
+ );
+
+ NProgress.configure(options || {});
+
+ // eslint-disable-next-line no-undef
+ let progressDoneTimer: NodeJS.Timeout;
+
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ useEffect(() => {
+ if (progressDoneTimer) clearTimeout(progressDoneTimer);
+ progressDoneTimer = setTimeout(() => {
+ NProgress.done();
+ }, stopDelay);
+ }, [pathname, searchParams]);
+
+ const elementsWithAttachedHandlers = useRef<(HTMLAnchorElement | SVGAElement)[]>([]);
+ useEffect(() => {
+ if (disableAnchorClick) {
+ return;
+ }
+
+ // eslint-disable-next-line no-undef
+ let timer: NodeJS.Timeout;
+
+ const startProgress = () => {
+ timer = setTimeout(() => {
+ if (startPosition > 0) NProgress.set(startPosition);
+ NProgress.start();
+ }, delay);
+ };
+
+ const stopProgress = () => {
+ if (timer) clearTimeout(timer);
+ timer = setTimeout(() => {
+ NProgress.done();
+ }, stopDelay);
+ };
+
+ const handleAnchorClick: any = (event: MouseEvent) => {
+ // Skip preventDefault
+ if (event.defaultPrevented) return;
+
+ const anchorElement = event.currentTarget as HTMLAnchorElement | SVGAElement;
+ const target = event.target as HTMLElement | Element;
+ // Check if the target or any of its parents have the attribute
+ const preventProgress =
+ hasPreventProgressAttribute(target) || anchorElement?.getAttribute("data-prevent-nprogress") === "true";
+
+ if (preventProgress) return;
+
+ const anchorTarget = getAnchorProperty(anchorElement, "target");
+ // Skip anchors with target="_blank"
+ if (anchorTarget === "_blank") return;
+
+ // Skip control/command/option/alt+click
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
+
+ const targetHref = getAnchorProperty(anchorElement, "href");
+ const targetUrl = targetPreprocessor ? targetPreprocessor(new URL(targetHref)) : new URL(targetHref);
+ const currentUrl = new URL(location.href);
+
+ if (shallowRouting && isSameURLWithoutSearch(targetUrl, currentUrl) && disableSameURL) return;
+ if (isSameURL(targetUrl, currentUrl) && disableSameURL) return;
+
+ startProgress();
+ };
+
+ // eslint-disable-next-line no-undef
+ const handleMutation: MutationCallback = () => {
+ const anchorElements = Array.from(document.querySelectorAll("a")) as (HTMLAnchorElement | SVGAElement)[];
+
+ const validAnchorElements = anchorElements.filter((anchor) => {
+ const href = getAnchorProperty(anchor, "href");
+ const isNProgressDisabled = anchor.getAttribute("data-disable-nprogress") === "true";
+ const isNotTelOrMailto =
+ href &&
+ !href.startsWith("tel:") &&
+ !href.startsWith("mailto:") &&
+ !href.startsWith("blob:") &&
+ !href.startsWith("javascript:");
+
+ return !isNProgressDisabled && isNotTelOrMailto && getAnchorProperty(anchor, "target") !== "_blank";
+ });
+
+ validAnchorElements.forEach((anchor) => anchor.addEventListener("click", handleAnchorClick));
+ elementsWithAttachedHandlers.current = validAnchorElements;
+ };
+
+ const mutationObserver = new MutationObserver(handleMutation);
+ mutationObserver.observe(document, { childList: true, subtree: true });
+
+ const originalWindowHistoryPushState = window.history.pushState;
+ // eslint-disable-next-line no-undef
+ window.history.pushState = new Proxy(window.history.pushState, {
+ apply: (target, thisArg, argArray: PushStateInput) => {
+ stopProgress();
+ return target.apply(thisArg, argArray);
+ },
+ });
+
+ return () => {
+ mutationObserver.disconnect();
+ elementsWithAttachedHandlers.current.forEach((anchor) => {
+ anchor.removeEventListener("click", handleAnchorClick);
+ });
+ elementsWithAttachedHandlers.current = [];
+ window.history.pushState = originalWindowHistoryPushState;
+ };
+ }, []);
+
+ return styles;
+ },
+ (prevProps, nextProps) => {
+ if (nextProps?.memo === false) {
+ return false;
+ }
+
+ if (!nextProps?.shouldCompareComplexProps) {
+ return true;
+ }
+
+ return (
+ prevProps?.color === nextProps?.color &&
+ prevProps?.height === nextProps?.height &&
+ prevProps?.shallowRouting === nextProps?.shallowRouting &&
+ prevProps?.startPosition === nextProps?.startPosition &&
+ prevProps?.delay === nextProps?.delay &&
+ prevProps?.disableSameURL === nextProps?.disableSameURL &&
+ prevProps?.stopDelay === nextProps?.stopDelay &&
+ prevProps?.nonce === nextProps?.nonce &&
+ JSON.stringify(prevProps?.options) === JSON.stringify(nextProps?.options) &&
+ prevProps?.style === nextProps?.style &&
+ prevProps.disableAnchorClick === nextProps.disableAnchorClick
+ );
+ }
+);
+
+AppProgressBar.displayName = "AppProgressBar";
+
+export function useRouter() {
+ const router = useNextRouter();
+
+ const startProgress = useCallback(
+ (startPosition?: number) => {
+ if (startPosition && startPosition > 0) NProgress.set(startPosition);
+ NProgress.start();
+ },
+ [router]
+ );
+
+ const progress = useCallback(
+ (href: string, options?: NavigateOptions, NProgressOptions?: RouterNProgressOptions) => {
+ if (NProgressOptions?.showProgressBar === false) {
+ return router.push(href, options);
+ }
+
+ const currentUrl = new URL(location.href);
+ const targetUrl = new URL(href, location.href);
+
+ if (isSameURL(targetUrl, currentUrl) && NProgressOptions?.disableSameURL !== false)
+ return router.push(href, options);
+
+ startProgress(NProgressOptions?.startPosition);
+ },
+ [router]
+ );
+
+ const push = useCallback(
+ (href: string, options?: NavigateOptions, NProgressOptions?: RouterNProgressOptions) => {
+ progress(href, options, NProgressOptions);
+ return router.push(href, options);
+ },
+ [router, startProgress]
+ );
+
+ const replace = useCallback(
+ (href: string, options?: NavigateOptions, NProgressOptions?: RouterNProgressOptions) => {
+ progress(href, options, NProgressOptions);
+ return router.replace(href, options);
+ },
+ [router, startProgress]
+ );
+
+ const back = useCallback(
+ (NProgressOptions?: RouterNProgressOptions) => {
+ if (NProgressOptions?.showProgressBar === false) return router.back();
+
+ startProgress(NProgressOptions?.startPosition);
+
+ return router.back();
+ },
+ [router]
+ );
+
+ const enhancedRouter = useMemo(() => ({ ...router, push, replace, back }), [router, push, replace, back]);
+
+ return enhancedRouter;
+}
diff --git a/dev-wiki/core/lib/n-progress/index.tsx b/dev-wiki/core/lib/n-progress/index.tsx
new file mode 100644
index 0000000000..ed7ad04b74
--- /dev/null
+++ b/dev-wiki/core/lib/n-progress/index.tsx
@@ -0,0 +1,68 @@
+import { start, done } from 'nprogress';
+import {
+ AppProgressBar as AppProgressBarComponent,
+ useRouter,
+} from './AppProgressBar';
+import withSuspense from './withSuspense';
+
+export interface NProgressOptions {
+ minimum?: number;
+ template?: string;
+ easing?: string;
+ speed?: number;
+ trickle?: boolean;
+ trickleSpeed?: number;
+ showSpinner?: boolean;
+ parent?: string;
+ positionUsing?: string;
+ barSelector?: string;
+ spinnerSelector?: string;
+}
+
+/**
+ * @param color Color of the progress bar. @default #0A2FFF
+ * @param height Height of the progress bar. @default 2px
+ * @param options NProgress options. @default undefined
+ * @param shallowRouting If the progress bar is not displayed when you use shallow routing - @default false
+ * @param startPosition The position of the progress bar at the start of the page load - @default 0
+ * @param delay When the page loads faster than the progress bar, it does not display - @default 0
+ * @param stopDelay Delay to stop the progress bar - @default 0
+ * @param style Custom css - @default undefined
+ * @param nonce Custom nonce for Content-Security-Policy directives - @default undefined
+ * @param shouldCompareComplexProps If you want to compare props in the React.memo return - @default false
+ * @param targetPreprocessor If you want to./AppProgressBaress the target URL - @default undefined
+ * @param disableAnchorClick Disable triggering progress bar on anchor clicks - @default false
+ */
+export interface ProgressBarProps {
+ color?: string;
+ height?: string;
+ options?: Partial;
+ shallowRouting?: boolean;
+ disableSameURL?: boolean;
+ startPosition?: number;
+ delay?: number;
+ stopDelay?: number;
+ style?: string;
+ nonce?: string;
+ memo?: boolean;
+ shouldCompareComplexProps?: boolean;
+ targetPreprocessor?: (url: URL) => URL;
+ disableAnchorClick?: boolean;
+}
+
+export interface RouterNProgressOptions {
+ showProgressBar?: boolean;
+ startPosition?: number;
+ disableSameURL?: boolean;
+}
+
+export const startProgress = () => {
+ start();
+};
+
+export const stopProgress = (force?: boolean) => {
+ done(force);
+};
+
+const AppProgressBar = withSuspense(AppProgressBarComponent);
+export { AppProgressBar, useRouter };
diff --git a/dev-wiki/core/lib/n-progress/utils/getAnchorProperty.ts b/dev-wiki/core/lib/n-progress/utils/getAnchorProperty.ts
new file mode 100644
index 0000000000..c11fe09ba5
--- /dev/null
+++ b/dev-wiki/core/lib/n-progress/utils/getAnchorProperty.ts
@@ -0,0 +1,59 @@
+function parsePath(path: string) {
+ const hashIndex = path.indexOf("#");
+ const queryIndex = path.indexOf("?");
+ const hasQuery = queryIndex > -1 && (hashIndex < 0 || queryIndex < hashIndex);
+
+ if (hasQuery || hashIndex > -1) {
+ return {
+ pathname: path.substring(0, hasQuery ? queryIndex : hashIndex),
+ query: hasQuery ? path.substring(queryIndex, hashIndex > -1 ? hashIndex : undefined) : "",
+ hash: hashIndex > -1 ? path.slice(hashIndex) : "",
+ };
+ }
+
+ return { pathname: path, query: "", hash: "" };
+}
+
+function addPathPrefix(path: string, prefix?: string) {
+ if (!path.startsWith("/") || !prefix) {
+ return path;
+ }
+
+ const { pathname, query, hash } = parsePath(path);
+ return `${prefix}${pathname}${query}${hash}`;
+}
+
+export function getAnchorProperty(
+ a: T,
+ key: K
+): P extends SVGAnimatedString ? string : P {
+ if (typeof key === "string" && key === "data-disable-nprogress") {
+ const dataKey = key.substring(5) as keyof DOMStringMap;
+ return a.dataset[dataKey] as any;
+ }
+
+ const prop = a[key];
+
+ if (prop instanceof SVGAnimatedString) {
+ const value = prop.baseVal as unknown;
+
+ if (key === "href") {
+ return addPathPrefix(value as string, location.origin) as any;
+ }
+
+ return value as any;
+ }
+
+ return prop as any;
+}
+
+// Utility function to check for attribute in parent elements
+export const hasPreventProgressAttribute = (element: Element | null): boolean => {
+ while (element) {
+ if (element?.getAttribute("data-prevent-nprogress") === "true") {
+ return true;
+ }
+ element = element?.parentElement;
+ }
+ return false;
+};
diff --git a/dev-wiki/core/lib/n-progress/utils/sameURL.ts b/dev-wiki/core/lib/n-progress/utils/sameURL.ts
new file mode 100644
index 0000000000..14c689f756
--- /dev/null
+++ b/dev-wiki/core/lib/n-progress/utils/sameURL.ts
@@ -0,0 +1,16 @@
+export function isSameURL(target: URL, current: URL) {
+ const cleanTarget =
+ target.protocol + '//' + target.host + target.pathname + target.search;
+ const cleanCurrent =
+ current.protocol + '//' + current.host + current.pathname + current.search;
+
+ return cleanTarget === cleanCurrent;
+}
+
+export function isSameURLWithoutSearch(target: URL, current: URL) {
+ const cleanTarget = target.protocol + '//' + target.host + target.pathname;
+ const cleanCurrent =
+ current.protocol + '//' + current.host + current.pathname;
+
+ return cleanTarget === cleanCurrent;
+}
diff --git a/dev-wiki/core/lib/n-progress/withSuspense.tsx b/dev-wiki/core/lib/n-progress/withSuspense.tsx
new file mode 100644
index 0000000000..2b36e9444a
--- /dev/null
+++ b/dev-wiki/core/lib/n-progress/withSuspense.tsx
@@ -0,0 +1,11 @@
+import React, { ComponentType, Suspense } from "react";
+
+export default function withSuspense(Component: ComponentType
) {
+ return function WithSuspenseComponent(props: P) {
+ return (
+
+
+
+ );
+ };
+}
diff --git a/dev-wiki/core/lib/polyfills/index.ts b/dev-wiki/core/lib/polyfills/index.ts
new file mode 100644
index 0000000000..20b3b476c0
--- /dev/null
+++ b/dev-wiki/core/lib/polyfills/index.ts
@@ -0,0 +1 @@
+export * from "./requestIdleCallback";
diff --git a/dev-wiki/core/lib/polyfills/requestIdleCallback.ts b/dev-wiki/core/lib/polyfills/requestIdleCallback.ts
new file mode 100644
index 0000000000..86d1655b69
--- /dev/null
+++ b/dev-wiki/core/lib/polyfills/requestIdleCallback.ts
@@ -0,0 +1,24 @@
+if (typeof window !== "undefined" && window) {
+ // Add request callback polyfill to browser incase it does not exist
+ window.requestIdleCallback =
+ window.requestIdleCallback ??
+ function (cb) {
+ const start = Date.now();
+ return setTimeout(function () {
+ cb({
+ didTimeout: false,
+ timeRemaining: function () {
+ return Math.max(0, 50 - (Date.now() - start));
+ },
+ });
+ }, 1);
+ };
+
+ window.cancelIdleCallback =
+ window.cancelIdleCallback ??
+ function (id) {
+ clearTimeout(id);
+ };
+}
+
+export {};
diff --git a/dev-wiki/core/lib/store-context.tsx b/dev-wiki/core/lib/store-context.tsx
new file mode 100644
index 0000000000..f06dfb4d83
--- /dev/null
+++ b/dev-wiki/core/lib/store-context.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { ReactElement, createContext } from "react";
+// plane web store
+import { RootStore } from "@/plane-web/store/root.store";
+
+export let rootStore = new RootStore();
+
+export const StoreContext = createContext(rootStore);
+
+const initializeStore = () => {
+ const newRootStore = rootStore ?? new RootStore();
+ if (typeof window === "undefined") return newRootStore;
+ if (!rootStore) rootStore = newRootStore;
+ return newRootStore;
+};
+
+export const store = initializeStore();
+
+export const StoreProvider = ({ children }: { children: ReactElement }) => {children} ;
diff --git a/dev-wiki/core/lib/wrappers/authentication-wrapper.tsx b/dev-wiki/core/lib/wrappers/authentication-wrapper.tsx
new file mode 100644
index 0000000000..3c235adc74
--- /dev/null
+++ b/dev-wiki/core/lib/wrappers/authentication-wrapper.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import { FC, ReactNode } from "react";
+import { observer } from "mobx-react";
+import { useSearchParams, usePathname } from "next/navigation";
+import useSWR from "swr";
+// components
+import { LogoSpinner } from "@/components/common";
+// helpers
+import { EPageTypes } from "@/helpers/authentication.helper";
+// hooks
+import { useUser, useUserProfile, useUserSettings, useWorkspace } from "@/hooks/store";
+import { useAppRouter } from "@/hooks/use-app-router";
+
+type TPageType = EPageTypes;
+
+type TAuthenticationWrapper = {
+ children: ReactNode;
+ pageType?: TPageType;
+};
+
+const isValidURL = (url: string): boolean => {
+ const disallowedSchemes = /^(https?|ftp):\/\//i;
+ return !disallowedSchemes.test(url);
+};
+
+export const AuthenticationWrapper: FC = observer((props) => {
+ const pathname = usePathname();
+ const router = useAppRouter();
+ const searchParams = useSearchParams();
+ const nextPath = searchParams.get("next_path");
+ // props
+ const { children, pageType = EPageTypes.AUTHENTICATED } = props;
+ // hooks
+ const { isLoading: isUserLoading, data: currentUser, fetchCurrentUser } = useUser();
+ const { data: currentUserProfile } = useUserProfile();
+ const { data: currentUserSettings } = useUserSettings();
+ const { loader: workspacesLoader, workspaces } = useWorkspace();
+
+ const { isLoading: isUserSWRLoading } = useSWR("USER_INFORMATION", async () => await fetchCurrentUser(), {
+ revalidateOnFocus: false,
+ shouldRetryOnError: false,
+ });
+
+ const isUserOnboard =
+ currentUserProfile?.is_onboarded ||
+ (currentUserProfile?.onboarding_step?.profile_complete &&
+ currentUserProfile?.onboarding_step?.workspace_create &&
+ currentUserProfile?.onboarding_step?.workspace_invite &&
+ currentUserProfile?.onboarding_step?.workspace_join) ||
+ false;
+
+ const getWorkspaceRedirectionUrl = (): string => {
+ let redirectionRoute = "/create-workspace";
+
+ // validating the nextPath from the router query
+ if (nextPath && isValidURL(nextPath.toString())) {
+ redirectionRoute = nextPath.toString();
+ return redirectionRoute;
+ }
+
+ // validate the last and fallback workspace_slug
+ const currentWorkspaceSlug =
+ currentUserSettings?.workspace?.last_workspace_slug || currentUserSettings?.workspace?.fallback_workspace_slug;
+
+ // validate the current workspace_slug is available in the user's workspace list
+ const isCurrentWorkspaceValid = Object.values(workspaces || {}).findIndex(
+ (workspace) => workspace.slug === currentWorkspaceSlug
+ );
+
+ if (isCurrentWorkspaceValid >= 0) redirectionRoute = `/${currentWorkspaceSlug}`;
+
+ return redirectionRoute;
+ };
+
+ if ((isUserSWRLoading || isUserLoading || workspacesLoader) && !currentUser?.id)
+ return (
+
+
+
+ );
+
+ if (pageType === EPageTypes.PUBLIC) return <>{children}>;
+
+ if (pageType === EPageTypes.NON_AUTHENTICATED) {
+ if (!currentUser?.id) return <>{children}>;
+ else {
+ if (currentUserProfile?.id && isUserOnboard) {
+ const currentRedirectRoute = getWorkspaceRedirectionUrl();
+ router.push(currentRedirectRoute);
+ return <>>;
+ } else {
+ router.push("/onboarding");
+ return <>>;
+ }
+ }
+ }
+
+ if (pageType === EPageTypes.ONBOARDING) {
+ if (!currentUser?.id) {
+ router.push(`/${pathname ? `?next_path=${pathname}` : ``}`);
+ return <>>;
+ } else {
+ if (currentUser && currentUserProfile?.id && isUserOnboard) {
+ const currentRedirectRoute = getWorkspaceRedirectionUrl();
+ router.replace(currentRedirectRoute);
+ return <>>;
+ } else return <>{children}>;
+ }
+ }
+
+ if (pageType === EPageTypes.SET_PASSWORD) {
+ if (!currentUser?.id) {
+ router.push(`/${pathname ? `?next_path=${pathname}` : ``}`);
+ return <>>;
+ } else {
+ if (currentUser && !currentUser?.is_password_autoset && currentUserProfile?.id && isUserOnboard) {
+ const currentRedirectRoute = getWorkspaceRedirectionUrl();
+ router.push(currentRedirectRoute);
+ return <>>;
+ } else return <>{children}>;
+ }
+ }
+
+ if (pageType === EPageTypes.AUTHENTICATED) {
+ if (currentUser?.id) {
+ if (currentUserProfile && currentUserProfile?.id && isUserOnboard) return <>{children}>;
+ else {
+ router.push(`/onboarding`);
+ return <>>;
+ }
+ } else {
+ router.push(`/${pathname ? `?next_path=${pathname}` : ``}`);
+ return <>>;
+ }
+ }
+
+ return <>{children}>;
+});
diff --git a/dev-wiki/core/lib/wrappers/index.ts b/dev-wiki/core/lib/wrappers/index.ts
new file mode 100644
index 0000000000..6dfc75b23a
--- /dev/null
+++ b/dev-wiki/core/lib/wrappers/index.ts
@@ -0,0 +1,2 @@
+export * from "./instance-wrapper";
+export * from "./authentication-wrapper";
diff --git a/dev-wiki/core/lib/wrappers/instance-wrapper.tsx b/dev-wiki/core/lib/wrappers/instance-wrapper.tsx
new file mode 100644
index 0000000000..c14cbd7564
--- /dev/null
+++ b/dev-wiki/core/lib/wrappers/instance-wrapper.tsx
@@ -0,0 +1,42 @@
+import { FC, ReactNode } from "react";
+import { observer } from "mobx-react";
+import useSWR from "swr";
+// components
+import { LogoSpinner } from "@/components/common";
+import { MaintenanceView, InstanceNotReady } from "@/components/instance";
+// hooks
+import { useInstance } from "@/hooks/store";
+
+type TInstanceWrapper = {
+ children: ReactNode;
+};
+
+export const InstanceWrapper: FC = observer((props) => {
+ const { children } = props;
+ // store
+ const { isLoading, instance, error, fetchInstanceInfo } = useInstance();
+
+ const { isLoading: isInstanceSWRLoading, error: instanceSWRError } = useSWR(
+ "INSTANCE_INFORMATION",
+ async () => await fetchInstanceInfo(),
+ { revalidateOnFocus: false }
+ );
+
+ // loading state
+ if ((isLoading || isInstanceSWRLoading) && !instance)
+ return (
+
+
+
+ );
+
+ if (instanceSWRError) return ;
+
+ // something went wrong while in the request
+ if (error && error?.status === "error") return <>{children}>;
+
+ // instance is not ready and setup is not done
+ if (instance?.is_setup_done === false) return ;
+
+ return <>{children}>;
+});
diff --git a/dev-wiki/core/lib/wrappers/store-wrapper.tsx b/dev-wiki/core/lib/wrappers/store-wrapper.tsx
new file mode 100644
index 0000000000..0243c34f48
--- /dev/null
+++ b/dev-wiki/core/lib/wrappers/store-wrapper.tsx
@@ -0,0 +1,67 @@
+import { ReactNode, useEffect, FC } from "react";
+import { observer } from "mobx-react";
+import { useParams } from "next/navigation";
+import { useTheme } from "next-themes";
+import { useTranslation, TLanguage } from "@plane/i18n";
+// helpers
+import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper";
+// hooks
+import { useRouterParams, useAppTheme, useUserProfile } from "@/hooks/store";
+
+type TStoreWrapper = {
+ children: ReactNode;
+};
+
+const StoreWrapper: FC = observer((props) => {
+ const { children } = props;
+ // theme
+ const { setTheme } = useTheme();
+ // router
+ const params = useParams();
+ // store hooks
+ const { setQuery } = useRouterParams();
+ const { sidebarCollapsed, toggleSidebar } = useAppTheme();
+ const { data: userProfile } = useUserProfile();
+ const { changeLanguage } = useTranslation();
+
+ /**
+ * Sidebar collapsed fetching from local storage
+ */
+ useEffect(() => {
+ const localValue = localStorage && localStorage.getItem("app_sidebar_collapsed");
+ const localBoolValue = localValue ? (localValue === "true" ? true : false) : false;
+ if (localValue && sidebarCollapsed === undefined) toggleSidebar(localBoolValue);
+ }, [sidebarCollapsed, setTheme, toggleSidebar]);
+
+ /**
+ * Setting up the theme of the user by fetching it from local storage
+ */
+ useEffect(() => {
+ if (!userProfile?.theme?.theme) return;
+ const currentTheme = userProfile?.theme?.theme || "system";
+ const currentThemePalette = userProfile?.theme?.palette;
+ if (currentTheme) {
+ setTheme(currentTheme);
+ if (currentTheme === "custom" && currentThemePalette) {
+ applyTheme(
+ currentThemePalette !== ",,,," ? currentThemePalette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
+ false
+ );
+ } else unsetCustomCssVariables();
+ }
+ }, [userProfile?.theme?.theme, userProfile?.theme?.palette, setTheme]);
+
+ useEffect(() => {
+ if (!userProfile?.language) return;
+ changeLanguage(userProfile?.language as TLanguage);
+ }, [userProfile?.language, changeLanguage]);
+
+ useEffect(() => {
+ if (!params) return;
+ setQuery(params);
+ }, [params, setQuery]);
+
+ return <>{children}>;
+});
+
+export default StoreWrapper;
diff --git a/dev-wiki/core/services/ai.service.ts b/dev-wiki/core/services/ai.service.ts
new file mode 100644
index 0000000000..bb0241e06d
--- /dev/null
+++ b/dev-wiki/core/services/ai.service.ts
@@ -0,0 +1,44 @@
+// helpers
+import { API_BASE_URL } from "@/helpers/common.helper";
+// plane web constants
+import { AI_EDITOR_TASKS } from "@/plane-web/constants/ai";
+// services
+import { APIService } from "@/services/api.service";
+// types
+// FIXME:
+// import { IGptResponse } from "@plane/types";
+// helpers
+
+export type TTaskPayload = {
+ casual_score?: number;
+ formal_score?: number;
+ task: AI_EDITOR_TASKS;
+ text_input: string;
+};
+
+export class AIService extends APIService {
+ constructor() {
+ super(API_BASE_URL);
+ }
+
+ async createGptTask(workspaceSlug: string, data: { prompt: string; task: string }): Promise {
+ return this.post(`/api/workspaces/${workspaceSlug}/ai-assistant/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async performEditorTask(
+ workspaceSlug: string,
+ data: TTaskPayload
+ ): Promise<{
+ response: string;
+ }> {
+ return this.post(`/api/workspaces/${workspaceSlug}/rephrase-grammar/`, data)
+ .then((res) => res?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+}
diff --git a/dev-wiki/core/services/api.service.ts b/dev-wiki/core/services/api.service.ts
new file mode 100644
index 0000000000..944990a05c
--- /dev/null
+++ b/dev-wiki/core/services/api.service.ts
@@ -0,0 +1,57 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import axios, { AxiosInstance, AxiosRequestConfig } from "axios";
+
+export abstract class APIService {
+ protected baseURL: string;
+ private axiosInstance: AxiosInstance;
+
+ constructor(baseURL: string) {
+ this.baseURL = baseURL;
+ this.axiosInstance = axios.create({
+ baseURL,
+ withCredentials: true,
+ });
+
+ this.setupInterceptors();
+ }
+
+ private setupInterceptors() {
+ this.axiosInstance.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ if (error.response && error.response.status === 401) {
+ const currentPath = window.location.pathname;
+ window.location.replace(`/${currentPath ? `?next_path=${currentPath}` : ``}`);
+ }
+ return Promise.reject(error);
+ }
+ );
+ }
+
+ get(url: string, params = {}, config: AxiosRequestConfig = {}) {
+ return this.axiosInstance.get(url, {
+ ...params,
+ ...config,
+ });
+ }
+
+ post(url: string, data = {}, config: AxiosRequestConfig = {}) {
+ return this.axiosInstance.post(url, data, config);
+ }
+
+ put(url: string, data = {}, config: AxiosRequestConfig = {}) {
+ return this.axiosInstance.put(url, data, config);
+ }
+
+ patch(url: string, data = {}, config: AxiosRequestConfig = {}) {
+ return this.axiosInstance.patch(url, data, config);
+ }
+
+ delete(url: string, data?: any, config: AxiosRequestConfig = {}) {
+ return this.axiosInstance.delete(url, { data, ...config });
+ }
+
+ request(config = {}) {
+ return this.axiosInstance(config);
+ }
+}
diff --git a/dev-wiki/core/services/api_token.service.ts b/dev-wiki/core/services/api_token.service.ts
new file mode 100644
index 0000000000..51078396cd
--- /dev/null
+++ b/dev-wiki/core/services/api_token.service.ts
@@ -0,0 +1,41 @@
+import { IApiToken } from "@plane/types";
+import { API_BASE_URL } from "@/helpers/common.helper";
+import { APIService } from "./api.service";
+
+export class APITokenService extends APIService {
+ constructor() {
+ super(API_BASE_URL);
+ }
+
+ async getApiTokens(workspaceSlug: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/api-tokens/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async retrieveApiToken(workspaceSlug: string, tokenId: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async createApiToken(workspaceSlug: string, data: Partial): Promise {
+ return this.post(`/api/workspaces/${workspaceSlug}/api-tokens/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteApiToken(workspaceSlug: string, tokenId: string): Promise {
+ return this.delete(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+}
diff --git a/dev-wiki/core/services/auth.service.ts b/dev-wiki/core/services/auth.service.ts
new file mode 100644
index 0000000000..7663afde86
--- /dev/null
+++ b/dev-wiki/core/services/auth.service.ts
@@ -0,0 +1,78 @@
+// types
+import { ICsrfTokenData, IEmailCheckData, IEmailCheckResponse } from "@plane/types";
+// helpers
+import { API_BASE_URL } from "@/helpers/common.helper";
+// services
+import { APIService } from "@/services/api.service";
+
+export class AuthService extends APIService {
+ constructor() {
+ super(API_BASE_URL);
+ }
+
+ async requestCSRFToken(): Promise {
+ return this.get("/auth/get-csrf-token/")
+ .then((response) => response.data)
+ .catch((error) => {
+ throw error;
+ });
+ }
+
+ emailCheck = async (data: IEmailCheckData): Promise =>
+ this.post("/auth/email-check/", data, { headers: {} })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+
+ async sendResetPasswordLink(data: { email: string }): Promise {
+ return this.post(`/auth/forgot-password/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async setPassword(token: string, data: { password: string }): Promise {
+ return this.post(`/auth/set-password/`, data, {
+ headers: {
+ "X-CSRFTOKEN": token,
+ },
+ })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async generateUniqueCode(data: { email: string }): Promise {
+ return this.post("/auth/magic-generate/", data, { headers: {} })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async signOut(baseUrl: string): Promise {
+ await this.requestCSRFToken().then((data) => {
+ const csrfToken = data?.csrf_token;
+
+ if (!csrfToken) throw Error("CSRF token not found");
+
+ const form = document.createElement("form");
+ const element1 = document.createElement("input");
+
+ form.method = "POST";
+ form.action = `${baseUrl}/auth/sign-out/`;
+
+ element1.value = csrfToken;
+ element1.name = "csrfmiddlewaretoken";
+ element1.type = "hidden";
+ form.appendChild(element1);
+
+ document.body.appendChild(form);
+
+ form.submit();
+ });
+ }
+}
diff --git a/dev-wiki/core/services/favorite/favorite.service.ts b/dev-wiki/core/services/favorite/favorite.service.ts
new file mode 100644
index 0000000000..8a4963cdf3
--- /dev/null
+++ b/dev-wiki/core/services/favorite/favorite.service.ts
@@ -0,0 +1,56 @@
+import type { IFavorite } from "@plane/types";
+// helpers
+import { API_BASE_URL } from "@/helpers/common.helper";
+// services
+import { APIService } from "@/services/api.service";
+// types
+
+export class FavoriteService extends APIService {
+ constructor() {
+ super(API_BASE_URL);
+ }
+
+ async addFavorite(workspaceSlug: string, data: Partial): Promise {
+ return this.post(`/api/workspaces/${workspaceSlug}/user-favorites/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async updateFavorite(workspaceSlug: string, favoriteId: string, data: Partial): Promise {
+ return this.patch(`/api/workspaces/${workspaceSlug}/user-favorites/${favoriteId}/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async deleteFavorite(workspaceSlug: string, favoriteId: string): Promise {
+ return this.delete(`/api/workspaces/${workspaceSlug}/user-favorites/${favoriteId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async getFavorites(workspaceSlug: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/user-favorites/`, {
+ params: {
+ all: true,
+ },
+ })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async getGroupedFavorites(workspaceSlug: string, favoriteId: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/user-favorites/${favoriteId}/group/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+}
diff --git a/dev-wiki/core/services/favorite/index.ts b/dev-wiki/core/services/favorite/index.ts
new file mode 100644
index 0000000000..41df23a178
--- /dev/null
+++ b/dev-wiki/core/services/favorite/index.ts
@@ -0,0 +1 @@
+export * from "./favorite.service";
diff --git a/dev-wiki/core/services/file-upload.service.ts b/dev-wiki/core/services/file-upload.service.ts
new file mode 100644
index 0000000000..c6421872a1
--- /dev/null
+++ b/dev-wiki/core/services/file-upload.service.ts
@@ -0,0 +1,39 @@
+import axios, { AxiosRequestConfig } from "axios";
+// services
+import { APIService } from "@/services/api.service";
+
+export class FileUploadService extends APIService {
+ private cancelSource: any;
+
+ constructor() {
+ super("");
+ }
+
+ async uploadFile(
+ url: string,
+ data: FormData,
+ uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
+ ): Promise {
+ this.cancelSource = axios.CancelToken.source();
+ return this.post(url, data, {
+ headers: {
+ "Content-Type": "multipart/form-data",
+ },
+ cancelToken: this.cancelSource.token,
+ withCredentials: false,
+ onUploadProgress: uploadProgressHandler,
+ })
+ .then((response) => response?.data)
+ .catch((error) => {
+ if (axios.isCancel(error)) {
+ console.log(error.message);
+ } else {
+ throw error?.response?.data;
+ }
+ });
+ }
+
+ cancelUpload() {
+ this.cancelSource.cancel("Upload canceled");
+ }
+}
diff --git a/dev-wiki/core/services/file.service.ts b/dev-wiki/core/services/file.service.ts
new file mode 100644
index 0000000000..5f69fb04fb
--- /dev/null
+++ b/dev-wiki/core/services/file.service.ts
@@ -0,0 +1,342 @@
+import { AxiosRequestConfig } from "axios";
+// plane types
+import { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
+// helpers
+import { EFileAssetType } from "@plane/types/src/enums";
+import { API_BASE_URL } from "@/helpers/common.helper";
+import { generateFileUploadPayload, getAssetIdFromUrl, getFileMetaDataForUpload } from "@/helpers/file.helper";
+// services
+import { APIService } from "@/services/api.service";
+import { FileUploadService } from "@/services/file-upload.service";
+
+export interface UnSplashImage {
+ id: string;
+ created_at: Date;
+ updated_at: Date;
+ promoted_at: Date;
+ width: number;
+ height: number;
+ color: string;
+ blur_hash: string;
+ description: null;
+ alt_description: string;
+ urls: UnSplashImageUrls;
+ [key: string]: any;
+}
+
+export interface UnSplashImageUrls {
+ raw: string;
+ full: string;
+ regular: string;
+ small: string;
+ thumb: string;
+ small_s3: string;
+}
+
+export enum TFileAssetType {
+ COMMENT_DESCRIPTION = "COMMENT_DESCRIPTION",
+ ISSUE_ATTACHMENT = "ISSUE_ATTACHMENT",
+ ISSUE_DESCRIPTION = "ISSUE_DESCRIPTION",
+ PAGE_DESCRIPTION = "PAGE_DESCRIPTION",
+ PROJECT_COVER = "PROJECT_COVER",
+ USER_AVATAR = "USER_AVATAR",
+ USER_COVER = "USER_COVER",
+ WORKSPACE_LOGO = "WORKSPACE_LOGO",
+}
+
+export class FileService extends APIService {
+ private cancelSource: any;
+ private fileUploadService: FileUploadService;
+
+ constructor() {
+ super(API_BASE_URL);
+ this.cancelUpload = this.cancelUpload.bind(this);
+ // upload service
+ this.fileUploadService = new FileUploadService();
+ }
+
+ private async updateWorkspaceAssetUploadStatus(workspaceSlug: string, assetId: string): Promise {
+ return this.patch(`/api/assets/v2/workspaces/${workspaceSlug}/${assetId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async uploadWorkspaceAsset(
+ workspaceSlug: string,
+ data: TFileEntityInfo,
+ file: File,
+ uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
+ ): Promise {
+ const fileMetaData = getFileMetaDataForUpload(file);
+ return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/`, {
+ ...data,
+ ...fileMetaData,
+ })
+ .then(async (response) => {
+ const signedURLResponse: TFileSignedURLResponse = response?.data;
+ const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
+ await this.fileUploadService.uploadFile(
+ signedURLResponse.upload_data.url,
+ fileUploadPayload,
+ uploadProgressHandler
+ );
+ await this.updateWorkspaceAssetUploadStatus(workspaceSlug.toString(), signedURLResponse.asset_id);
+ return signedURLResponse;
+ })
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteWorkspaceAsset(workspaceSlug: string, assetId: string): Promise {
+ return this.delete(`/api/assets/v2/workspaces/${workspaceSlug}/${assetId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ private async updateProjectAssetUploadStatus(
+ workspaceSlug: string,
+ projectId: string,
+ assetId: string
+ ): Promise {
+ return this.patch(`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${assetId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async updateBulkWorkspaceAssetsUploadStatus(
+ workspaceSlug: string,
+ entityId: string,
+ data: {
+ asset_ids: string[];
+ }
+ ): Promise {
+ return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/${entityId}/bulk/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async updateBulkProjectAssetsUploadStatus(
+ workspaceSlug: string,
+ projectId: string,
+ entityId: string,
+ data: {
+ asset_ids: string[];
+ }
+ ): Promise {
+ return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${entityId}/bulk/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async updateBulkInitiativeAssetsUploadStatus(
+ workspaceSlug: string,
+ initiativeId: string,
+ data: {
+ asset_ids: string[];
+ }
+ ): Promise {
+ return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/initiatives/${initiativeId}/attachments/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async updateBulkInitiativeCommentAssetsUploadStatus(
+ workspaceSlug: string,
+ initiativeId: string,
+ commentId: string,
+ data: {
+ asset_ids: string[];
+ }
+ ): Promise {
+ return this.post(
+ `/api/assets/v2/workspaces/${workspaceSlug}/initiatives/${initiativeId}/comments/${commentId}/attachments/`,
+ data
+ )
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async uploadProjectAsset(
+ workspaceSlug: string,
+ projectId: string,
+ data: TFileEntityInfo,
+ file: File,
+ uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
+ ): Promise {
+ const fileMetaData = getFileMetaDataForUpload(file);
+ return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/`, {
+ ...data,
+ ...fileMetaData,
+ })
+ .then(async (response) => {
+ const signedURLResponse: TFileSignedURLResponse = response?.data;
+ const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
+ await this.fileUploadService.uploadFile(
+ signedURLResponse.upload_data.url,
+ fileUploadPayload,
+ uploadProgressHandler
+ );
+ await this.updateProjectAssetUploadStatus(workspaceSlug, projectId, signedURLResponse.asset_id);
+ return signedURLResponse;
+ })
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ private async updateUserAssetUploadStatus(assetId: string): Promise {
+ return this.patch(`/api/assets/v2/user-assets/${assetId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async uploadUserAsset(data: TFileEntityInfo, file: File): Promise {
+ const fileMetaData = getFileMetaDataForUpload(file);
+ return this.post(`/api/assets/v2/user-assets/`, {
+ ...data,
+ ...fileMetaData,
+ })
+ .then(async (response) => {
+ const signedURLResponse: TFileSignedURLResponse = response?.data;
+ const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
+ await this.fileUploadService.uploadFile(signedURLResponse.upload_data.url, fileUploadPayload);
+ await this.updateUserAssetUploadStatus(signedURLResponse.asset_id);
+ return signedURLResponse;
+ })
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteUserAsset(assetId: string): Promise {
+ return this.delete(`/api/assets/v2/user-assets/${assetId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteNewAsset(assetPath: string): Promise {
+ return this.delete(assetPath)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteOldWorkspaceAsset(workspaceId: string, src: string): Promise {
+ const assetKey = getAssetIdFromUrl(src);
+ return this.delete(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/`)
+ .then((response) => response?.status)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteOldWorkspaceAssetV2(workspaceSlug: string, src: string): Promise {
+ const assetKey = getAssetIdFromUrl(src);
+ return this.delete(`/api/assets/v2/workspaces/${workspaceSlug}/${assetKey}/`)
+ .then((response) => response?.status)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteOldUserAsset(src: string): Promise {
+ const assetKey = getAssetIdFromUrl(src);
+ return this.delete(`/api/users/file-assets/${assetKey}/`)
+ .then((response) => response?.status)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async restoreNewAsset(workspaceSlug: string, src: string): Promise {
+ // remove the last slash and get the asset id
+ const assetId = getAssetIdFromUrl(src);
+ return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/restore/${assetId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async checkIfAssetExists(
+ workspaceSlug: string,
+ assetId: string
+ ): Promise<{
+ exists: boolean;
+ }> {
+ return this.get(`/api/assets/v2/workspaces/${workspaceSlug}/check/${assetId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async restoreOldEditorAsset(workspaceId: string, src: string): Promise {
+ const assetKey = getAssetIdFromUrl(src);
+ return this.post(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/restore/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async duplicateAssets(
+ workspaceSlug: string,
+ data: {
+ entity_id: string;
+ entity_type: EFileAssetType;
+ project_id?: string;
+ asset_ids: string[];
+ }
+ ): Promise> {
+ return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/duplicate-assets/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ cancelUpload() {
+ this.cancelSource.cancel("Upload canceled");
+ }
+
+ async getUnsplashImages(query?: string): Promise {
+ return this.get(`/api/unsplash/`, {
+ params: {
+ query,
+ },
+ })
+ .then((res) => res?.data?.results ?? res?.data)
+ .catch((err) => {
+ throw err?.response?.data;
+ });
+ }
+
+ async getProjectCoverImages(): Promise {
+ return this.get(`/api/project-covers/`)
+ .then((res) => res?.data)
+ .catch((err) => {
+ throw err?.response?.data;
+ });
+ }
+}
diff --git a/dev-wiki/core/services/instance.service.ts b/dev-wiki/core/services/instance.service.ts
new file mode 100644
index 0000000000..f240f90227
--- /dev/null
+++ b/dev-wiki/core/services/instance.service.ts
@@ -0,0 +1,37 @@
+// types
+import type { IInstanceInfo, IInstanceUpdate, TPage } from "@plane/types";
+// helpers
+import { API_BASE_URL } from "@/helpers/common.helper";
+// services
+import { APIService } from "@/services/api.service";
+
+export class InstanceService extends APIService {
+ constructor() {
+ super(API_BASE_URL);
+ }
+
+ async requestCSRFToken(): Promise<{ csrf_token: string }> {
+ return this.get("/auth/get-csrf-token/")
+ .then((response) => response.data)
+ .catch((error) => {
+ throw error;
+ });
+ }
+
+ async getInstanceInfo(): Promise {
+ return this.get("/api/instances/")
+ .then((response) => response.data)
+ .catch((error) => {
+ throw error;
+ });
+ }
+
+
+ async checkForUpdates(): Promise {
+ return this.get("/api/instances/check-updates/")
+ .then((response) => response.data)
+ .catch((error) => {
+ throw error;
+ });
+ }
+}
diff --git a/dev-wiki/core/services/user.service.ts b/dev-wiki/core/services/user.service.ts
new file mode 100644
index 0000000000..c888dfa674
--- /dev/null
+++ b/dev-wiki/core/services/user.service.ts
@@ -0,0 +1,264 @@
+// services
+import type {
+ TIssue,
+ IUser,
+ IUserActivityResponse,
+ IInstanceAdminStatus,
+ IUserProfileData,
+ IUserProfileProjectSegregation,
+ IUserSettings,
+ IUserEmailNotificationSettings,
+ TIssuesResponse,
+ TUserProfile,
+} from "@plane/types";
+import { API_BASE_URL } from "@/helpers/common.helper";
+import { APIService } from "@/services/api.service";
+// types
+// helpers
+
+export class UserService extends APIService {
+ constructor() {
+ super(API_BASE_URL);
+ }
+
+ currentUserConfig() {
+ return {
+ url: `${this.baseURL}/api/users/me/`,
+ };
+ }
+
+ async userIssues(
+ workspaceSlug: string,
+ params: any
+ ): Promise<
+ | {
+ [key: string]: TIssue[];
+ }
+ | TIssue[]
+ > {
+ return this.get(`/api/workspaces/${workspaceSlug}/my-issues/`, {
+ params,
+ })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async currentUser(): Promise {
+ // Using validateStatus: null to bypass interceptors for unauthorized errors.
+ return this.get("/api/users/me/", { validateStatus: null })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async getCurrentUserProfile(): Promise {
+ return this.get("/api/users/me/profile/")
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+ async updateCurrentUserProfile(data: any): Promise {
+ return this.patch("/api/users/me/profile/", data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async getCurrentUserAccounts(): Promise {
+ return this.get("/api/users/me/accounts/")
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async currentUserInstanceAdminStatus(): Promise {
+ return this.get("/api/users/me/instance-admin/")
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async currentUserSettings(): Promise {
+ return this.get("/api/users/me/settings/")
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async currentUserEmailNotificationSettings(): Promise {
+ return this.get("/api/users/me/notification-preferences/")
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async updateUser(data: Partial): Promise {
+ return this.patch("/api/users/me/", data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async updateUserOnBoard(): Promise {
+ return this.patch("/api/users/me/onboard/", {
+ is_onboarded: true,
+ })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async updateUserTourCompleted(): Promise {
+ return this.patch("/api/users/me/tour-completed/", {
+ is_tour_completed: true,
+ })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async updateCurrentUserEmailNotificationSettings(data: Partial): Promise {
+ return this.patch("/api/users/me/notification-preferences/", data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async getUserActivity(params: { per_page: number; cursor?: string }): Promise {
+ return this.get("/api/users/me/activities/", { params })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async changePassword(token: string, data: { old_password?: string; new_password: string }): Promise {
+ return this.post(`/auth/change-password/`, data, {
+ headers: {
+ "X-CSRFTOKEN": token,
+ },
+ })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async getUserProfileData(workspaceSlug: string, userId: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/user-stats/${userId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async getUserProfileProjectsSegregation(
+ workspaceSlug: string,
+ userId: string
+ ): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/user-profile/${userId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async getUserProfileActivity(
+ workspaceSlug: string,
+ userId: string,
+ params: {
+ per_page: number;
+ cursor?: string;
+ }
+ ): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/user-activity/${userId}/`, {
+ params,
+ })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async downloadProfileActivity(
+ workspaceSlug: string,
+ userId: string,
+ data: {
+ date: string;
+ }
+ ): Promise {
+ return this.post(`/api/workspaces/${workspaceSlug}/user-activity/${userId}/export/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async getUserProfileIssues(
+ workspaceSlug: string,
+ userId: string,
+ params: any,
+ config = {}
+ ): Promise {
+ return this.get(
+ `/api/workspaces/${workspaceSlug}/user-issues/${userId}/`,
+ {
+ params,
+ },
+ config
+ )
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deactivateAccount() {
+ return this.delete(`/api/users/me/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async leaveWorkspace(workspaceSlug: string) {
+ return this.post(`/api/workspaces/${workspaceSlug}/members/leave/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async joinProject(workspaceSlug: string, project_ids: string[]): Promise {
+ return this.post(`/api/users/me/workspaces/${workspaceSlug}/projects/invitations/`, { project_ids })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async leaveProject(workspaceSlug: string, projectId: string) {
+ return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/leave/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+}
+
+const userService = new UserService();
+
+export default userService;
diff --git a/dev-wiki/core/services/webhook.service.ts b/dev-wiki/core/services/webhook.service.ts
new file mode 100644
index 0000000000..f85d4966a4
--- /dev/null
+++ b/dev-wiki/core/services/webhook.service.ts
@@ -0,0 +1,60 @@
+// api services
+import { IWebhook } from "@plane/types";
+import { API_BASE_URL } from "@/helpers/common.helper";
+import { APIService } from "@/services/api.service";
+// helpers
+// types
+
+export class WebhookService extends APIService {
+ constructor() {
+ super(API_BASE_URL);
+ }
+
+ async fetchWebhooksList(workspaceSlug: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/webhooks/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async fetchWebhookDetails(workspaceSlug: string, webhookId: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/webhooks/${webhookId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async createWebhook(workspaceSlug: string, data = {}): Promise {
+ return this.post(`/api/workspaces/${workspaceSlug}/webhooks/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async updateWebhook(workspaceSlug: string, webhookId: string, data = {}): Promise {
+ return this.patch(`/api/workspaces/${workspaceSlug}/webhooks/${webhookId}/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteWebhook(workspaceSlug: string, webhookId: string): Promise {
+ return this.delete(`/api/workspaces/${workspaceSlug}/webhooks/${webhookId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async regenerateSecretKey(workspaceSlug: string, webhookId: string): Promise {
+ return this.post(`/api/workspaces/${workspaceSlug}/webhooks/${webhookId}/regenerate/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+}
diff --git a/dev-wiki/core/services/workspace.service.ts b/dev-wiki/core/services/workspace.service.ts
new file mode 100644
index 0000000000..95ed2c976b
--- /dev/null
+++ b/dev-wiki/core/services/workspace.service.ts
@@ -0,0 +1,386 @@
+// services
+import {
+ IWorkspace,
+ IWorkspaceMemberMe,
+ IWorkspaceMember,
+ IWorkspaceMemberInvitation,
+ ILastActiveWorkspaceDetails,
+ IWorkspaceSearchResults,
+ IProductUpdateResponse,
+ IWorkspaceBulkInviteFormData,
+ IWorkspaceViewProps,
+ IUserProjectsRole,
+ IWorkspaceView,
+ TIssuesResponse,
+ TLink,
+ TSearchResponse,
+ TSearchEntityRequestPayload,
+ TWidgetEntityData,
+ TActivityEntityData,
+} from "@plane/types";
+import { IWorkspaceSidebarNavigationItem, IWorkspaceSidebarNavigation } from "@plane/types/src/workspace";
+import { APIService } from "@/services/api.service";
+// helpers
+// types
+
+export class WorkspaceService extends APIService {
+ constructor(baseUrl: string) {
+ super(baseUrl);
+ }
+
+ async userWorkspaces(): Promise {
+ return this.get("/api/users/me/workspaces/")
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async getWorkspace(workspaceSlug: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async createWorkspace(data: Partial): Promise {
+ return this.post("/api/workspaces/", data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async updateWorkspace(workspaceSlug: string, data: Partial): Promise {
+ return this.patch(`/api/workspaces/${workspaceSlug}/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteWorkspace(workspaceSlug: string): Promise {
+ return this.delete(`/api/workspaces/${workspaceSlug}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async inviteWorkspace(workspaceSlug: string, data: IWorkspaceBulkInviteFormData): Promise {
+ return this.post(`/api/workspaces/${workspaceSlug}/invitations/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async joinWorkspace(workspaceSlug: string, invitationId: string, data: any): Promise {
+ return this.post(`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/join/`, data, {
+ headers: {},
+ })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async joinWorkspaces(data: any): Promise {
+ return this.post("/api/users/me/workspaces/invitations/", data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async getLastActiveWorkspaceAndProjects(): Promise {
+ return this.get("/api/users/last-visited-workspace/")
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async userWorkspaceInvitations(): Promise {
+ return this.get("/api/users/me/workspaces/invitations/")
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async workspaceMemberMe(workspaceSlug: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/workspace-members/me/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async updateWorkspaceView(workspaceSlug: string, data: { view_props: IWorkspaceViewProps }): Promise {
+ return this.post(`/api/workspaces/${workspaceSlug}/workspace-views/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async fetchWorkspaceMembers(workspaceSlug: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/members/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async updateWorkspaceMember(
+ workspaceSlug: string,
+ memberId: string,
+ data: Partial
+ ): Promise {
+ return this.patch(`/api/workspaces/${workspaceSlug}/members/${memberId}/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteWorkspaceMember(workspaceSlug: string, memberId: string): Promise {
+ return this.delete(`/api/workspaces/${workspaceSlug}/members/${memberId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async workspaceInvitations(workspaceSlug: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/invitations/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async getWorkspaceInvitation(workspaceSlug: string, invitationId: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/join/`, { headers: {} })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async updateWorkspaceInvitation(
+ workspaceSlug: string,
+ invitationId: string,
+ data: Partial
+ ): Promise {
+ return this.patch(`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteWorkspaceInvitations(workspaceSlug: string, invitationId: string): Promise {
+ return this.delete(`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async workspaceSlugCheck(slug: string): Promise {
+ return this.get(`/api/workspace-slug-check/?slug=${slug}`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async searchWorkspace(
+ workspaceSlug: string,
+ params: {
+ project_id?: string;
+ search: string;
+ workspace_search: boolean;
+ }
+ ): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/search/`, {
+ params,
+ })
+ .then((res) => res?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+ async getProductUpdates(): Promise {
+ return this.get("/api/release-notes/")
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async createView(workspaceSlug: string, data: Partial): Promise {
+ return this.post(`/api/workspaces/${workspaceSlug}/views/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async updateView(workspaceSlug: string, viewId: string, data: Partial): Promise {
+ return this.patch(`/api/workspaces/${workspaceSlug}/views/${viewId}/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteView(workspaceSlug: string, viewId: string): Promise {
+ return this.delete(`/api/workspaces/${workspaceSlug}/views/${viewId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async getAllViews(workspaceSlug: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/views/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async getViewDetails(workspaceSlug: string, viewId: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/views/${viewId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async getViewIssues(workspaceSlug: string, params: any, config = {}): Promise {
+ return this.get(
+ `/api/workspaces/${workspaceSlug}/issues/`,
+ {
+ params,
+ },
+ config
+ )
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async getWorkspaceUserProjectsRole(workspaceSlug: string): Promise {
+ return this.get(`/api/users/me/workspaces/${workspaceSlug}/project-roles/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ // quicklinks
+ async fetchWorkspaceLinks(workspaceSlug: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/quick-links/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async createWorkspaceLink(workspaceSlug: string, data: Partial): Promise {
+ return this.post(`/api/workspaces/${workspaceSlug}/quick-links/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async updateWorkspaceLink(workspaceSlug: string, linkId: string, data: Partial): Promise {
+ return this.patch(`/api/workspaces/${workspaceSlug}/quick-links/${linkId}/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async deleteWorkspaceLink(workspaceSlug: string, linkId: string): Promise {
+ return this.delete(`/api/workspaces/${workspaceSlug}/quick-links/${linkId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async searchEntity(workspaceSlug: string, params: TSearchEntityRequestPayload): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/entity-search/`, {
+ params: {
+ ...params,
+ query_type: params.query_type.join(","),
+ },
+ })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ // recents
+ async fetchWorkspaceRecents(workspaceSlug: string, entity_name?: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/recent-visits/`, {
+ params: {
+ entity_name,
+ },
+ })
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ // widgets
+ async fetchWorkspaceWidgets(workspaceSlug: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/home-preferences/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async updateWorkspaceWidget(
+ workspaceSlug: string,
+ widgetKey: string,
+ data: Partial
+ ): Promise {
+ return this.patch(`/api/workspaces/${workspaceSlug}/home-preferences/${widgetKey}/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async fetchSidebarNavigationPreferences(workspaceSlug: string): Promise {
+ return this.get(`/api/workspaces/${workspaceSlug}/sidebar-preferences/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+
+ async updateSidebarPreference(
+ workspaceSlug: string,
+ key: string,
+ data: Partial
+ ): Promise {
+ return this.patch(`/api/workspaces/${workspaceSlug}/sidebar-preferences/${key}/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response;
+ });
+ }
+}
diff --git a/dev-wiki/core/store/editor/asset.store.ts b/dev-wiki/core/store/editor/asset.store.ts
new file mode 100644
index 0000000000..bb42c3e782
--- /dev/null
+++ b/dev-wiki/core/store/editor/asset.store.ts
@@ -0,0 +1,128 @@
+import debounce from "lodash/debounce";
+import set from "lodash/set";
+import { action, computed, makeObservable, observable, runInAction } from "mobx";
+import { computedFn } from "mobx-utils";
+import { v4 as uuidv4 } from "uuid";
+// plane types
+import { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
+// services
+import { FileService } from "@/services/file.service";
+
+export type TAttachmentUploadStatus = {
+ id: string;
+ name: string;
+ progress: number;
+ size: number;
+ type: string;
+};
+
+export interface IEditorAssetStore {
+ // computed
+ assetsUploadPercentage: Record;
+ // helper methods
+ getAssetUploadStatusByEditorBlockId: (blockId: string) => TAttachmentUploadStatus | undefined;
+ // actions
+ uploadEditorAsset: ({
+ blockId,
+ data,
+ file,
+ projectId,
+ workspaceSlug,
+ }: {
+ blockId: string;
+ data: TFileEntityInfo;
+ file: File;
+ projectId?: string;
+ workspaceSlug: string;
+ }) => Promise;
+}
+
+export class EditorAssetStore implements IEditorAssetStore {
+ // observables
+ assetsUploadStatus: Record = {};
+ // services
+ fileService: FileService;
+
+ constructor() {
+ makeObservable(this, {
+ // observables
+ assetsUploadStatus: observable,
+ // computed
+ assetsUploadPercentage: computed,
+ // actions
+ uploadEditorAsset: action,
+ });
+ // services
+ this.fileService = new FileService();
+ }
+
+ get assetsUploadPercentage() {
+ const assetsStatus = this.assetsUploadStatus;
+ const assetsPercentage: Record = {};
+ Object.keys(assetsStatus).forEach((blockId) => {
+ const asset = assetsStatus[blockId];
+ if (asset) assetsPercentage[blockId] = asset.progress;
+ });
+ return assetsPercentage;
+ }
+
+ // helper methods
+ getAssetUploadStatusByEditorBlockId: IEditorAssetStore["getAssetUploadStatusByEditorBlockId"] = computedFn(
+ (blockId) => {
+ const blockDetails = this.assetsUploadStatus[blockId];
+ if (!blockDetails) return undefined;
+ return blockDetails;
+ }
+ );
+
+ // actions
+ private debouncedUpdateProgress = debounce((blockId: string, progress: number) => {
+ runInAction(() => {
+ set(this.assetsUploadStatus, [blockId, "progress"], progress);
+ });
+ }, 16);
+
+ uploadEditorAsset: IEditorAssetStore["uploadEditorAsset"] = async (args) => {
+ const { blockId, data, file, projectId, workspaceSlug } = args;
+ const tempId = uuidv4();
+
+ try {
+ // update attachment upload status
+ runInAction(() => {
+ set(this.assetsUploadStatus, [blockId], {
+ id: tempId,
+ name: file.name,
+ progress: 0,
+ size: file.size,
+ type: file.type,
+ });
+ });
+ if (projectId) {
+ const response = await this.fileService.uploadProjectAsset(
+ workspaceSlug,
+ projectId,
+ data,
+ file,
+ (progressEvent) => {
+ const progressPercentage = Math.round((progressEvent.progress ?? 0) * 100);
+ this.debouncedUpdateProgress(blockId, progressPercentage);
+ }
+ );
+ return response;
+ } else {
+ const response = await this.fileService.uploadWorkspaceAsset(workspaceSlug, data, file, (progressEvent) => {
+ const progressPercentage = Math.round((progressEvent.progress ?? 0) * 100);
+ this.debouncedUpdateProgress(blockId, progressPercentage);
+ });
+ return response;
+ }
+ } catch (error) {
+ console.error("Error in uploading page asset:", error);
+ throw error;
+ } finally {
+ runInAction(() => {
+ delete this.assetsUploadStatus[blockId];
+ });
+ }
+ };
+}
diff --git a/dev-wiki/core/store/favorite.store.ts b/dev-wiki/core/store/favorite.store.ts
new file mode 100644
index 0000000000..feea99a4b2
--- /dev/null
+++ b/dev-wiki/core/store/favorite.store.ts
@@ -0,0 +1,375 @@
+import { orderBy, result, uniqBy } from "lodash";
+import set from "lodash/set";
+import { action, observable, makeObservable, runInAction, computed } from "mobx";
+import { v4 as uuidv4 } from "uuid";
+import { IFavorite } from "@plane/types";
+import { FavoriteService } from "@/services/favorite";
+import { CoreRootStore } from "./root.store";
+
+export interface IFavoriteStore {
+ // observables
+
+ favoriteIds: string[];
+ favoriteMap: {
+ [favoriteId: string]: IFavorite;
+ };
+ entityMap: {
+ [entityId: string]: IFavorite;
+ };
+ // computed actions
+ existingFolders: string[];
+ groupedFavorites: { [favoriteId: string]: IFavorite };
+ // actions
+ fetchFavorite: (workspaceSlug: string) => Promise;
+ // CRUD actions
+ addFavorite: (workspaceSlug: string, data: Partial) => Promise;
+ updateFavorite: (workspaceSlug: string, favoriteId: string, data: Partial) => Promise;
+ deleteFavorite: (workspaceSlug: string, favoriteId: string) => Promise;
+ getGroupedFavorites: (workspaceSlug: string, favoriteId: string) => Promise;
+ moveFavoriteToFolder: (workspaceSlug: string, favoriteId: string, data: Partial) => Promise;
+ removeFavoriteEntity: (workspaceSlug: string, entityId: string) => Promise;
+ reOrderFavorite: (
+ workspaceSlug: string,
+ favoriteId: string,
+ destinationId: string,
+ edge: string | undefined
+ ) => Promise;
+ removeFromFavoriteFolder: (workspaceSlug: string, favoriteId: string) => Promise;
+ removeFavoriteFromStore: (entity_identifier: string) => void;
+}
+
+export class FavoriteStore implements IFavoriteStore {
+ // observables
+ favoriteIds: string[] = [];
+ favoriteMap: {
+ [favoriteId: string]: IFavorite;
+ } = {};
+ entityMap: {
+ [entityId: string]: IFavorite;
+ } = {};
+ // service
+ favoriteService;
+ constructor(_rootStore: CoreRootStore) {
+ makeObservable(this, {
+ // observable
+ favoriteMap: observable,
+ entityMap: observable,
+ favoriteIds: observable,
+ //computed
+ existingFolders: computed,
+ groupedFavorites: computed,
+ // action
+ fetchFavorite: action,
+ // CRUD actions
+ addFavorite: action,
+ getGroupedFavorites: action,
+ moveFavoriteToFolder: action,
+ removeFavoriteEntity: action,
+ reOrderFavorite: action,
+ removeFavoriteEntityFromStore: action,
+ removeFromFavoriteFolder: action,
+ });
+ this.favoriteService = new FavoriteService();
+ }
+
+ get existingFolders() {
+ return Object.values(this.favoriteMap).map((fav) => fav.name);
+ }
+
+ get groupedFavorites() {
+ const data: { [favoriteId: string]: IFavorite } = JSON.parse(JSON.stringify(this.favoriteMap));
+
+ Object.values(data).forEach((fav) => {
+ if (fav.parent && data[fav.parent]) {
+ if (data[fav.parent].children) {
+ if (!data[fav.parent].children.some((f) => f.id === fav.id)) {
+ data[fav.parent].children.push(fav);
+ }
+ } else {
+ data[fav.parent].children = [fav];
+ }
+ }
+ });
+ return data;
+ }
+
+ /**
+ * Creates a favorite in the workspace and adds it to the store
+ * @param workspaceSlug
+ * @param data
+ * @returns Promise
+ */
+ addFavorite = async (workspaceSlug: string, data: Partial) => {
+ data = { ...data, parent: null, is_folder: data.entity_type === "folder" };
+
+ if (data.entity_identifier && this.entityMap[data.entity_identifier]) return this.entityMap[data.entity_identifier];
+ const id = uuidv4();
+ try {
+ // optimistic addition
+ runInAction(() => {
+ set(this.favoriteMap, [id], data);
+ data.entity_identifier && set(this.entityMap, [data.entity_identifier], data);
+ this.favoriteIds = [id, ...this.favoriteIds];
+ });
+ const response = await this.favoriteService.addFavorite(workspaceSlug, data);
+
+ // overwrite the temp id
+ runInAction(() => {
+ delete this.favoriteMap[id];
+ set(this.favoriteMap, [response.id], response);
+ response.entity_identifier && set(this.entityMap, [response.entity_identifier], response);
+ this.favoriteIds = [response.id, ...this.favoriteIds.filter((favId) => favId !== id)];
+ });
+ return response;
+ } catch (error) {
+ delete this.favoriteMap[id];
+ data.entity_identifier && delete this.entityMap[data.entity_identifier];
+ this.favoriteIds = this.favoriteIds.filter((favId) => favId !== id);
+
+ console.error("Failed to create favorite from favorite store");
+ throw error;
+ }
+ };
+
+ /**
+ * Updates a favorite in the workspace and updates the store
+ * @param workspaceSlug
+ * @param favoriteId
+ * @param data
+ * @returns Promise
+ */
+ updateFavorite = async (workspaceSlug: string, favoriteId: string, data: Partial) => {
+ const initialState = this.favoriteMap[favoriteId];
+ try {
+ runInAction(() => {
+ set(this.favoriteMap, [favoriteId], { ...this.favoriteMap[favoriteId], ...data });
+ });
+ const response = await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, data);
+
+ return response;
+ } catch (error) {
+ console.error("Failed to update favorite from favorite store");
+ runInAction(() => {
+ set(this.favoriteMap, [favoriteId], initialState);
+ });
+ throw error;
+ }
+ };
+
+ /**
+ * Moves a favorite in the workspace and updates the store
+ * @param workspaceSlug
+ * @param favoriteId
+ * @param data
+ * @returns Promise
+ */
+ moveFavoriteToFolder = async (workspaceSlug: string, favoriteId: string, data: Partial) => {
+ try {
+ await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, data);
+ runInAction(() => {
+ // add parent of the favorite
+ set(this.favoriteMap, [favoriteId, "parent"], data.parent);
+ });
+ } catch (error) {
+ console.error("Failed to move favorite to folder", error);
+ throw error;
+ }
+ };
+
+ reOrderFavorite = async (
+ workspaceSlug: string,
+ favoriteId: string,
+ destinationId: string,
+ edge: string | undefined
+ ) => {
+ try {
+ let resultSequence = 10000;
+ if (edge) {
+ const sortedIds = orderBy(Object.values(this.favoriteMap), "sequence", "desc").map((fav: IFavorite) => fav.id);
+ const destinationSequence = this.favoriteMap[destinationId]?.sequence || undefined;
+ if (destinationSequence) {
+ const destinationIndex = sortedIds.findIndex((id) => id === destinationId);
+ if (edge === "reorder-above") {
+ const prevSequence = this.favoriteMap[sortedIds[destinationIndex - 1]]?.sequence || undefined;
+ if (prevSequence) {
+ resultSequence = (destinationSequence + prevSequence) / 2;
+ } else {
+ resultSequence = destinationSequence + resultSequence;
+ }
+ } else {
+ resultSequence = destinationSequence - resultSequence;
+ }
+ }
+ }
+
+ await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, { sequence: resultSequence });
+
+ runInAction(() => {
+ set(this.favoriteMap, [favoriteId, "sequence"], resultSequence);
+ });
+ } catch (error) {
+ console.error("Failed to move favorite folder");
+ throw error;
+ }
+ };
+
+ removeFromFavoriteFolder = async (workspaceSlug: string, favoriteId: string) => {
+ try {
+ await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, { parent: null });
+ runInAction(() => {
+ //remove parent
+ set(this.favoriteMap, [favoriteId, "parent"], null);
+ });
+ } catch (error) {
+ console.error("Failed to move favorite");
+ throw error;
+ }
+ };
+
+ removeFavoriteEntityFromStore = (entity_identifier: string, entity_type: string) => {
+ switch (entity_type) {
+ default:
+ return;
+ }
+ };
+
+ /**
+ * Deletes a favorite from the workspace and updates the store
+ * @param workspaceSlug
+ * @param favoriteId
+ * @returns Promise