Backend DevelopmentFrontend Development

Building Simple File Manager With Laravel and Vue 3 Part4

In this part of building a file manager with Laravel and Vue we will handle file actions like creating new files, uploading, deleting, editing and more.

 

 

We will handle the file action controls as shown in this figure:

file-actions

File Actions Service Class and Controller

Create a new service class that will contain the necessary methods for file actions. In app/Services/ directory create FileActionsService.php class

app/Services/FileActionsService.php

<?php

namespace App\Services;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Response;

class FileActionsService
{
    private $separator = "/";

    function __construct()
    {
        $this->separator = DIRECTORY_SEPARATOR;
    }


}

Next add new controller that will contain the routes for the file actions:

app/Http/Controllers/FileManager/FileActionsController.php

<?php

namespace App\Http\Controllers\FileManager;

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

class FileActionsController extends Controller
{
    function __construct(protected FileActionsService $fileActionsService)
    {
    }

}

In the controller i injected the FileActionsService class in the constructor to be used later when adding each controller action. In the service class i added a property $separator and initialized it in the constructor with the PHP constant DIRECTORY_SEPARATOR.

 

Handling file/folder Creation From Filemanager

The first action we will start with is the ability to create new file/folder from file manager screen.

Update the FileActionsService.php class by adding these two methods:

<?php

namespace App\Services;

.....
.....

class FileActionsService
{
    .....
    .....


