Building a File Explorer in Vue 3

Build a User-Friendly File Manager Using Vue 3

In the previous article Using Recursive Queries to explore a file system structure stored in PostgreSQL we looked at how a file system representation could be stored and queried from a database. In this article we will implement a file explorer in VueJS that can display this file system representation. Navigation in this file explorer should work similar to how popular services such as Google Drive, Dropbox, etc work. By the end of this article we should build a file explorer that looks like this.

file explorer web app  with folders animation

Follow the tutorial and you will get something like this.

The complete working code for this article can be found on Github https://github.com/n05la3/building-a-file-explorer.

Implementation

Throughout this article we assume that the implementation is done in a new Vue 3 project (v3.4.21). So watch out for any boilerplate code differences between this implementation and your setup at the time you read this article. We also assume basic understanding of VueJS concepts, so we won’t be explaining certain Vue specific details.

We start off by implementing a function that generates a list of files in /src/files.ts. Typically, the data generated here should be coming from your server. To keep things simple, we will mock the data in a function generateFiles(). The code has been folded, you can open to view its full content.


Click to see the code
export interface FileOrDirectory {
  id: number;
  name: string;
  isDirectory: boolean;
  parentDirectoryId: number | undefined;
}

function generateFile(
  id: number,
  parentId?: number,
  directory?: boolean
): FileOrDirectory {
  const isDirectory = directory ?? Math.random() > 0.5; // Randomly choose file or directory
  const name = isDirectory ? `Folder ${id}` : `File ${id}`;

  return {
    id,
    name,
    isDirectory,
    parentDirectoryId: parentId,
  };
}

export function generateFiles(totalNumberOfFiles: number): FileOrDirectory[] {
  const files: FileOrDirectory[] = [];

  // Let's start off by generating a directory
  const directory = generateFile(1, undefined, true);
  files.push(directory);

  const directoriesGenerated: FileOrDirectory[] = [directory];
  let rootFileCount = 1;

  // Generate remaining files with some nesting
  for (let i = 2; i < totalNumberOfFiles; i++) {
    // If there are too many files at the root, let's ensure new files are placed in an existing directory
    const hasTooManyRootFiles = rootFileCount >= totalNumberOfFiles / 5;
    const pickFileFrom = hasTooManyRootFiles ? directoriesGenerated : files;

    const potentialParent = pickFileFrom.at(
      Math.floor(Math.random() * pickFileFrom.length)
    );
    const file = generateFile(
      i,
      potentialParent?.isDirectory ? potentialParent.id : undefined
    );

    if (!file.parentDirectoryId) {
      rootFileCount += 1;
    }
    if (file.isDirectory) {
      directoriesGenerated.push(file);
    }

    files.push(file);
  }

  return files;
}

A file as represented here can be a simple file or a directory.
The isDirectory flag differentiates directories from files.
The parentDirectoryId prop on a file marks the directory in which this file is located. When it is undefined, then the file is found at the root of the file system.

The files are generated such that there are not too many files at the root (See comment in the loop of generateFile()) The list of files generated will look something like this:

[{id: 1, name: ‘File 1’, parentDirectoryId: undefined, isDirectory: false}, ...]

Note that it does not contain the file path or any extra information about the file. This is because we want to keep it simple and lean. Typically, it could also contain a file path, usually an S3-like key to the actual file.

Now that we have a list of files ready to be displayed, create a file at src/components/file-item.vue which displays a file.

<template>
  <div class="file-item-container">
    <svg
      role="presentation"
      viewBox="0 0 24 24"
      :style="{ height: size, width: size }"
    >
      <title>{{ name }}</title>
      <path
        v-if="isDirectory"
        d="M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z"
        style="fill: #64748b"
      ></path>

      <path
        v-else
        d="M13,9V3.5L18.5,9M6,2C4.89,2 4,2.89 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6Z"
        style="fill: #64748b"
      ></path>
    </svg>
    <div class="item-label">{{ name }}</div>
  </div>
</template>

Click to reveal the style section
<style scoped lang="scss">
  .file-item-container {
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    gap: 8px;
    padding: 4px;
    transition: color, background-color 0.2s ease-in-out;
    overflow: hidden;

    &:hover {
      background-color: rgba(100, 116, 139, 0.4);
      border-radius: 4px;
      color: white;
    }

    .item-label {
      max-width: 100%;
      overflow: hidden;
      white-space: nowrap;
      text-overflow: ellipsis;
    }
  }
