Yuki Takei 4 месяцев назад
Родитель
Сommit
7199c904bf

+ 12 - 3
apps/app/src/client/components/Sidebar/PageTreeItem/SimplifiedPageTreeItem.tsx

@@ -10,7 +10,10 @@ import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
 import { toastSuccess } from '~/client/util/toastr';
 import { toastSuccess } from '~/client/util/toastr';
-import { ROOT_PAGE_VIRTUAL_ID, usePageTreeInformationUpdate, usePageRename } from '~/features/page-tree';
+import {
+  CREATING_PAGE_VIRTUAL_ID,
+  ROOT_PAGE_VIRTUAL_ID, usePageTreeInformationUpdate, usePageRename, usePageCreate,
+} from '~/features/page-tree';
 import type { IPageForItem } from '~/interfaces/page';
 import type { IPageForItem } from '~/interfaces/page';
 import type { OnDeletedFunction, OnDuplicatedFunction } from '~/interfaces/ui';
 import type { OnDeletedFunction, OnDuplicatedFunction } from '~/interfaces/ui';
 import { useCurrentPagePath, useFetchCurrentPage } from '~/states/page';
 import { useCurrentPagePath, useFetchCurrentPage } from '~/states/page';
@@ -108,6 +111,12 @@ export const SimplifiedPageTreeItem: FC<TreeItemProps> = ({
   // Rename feature from usePageRename hook
   // Rename feature from usePageRename hook
   const { isRenaming, RenameAlternativeComponent } = usePageRename();
   const { isRenaming, RenameAlternativeComponent } = usePageRename();
 
 
+  // Page create feature
+  const { CreateAlternativeComponent } = usePageCreate();
+
+  // Check if this is the creating placeholder node
+  const isCreatingPlaceholder = itemData._id === CREATING_PAGE_VIRTUAL_ID;
+
   const itemSelectedHandler = useCallback((page: IPageForItem) => {
   const itemSelectedHandler = useCallback((page: IPageForItem) => {
     if (page.path == null || page._id == null) return;
     if (page.path == null || page._id == null) return;
 
 
@@ -138,8 +147,8 @@ export const SimplifiedPageTreeItem: FC<TreeItemProps> = ({
       onClickDeleteMenuItem={onClickDeleteMenuItem}
       onClickDeleteMenuItem={onClickDeleteMenuItem}
       customEndComponents={[CountBadgeForPageTreeItem]}
       customEndComponents={[CountBadgeForPageTreeItem]}
       customHoveredEndComponents={[Control]}
       customHoveredEndComponents={[Control]}
-      showAlternativeContent={isRenaming(item)}
-      customAlternativeComponents={[RenameAlternativeComponent]}
+      showAlternativeContent={isRenaming(item) || isCreatingPlaceholder}
+      customAlternativeComponents={isCreatingPlaceholder ? [CreateAlternativeComponent] : [RenameAlternativeComponent]}
     />
     />
   );
   );
 };
 };

+ 28 - 1
apps/app/src/client/components/Sidebar/PageTreeItem/use-page-item-control.tsx

@@ -4,14 +4,16 @@ import React, { useCallback } from 'react';
 import type { IPageInfoExt, IPageToDeleteWithMeta } from '@growi/core';
 import type { IPageInfoExt, IPageToDeleteWithMeta } from '@growi/core';
 import { getIdStringForRef } from '@growi/core';
 import { getIdStringForRef } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import { DropdownToggle } from 'reactstrap';
+import { DropdownItem, DropdownToggle } from 'reactstrap';
 
 
 import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
 import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
 import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
 import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
+import { usePageCreate } from '~/features/page-tree';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useSWRMUTxPageInfo } from '~/stores/page';
 import { useSWRMUTxPageInfo } from '~/stores/page';
 
 
+import type { AdditionalMenuItemsRendererProps } from '../../Common/Dropdown/PageItemControl';
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
 import type { TreeItemToolProps } from '../../TreeItem';
 import type { TreeItemToolProps } from '../../TreeItem';
 
 
@@ -22,6 +24,7 @@ type UsePageItemControl = {
 
 
 export const usePageItemControl = (): UsePageItemControl => {
 export const usePageItemControl = (): UsePageItemControl => {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const { startCreating } = usePageCreate();
 
 
 
 
   const Control: FC<TreeItemToolProps> = (props) => {
   const Control: FC<TreeItemToolProps> = (props) => {
@@ -64,6 +67,11 @@ export const usePageItemControl = (): UsePageItemControl => {
       item.startRenaming();
       item.startRenaming();
     }, [item]);
     }, [item]);
 
 
+    const createMenuItemClickHandler = useCallback(() => {
+      // Start creating a new page under this item
+      startCreating(item);
+    }, [item]);
+
     const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoExt | undefined): Promise<void> => {
     const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoExt | undefined): Promise<void> => {
       if (onClickDeleteMenuItem == null) {
       if (onClickDeleteMenuItem == null) {
         return;
         return;
@@ -95,6 +103,24 @@ export const usePageItemControl = (): UsePageItemControl => {
       }
       }
     };
     };
 
 
+    // Renderer for "Create new page" menu item at the top
+    const CreateMenuItemRenderer: FC<AdditionalMenuItemsRendererProps> = useCallback(() => {
+      if (!isEnableActions || isReadOnlyUser) {
+        return null;
+      }
+
+      return (
+        <DropdownItem
+          onClick={createMenuItemClickHandler}
+          className="grw-page-control-dropdown-item"
+          data-testid="create-page-btn"
+        >
+          <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">add</span>
+          {t('Create')}
+        </DropdownItem>
+      );
+    }, [isEnableActions, isReadOnlyUser, createMenuItemClickHandler]);
+
     return (
     return (
       <NotAvailableForGuest>
       <NotAvailableForGuest>
         <div className="grw-pagetree-control d-flex">
         <div className="grw-pagetree-control d-flex">
@@ -107,6 +133,7 @@ export const usePageItemControl = (): UsePageItemControl => {
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
             onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
             onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
+            additionalMenuItemOnTopRenderer={CreateMenuItemRenderer}
             isInstantRename
             isInstantRename
             // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
             // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
             operationProcessData={page.processData}
             operationProcessData={page.processData}

+ 17 - 0
apps/app/src/features/page-tree/client/components/CreateInput.module.scss

@@ -0,0 +1,17 @@
+@use './tree-item-variables' as vars;
+
+.create-input-wrapper {
+  padding-top: 4px;
+  padding-right: 8px;
+  padding-bottom: 4px;
+  padding-left: vars.$indent-size;
+
+  .create-input {
+    width: 100%;
+
+    input {
+      height: vars.$tree-item-height;
+      font-size: 14px;
+    }
+  }
+}

+ 99 - 0
apps/app/src/features/page-tree/client/components/CreateInput.tsx

@@ -0,0 +1,99 @@
+import type { CSSProperties, FC, KeyboardEvent } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { debounce } from 'throttle-debounce';
+
+import type { InputValidationResult } from '~/client/util/use-input-validator';
+
+import styles from './CreateInput.module.scss';
+
+const wrapperClass = styles['create-input-wrapper'] ?? '';
+const inputClass = styles['create-input'] ?? '';
+
+type CreateInputProps = {
+  validateName?: (name: string) => InputValidationResult | null;
+  onSubmit?: (value: string) => void;
+  onCancel?: () => void;
+  className?: string;
+  style?: CSSProperties;
+  placeholder?: string;
+};
+
+export const CreateInput: FC<CreateInputProps> = ({
+  validateName,
+  onSubmit,
+  onCancel,
+  className,
+  style,
+  placeholder = 'New Page',
+}) => {
+  const inputRef = useRef<HTMLInputElement>(null);
+  const [value, setValue] = useState('');
+  const [validationResult, setValidationResult] =
+    useState<InputValidationResult | null>(null);
+
+  // Auto focus on mount
+  useEffect(() => {
+    inputRef.current?.focus();
+    inputRef.current?.select();
+  }, []);
+
+  const validate = debounce(300, (inputValue: string) => {
+    setValidationResult(validateName?.(inputValue) ?? null);
+  });
+
+  const handleChange = useCallback(
+    (e: React.ChangeEvent<HTMLInputElement>) => {
+      const newValue = e.target.value;
+      setValue(newValue);
+      validate(newValue);
+    },
+    [validate],
+  );
+
+  const handleKeyDown = useCallback(
+    (e: KeyboardEvent<HTMLInputElement>) => {
+      if (e.key === 'Enter') {
+        e.preventDefault();
+        if (validationResult == null && value.trim() !== '') {
+          onSubmit?.(value);
+        }
+      } else if (e.key === 'Escape') {
+        e.preventDefault();
+        onCancel?.();
+      }
+    },
+    [value, validationResult, onSubmit, onCancel],
+  );
+
+  const handleBlur = useCallback(() => {
+    // Cancel if blurred without submitting
+    // Delay to allow click events on submit button (if any)
+    setTimeout(() => {
+      onCancel?.();
+    }, 150);
+  }, [onCancel]);
+
+  const isInvalid = validationResult != null;
+
+  return (
+    <div className={`${wrapperClass} ${className ?? ''}`} style={style}>
+      <div className={`${inputClass} flex-fill`}>
+        <input
+          ref={inputRef}
+          type="text"
+          value={value}
+          onChange={handleChange}
+          onKeyDown={handleKeyDown}
+          onBlur={handleBlur}
+          placeholder={placeholder}
+          className={`form-control form-control-sm ${isInvalid ? 'is-invalid' : ''}`}
+        />
+        {isInvalid && (
+          <div className="invalid-feedback d-block my-1">
+            {validationResult.message}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+};

+ 41 - 2
apps/app/src/features/page-tree/client/components/SimplifiedItemsTree.tsx

@@ -1,5 +1,5 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
-import { useCallback, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
 import {
 import {
   asyncDataLoaderFeature,
   asyncDataLoaderFeature,
   hotkeysCoreFeature,
   hotkeysCoreFeature,
@@ -17,6 +17,7 @@ import { useDataLoader } from '../hooks/use-data-loader';
 import { usePageRename } from '../hooks/use-page-rename';
 import { usePageRename } from '../hooks/use-page-rename';
 import { useScrollToSelectedItem } from '../hooks/use-scroll-to-selected-item';
 import { useScrollToSelectedItem } from '../hooks/use-scroll-to-selected-item';
 import type { TreeItemProps } from '../interfaces';
 import type { TreeItemProps } from '../interfaces';
+import { useCreatingParentId } from '../states/page-tree-create';
 import {
 import {
   usePageTreeInformationGeneration,
   usePageTreeInformationGeneration,
   usePageTreeRevalidationEffect,
   usePageTreeRevalidationEffect,
@@ -57,6 +58,9 @@ export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
   // Page rename hook
   // Page rename hook
   const { rename, getPageName } = usePageRename();
   const { rename, getPageName } = usePageRename();
 
 
+  // Get creating parent id to determine if item should be treated as folder
+  const creatingParentId = useCreatingParentId();
+
   // onRename handler for headless-tree
   // onRename handler for headless-tree
   const handleRename = useCallback(
   const handleRename = useCallback(
     async (item, newValue: string) => {
     async (item, newValue: string) => {
@@ -71,7 +75,20 @@ export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
     rootItemId: ROOT_PAGE_VIRTUAL_ID,
     rootItemId: ROOT_PAGE_VIRTUAL_ID,
     getItemName: (item) => getPageName(item),
     getItemName: (item) => getPageName(item),
     initialState: { expandedItems: [ROOT_PAGE_VIRTUAL_ID] },
     initialState: { expandedItems: [ROOT_PAGE_VIRTUAL_ID] },
-    isItemFolder: (item) => item.getItemData().descendantCount > 0,
+    // Item is a folder if it has loaded children OR if it's currently in "creating" mode
+    // Use getChildren() to check actual cached children instead of descendantCount
+    isItemFolder: (item) => {
+      const itemData = item.getItemData();
+      const isCreatingUnderThis = creatingParentId === itemData._id;
+      if (isCreatingUnderThis) return true;
+
+      // Check cached children - getChildren() returns cached child items
+      const children = item.getChildren();
+      if (children.length > 0) return true;
+
+      // Fallback to descendantCount for items not yet expanded
+      return itemData.descendantCount > 0;
+    },
     createLoadingItemData: () => ({
     createLoadingItemData: () => ({
       _id: '',
       _id: '',
       path: 'Loading...',
       path: 'Loading...',
@@ -102,6 +119,28 @@ export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
     onRevalidated: () => setLocalGeneration(globalGeneration),
     onRevalidated: () => setLocalGeneration(globalGeneration),
   });
   });
 
 
+  // Expand and rebuild tree when creatingParentId changes
+  useEffect(() => {
+    if (creatingParentId == null) return;
+
+    const { getItemInstance, rebuildTree } = tree;
+
+    // Rebuild tree first to re-evaluate isItemFolder
+    rebuildTree();
+
+    // Then expand the parent item
+    const parentItem = getItemInstance(creatingParentId);
+    if (parentItem != null && !parentItem.isExpanded()) {
+      parentItem.expand();
+    }
+
+    // Invalidate children to load placeholder
+    parentItem?.invalidateChildrenIds(true);
+
+    // Trigger re-render
+    setRebuildTrigger((prev) => prev + 1);
+  }, [creatingParentId, tree]);
+
   const items = tree.getItems();
   const items = tree.getItems();
 
 
   const virtualizer = useVirtualizer({
   const virtualizer = useVirtualizer({

+ 3 - 8
apps/app/src/features/page-tree/client/components/TreeItemLayout.tsx

@@ -9,7 +9,6 @@ import {
 import { addTrailingSlash } from '@growi/core/dist/utils/path-utils';
 import { addTrailingSlash } from '@growi/core/dist/utils/path-utils';
 
 
 import type { TreeItemProps, TreeItemToolProps } from '../interfaces';
 import type { TreeItemProps, TreeItemToolProps } from '../interfaces';
-import { usePageTreeDescCountMap } from '../states/page-tree-desc-count-map';
 import { SimpleItemContent } from './SimpleItemContent';
 import { SimpleItemContent } from './SimpleItemContent';
 
 
 import styles from './TreeItemLayout.module.scss';
 import styles from './TreeItemLayout.module.scss';
@@ -33,7 +32,6 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
     isReadOnlyUser,
     isReadOnlyUser,
     isWipPageShown = true,
     isWipPageShown = true,
     showAlternativeContent,
     showAlternativeContent,
-    validateName,
     onRenamed,
     onRenamed,
     onClick,
     onClick,
     onClickDuplicateMenuItem,
     onClickDuplicateMenuItem,
@@ -84,12 +82,9 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
     [onWheelClick, page],
     [onWheelClick, page],
   );
   );
 
 
-  // descendantCount
-  const { getDescCount } = usePageTreeDescCountMap();
-  const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
-
-  // hasDescendants flag
-  const hasDescendants = descendantCount > 0;
+  // Use item.isFolder() which is evaluated by headless-tree's isItemFolder config
+  // This will be re-evaluated after rebuildTree()
+  const hasDescendants = item.isFolder();
 
 
   // auto open if targetPath is descendant of this page
   // auto open if targetPath is descendant of this page
   useEffect(() => {
   useEffect(() => {

+ 1 - 0
apps/app/src/features/page-tree/client/components/_tree-item-variables.scss

@@ -1,2 +1,3 @@
 $btn-triangle-min-width: 35px;
 $btn-triangle-min-width: 35px;
 $tree-item-height: 40px;
 $tree-item-height: 40px;
+$indent-size: 10px;

+ 32 - 2
apps/app/src/features/page-tree/client/hooks/use-data-loader.ts

@@ -5,6 +5,12 @@ import { apiv3Get } from '~/client/util/apiv3-client';
 import type { IPageForTreeItem } from '~/interfaces/page';
 import type { IPageForTreeItem } from '~/interfaces/page';
 
 
 import { ROOT_PAGE_VIRTUAL_ID } from '../../constants';
 import { ROOT_PAGE_VIRTUAL_ID } from '../../constants';
+import {
+  CREATING_PAGE_VIRTUAL_ID,
+  createPlaceholderPageData,
+  useCreatingParentId,
+  useCreatingParentPath,
+} from '../states/page-tree-create';
 
 
 function constructRootPageForVirtualRoot(
 function constructRootPageForVirtualRoot(
   rootPageId: string,
   rootPageId: string,
@@ -25,6 +31,9 @@ export const useDataLoader = (
   rootPageId: string,
   rootPageId: string,
   allPagesCount: number,
   allPagesCount: number,
 ): TreeDataLoader<IPageForTreeItem> => {
 ): TreeDataLoader<IPageForTreeItem> => {
+  const creatingParentId = useCreatingParentId();
+  const creatingParentPath = useCreatingParentPath();
+
   const getItem = useCallback(
   const getItem = useCallback(
     async (itemId: string): Promise<IPageForTreeItem> => {
     async (itemId: string): Promise<IPageForTreeItem> => {
       // Virtual root (should rarely be called since it's provided by getChildrenWithData)
       // Virtual root (should rarely be called since it's provided by getChildrenWithData)
@@ -32,6 +41,12 @@ export const useDataLoader = (
         return constructRootPageForVirtualRoot(rootPageId, allPagesCount);
         return constructRootPageForVirtualRoot(rootPageId, allPagesCount);
       }
       }
 
 
+      // Creating placeholder node - return placeholder data
+      if (itemId === CREATING_PAGE_VIRTUAL_ID) {
+        // This shouldn't normally be called, but return empty placeholder if it is
+        return createPlaceholderPageData('', '/');
+      }
+
       // For all pages (including root), use /page-listing/item endpoint
       // For all pages (including root), use /page-listing/item endpoint
       // Note: This should rarely be called thanks to getChildrenWithData caching
       // Note: This should rarely be called thanks to getChildrenWithData caching
       const response = await apiv3Get<{ item: IPageForTreeItem }>(
       const response = await apiv3Get<{ item: IPageForTreeItem }>(
@@ -61,12 +76,27 @@ export const useDataLoader = (
         '/page-listing/children',
         '/page-listing/children',
         { id: itemId },
         { id: itemId },
       );
       );
-      return response.data.children.map((child) => ({
+
+      const children = response.data.children.map((child) => ({
         id: child._id,
         id: child._id,
         data: child,
         data: child,
       }));
       }));
+
+      // If this parent is in "creating" mode, prepend placeholder node
+      if (creatingParentId === itemId && creatingParentPath != null) {
+        const placeholderData = createPlaceholderPageData(
+          itemId,
+          creatingParentPath,
+        );
+        return [
+          { id: CREATING_PAGE_VIRTUAL_ID, data: placeholderData },
+          ...children,
+        ];
+      }
+
+      return children;
     },
     },
-    [allPagesCount, rootPageId],
+    [allPagesCount, rootPageId, creatingParentId, creatingParentPath],
   );
   );
 
 
   return { getItem, getChildrenWithData };
   return { getItem, getChildrenWithData };

+ 264 - 0
apps/app/src/features/page-tree/client/hooks/use-page-create.tsx

@@ -0,0 +1,264 @@
+import type { FC } from 'react';
+import { useCallback, useMemo } from 'react';
+import { Origin } from '@growi/core';
+import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
+import type { ItemInstance } from '@headless-tree/core';
+import { useTranslation } from 'next-i18next';
+import { join } from 'pathe';
+
+import { useCreatePage } from '~/client/services/create-page';
+import { toastError, toastSuccess, toastWarning } from '~/client/util/toastr';
+import type { InputValidationResult } from '~/client/util/use-input-validator';
+import {
+  useInputValidator,
+  ValidationTarget,
+} from '~/client/util/use-input-validator';
+import type { IPageForItem } from '~/interfaces/page';
+import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
+import { shouldCreateWipPage } from '~/utils/should-create-wip-page';
+
+import { CreateInput } from '../components/CreateInput';
+import type { TreeItemToolProps } from '../interfaces';
+import {
+  useCreatingParentId,
+  usePageTreeCreateActions,
+} from '../states/page-tree-create';
+import { usePageTreeInformationUpdate } from '../states/page-tree-update';
+
+type CreateResult = {
+  success: boolean;
+  path?: string;
+  error?: Error;
+};
+
+type UsePageCreateReturn = {
+  /**
+   * Create a new page under the specified parent
+   */
+  create: (
+    parentItem: ItemInstance<IPageForItem>,
+    pageName: string,
+  ) => Promise<CreateResult>;
+
+  /**
+   * Validate page name
+   */
+  validateName: (name: string) => InputValidationResult | null;
+
+  /**
+   * Start creating a new page under the specified parent
+   */
+  startCreating: (parentItem: ItemInstance<IPageForItem>) => void;
+
+  /**
+   * Cancel page creation
+   */
+  cancelCreating: () => void;
+
+  /**
+   * Check if a child is being created under this item
+   */
+  isCreatingChild: (item: ItemInstance<IPageForItem>) => boolean;
+
+  /**
+   * Alternative component for creating a child page (used in TreeItemLayout)
+   * This renders the CreateInput with proper indentation for child level
+   */
+  CreateAlternativeComponent: FC<TreeItemToolProps>;
+
+  /**
+   * @deprecated Use CreateAlternativeComponent instead
+   * CreateInput component to use as HeadOfChildrenComponent
+   */
+  CreateInputComponent: FC<TreeItemToolProps>;
+};
+
+/**
+ * Hook for page creation logic in tree
+ * Uses Jotai atom to manage creating state
+ */
+export const usePageCreate = (): UsePageCreateReturn => {
+  const { t } = useTranslation();
+  const { create: createPage } = useCreatePage();
+  const inputValidator = useInputValidator(ValidationTarget.PAGE);
+  const { notifyUpdateItems } = usePageTreeInformationUpdate();
+  const creatingParentId = useCreatingParentId();
+  const {
+    startCreating: startCreatingAction,
+    cancelCreating: cancelCreatingAction,
+  } = usePageTreeCreateActions();
+
+  const validateName = useCallback(
+    (name: string): InputValidationResult | null => {
+      const result = inputValidator(name);
+      return result ?? null;
+    },
+    [inputValidator],
+  );
+
+  // Wrapped cancelCreating that also notifies tree to remove placeholder
+  const cancelCreating = useCallback(() => {
+    const parentIdToUpdate = creatingParentId;
+    cancelCreatingAction();
+
+    // Notify tree to reload children (which will remove the placeholder)
+    if (parentIdToUpdate != null) {
+      notifyUpdateItems([parentIdToUpdate]);
+    }
+  }, [cancelCreatingAction, creatingParentId, notifyUpdateItems]);
+
+  const startCreating = useCallback(
+    (parentItem: ItemInstance<IPageForItem>) => {
+      const parentId = parentItem.getId();
+      const parentPath = parentItem.getItemData().path ?? '/';
+
+      // Set creating state - expansion will be handled by SimplifiedItemsTree
+      startCreatingAction(parentId, parentPath);
+    },
+    [startCreatingAction],
+  );
+
+  const isCreatingChild = useCallback(
+    (item: ItemInstance<IPageForItem>): boolean => {
+      return creatingParentId === item.getId();
+    },
+    [creatingParentId],
+  );
+
+  const create = useCallback(
+    async (
+      parentItem: ItemInstance<IPageForItem>,
+      pageName: string,
+    ): Promise<CreateResult> => {
+      const parentPage = parentItem.getItemData();
+      const parentPath = parentPage.path ?? '/';
+
+      // Trim and validate - empty input means cancel
+      const trimmedName = pageName.trim();
+      if (trimmedName === '') {
+        cancelCreating();
+        return { success: false };
+      }
+
+      // Build new page path
+      const newPagePath = join(
+        pathUtils.addTrailingSlash(parentPath),
+        trimmedName,
+      );
+
+      // Check if page path is creatable
+      const isCreatable = pagePathUtils.isCreatablePage(newPagePath);
+      if (!isCreatable) {
+        toastWarning(t('you_can_not_create_page_with_this_name_or_hierarchy'));
+        return { success: false };
+      }
+
+      // Cancel creating mode first (removes placeholder)
+      cancelCreating();
+
+      try {
+        await createPage(
+          {
+            path: newPagePath,
+            parentPath,
+            body: undefined,
+            // keep grant info undefined to inherit from parent
+            grant: undefined,
+            grantUserGroupIds: undefined,
+            origin: Origin.View,
+            wip: shouldCreateWipPage(newPagePath),
+          },
+          {
+            skipTransition: true,
+            onCreated: () => {
+              mutatePageTree();
+              mutateRecentlyUpdated();
+
+              // Notify headless-tree to update parent's children
+              const parentId = parentItem.getId();
+              notifyUpdateItems([parentId]);
+
+              toastSuccess(t('successfully_saved_the_page'));
+            },
+          },
+        );
+
+        return { success: true, path: newPagePath };
+      } catch (err) {
+        toastError(err);
+        return { success: false, error: err as Error };
+      }
+    },
+    [t, createPage, notifyUpdateItems, cancelCreating],
+  );
+
+  // CreateInput as alternative component for TreeItemLayout
+  // This renders inside the TreeItemLayout, replacing the normal content
+  // The item here is the placeholder node, so we need to get parent from it
+  const CreateAlternativeComponent: FC<TreeItemToolProps> = useMemo(() => {
+    const Component: FC<TreeItemToolProps> = ({ item }) => {
+      // Get the parent item (the placeholder's parent is the actual parent where we create)
+      const parentItem = item.getParent();
+
+      const handleCreate = async (value: string) => {
+        if (parentItem == null) {
+          cancelCreating();
+          return;
+        }
+        await create(parentItem, value);
+      };
+
+      const handleCancel = () => {
+        cancelCreating();
+      };
+
+      return (
+        <CreateInput
+          validateName={validateName}
+          onSubmit={handleCreate}
+          onCancel={handleCancel}
+          className="flex-grow-1"
+        />
+      );
+    };
+    return Component;
+  }, [create, cancelCreating, validateName]);
+
+  // Legacy: CreateInput as HeadOfChildrenComponent (with custom padding)
+  const CreateInputComponent: FC<TreeItemToolProps> = useMemo(() => {
+    const Component: FC<TreeItemToolProps> = ({ item }) => {
+      const handleCreate = async (value: string) => {
+        await create(item, value);
+      };
+
+      const handleCancel = () => {
+        cancelCreating();
+      };
+
+      // Calculate indent based on item level (child should be indented one level more than parent)
+      const parentLevel = item.getItemMeta().level;
+      const childLevel = parentLevel + 1;
+      const indentSize = 10; // px, same as TreeItemLayout
+
+      return (
+        <CreateInput
+          validateName={validateName}
+          onSubmit={handleCreate}
+          onCancel={handleCancel}
+          style={{ paddingLeft: `${childLevel * indentSize}px` }}
+        />
+      );
+    };
+    return Component;
+  }, [create, cancelCreating, validateName]);
+
+  return {
+    create,
+    validateName,
+    startCreating,
+    cancelCreating,
+    isCreatingChild,
+    CreateAlternativeComponent,
+    CreateInputComponent,
+  };
+};

+ 91 - 0
apps/app/src/features/page-tree/client/states/page-tree-create.ts

@@ -0,0 +1,91 @@
+import { useCallback } from 'react';
+import { atom, useAtomValue, useSetAtom } from 'jotai';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+/**
+ * Virtual ID for the placeholder node during page creation
+ */
+export const CREATING_PAGE_VIRTUAL_ID = '__creating_page_placeholder__';
+
+/**
+ * Create a placeholder page data for the creating node
+ */
+export const createPlaceholderPageData = (
+  parentId: string,
+  parentPath: string,
+): IPageForTreeItem => ({
+  _id: CREATING_PAGE_VIRTUAL_ID,
+  path: `${parentPath === '/' ? '' : parentPath}/`,
+  parent: parentId,
+  descendantCount: 0,
+  grant: 1,
+  isEmpty: true,
+  wip: false,
+});
+
+/**
+ * State for managing page creation in the tree
+ * Stores the parent page info where a new page is being created
+ */
+type CreatingParentInfo = {
+  id: string;
+  path: string;
+} | null;
+
+const creatingParentInfoAtom = atom<CreatingParentInfo>(null);
+
+/**
+ * Hook to get the current creating parent ID
+ */
+export const useCreatingParentId = (): string | null => {
+  const info = useAtomValue(creatingParentInfoAtom);
+  return info?.id ?? null;
+};
+
+/**
+ * Hook to get the current creating parent path
+ */
+export const useCreatingParentPath = (): string | null => {
+  const info = useAtomValue(creatingParentInfoAtom);
+  return info?.path ?? null;
+};
+
+/**
+ * Hook to check if a specific item is in "creating child" mode
+ */
+export const useIsCreatingChild = (parentId: string | undefined): boolean => {
+  const creatingParentId = useCreatingParentId();
+  return parentId != null && creatingParentId === parentId;
+};
+
+type PageTreeCreateActions = {
+  /**
+   * Start creating a new page under the specified parent
+   */
+  startCreating: (parentId: string, parentPath: string) => void;
+  /**
+   * Cancel the current page creation
+   */
+  cancelCreating: () => void;
+};
+
+/**
+ * Hook to get page tree create actions
+ */
+export const usePageTreeCreateActions = (): PageTreeCreateActions => {
+  const setCreatingParentInfo = useSetAtom(creatingParentInfoAtom);
+
+  const startCreating = useCallback(
+    (parentId: string, parentPath: string) => {
+      setCreatingParentInfo({ id: parentId, path: parentPath });
+    },
+    [setCreatingParentInfo],
+  );
+
+  const cancelCreating = useCallback(() => {
+    setCreatingParentInfo(null);
+  }, [setCreatingParentInfo]);
+
+  return { startCreating, cancelCreating };
+};

+ 11 - 1
apps/app/src/features/page-tree/client/states/page-tree-update.ts

@@ -52,7 +52,7 @@ export const usePageTreeRevalidationEffect = (
   const globalGeneration = useAtomValue(generationAtom);
   const globalGeneration = useAtomValue(generationAtom);
   const globalLastUpdatedItemIds = useAtomValue(lastUpdatedItemIdsAtom);
   const globalLastUpdatedItemIds = useAtomValue(lastUpdatedItemIdsAtom);
 
 
-  const { getItemInstance } = tree;
+  const { getItemInstance, rebuildTree } = tree;
 
 
   useEffect(() => {
   useEffect(() => {
     if (globalGeneration <= generation) return; // Already up to date
     if (globalGeneration <= generation) return; // Already up to date
@@ -68,16 +68,26 @@ export const usePageTreeRevalidationEffect = (
       // Partial update: refetch children of specified items
       // Partial update: refetch children of specified items
       globalLastUpdatedItemIds.forEach((itemId) => {
       globalLastUpdatedItemIds.forEach((itemId) => {
         const item = getItemInstance(itemId);
         const item = getItemInstance(itemId);
+        // Invalidate children to refresh child list
         item?.invalidateChildrenIds(true);
         item?.invalidateChildrenIds(true);
       });
       });
     }
     }
 
 
+    // Rebuild tree after a short delay to allow async data fetching to complete
+    // This ensures isItemFolder is re-evaluated with fresh children data
+    const timeoutId = setTimeout(() => {
+      rebuildTree();
+    }, 100);
+
     opts?.onRevalidated?.();
     opts?.onRevalidated?.();
+
+    return () => clearTimeout(timeoutId);
   }, [
   }, [
     globalGeneration,
     globalGeneration,
     generation,
     generation,
     getItemInstance,
     getItemInstance,
     globalLastUpdatedItemIds,
     globalLastUpdatedItemIds,
+    rebuildTree,
     opts,
     opts,
   ]);
   ]);
 };
 };

+ 10 - 1
apps/app/src/features/page-tree/index.ts

@@ -1,14 +1,24 @@
 // Components
 // Components
+export { CreateInput } from './client/components/CreateInput';
 export { RenameInput } from './client/components/RenameInput';
 export { RenameInput } from './client/components/RenameInput';
 export { SimpleItemContent } from './client/components/SimpleItemContent';
 export { SimpleItemContent } from './client/components/SimpleItemContent';
 export { SimplifiedItemsTree } from './client/components/SimplifiedItemsTree';
 export { SimplifiedItemsTree } from './client/components/SimplifiedItemsTree';
 export { TreeItemLayout } from './client/components/TreeItemLayout';
 export { TreeItemLayout } from './client/components/TreeItemLayout';
 // Hooks
 // Hooks
 export { useDataLoader } from './client/hooks/use-data-loader';
 export { useDataLoader } from './client/hooks/use-data-loader';
+export { usePageCreate } from './client/hooks/use-page-create';
 export { usePageRename } from './client/hooks/use-page-rename';
 export { usePageRename } from './client/hooks/use-page-rename';
 export { useScrollToSelectedItem } from './client/hooks/use-scroll-to-selected-item';
 export { useScrollToSelectedItem } from './client/hooks/use-scroll-to-selected-item';
 // Interfaces
 // Interfaces
 export * from './client/interfaces';
 export * from './client/interfaces';
+// States
+export {
+  CREATING_PAGE_VIRTUAL_ID,
+  useCreatingParentId,
+  useCreatingParentPath,
+  useIsCreatingChild,
+  usePageTreeCreateActions,
+} from './client/states/page-tree-create';
 export {
 export {
   type PageTreeDescCountMapActions,
   type PageTreeDescCountMapActions,
   type PageTreeDescCountMapGetter,
   type PageTreeDescCountMapGetter,
@@ -16,7 +26,6 @@ export {
   usePageTreeDescCountMap,
   usePageTreeDescCountMap,
   usePageTreeDescCountMapAction,
   usePageTreeDescCountMapAction,
 } from './client/states/page-tree-desc-count-map';
 } from './client/states/page-tree-desc-count-map';
-// States
 export {
 export {
   usePageTreeInformationGeneration,
   usePageTreeInformationGeneration,
   usePageTreeInformationLastUpdatedItemIds,
   usePageTreeInformationLastUpdatedItemIds,