    /**
    * Create File
    */
    public function createFile(string $path, string $filename, bool $overwrite = false)
    {
        try {
            $path = $this->getValidPath($path);


            if($overwrite) {
                File::delete($path . $filename);
            } else {
                if(File::exists($path . $filename)) {
                    throw new \Exception("File exists");
                }
            }

            File::put($path . $filename, '');

            return response()->json(data: ['status' => 'success', 'data' => $path . $filename]);

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


    /**
    * Create Directory
    */
    public function createDirectory(string $path, string $directoryName, bool $overwrite = false)
    {
        try {
             $path = $this->getValidPath($path);

            if($overwrite) {
                File::deleteDirectory($path . $directoryName);
            }

            File::makeDirectory($path . $directoryName, 0755);

            return response()->json(data: ['status' => 'success', 'data' => $path . $directoryName]);

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

    private function getValidPath($path) : string
    {
        return $path . (!str_ends_with($path, $this->separator) ? $this->separator : '');
    }
}

The createFile() and createDirectory() methods accept the $path, $filename, $overwrite arguments respectively. createFile() responsible for creating new file while the createDirectory() creates new directory.

Inside each method the code is wrapped with a try/catch block for any failure that might happen. I added a helper method getValidPath() which returns the path suffixed with “/” if not exists.

In createFile() method first i invoked getValidPath() method , then we check for the overwrite functionality using the $overwrite parameter.

If $overwrite is true then we delete any references to this file, otherwise we through an exception. Next we create the file using File::put() method.

The same is done for createDirectory() method except that in this case we create a new directory using File::makeDirectory() method.

Finally i return a json success response.

Now let’s add an action in our FileActionsController that will call that service methods to create file:

FileActionsController.php

<?php

namespace App\Http\Controllers\FileManager;

....
....

class FileActionsController extends Controller
{

   ....
   ....
    
    public function postNewFile(Request $request)
    {
        if(!$request->input('filename')) {
            return false;
        }

        if($request->input('type') === 'file') {
          return $this->fileActionsService->createFile($request->input('path'), $request->input('filename'), $request->input('overwrite'));
        } else {
          return $this->fileActionsService->createDirectory($request->input('path'), $request->input('filename'), $request->input('overwrite'));
        }
    }
}

Here i added the postNewfile() method, then we check for the $request->input('type'). If the type is a file then we call the service method createFile() otherwise we call the createDirectory() method.

Now add the route for this action in api.php

routes/api.php

<?php

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

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

Route::controller(FileActionsController::class)->group(function () {
    Route::post('new-file', 'postNewFile');
});

 

The next step is handling the client side logic for file/folder creation in Vuejs.

 

Handling file/folder Creation in Vuejs App

Open resources/js/app/components/file-actions.vue and update as shown:

<script setup>
    import {inject, ref, watch} from "vue";
    import CreateFileModal from "./create-file-modal.vue";
    import ActionBtn from "./action-btn.vue";

    const emits = defineEmits(['reload']);
    const props = defineProps({
        selected: Array
    });
    const file_type = ref('file');

    const isDownloadDisabled = () => {
        if(!props.selected.length) return true;

        return !!(props.selected && props.selected.filter(s => s.file_item.type === 'directory').length);
    }
</script>

<template>
    <ul class="navbar-nav controls">
        <li class="nav-item control dropdown">
            <action-btn class="dropdown-toggle" data-bs-toggle="dropdown" title="New">
                <i class="bi bi-plus-lg"></i>
                <span>New</span>
            </action-btn>
            <ul class="dropdown-menu">
                <li><a href="#" class="dropdown-item" data-bs-toggle="modal" data-bs-target="#new-file-modal" @click="file_type='file'">File</a></li>
                <li><a href="#" class="dropdown-item" data-bs-toggle="modal" data-bs-target="#new-file-modal" @click="file_type='folder'">Folder</a></li>
            </ul>
        </li>
        <li class="nav-item control">
            <action-btn title="Upload">
             <i class="bi bi-upload"></i>
             <span>Upload</span>
           </action-btn>
        </li>
        <li class="nav-item control">
            <action-btn title="Download" :class="{disabled: isDownloadDisabled()}">
                <i class="bi bi-download"></i>
                <span>Download</span>
            </action-btn>
        </li>
        <li class="nav-item control">
            <action-btn title="Delete" :class="{disabled: selected.length === 0}">
                <i class="bi bi-x-lg"></i>
                <span>Delete</span>
            </action-btn>
        </li>
        <li class="nav-item control">
            <action-btn title="Edit" :class="{disabled: selected.length === 0 || selected.length > 1}">
                <i class="bi bi-pencil-fill"></i>
                <span>Edit</span>
            </action-btn>
        </li>
        <li class="nav-item control">
            <action-btn title="Rename" :class="{disabled: selected.length === 0 || selected.length > 1}">
             <i class="bi bi-file-earmark-fill"></i>
             <span>Rename</span>
           </action-btn>
        </li>
        <li class="nav-item control">
            <action-btn title="Copy" :class="{disabled: selected.length === 0}">
               <i class="bi bi-copy"></i>
                <span>Copy</span>
            </action-btn>
        </li>
        <li class="nav-item control">
            <action-btn title="Move" :class="{disabled: selected.length === 0}">
               <i class="bi bi-arrows-move"></i>
                <span>Move</span>
            </action-btn>
        </li>
    </ul>

    <Teleport to="body">
        <create-file-modal :type="file_type" @file-created="$emit('reload')" />
    </Teleport>
</template>

I have created a helper component <action-btn/> that acts as a button, we will create it below. Next it’s important to make the action buttons disabled or enabled based on whether there is selected files. The selected files prop is expected using the defineProps() function:

const props = defineProps({
        selected: Array
    });

Then in each action button i added the :class attribute to assign a dynamic class ‘disabled’, for example for the delete button the disabled is true if selected.length==0:

:class="{disabled: selected.length === 0}"

For the download button i created a function isDownloadDisabled() which return true if there no selected files or if there is a directory inside the selected files. The same is done for the other buttons.

When the new action button clicked it display a nav menu with two options file or folder. For this i declared a const file_type reactive variable with initial value of file:

const file_type = ref('file');

In the <template> i modified the new button like so:

<action-btn class="dropdown-toggle" data-bs-toggle="dropdown" title="New">
                <i class="bi bi-plus-lg"></i>
                <span>New</span>
            </action-btn>
            <ul class="dropdown-menu">
                <li><a href="#" class="dropdown-item" data-bs-toggle="modal" data-bs-target="#new-file-modal" @click="file_type='file'">File</a></li>
                <li><a href="#" class="dropdown-item" data-bs-toggle="modal" data-bs-target="#new-file-modal" @click="file_type='folder'">Folder</a></li>
            </ul>

I utilized bootstrap dropdown menu feature to display a nav menu and i added the @click event to each menu item to to set the file_type. Also i added another component <create-file-modal /> which display a modal to create a file:

<Teleport to="body">
        <create-file-modal :type="file_type" @file-created="$emit('reload')" />
    </Teleport>

The <create-file-modal/> component accepts the :type prop and emits a custom event @file-created.

Before looking at the <create-file-modal /> component, open the <App/> component and make these changes:

In the <template> replace:

<FileActions />

With

<FileActions
           :selected="selected_files"
           @reload="reload"
/>

In the <script> update this import statement:

import {ref} from "vue";

By including “provide” and “computed” :

import {computed, provide, ref} from "vue";

Next below navigation_dir declaration add the provide call:

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

provide('app_global', computed(() => ({
    navigation_dir: navigation_dir.value
    })
));

The provide() function is part of Vue provide/inject Api. The Provide/Inject Api allow us to send data to deep nested components without sending it as props which solves the problem of props drilling.

In this case we are sending an object app_global whose value is a computed() property with the navigation_dir key, so that we can access the current navigation directory value in child components.

Also define the reload() function in the bottom of the <script>

const reload = () => {
    fetchFiles(navigation_dir.value);
    clearSelectedFiles();
}

Now add the <create-file-modal/> component:

resources/js/app/components/create-file-modal.vue

<script setup>

import {inject, ref, watch} from "vue";

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

    const emits = defineEmits(["fileCreated"]);

    const app_global = inject('app_global', {});

    const navigation_dir = ref(app_global.value.navigation_dir);

    const name = ref('');
    const overwrite = ref(false);

    watch(() => app_global.value, (newVal, oldVal) => {
        navigation_dir.value = newVal.navigation_dir;
    });

    const create = () => {
        if(!name.value) {
            alert(`Please enter ${props.type === 'file'?'file':'folder'}`);
            return;
        }

        axios.post('/api/new-file', {
            path: navigation_dir.value,
            filename: name.value,
            overwrite: overwrite.value,
            type: props.type,
        })
            .then(({data}) => {
                emits('fileCreated', {filepath: data.data});
                name.value = "";
                overwrite.value = false;
                document.querySelector('#new-file-modal .btn-close').click();
            }).catch((error) => {
                alert("There was an error creating the file/folder: " + error.response.data.message);
                console.error(error.response.data.message);
            });
    }
</script>

<template>
        <div class="modal fade" id="new-file-modal">
            <div class="modal-dialog">
                <div class="modal-content">
                    <div class="modal-header">
                        <h5 class="modal-title">Create {{type==='file' ? 'File' : 'Folder'}}</h5>
                        <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
                    </div>
                    <div class="modal-body">
                        <div class="form-group">
                            <label class="control-label">{{type==='file' ? 'File' : 'Folder'}} Name</label>
                            <input type="text" class="form-control" name="file_name" v-model="name" />
                        </div>
                        <div class="form-check mt-3">
                            <input class="form-check-input" type="checkbox" id="overwrite" name="overwrite" value="1" v-model="overwrite" />
                            <label class="form-check-label" for="overwrite">Overwrite if exist</label>
                        </div>
                    </div>
                    <div class="modal-footer">
                        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
                        <button type="button" class="btn btn-primary" @click="create">Create</button>
                    </div>
                </div>
            </div>
        </div>
</template>

I am capturing the app_global data using Vue inject() function. The inject() function is the counterpart of the provide() function we used above in the <App/> component whose job is to receive the data from the provide() function. Then i am storing navigation_dir in a ref() so that we can watch for the changes from app_global.value.

In this component template i am using a bootstrap modal. I am displaying the modal header by checking for the type prop:

<h5 class="modal-title">Create {{type==='file' ? 'File' : 'Folder'}}</h5>

To indicate if this is a file or a folder. In the modal body we display a simple form consisting of the name of the file/folder to create, and a checkbox to determine if overwrite is enabled if this file/folder exists is the current directory.

To submit the data i added the create() function which triggers on button click @click. The create() function first check that the name is not empty:

if(!name.value) {
            alert(`Please enter ${props.type === 'file'?'file':'folder'}`);
            return;
        }

Next we are making axios post request to the server to /api/new-file endpoint passing in this payload:

{
            path: navigation_dir.value,
            filename: name.value,
            overwrite: overwrite.value,
            type: props.type,
        }

When this request successes, then we emit an event “fileCreated” and setting the name.value to be empty and overwrite.value=false and close the modal.

Create the <action-btn /> component:

resources/js/app/components/action-btn.vue:

<script setup>
    defineProps(['title']);
</script>

<template>
    <a class="nav-link d-flex gap-1 align-items-center" href="#" :title="title">
        <slot />
    </a>
</template>

Now the file create functionality is completed, let’s move to the next step.

 

Handling Upload and Download

filemanager-upload-download

We will work on the server side code for the upload and download first. So open the FileActionsService class and add these methods:

FileActionsService.php

<?php
...
...

use Illuminate\Support\Facades\Response;


class FileActionsService {
    ...
    ...

    public function uploadFile(Request $request)
    {
        try {
            $request->validate([
                "file" => "required|mimes:jpg,png,gif,jpeg,txt,json,php,zip,gz|max:10000",
                "path" => "required"
            ]);

            $path = $this->getValidPath($request->input('path'));
            $overwrite = $request->input('overwrite');

            $file = $request->file('file');

            $name = $file->getClientOriginalName();

            if($overwrite) {
                File::delete($path . $name);
            } else {
                if(File::exists($path . $name)) {
                    return response()->json(data: ['status' => 'error', 'is_duplicate' => 1, 'message' => 'File already exist'], status:500);
                }
            }

            $file->move($path, $name);

            return response()->json(data: ['status' => 'success', 'data' => $name]);

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

    public function prepareDownloadedFiles(Request $request)
    {
        if(count($request->input('files'))) {
            $files = [];
            $path = $request->input('path');

            $files = array_map(fn($file) => url("/api/download?filename=$file&path=$path"), $request->input('files'));

            return response()->json(data: ['status' => 'success', 'data' => $files]);
        }

        return response()->json(data: ['status' => 'error'], status: 500);
    }

    public function downloadFile(Request $request)
    {
        if($request->input('filename') && $request->input('path')) {
            $file = $this->getValidPath($request->input('path')) . $request->input('filename');

            return Response::download(public_path($file));
        }

        return false;
    }

   
   ....
}

The upload() method accepts the $request and responsible for uploading file. Before uploading we must validate the incoming input which is the file and path. Here we are expecting specific extensions for security purposes. 

Then we are checking for the $overwrite parameter. If the $overwrite is true and there is a file with the same name then we delete that file, otherwise we return an error response:

if(File::exists($path . $name)) {
     return response()->json(data: ['status' => 'error', 'is_duplicate' => 1, 'message' => 'File already exist'], status:500);
 }

Note the is_duplicate key in the response we will use it in the Vue app. Finally we move the file using $file->move() method passing in the $path and $name and return a success response.

The next two methods prepareDownloadedFiles() and downloadFile() perform the download functionality. prepareDownloadedFiles() expects an array of files names and return an array of download urls for these filenames using PHP array_map() function.

The downloadFile() method handles the actual downloading of particular file. The input for this method is the filename and path and we are using laravel Response facade download() method for downloading.

Now add these actions in the FileActionsController:

<?php

...
...

class FileActionsController extends Controller
{

  ....
  ....

   public function postUpload(Request $request)
   {
      return $this->fileActionsService->uploadFile($request);
   }

   public function postFilesForDownload(Request $request)
    {
        return $this->fileActionsService->prepareDownloadedFiles($request);
    }

    public function getDownloadFile(Request $request)
    {
        return $this->fileActionsService->downloadFile($request);
    }


}

We need two routes for these controller actions. Open routes/api.php and add those lines between Route::controller(FileActionsController::class)->group():

Route::controller(FileActionsController::class)->group(function () {
   ...
   ...

    Route::post('upload', 'postUpload');
    Route::post('get-file-urls', 'postFilesForDownload');
    Route::get('download', 'getDownloadFile');
});

Let’s implement the client side part for uploading and downloading.

Open file-actions.vue and update these buttons:

<script setup>
   import UploadBtn from "./upload-btn.vue";
</script>

And update the template:

<li class="nav-item control">
            <upload-btn @upload-success="$emit('reload')" />
        </li>
        <li class="nav-item control">
            <action-btn title="Download" @click.prevent="$emit('download')" :class="{disabled: isDownloadDisabled()}">
                <i class="bi bi-download"></i>
                <span>Download</span>
            </action-btn>
        </li>

I have replaced the upload button with this component <upload-btn /> we will see it below. This component emits the @upload-success event. Also when clicking the download link i emit a custom event “download” which we will manipulat it on the <App /> component.

Let’s see the code for the <upload-btn/>

resources/js/app/components/upload-btn.vue

<script setup>
import {inject, ref, watch} from "vue";
import ActionBtn from "./action-btn.vue";

const app_global = inject('app_global', {});

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

const navigation_dir = ref(app_global.value.navigation_dir);
const upload_btn = ref(null);
const file = ref(null);
const prompt_overwrite = ref(false);


const upload = ev => {
    if(!ev.target.files.length) return;

    file.value = ev.target.files[0];

    sendRequest();
}

const sendRequest = () => {
    const formData = new FormData();
    formData.append('file', file.value);
    formData.append('path', navigation_dir.value);

    if(prompt_overwrite.value) {
        formData.append('overwrite', 1);
    }

    axios.post('/api/upload', formData)
        .then(({data}) => {
            if(data.status === 'success') {
                alert("Upload success");
                emits('uploadSuccess');
                file.value = null;
            }

            prompt_overwrite.value = false;
        }).catch(error => {
            console.error(error);

            alert('Error uploading file: ' + error.response.data.message);
            prompt_overwrite.value = error.response.data.is_duplicate !== undefined;
    });
}

const reset = () => {
    file.value = null;
    prompt_overwrite.value = false;
}

const overwrite = () => {
    if(file.value) {
        sendRequest();
    }
}

watch(() => app_global.value, (newVal, oldVal) => {
    navigation_dir.value = newVal.navigation_dir;
});
</script>

<template>
    <action-btn title="Upload" @click.prevent="upload_btn.click()">
        <i class="bi bi-upload"></i>
        <span>Upload</span>
    </action-btn>
    <input type="file" name="upload_file" class="d-none" ref="upload_btn" @change="upload($event)" />

    <Teleport to="body">
        <div class="modal fade show" id="prompt-overwrite-modal" style="display: block" v-if="prompt_overwrite">
            <div class="modal-dialog">
                <div class="modal-content">
                    <div class="modal-header">
                        <h5 class="modal-title">Confirm Overwrite File</h5>
                        <button type="button" class="btn-close" data-bs-dismiss="modal" @click="reset"></button>
                    </div>
                    <div class="modal-body">
                        <p>It seems that file with same name already exist! Do you want to overwrite?</p>
                    </div>
                    <div class="modal-footer">
                        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @click="reset">Cancel</button>
                        <button type="button" class="btn btn-primary" @click="overwrite">Overwrite</button>
                    </div>
                </div>
            </div>
        </div>
    </Teleport>
</template>

At the top i imported the inject(), ref() and watch() functions from vue. Next i imported the <action-btn /> component to be used as a wrapper for our upload button.

To access the global_data i used the inject('app_global') function as we learned previously. I declared some reactive state variables like file,prompt_overwrite, upload_btn and navigation_dir.

The navigation_dir is updated using vue watcher function:

watch(() => app_global.value, (newVal, oldVal) => {
    navigation_dir.value = newVal.navigation_dir;
});

In the <template> i added a hidden <input type="file"/>:

<input type="file" name="upload_file" class="d-none" ref="upload_btn" @change="upload($event)" />

This button is triggered when clicking the visible upload button, for this we assigning a ref="upload_btn" to this button:

<action-btn title="Upload" @click.prevent="upload_btn.click()">
       <i class="bi bi-upload"></i>
       <span>Upload</span>
   </action-btn>

When the user select a file to upload the upload() function gets executed. In the upload() function we check if there is file selected, and then we set the file.value=ev.target.files[0] and finally calling the sendRequest() function to make the axios post request.

The sendRequest() function makes a formData object to be sent as the payload to server. If the upload done successfully then we show a success message, and emit the “uploadSuccess” event and reset the file.value.

If the upload failed then we show an error message, and set the prompt_overwrite to be true if the json coming from the server contains the is_duplicate key.

The prompt_overwrite will trigger a modal to ask the user if he wants to overwrite the file and continue the upload otherwise cancel.

 

For the rest of the actions i already implemented them, you can find them functional on the source code repository below

Source Code

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