Backend DevelopmentFrontend Development

Building Simple File Manager With Laravel and Vue 3 Part2

Building Simple File Manager With Laravel and Vue 3

In part 2 of building a file manager with Laravel and Vue 3 we will continue and in this part we will manipulate directory navigation actions like navigation to particular directory, back, forward and more.

 

 

In the previous part we created the laravel project and installed the npm dependencies alongside Vuejs. Also we prepared the project structure and added some styling for the file manager.

 

Listing Directory Contents

Let’s begin by viewing current directory contents(files, sub-folders) or in other words this section of the file manager screen:

file_manager_file_list

 

To start let’s begin with the server side code and create the necessary Server side API. So create a new directory FileManager/ inside of app/Http/Controllers . This directory will encapsulate all the file manager controller. Then inside of it create the DirectoryNavigationController.php

app/Http/Controllers/FileManager/DirectoryNavigationController.php

<?php

namespace App\Http\Controllers\FileManager;

use App\Http\Controllers\Controller;
use App\Services\DirectoryNavigationService;
use Illuminate\Http\Request;

class DirectoryNavigationController extends Controller
{
    public function __construct(protected DirectoryNavigationService $directoryNavigationService)
    {
    }

    public function navigate(Request $request)
    {
        return $this->directoryNavigationService->navigate($request->input('dir'));
    }
}

The DirectoryNavigationController contains the Api’s responsible for navigating to directory. In the class constructor i injected a service DirectoryNavigationService which we will see below that contains all the methods for directory navigation.

Create a new folder Services/ in app/ directory and create the DirectoryNavigationService.php

app/Services/DirectoryNavigationService.php

<?php

namespace App\Services;

use Illuminate\Support\Facades\File;

class DirectoryNavigationService
{
    protected string $startDir = "";

    public function __construct()
    {
        $this->startDir = env("UPLOADS_DIR");
    }

    public function navigate($directory)
    {
        try {
            $directory = strtolower($directory);

            if($directory === DIRECTORY_SEPARATOR
                || $directory === DIRECTORY_SEPARATOR . $this->startDir
                || $directory === str_replace(DIRECTORY_SEPARATOR, "", $this->startDir)
                ) {
                $directory = $this->startDir;
            }

            // Confirming that user must not go outside of uploads dir
            if(!str_starts_with($directory, $this->startDir))
                throw new \Exception('Invalid directory!');

            if(!File::exists($directory)) {
                throw new \Exception('Directory not exists');
            }

            if(!File::isDirectory($directory)) {
                throw new \Exception('Not a directory');
            }

            $fileList = $this->listFiles($directory);

            return response()->json(data: ['status' => 'success', 'data' => $fileList]);
        } catch (\Throwable $ex) {
            return response()->json(data: ['status' => 'error', 'message' => $ex->getMessage()], status:500);
        }
    }

    public function listFiles($directory)
    {
        $dirs = [];
        $files = [];

        foreach (new \FilesystemIterator(public_path($directory), 0) as $file) {
            if($file->getFileName() === ".") continue;       // Skip Dot
            
            if($directory === $this->startDir && $file->getFileName() == '..') continue;
            if($file->isDir()) {
                $dirs[] = $this->toFileArr($file);
            }

            if($file->isFile()) {
                $files[] = $this->toFileArr($file);
            }
        }

        return [...$dirs, ...$files];
    }

    private function toFileArr(\SplFileInfo $file)
    {
        $type = $file->getType() === 'dir' ? 'directory' : (@is_array(getimagesize($file->getPathname()))? 'file-image' : 'file-' . pathinfo($file->getFilename(), PATHINFO_EXTENSION));

        return [
            'name' => $file->getFilename(),
            'type' => $type,
            'size' => $this->formatBytes($file->getSize()),
            'size_in_bytes' => $file->getSize(),
            'modified_date' => date('Y-m-d h:i:s', $file->getMTime()),
            'mime_type' => mime_content_type($file->getPathname()),
            'permission' => substr(sprintf('%o', $file->getPerms()), -4),
        ];
    }

    private function formatBytes($bytes, $precision = 2) {
        $kilobyte = 1024;
        $megabyte = $kilobyte * 1024;
        $gigabyte = $megabyte * 1024;

        if ($bytes < $kilobyte) {
            return $bytes . ' B';
        } elseif ($bytes < $megabyte) {
            return round($bytes / $kilobyte, $precision) . ' KB';
        } elseif ($bytes < $gigabyte) {
            return round($bytes / $megabyte, $precision) . ' MB';
        } else {
            return round($bytes / $gigabyte, $precision) . ' GB';
        }
    }
}

The navigate() method does the job of navigation by passing the directory path and return a json response containing the list of files and directories. At first i added some basic validations to confirm that the user is sending a correct directory path:

if($directory === DIRECTORY_SEPARATOR
               || $directory === DIRECTORY_SEPARATOR . $this->startDir
               || $directory === str_replace(DIRECTORY_SEPARATOR, "", $this->startDir)
               ) {
               $directory = $this->startDir;
           }