</style>

It is responsible for displaying the basic information of a file including its icon and name. The icon depends on whether the file is a directory or a simple file. The svg icons here are material icons taken from https://pictogrammers.com/library/mdi/

Next, let’s create the file src/views/file-explorer.vue and make sure it works by updating src/router/index.ts to the following:

import { createRouter, createWebHistory } from 'vue-router';

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      // we want to redirect to the explorer if the user navigates to the root
      redirect: { name: 'home' },
      children: [
        {
          path: 'files/:id?',
          name: 'home',
          props: true,
          component: () => import('../views/file-explorer.vue'),
        },
      ],
    },
  ],
});

export default router;

In the path definition /files/:id? for the home route, the file-explorer.vue component accepts an optional id prop. This prop represents the id of the current directory being displayed. When it is undefined, then the root directory is being displayed.

Let’s build the actual explorer. The logic for the file explorer is implemented in the src/views/file-explorer.vue component. So let’s go ahead and implement it.

The feel of being an Explorer, the template

The template starts off by displaying a search field for filtering and a breadcrumb for navigating the file system. It then displays the list of files and directories in the current directory or a text to indicate that there are no files in the current directory.

<template>
  <div class="main-container">
    <div class="search-container">
      <input
        v-model="filter"
        type="text"
        class="search-field"
        placeholder="Search file"
      />
    </div>
    <div>
      <div class="breadcrumb">
        <router-link :to="{ name: 'home' }"> Home </router-link>
        <span v-if="currentDirectory.pathToRoot.length > 0"> / </span>
        <template
          v-for="(file, index) in currentDirectory.pathToRoot"
          :key="file.id"
        >
          <router-link
            :to="{ name: 'home', params: { id: file.id } }"
            class="link"
          >
            {{ file.name }}
          </router-link>
          <span v-if="index !== currentDirectory.pathToRoot.length - 1">
            /
          </span>
        </template>
      </div>
      <div v-if="filteredFiles.length === 0" class="no-files outline-container">
        No files in this directory
      </div>
      <div v-else class="grid-container outline-container">
        <!-- @dragover.prevent is needed to register the @drop event -->
        <file-item
          v-for="file in filteredFiles"
          :key="file.id"
          :is-directory="file.isDirectory"
          :name="file.name"
          draggable="true"
          @dragover.prevent
          @dragstart="handleDragStart($event, file)"
          @drop="file.isDirectory ? handleDrop($event, file) : undefined"
          @click="handleFileClick(file)"
        />
      </div>
    </div>
  </div>
</template>

The home button in the breadcrumb links to the root directory while the other breadcrumb items will take the user to the respective directories.

We will see how the variables used in the template and the event handlers are implemented as we move forward. For now, take a look at the image at the start again to see the template in action.

The beauty of Exploration, the style

Add the following style to file-explorer.vue.


Click to reveal code
<style scoped>
.main-container {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}
.grid-container {
  display: grid;
  grid-auto-rows: 100px;
  grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
  gap: 24px;
  justify-content: start;
  padding: 10px;
}
.outline-container {
  width: 70vw;
  border: 1px solid #ccc;
  border-radius: 4px;
  padding: 24px;
}

.no-files {
  font-size: 20px;
  text-align: center;
}

.breadcrumb {
  display: flex;
  gap: 4px;
  width: 70vw;
  font-size: 16px;
  color: rgba(0, 0, 0, 0.7);
  margin-top: 24px;
  margin-bottom: 4px;
  align-self: start;
}

.breadcrumb a {
  color: rgba(0, 0, 0, 0.7);
  text-decoration: none;
}
.router-link-exact-active {
  color: rgba(0, 0, 0, 0.87) !important;
}

.search-container {
  display: flex;
  width: fit-content;
  border: 1px solid #ccc;
  border-radius: 5px;
  margin-top: 24px;
}
.search-field {
  padding: 8px;
  font-size: 16px;
  border: none;
  outline: none;
  width: 100%;
  background-color: rgba(255, 255, 255, 0.6);
  color: rgba(0, 0, 0, 0.7);
  border-radius: 4px;
}
</style>

It displays the files in a grid inside a bordered container. Take a look at the image at the start of the article to have a better idea of how the styles translate to the final result.

The Gem of Exploration, the script

The script holds the logic for the file explorer. So let’s break it down and look at each part.

Opening a Folder by Clicking on it

We registered a handler for the click event on the file-item component. Here is its implementation.

const router = useRouter();
function handleFileClick(file: FileOrDirectory) {
  if (!file.isDirectory) {
    console.error('File opening is not implemented yet');
    return;
  }

  router.push({ name: 'home', params: { id: file.id } });
}

It navigates into the specific directory by opening the home route with the id of the directory as a parameter (See src/routes/index.ts). We will look at the logic for actually displaying the files in the directory clicked shortly.

Moving a File

function handleDragStart(event: DragEvent, file: unknown) {
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  event.dataTransfer!.setData('moved-item', JSON.stringify(file));
}

function moveFile(draggedItemId: number, newFolderId: number) {
  files.value = files.value.map((file) =>
    file.id === draggedItemId
      ? {
          ...file,
          parentDirectoryId: newFolderId,
        }
      : file
  );
}

function handleDrop(event: DragEvent, file: FileOrDirectory) {
  const draggedItemData = event.dataTransfer?.getData('moved-item');
  if (!draggedItemData) {
    return;
  }
  const draggedItem = JSON.parse(draggedItemData) as FileOrDirectory;

  const isDroppingToSameFolder = file.id === draggedItem.id;
  if (isDroppingToSameFolder || !file.isDirectory) {
    return;
  }
  moveFile(draggedItem.id, file.id);
}

We registered the events (dragstart, dragover and drop ) for moving files on file-item. For this to work, we need to set draggable="true". dragover event is needed to ensure the drop event is fired.

When the drag starts, we set the file being moved on the dataTransfer object and when the drop event is fired, we move this file to the directory on which the drop event was fired by changing the parentDirectoryId of the file.

Constructing the file system structure and filtering

const props = defineProps<{
  id?: string;
}>();

const filter = ref('');
const files = ref(generateFiles(100));

const currentDirectoryId = computed(() =>
  props.id ? Number(props.id) : undefined
);
const { itemsAtDirectory: currentDirectory, isInDirectory } =
  useFileOrDirectoryStructure(files, currentDirectoryId);

const filteredFiles = computed(() =>
  currentDirectory.value.files.filter((file) =>
    file.isDirectory
      ? isInDirectory(file.id, filter.value)
      : file.name.toLowerCase().includes(filter.value.toLowerCase())
  )
);

The component accepts a prop id representing the id of the current directory being displayed. A list of 100 files is generated and this is passed to the useFileOrDirectoryStructure composable to construct the file system structure. The actual files displayed are from the filteredFiles computed property. It filters the files based on the filter input field which we defined in the template. isInDirectory ensures that during filtering, if a file is nested in a directory and is being searched for, then the directory is also included in the search results.

Even though clicking on a file changes the route, the files are generated only once because they are all rendered in the same component which does not get re-rendered when the route changes, i.e. when the route param id changes.

We have made the current directory a computed function so that the directory structure will be reconstructed when the current directory changes, thereby displaying the correct files in the current directory.

The useFileOrDirectoryStructure composable

The file src/composables/file-or-directory.ts is responsible for constructing the file system structure. Before we define the composable, let’s create a method which enables it to build the file system structure.

export type Directory = FileOrDirectory & { children?: FileOrDirectory[] };
type FlatDirectory = Map<number, Directory>;

function buildFileStructure(files: FileOrDirectory[]) {
  const flatDirectory: FlatDirectory = new Map();
  const rootDirectory: FileOrDirectory[] = [];

  for (const file of files) {
    if (file.isDirectory) {
      flatDirectory.set(file.id, {
        ...file,
        children: files.filter(
          ({ parentDirectoryId }) => parentDirectoryId === file.id
        ),
      });
    }

    if (!file.parentDirectoryId) {
      rootDirectory.push(file);
    }
  }

  return {
    flatDirectory,
    rootDirectory,
  };
}

This function builds a flat folder structure. This structure is a map whose keys are the file ids and values are the respective directories together with the files in the directory defined as children. It also returns all the files at the root.

Now that we have this structure in place, given a file id, we can easily get all the files in it from the Map. If this id is undefined, then we can return all the files at the root. This is what the useFileOrDirectoryStructure composable does. The computed property itemsAtDirectory picks up the files in a directory with the help of the directory structure built by the buildFileStructure function.

function buildPathToRoot(
  flatDirectory: FlatDirectory,
  directoryId: number,
  paths: Directory[]
) {
  const directory = flatDirectory.get(directoryId);
  if (directory) {
    paths.push(directory);
  }

  if (directory?.parentDirectoryId) {
    buildPathToRoot(flatDirectory, directory.parentDirectoryId, paths);
  }

  return paths;
}

export function useFileOrDirectoryStructure(
  files: Ref<FileOrDirectory[]>,
  currentDirectoryId: ComputedRef<number | undefined>
) {
  const directoryStructure = computed(() => buildFileStructure(files.value));
  const currentDirectoryPathToRoot: Record<string, Directory[] | undefined> =
    {};

  function isInDirectory(directoryId: number, filename?: string): boolean {
    if (!filename) {
      return true;
    }

    const needle = filename?.toLowerCase();
    if (!directoryStructure.value.flatDirectory.get(directoryId)) {
      return false;
    }

    const directory = directoryStructure.value.flatDirectory.get(directoryId);
    if (needle && directory?.name.toLowerCase().includes(needle)) {
      return true;
    }

    if (!directory?.children) {
      return false;
    }

    if (needle) {
      const matchFoundInChildren = directory.children.some(
        (file) => !needle || file.name.toLowerCase().includes(needle)
      );
      if (matchFoundInChildren) {
        return true;
      }
    }

    const directoriesInChildren = directory.children.filter(
      ({ isDirectory }) => isDirectory
    );

    for (const directory of directoriesInChildren) {
      if (isInDirectory(directory.id, needle)) {
        return true;
      }
    }

    return false;
  }

  return {
    itemsAtDirectory: readonly(
      computed(() => {
        let directoryHierarchy: Directory[] = [];
        let currentDirectory: Directory | undefined = undefined;

        if (!currentDirectoryId.value) {
          return {
            files: directoryStructure.value.rootDirectory,
            pathToRoot: directoryHierarchy,
          };
        }
        if (
          !directoryStructure.value.flatDirectory.get(currentDirectoryId.value)
        ) {
          return {
            files: [],
            pathToRoot: directoryHierarchy,
          };
        }

        // Builds the path structure in the form of an array containing all the directories.
        // It only builds this structure if the directory is visited and saves it so that when
        // the path is visited and the saved version is used instead.
        const path = currentDirectoryPathToRoot[currentDirectoryId.value];
        if (path) {
          directoryHierarchy = path;
        } else {
          directoryHierarchy = buildPathToRoot(
            directoryStructure.value.flatDirectory,
            currentDirectoryId.value,
            []
          );
          currentDirectoryPathToRoot[currentDirectoryId.value] =
            directoryHierarchy;
        }

        currentDirectory = directoryStructure.value.flatDirectory.get(
          currentDirectoryId.value
        );

        return {
          files: currentDirectory?.children ?? [],
          pathToRoot: directoryHierarchy.slice().reverse(),
        };
      })
    ),
    isInDirectory,
  };
}

Because the files from which the construction is done and the currentDirectoryId are reactive, it means that every time they change the itemsAtDirectory is recomputed. Hence, if the user clicks on a file, the currentDirectoryId changes, and this triggers the recomputation of the itemsAtDirectory. This then leads to the files at the current directory being displayed. The itemsAtDirectory returns both the files at the current directory and the path from the current directory to the root directory. The path to the root is built recursively using the buildPathToRoot function. It is this path from the current directory to root that is used to build the breadcrumb.

The composable also exposes a method isInDirectory which as described earlier, is used to correctly filter directories. Even if the directory does not match the filter, it is still included if a file within it matches the filter.

Conclusion

Okay, that was a lot of code all over the place. The code is all organised in this repo https://github.com/n05la3/building-a-file-explorer, you can clone and test it out. If you got this working and you are looking to improve the file upload experience for your users, checkout my other articles on Reading and organising files and folders dropped on a web page from the file system and on Building a Simple Parallel and Controlled Task Runner in JavaScript.

Ready to build the apps your users will love and enjoy using? Dreamonkey has experts to bring your dream app to live. If you need any help with this tutorial or if you would like to request a development consultancy you can Contact us or you can check our Github Sponsorship page directly.