React 파일 관리자를 만들어 봅시다 12장: 진행률 표시줄, 스켈톤 및 오버레이

이제 디렉터리 사이를 탐색할 수 있지만 파일을 로드할 때 피드백이 없으므로 로드 진행률 표시줄을 추가해 보겠습니다.

루트 경로 업데이트



파일 관리자를 로드할 때 파일 관리자 루트 경로를 업데이트하는 것을 잊었으므로 지금 수행하겠습니다.

// FileManager.tsx

  // load the given directory path
  const load = useCallback(
    (path: string, isRoot = false) => {
      setIsLoading(true);

+      if (isRoot) {
+        fileManager.setRootPath(path);
+      }

      fileManager.load(path).then(node => {
        setCurrentDirectoryNode(node);

        setIsLoading(false);
        if (isRoot) {
          setRootDirectoryNode(node);
        }
      });
    },
    [fileManager],
  );


모달 제거



따라서 모달은 파일 관리자를 격리된 레이아웃으로 표시하는 데 적합하지만 전체 페이지를 차지하도록 해야 하므로 모달을 제거해야 하고 모달에 대한 다른 래퍼 구성 요소를 만들 수 있습니다.

// FileManager.tsx
import { Grid } from "@mantine/core";
import BaseFileManager from "app/file-manager/utils/FileManager";
import { useCallback, useEffect, useRef, useState } from "react";
import Content from "../../Content";
import FileManagerContext from "../../contexts/FileManagerContext";
import { Node } from "../../types/FileManager.types";
import { BodyWrapper } from "./FileManager.styles";
import { FileManagerProps } from "./FileManager.types";
import LoadingProgressBar from "./LoadingProgressBar";
import Sidebar from "./Sidebar";
import Toolbar from "./Toolbar";

export default function FileManager({ rootPath }: FileManagerProps) {
  const [isLoading, setIsLoading] = useState(true);
  const [currentDirectoryNode, setCurrentDirectoryNode] = useState<Node>();
  const [rootDirectoryNode, setRootDirectoryNode] = useState<Node>();

  const { current: fileManager } = useRef(new BaseFileManager());

  // load the given directory path
  const load = useCallback(
    (path: string, isRoot = false) => {
      setIsLoading(true);

      if (isRoot) {
        fileManager.setRootPath(path);
      }

      fileManager.load(path).then(node => {
        setCurrentDirectoryNode(node);

        setIsLoading(false);
        if (isRoot) {
          setRootDirectoryNode(node);
        }
      });
    },
    [fileManager],
  );

  // load root directory
  useEffect(() => {
    if (!rootPath) return;

    load(rootPath, true);
  }, [rootPath, fileManager, load]);

  return (
    <FileManagerContext.Provider value={fileManager}>
      <LoadingProgressBar />
      <Toolbar />
      <BodyWrapper>
        <Grid>
          <Grid.Col span={3}>
            <Sidebar rootDirectory={rootDirectoryNode} />
          </Grid.Col>
          <Grid.Col span={9}>
            <Content />
          </Grid.Col>
        </Grid>
      </BodyWrapper>
    </FileManagerContext.Provider>
  );
}

FileManager.defaultProps = {
  rootPath: "/",
};


소품 유형도 업데이트하는 것을 잊지 마십시오.

// FileManager.types.ts
import { Node } from "../../types/FileManager.types";

export type FileManagerProps = {
  /**
   * Root path to open in the file manager
   *
   * @default "/"
   */
  rootPath?: string;
  /**
   * Callback for when a file/directory is selected
   */
  onSelect?: (node: Node) => void;
  /**
   * Callback for when a file/directory is double clicked
   */
  onDoubleClick?: (node: Node) => void;
  /**
   * Callback for when a file/directory is right clicked
   */
  onRightClick?: (node: Node) => void;
  /**
   * Callback for when a file/directory is copied
   */
  onCopy?: (node: Node) => void;
  /**
   * Callback for when a file/directory is cut
   */
  onCut?: (node: Node) => void;
  /**
   * Callback for when a file/directory is pasted
   * The old node will contain the old path and the new node will contain the new path
   */
  onPaste?: (node: Node, oldNode: Node) => void;
  /**
   * Callback for when a file/directory is deleted
   */
  onDelete?: (node: Node) => void;
  /**
   * Callback for when a file/directory is renamed
   * The old node will contain the old path/name and the new node will contain the new path/name
   */
  onRename?: (node: Node, oldNode: Node) => void;
  /**
   * Callback for when a directory is created
   */
  onCreateDirectory?: (directory: Node) => void;
  /**
   * Callback for when file(s) is uploaded
   */
  onUpload?: (files: Node[]) => void;
  /**
   * Callback for when a file is downloaded
   */
  onDownload?: (node: Node) => void;
};