The protected $startDir property stores the uploads/ dir path and is initialized in the class constructor. Next i check for the $directory to be start with uploads/

if(!str_starts_with($directory, $this->startDir))
   throw new \Exception('Invalid directory!');

This check is important to ensure that the user not passing a path outside of the application, thereby this is a security consideration.

After that we do a check that the directory exist and it’s not a file:

if(!File::exists($directory)) {
       throw new \Exception('Directory not exists');
}

if(!File::isDirectory($directory)) {
      throw new \Exception('Not a directory');
 }

Finally we invoke the listFiles() method which return an array of files and dirs. To read the filesystem files there are a lot of ways. In our example we are using the FilesystemIterator built-in php class in the listFiles() method:

foreach (new \FilesystemIterator(public_path($directory), 0) as $file) {
   //
}

Inside the foreach loop i stored the $files and $dirs arrays and then merged them using php spread operator:

return [...$dirs, ...$files];

The toFileArr() method return a single file info and accepts an SplFileInfo instance. The array returned contains the file name, type, size, mime-type, etc. we will need these info when rendering the file list in the vue app.

The formatBytes() method return a formatted file size like 100 KB, 2 MB depending on the file size.

 

Now let’s add the api route for navigation. In your laravel project create the api.php if not exists and add this code:

routes/api.php

<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\FileManager\DirectoryNavigationController;

Route::get('navigate', [DirectoryNavigationController::class, 'navigate']);

Ensure the api.php is registered in bootstrap/app.php inside the withRouting() method:

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',                     // Add this line
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )

....
....
...

Now when retrieving files you have to send a request to <host>/api/navigate?dir=uploads/ ending sending the “dir” query string with the directory path.

 

Directory Navigation Client Side

Now let’s work on the client-side code for directory navigation which is done in Vuejs.

Open resources/js/app/App.vue and update as shown:

<template>
    <div id="file-manager">
        <nav class="navbar navbar-expand-lg bg-body-tertiary">
            <div class="container-fluid">
                <a class="navbar-brand" href="#">File Manager</a>
                <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
                    <span class="navbar-toggler-icon"></span>
                </button>
                <div class="collapse navbar-collapse" id="navbarNav">
                    <FileActions />
                </div>
            </div>
        </nav>

        <DirectoryNavigation />

        <FileList :file_list="file_list" />
    </div>
</template>

<script setup>
import {ref} from "vue";
import FileActions from "./components/file-actions.vue";
import DirectoryNavigation from "./components/directory-navigation.vue";
import FileList from "./components/file-list.vue";

import {useNavigation} from "./composables/useNavigation.js";

const navigation_dir = ref(import.meta.env.VITE_UPLOADS_DIR);

const {file_list, fetchFiles} = useNavigation(navigation_dir.value);

const navigateToDir = (ev) => {
    fetchFiles(ev);

    navigation_dir.value = ev;    // update the navigation_dir value
}

</script>

Also create this file resources/js/app/composables/useNavigation.js

import {onMounted, ref} from "vue";
import axios from "axios";

export const useNavigation = (navigation_dir)=> {
    const file_list = ref([]);

    const fetchFiles = (navigation_dir) => {
        axios.get(`/api/navigate?dir=${navigation_dir}`)
            .then(({data}) => {
                if(data.status === 'success') {
                    file_list.value = data.data;
                }
            }).catch(({response}) => {
            if (response) {
                alert(response.data.message);
            }
        });
    };

    onMounted(() => {
        fetchFiles(navigation_dir);
    });

    return {
        file_list,
        fetchFiles
    }
}

useNavigation.js is a composable file which exports useNavigation() function. composables is a term used in Vue 3 composition Api which allows to reuse functionality in several components.

In this composable i declared a file_list ref variable which holds the file list. Then i added a function fetchFiles() which makes the axios http request to the /api/navigate end-point and stores the response in the file_list variable.

Next we invoke the fetchFiles() on the onMounted() hook to load the file list when the application first loads. And finally we return an object with the file_list and fetchFiles() function.

In the App.vue component we imported the useNavigation composable extracting the file_list and fetchFiles:

const {file_list, fetchFiles} = useNavigation(navigation_dir.value);

In the template we passed the file_list as a prop to <FileList /> component.

Now open FileList.vue and update with this code:

<script setup>
    import FileRow from "./file-row.vue";

    const props = defineProps({
        file_list: {
            type: Array
        }
    });

</script>

<template>
    <div id="file-list" class="mt-3">
        <table class="table table-hover">
            <thead class="table-light">
                <tr>
                    <th>Name</th>
                    <th>Size</th>
                    <th>Last Modified</th>
                    <th>Type</th>
                    <th>Permissions</th>
                </tr>
            </thead>
            <tbody class="table-group-divider">

                <FileRow v-for="(file_item, index) in file_list"
                         :key="index"
                         :fileItem="file_item"
                         />

            </tbody>
        </table>
    </div>
</template>

The file_list now is passed as a prop from the <App/> component and removed the file_list as shown i declared a props variable using defineProps()