이제 홈 페이지를 정리하고 파일 관리자를 렌더링해 보겠습니다.

// HomePage.tsx
import Helmet from "@mongez/react-helmet";
import FileManager from "app/file-manager/components/FileManager";

export default function HomePage() {
  return (
    <>
      <Helmet title="home" appendAppName={false} />
      <FileManager />
    </>
  );
}


진행률 표시줄 구성 요소 추가



이전에 언급했듯이 매우 강력하기 때문에 이벤트를 사용하여 파일 관리자가 로드될 때 수신 대기하고 진행률 표시줄을 표시하고 로드가 완료되면 숨길 수 있습니다.
components/LoadingProgressBar.tsx 파일을 만들고 다음 코드를 추가합니다.

// LoadingProgressBar.tsx
import useFileManager from "../../hooks/useFileManager";

export default function LoadingProgressBar() {
  const fileManager = useFileManager();
  return <div>LoadingProgressBar</div>;
}


여기에 멋진 것은 없습니다. 파일 관리자 인스턴스가 해당 이벤트를 수신하도록 해야 합니다.

이제 파일 관리자 구성 요소에서 가져오고 본문에 추가하겠습니다.

// FileManager.tsx
import { Grid, Modal } from "@mantine/core";
import BaseFileManager from "app/file-manager/utils/FileManager";
import { useCallback, useEffect, useRef, useState } from "react";
import Content from "../../Content";
import FileManagerContext from "../../contexts/FileManagerContext";
import { Node } from "../../types/FileManager.types";
import { BodyWrapper } from "./FileManager.styles";
import { FileManagerProps } from "./FileManager.types";
import LoadingProgressBar from "./LoadingProgressBar";
import Sidebar from "./Sidebar";
import Toolbar from "./Toolbar";

export default function FileManager({
  open,
  onClose,
  rootPath,
}: FileManagerProps) {
  const [isLoading, setIsLoading] = useState(true);
  const [currentDirectoryNode, setCurrentDirectoryNode] = useState<Node>();
  const [rootDirectoryNode, setRootDirectoryNode] = useState<Node>();

  const { current: fileManager } = useRef(new BaseFileManager());

  // load the given directory path
  const load = useCallback(
    (path: string, isRoot = false) => {
      setIsLoading(true);

      if (isRoot) {
        fileManager.setRootPath(path);
      }

      fileManager.load(path).then(node => {
        setCurrentDirectoryNode(node);

        setIsLoading(false);
        if (isRoot) {
          setRootDirectoryNode(node);
        }
      });
    },
    [fileManager],
  );

  // load root directory
  useEffect(() => {
    if (!rootPath || !open) return;

    load(rootPath, true);
  }, [rootPath, fileManager, open, load]);

  return (
    <FileManagerContext.Provider value={fileManager}>
      <Modal size="xl" opened={open} onClose={onClose}>
        <LoadingProgressBar />
        <Toolbar />
        <BodyWrapper>
          <Grid>
            <Grid.Col span={3}>
              <Sidebar rootDirectory={rootDirectoryNode} />
            </Grid.Col>
            <Grid.Col span={9}>
              <Content />
            </Grid.Col>
          </Grid>
        </BodyWrapper>
      </Modal>
    </FileManagerContext.Provider>
  );
}

FileManager.defaultProps = {
  rootPath: "/",
};


Sometimes i paste the entire component code, and others i don't so you can see the changes, but you can always check the full code in the github repo.



이제 Mantine Progress Bar을 사용하여 사용해 봅시다.

// LoadingProgressBar.tsx
import { Progress } from "@mantine/core";
import useFileManager from "../../hooks/useFileManager";

export default function LoadingProgressBar() {
  const fileManager = useFileManager();
  return <Progress size="lg" value={50} striped animate />;
}


그것은 다음과 같아야합니다



이제 진행률 표시줄을 표시하거나 숨기는 논리를 추가해 보겠습니다.

// LoadingProgressBar.tsx
import { Progress } from "@mantine/core";
import { useEffect, useState } from "react";
import useFileManager from "../../hooks/useFileManager";

export default function LoadingProgressBar() {
  const fileManager = useFileManager();
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    // let's create an interval that will update progress every 300ms
    let interval: ReturnType<typeof setInterval>;

    // we'll listen for loading state
    const loadingEvent = fileManager.on("loading", () => {
      setProgress(5);

      interval = setInterval(() => {
        // we'll increase it by 10% every 100ms
        // if it's more than 100% we'll set it to 100%
        setProgress(progress => {
          if (progress >= 100) {
            clearInterval(interval);
            return 100;
          }

          return progress + 2;
        });
      }, 100);
    });

    // now let's listen when the loading is finished
    const loadEvent = fileManager.on("load", () => {
      // clear the interval
      setProgress(100);

      setTimeout(() => {
        clearInterval(interval);

        // set progress to 0
        setProgress(0);
      }, 300);
    });

    // unsubscribe events on unmount or when use effect dependencies change
    return () => {
      loadingEvent.unsubscribe();
      loadEvent.unsubscribe();
    };
  }, [fileManager]);

  if (progress === 0) return null;

  return <Progress size="lg" value={progress} striped animate />;
}


코드가 약간 복잡해 보이지만 그렇게 어렵지는 않습니다. 100ms마다 진행률을 10%씩 증가시키는 간격을 만들고 loadingload 이벤트를 수신하여 간격을 시작하고 중지합니다.

효과가 마운트 해제되거나 종속성이 변경되면 이벤트 구독을 취소합니다.

이제 이를 테스트하기 위해 setTimeout 함수에 list를 추가하여 로딩을 속일 것입니다.

// file-manager-service.ts
import FileManagerServiceInterface from "../types/FileManagerServiceInterface";
import fetchNode from "../utils/helpers";

export class FileManagerService implements FileManagerServiceInterface {
  /**
   * {@inheritDoc}
   */
  public list(directoryPath: string): Promise<any> {
    return new Promise(resolve => {
      setTimeout(() => {
        resolve({
          data: {
            node: fetchNode(directoryPath),
          },
        });
      }, 3000);
    });
  }
}


이제 진행률 표시줄은 다음과 같아야 합니다.



사이드바가 숨겨져 있습니다. Skelton을 추가해 보겠습니다.

먼저 로딩 상태를 선언한 다음 로딩 이벤트를 수신합니다.

// Sidebar.tsx
import { Card, Skeleton } from "@mantine/core";
import { IconFolder, IconHome2 } from "@tabler/icons";
import { useEffect, useMemo, useState } from "react";
import useFileManager from "../../../hooks/useFileManager";
import { Node } from "../../../types/FileManager.types";
import SidebarNode from "./SidebarNode";

export type SidebarProps = {
  rootDirectory?: Node;
};

export default function Sidebar({ rootDirectory }: SidebarProps) {
  const rootChildren = useMemo(() => {
    return rootDirectory?.children?.filter(child => child.isDirectory);
  }, [rootDirectory]);

  const fileManager = useFileManager();

  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    const loadingEvent = fileManager.on("loading", () => setIsLoading(true));

    const loadEvent = fileManager.on("load", () => setIsLoading(false));

    return () => {
      loadingEvent.unsubscribe();
      loadEvent.unsubscribe();
    };
  }, [fileManager]);

  if (isLoading) {
    return (
      <Card shadow={"sm"}>
        <Skeleton height={8} mt={6} radius="xl" />
        <Skeleton height={12} mt={6} width="80%" radius="sm" />
        <Skeleton height={8} mt={6} width="60%" radius="xl" />
        <Skeleton height={8} mt={6} radius="xl" />
        <Skeleton height={12} mt={6} width="80%" radius="sm" />
        <Skeleton height={8} mt={6} width="60%" radius="xl" />
        <Skeleton height={8} mt={6} radius="xl" />
        <Skeleton height={12} mt={6} width="80%" radius="sm" />
        <Skeleton height={8} mt={6} width="60%" radius="xl" />
        <Skeleton height={8} mt={6} radius="xl" />
        <Skeleton height={12} mt={6} width="80%" radius="sm" />
        <Skeleton height={8} mt={6} width="60%" radius="xl" />
      </Card>
    );
  }

  if (!rootDirectory) return null;

  return (
    <>
      <Card shadow="sm">
        <SidebarNode
          node={rootDirectory}
          navProps={{
            p: 0,
          }}
          icon={<IconHome2 size={16} color="#78a136" />}
        />
        {rootChildren?.map(child => (
          <SidebarNode
            navProps={{
              p: 0,
              pl: 10,
            }}
            key={child.path}
            icon={<IconFolder size={16} fill="#31caf9" />}
            node={child}
          />
        ))}
      </Card>
    </>
  );
}


꽤 깔끔하죠?

이제 콘텐츠 부분으로 이동하겠습니다.

콘텐츠 로드 상태



콘텐츠 부분에 로드 상태를 추가하고 콘텐츠가 로드될 때 Overlay을 표시합니다.

이전 사이드바에서처럼 로딩 상태를 추가하고 로딩 이벤트를 수신할 것입니다. xD 코드를 복사/붙여넣기만 하면 됩니다.

// Content.tsx
import { Card } from "@mantine/core";
import { useEffect, useState } from "react";
import useFileManager from "../hooks/useFileManager";

export default function Content() {
  const fileManager = useFileManager();

  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    const loadingEvent = fileManager.on("loading", () => setIsLoading(true));

    const loadEvent = fileManager.on("load", () => setIsLoading(false));

    return () => {
      loadingEvent.unsubscribe();
      loadEvent.unsubscribe();
    };
  }, [fileManager]);

  return (
    <>
      <Card shadow="sm">
        <div>Content</div>
      </Card>
    </>
  );
}


알아차리면 여기에 패턴이 있고, 로딩 상태를 찾고 있고, 로딩 이벤트를 수신하고 있으므로 이를 처리하기 위한 사용자 지정 후크를 만들 수 있습니다.
hooks/useLoading 후크를 만들어 보겠습니다.

// hooks/useLoading.ts
import { useEffect, useState } from "react";
import useFileManager from "./useFileManager";

export default function useLoading(): boolean {
  const fileManager = useFileManager();

  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    const loadingEvent = fileManager.on("loading", () => setIsLoading(true));

    const loadEvent = fileManager.on("load", () => setIsLoading(false));

    return () => {
      loadingEvent.unsubscribe();
      loadEvent.unsubscribe();
    };
  }, [fileManager]);

  return isLoading;
}


이제 Content.tsxSidebar.tsx 에서 사용하겠습니다.

// Sidebar.tsx
import { Card, Skeleton } from "@mantine/core";
import { IconFolder, IconHome2 } from "@tabler/icons";
import { useMemo } from "react";
import useLoading from "../../../hooks/useLoading";
import { Node } from "../../../types/FileManager.types";
import SidebarNode from "./SidebarNode";

export type SidebarProps = {
  rootDirectory?: Node;
};

export default function Sidebar({ rootDirectory }: SidebarProps) {
  const rootChildren = useMemo(() => {
    return rootDirectory?.children?.filter(child => child.isDirectory);
  }, [rootDirectory]);

  const isLoading = useLoading();

  if (isLoading) {
    return (
      <Card shadow={"sm"}>
        <Skeleton height={8} mt={6} radius="xl" />
        <Skeleton height={12} mt={6} width="80%" radius="sm" />
        <Skeleton height={8} mt={6} width="60%" radius="xl" />
        <Skeleton height={8} mt={6} radius="xl" />
        <Skeleton height={12} mt={6} width="80%" radius="sm" />
        <Skeleton height={8} mt={6} width="60%" radius="xl" />
        <Skeleton height={8} mt={6} radius="xl" />
        <Skeleton height={12} mt={6} width="80%" radius="sm" />
        <Skeleton height={8} mt={6} width="60%" radius="xl" />
        <Skeleton height={8} mt={6} radius="xl" />
        <Skeleton height={12} mt={6} width="80%" radius="sm" />
        <Skeleton height={8} mt={6} width="60%" radius="xl" />
      </Card>
    );
  }

  if (!rootDirectory) return null;
...

Content.tsx에서 동일합니다.

// Content.tsx
import { Card } from "@mantine/core";
import useLoading from "../hooks/useLoading";

export default function Content() {
  const isLoading = useLoading();

  return (
    <>
      <Card shadow="sm">
        <div>Content</div>
      </Card>
    </>
  );
}


이제 오버레이를 만들겠습니다. 하지만 먼저 콘텐츠에 대한 래퍼를 만들어야 오버레이를 배치할 수 있습니다.
Content.styles.tsx 파일을 만들고 다음 코드를 추가합니다.

// Content.styles.tsx
import styled from "@emotion/styled";

export const ContentWrapper = styled.div`
  label: ContentWrapper;
  position: relative;
`;


이제 우리는 그것을 가져옵니다

// Content.tsx
import { Card } from "@mantine/core";
import useLoading from "../hooks/useLoading";
import { ContentWrapper } from "./Content.styles";

export default function Content() {
  const isLoading = useLoading();

  return (
    <>
      <Card shadow="sm">
        <ContentWrapper>Content</ContentWrapper>
      </Card>
    </>
  );
}


콘텐츠 높이가 작기 때문에 높이를 설정하고 콘텐츠 래퍼에 overflow: auto를 추가해 보겠습니다.

// Content.styles.tsx
import styled from "@emotion/styled";

export const ContentWrapper = styled.div`
  label: ContentWrapper;
  position: relative;
  height: 300px;
  overflow: auto;
`;


또한 SidebarWrapper를 만들고 여기에 overflow: auto를 추가해 보겠습니다.

// Sidebar.styles.tsx
import styled from "@emotion/styled";

export const SidebarWrapper = styled.div`
  label: SidebarWrapper;
  overflow: auto;
  height: 300px;
  position: relative;
`;


하나는 로드 중이고 다른 하나는 콘텐츠용입니다.

// Sidebar.tsx
import { Card, Skeleton } from "@mantine/core";
import { IconFolder, IconHome2 } from "@tabler/icons";
import { useMemo } from "react";
import useLoading from "../../../hooks/useLoading";
import { Node } from "../../../types/FileManager.types";
import { SidebarWrapper } from "./Sidebar.styles";
import SidebarNode from "./SidebarNode";

export type SidebarProps = {
  rootDirectory?: Node;
};

export default function Sidebar({ rootDirectory }: SidebarProps) {
  const rootChildren = useMemo(() => {
    return rootDirectory?.children?.filter(child => child.isDirectory);
  }, [rootDirectory]);

  const isLoading = useLoading();

  if (isLoading) {
    return (
      <Card shadow={"sm"}>
        <SidebarWrapper>
          <Skeleton height={8} mt={6} radius="xl" />
          <Skeleton height={12} mt={6} width="80%" radius="sm" />
          <Skeleton height={8} mt={6} width="60%" radius="xl" />
          <Skeleton height={8} mt={6} radius="xl" />
          <Skeleton height={12} mt={6} width="80%" radius="sm" />
          <Skeleton height={8} mt={6} width="60%" radius="xl" />
          <Skeleton height={8} mt={6} radius="xl" />
          <Skeleton height={12} mt={6} width="80%" radius="sm" />
          <Skeleton height={8} mt={6} width="60%" radius="xl" />
          <Skeleton height={8} mt={6} radius="xl" />
          <Skeleton height={12} mt={6} width="80%" radius="sm" />
          <Skeleton height={8} mt={6} width="60%" radius="xl" />
        </SidebarWrapper>
      </Card>
    );
  }

  if (!rootDirectory) return null;

  return (
    <Card shadow="sm">
      <SidebarWrapper>
        <SidebarNode
          node={rootDirectory}
          navProps={{
            p: 0,
          }}
          icon={<IconHome2 size={16} color="#78a136" />}
        />
        {rootChildren?.map(child => (
          <SidebarNode
            navProps={{
              p: 0,
              pl: 10,
            }}
            key={child.path}
            icon={<IconFolder size={16} fill="#31caf9" />}
            node={child}
          />
        ))}
      </SidebarWrapper>
    </Card>
  );
}


이제 다음과 같이 표시됩니다.



콘텐츠 구성 요소로 돌아가서 오버레이를 추가해 보겠습니다.

// Content.tsx
import { Card, LoadingOverlay } from "@mantine/core";
import useLoading from "../hooks/useLoading";
import { ContentWrapper } from "./Content.styles";

export default function Content() {
  const isLoading = useLoading();

  return (
    <>
      <Card shadow="sm">
        <ContentWrapper>
          <LoadingOverlay visible={isLoading} overlayBlur={2} />
        </ContentWrapper>
      </Card>
    </>
  );
}


최종 모습은 다음과 같습니다.



로더 작업이 끝났습니다.

이제 진행 상황이 좋습니다. 다음 장에서는 중지하고 코드를 정리하고 파일과 구조를 재구성할 것입니다.

기사 저장소



Github Repository에서 챕터 파일을 볼 수 있습니다.

Don't forget the main branch has the latest updated code.



지금 어디 있는지 말해줘



이 시리즈를 저와 함께 후속 조치하는 경우 현재 위치와 어려움을 겪고 있는 부분을 알려주시면 최대한 도와드리겠습니다.

살람.

좋은 웹페이지 즐겨찾기