To test this create an uploads/ directory inside of laravel public/ directory and add some files and directories

uploads-preview

and run the application:

npm run dev
php artisan serve

 

Directory Navigation Handling

The next step is to handle the directory navigation controls like viewing specific directory contents, go backward, selecting files and directories as shown in this figure:

file_manager_direcotry_navigation

These actions located in directory-navigation.vue component. I already added the necessary code, so open this file and update with this code

resources/js/app/components/directory-navigation.vue

<script setup>
    import {ref} from "vue";
    import {removeLastPathSegment} from "../helpers/functions.js";

    const props = defineProps({
        nav_dir: {
            type: String
        }
    })

    const emits = defineEmits(['navigate']);

    const navigation_dir = ref(props.nav_dir);

    const baseDir = import.meta.env.VITE_UPLOADS_DIR;

    const navigate = () => {
        if(!navigation_dir.value) return;
        
        if(navigation_dir.value === "/" || navigation_dir.value === "\\") {
            navigation_dir.value = baseDir;
        }

        emits('navigate', navigation_dir.value);
    }

    const navigateToHome = () => {
        navigation_dir.value = baseDir;
        emits('navigate', navigation_dir.value);
    }

    const goUp = () => {
        if(navigation_dir === '/' || navigation_dir === baseDir) {
            return;
        }

        navigation_dir.value = removeLastPathSegment(navigation_dir.value) + "/";
        emits('navigate', navigation_dir.value);
    }

    const reload = () => {
        emits('navigate', navigation_dir.value);
    }

    watch(() => props.nav_dir, (newVal, oldVal) => {
      if(newVal !== oldVal) {
         navigation_dir.value = newVal;
      }
   });
</script>

<template>
    <div id="navigator" class="d-flex gap-2 pt-2 pb-2">
        <div class="directory-path">
            <form @submit.prevent="navigate">
                <input type="text" placeholder="/" class="px-1" v-model="navigation_dir" />
            </form>
        </div>
        <div id="directory-home">
            <a href="#" class="text-decoration-none" @click.prevent="navigateToHome">
                <i class="bi bi-house-fill mx-1"></i>
                <span>Home</span>
            </a>
        </div>
        <div id="directory-up">
            <a href="#" class="text-decoration-none" @click.prevent="goUp" :class="{disabled: navigation_dir === '/' || navigation_dir === baseDir}">
                <i class="bi bi-arrow-up mx-1"></i>
                <span>Up one level</span>
            </a>
        </div>
        <div id="directory-reload">
            <a href="#" class="text-decoration-none" @click.prevent="reload">
                <i class="bi bi-arrow-repeat mx-1"></i>
                <span>Reload</span>
            </a>
        </div>
        <div id="directory-select-all">
            <a href="#" class="text-decoration-none">
                <i class="bi bi-check-square mx-1"></i>
                <span>Select all</span>
            </a>
        </div>
        <div id="directory-unselect-all">
            <a href="#" class="text-decoration-none">
                <i class="bi bi-square mx-1"></i>
                <span>Unselect all</span>
            </a>
        </div>
    </div>
</template>

Also update the <DirectoryNavigation /> component in App.vue component as follows:

<DirectoryNavigation
            :nav_dir="navigation_dir"
            @navigate="navigateToDir($event)"
        />

The <DirectoryNavigation /> contains the controls needed to navigate to directories. In the component <template> there is a simple input that allows us to input a directory path. This input is bound using v-model directive to navigation_dir ref variable.

The navigation_dir variable is initially set to props.nav_dir which holds the current uploads/ directory value.

When submitting the form input using keyboard enter key the the navigate() function is triggered:

const navigate = () => {
        if(!navigation_dir.value) return;
        
        if(navigation_dir.value === "/" || navigation_dir.value === "\\") {
            navigation_dir.value = baseDir;
        }

        emits('navigate', navigation_dir.value);
    }

The navigate() function emits an event called “navigate” with the navigation_dir value which is sent to the upper component to call the server side api to fetch the file list for this path.

The navigateToHome() function is called when we click on the home link which does a similar job like the input field, however this navigate to the base directory which is uploads/ dir.

The goUp() function goes one level up of the current directory by removing the last segment of the current path:

navigation_dir.value = removeLastPathSegment(navigation_dir.value) + "/";

We will define removeLastPathSegment() function below in the functions.js file.

The reload() function just emit the same navigate event to refetch the file list of the current path.

Create the functions.js file:

resources/js/app/helpers/functions.js

export const removeLastPathSegment = (path_str) => {
    let path_segments = path_str.split("/");

    if(path_segments[path_segments.length-1] === "") {
        path_segments.pop();
        path_segments.pop();
    } else {
        path_segments.pop();
    }

    return path_segments.join("/");
}

Now check the result right now by checking the directory navigation actions by clicking home link, go back, entering specific directory.

 

Continue to part3

0 0 votes
Article Rating

What's your reaction?

Excited
0
Happy
0
Not Sure
0
Confused
0

You may also like

Subscribe
Notify of
guest

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments