]> BookStack Code Mirror - bookstack/commitdiff
Migrated attachment manager to vue
authorDan Brown <redacted>
Sat, 19 Aug 2017 12:55:56 +0000 (13:55 +0100)
committerDan Brown <redacted>
Sat, 19 Aug 2017 12:55:56 +0000 (13:55 +0100)
resources/assets/js/controllers.js
resources/assets/js/directives.js
resources/assets/js/global.js
resources/assets/js/vues/attachment-manager.js [new file with mode: 0644]
resources/assets/js/vues/image-manager.js
resources/assets/js/vues/vues.js
resources/assets/sass/_components.scss
resources/assets/sass/_tables.scss
resources/views/pages/form-toolbox.blade.php

index 132580f68603ba4708369b5e1553db9bf12c1dee..8b37379fa18543cbeca92b542f61f88c1475ba6c 100644 (file)
@@ -145,202 +145,6 @@ module.exports = function (ngApp, events) {
 
     }]);
 
-    ngApp.controller('PageAttachmentController', ['$scope', '$http', '$attrs',
-        function ($scope, $http, $attrs) {
-
-            const pageId = $scope.uploadedTo = $attrs.pageId;
-            let currentOrder = '';
-            $scope.files = [];
-            $scope.editFile = false;
-            $scope.file = getCleanFile();
-            $scope.errors = {
-                link: {},
-                edit: {}
-            };
-
-            function getCleanFile() {
-                return {
-                    page_id: pageId
-                };
-            }
-
-            // Angular-UI-Sort options
-            $scope.sortOptions = {
-                handle: '.handle',
-                items: '> tr',
-                containment: "parent",
-                axis: "y",
-                stop: sortUpdate,
-            };
-
-            /**
-             * Event listener for sort changes.
-             * Updates the file ordering on the server.
-             * @param event
-             * @param ui
-             */
-            function sortUpdate(event, ui) {
-                let newOrder = $scope.files.map(file => {return file.id}).join(':');
-                if (newOrder === currentOrder) return;
-
-                currentOrder = newOrder;
-                $http.put(window.baseUrl(`/attachments/sort/page/${pageId}`), {files: $scope.files}).then(resp => {
-                    events.emit('success', resp.data.message);
-                }, checkError('sort'));
-            }
-
-            /**
-             * Used by dropzone to get the endpoint to upload to.
-             * @returns {string}
-             */
-            $scope.getUploadUrl = function (file) {
-                let suffix = (typeof file !== 'undefined') ? `/${file.id}` : '';
-                return window.baseUrl(`/attachments/upload${suffix}`);
-            };
-
-            /**
-             * Get files for the current page from the server.
-             */
-            function getFiles() {
-                let url = window.baseUrl(`/attachments/get/page/${pageId}`);
-                $http.get(url).then(resp => {
-                    $scope.files = resp.data;
-                    currentOrder = resp.data.map(file => {return file.id}).join(':');
-                }, checkError('get'));
-            }
-            getFiles();
-
-            /**
-             * Runs on file upload, Adds an file to local file list
-             * and shows a success message to the user.
-             * @param file
-             * @param data
-             */
-            $scope.uploadSuccess = function (file, data) {
-                $scope.$apply(() => {
-                    $scope.files.push(data);
-                });
-                events.emit('success', trans('entities.attachments_file_uploaded'));
-            };
-
-            /**
-             * Upload and overwrite an existing file.
-             * @param file
-             * @param data
-             */
-            $scope.uploadSuccessUpdate = function (file, data) {
-                $scope.$apply(() => {
-                    let search = filesIndexOf(data);
-                    if (search !== -1) $scope.files[search] = data;
-
-                    if ($scope.editFile) {
-                        $scope.editFile = angular.copy(data);
-                        data.link = '';
-                    }
-                });
-                events.emit('success', trans('entities.attachments_file_updated'));
-            };
-
-            /**
-             * Delete a file from the server and, on success, the local listing.
-             * @param file
-             */
-            $scope.deleteFile = function(file) {
-                if (!file.deleting) {
-                    file.deleting = true;
-                    return;
-                }
-                  $http.delete(window.baseUrl(`/attachments/${file.id}`)).then(resp => {
-                      events.emit('success', resp.data.message);
-                      $scope.files.splice($scope.files.indexOf(file), 1);
-                  }, checkError('delete'));
-            };
-
-            /**
-             * Attach a link to a page.
-             * @param file
-             */
-            $scope.attachLinkSubmit = function(file) {
-                file.uploaded_to = pageId;
-                $http.post(window.baseUrl('/attachments/link'), file).then(resp => {
-                    $scope.files.push(resp.data);
-                    events.emit('success', trans('entities.attachments_link_attached'));
-                    $scope.file = getCleanFile();
-                }, checkError('link'));
-            };
-
-            /**
-             * Start the edit mode for a file.
-             * @param file
-             */
-            $scope.startEdit = function(file) {
-                $scope.editFile = angular.copy(file);
-                $scope.editFile.link = (file.external) ? file.path : '';
-            };
-
-            /**
-             * Cancel edit mode
-             */
-            $scope.cancelEdit = function() {
-                $scope.editFile = false;
-            };
-
-            /**
-             * Update the name and link of a file.
-             * @param file
-             */
-            $scope.updateFile = function(file) {
-                $http.put(window.baseUrl(`/attachments/${file.id}`), file).then(resp => {
-                    let search = filesIndexOf(resp.data);
-                    if (search !== -1) $scope.files[search] = resp.data;
-
-                    if ($scope.editFile && !file.external) {
-                        $scope.editFile.link = '';
-                    }
-                    $scope.editFile = false;
-                    events.emit('success', trans('entities.attachments_updated_success'));
-                }, checkError('edit'));
-            };
-
-            /**
-             * Get the url of a file.
-             */
-            $scope.getFileUrl = function(file) {
-                return window.baseUrl('/attachments/' + file.id);
-            };
-
-            /**
-             * Search the local files via another file object.
-             * Used to search via object copies.
-             * @param file
-             * @returns int
-             */
-            function filesIndexOf(file) {
-                for (let i = 0; i < $scope.files.length; i++) {
-                    if ($scope.files[i].id == file.id) return i;
-                }
-                return -1;
-            }
-
-            /**
-             * Check for an error response in a ajax request.
-             * @param errorGroupName
-             */
-            function checkError(errorGroupName) {
-                $scope.errors[errorGroupName] = {};
-                return function(response) {
-                    if (typeof response.data !== 'undefined' && typeof response.data.error !== 'undefined') {
-                        events.emit('error', response.data.error);
-                    }
-                    if (typeof response.data !== 'undefined' && typeof response.data.validation !== 'undefined') {
-                        $scope.errors[errorGroupName] = response.data.validation;
-                        console.log($scope.errors[errorGroupName])
-                    }
-                }
-            }
-
-        }]);
-
     // Controller used to reply to and add new comments
     ngApp.controller('CommentReplyController', ['$scope', '$http', '$timeout', function ($scope, $http, $timeout) {
         const MarkdownIt = require("markdown-it");
index 2a0547c97ab52cdda0f4f97d8363c2dcb08437b6..fc92121ff7b1cd35b7ff808040d97323c4754fd8 100644 (file)
 "use strict";
-const DropZone = require("dropzone");
 const MarkdownIt = require("markdown-it");
 const mdTasksLists = require('markdown-it-task-lists');
 const code = require('./code');
 
 module.exports = function (ngApp, events) {
 
-    /**
-     * Common tab controls using simple jQuery functions.
-     */
-    ngApp.directive('tabContainer', function() {
-        return {
-            restrict: 'A',
-            link: function (scope, element, attrs) {
-                const $content = element.find('[tab-content]');
-                const $buttons = element.find('[tab-button]');
-
-                if (attrs.tabContainer) {
-                    let initial = attrs.tabContainer;
-                    $buttons.filter(`[tab-button="${initial}"]`).addClass('selected');
-                    $content.hide().filter(`[tab-content="${initial}"]`).show();
-                } else {
-                    $content.hide().first().show();
-                    $buttons.first().addClass('selected');
-                }
-
-                $buttons.click(function() {
-                    let clickedTab = $(this);
-                    $buttons.removeClass('selected');
-                    $content.hide();
-                    let name = clickedTab.addClass('selected').attr('tab-button');
-                    $content.filter(`[tab-content="${name}"]`).show();
-                });
-            }
-        };
-    });
-
-    /**
-     * Sub form component to allow inner-form sections to act like their own forms.
-     */
-    ngApp.directive('subForm', function() {
-        return {
-            restrict: 'A',
-            link: function (scope, element, attrs) {
-                element.on('keypress', e => {
-                    if (e.keyCode === 13) {
-                        submitEvent(e);
-                    }
-                });
-
-                element.find('button[type="submit"]').click(submitEvent);
-
-                function submitEvent(e) {
-                    e.preventDefault();
-                    if (attrs.subForm) scope.$eval(attrs.subForm);
-                }
-            }
-        };
-    });
-
-    /**
-     * DropZone
-     * Used for uploading images
-     */
-    ngApp.directive('dropZone', [function () {
-        return {
-            restrict: 'E',
-            template: `
-            <div class="dropzone-container">
-                <div class="dz-message">{{message}}</div>
-            </div>
-            `,
-            scope: {
-                uploadUrl: '@',
-                eventSuccess: '=',
-                eventError: '=',
-                uploadedTo: '@',
-            },
-            link: function (scope, element, attrs) {
-                scope.message = attrs.message;
-                if (attrs.placeholder) element[0].querySelector('.dz-message').textContent = attrs.placeholder;
-                let dropZone = new DropZone(element[0].querySelector('.dropzone-container'), {
-                    url: scope.uploadUrl,
-                    init: function () {
-                        let dz = this;
-                        dz.on('sending', function (file, xhr, data) {
-                            let token = window.document.querySelector('meta[name=token]').getAttribute('content');
-                            data.append('_token', token);
-                            let uploadedTo = typeof scope.uploadedTo === 'undefined' ? 0 : scope.uploadedTo;
-                            data.append('uploaded_to', uploadedTo);
-                        });
-                        if (typeof scope.eventSuccess !== 'undefined') dz.on('success', scope.eventSuccess);
-                        dz.on('success', function (file, data) {
-                            $(file.previewElement).fadeOut(400, function () {
-                                dz.removeFile(file);
-                            });
-                        });
-                        if (typeof scope.eventError !== 'undefined') dz.on('error', scope.eventError);
-                        dz.on('error', function (file, errorMessage, xhr) {
-                            console.log(errorMessage);
-                            console.log(xhr);
-                            function setMessage(message) {
-                                $(file.previewElement).find('[data-dz-errormessage]').text(message);
-                            }
-
-                            if (xhr.status === 413) setMessage(trans('errors.server_upload_limit'));
-                            if (errorMessage.file) setMessage(errorMessage.file[0]);
-
-                        });
-                    }
-                });
-            }
-        };
-    }]);
-
     /**
      * TinyMCE
      * An angular wrapper around the tinyMCE editor.
index 28d1e3b0c35f3a45d8a559aaa4bf52f6ebe58410..ee7cf3cc12913926a7dee6557ba33d18cf4ff959 100644 (file)
@@ -9,6 +9,31 @@ window.baseUrl = function(path) {
     return basePath + '/' + path;
 };
 
+// Global Event System
+class EventManager {
+    constructor() {
+        this.listeners = {};
+    }
+
+    emit(eventName, eventData) {
+        if (typeof this.listeners[eventName] === 'undefined') return this;
+        let eventsToStart = this.listeners[eventName];
+        for (let i = 0; i < eventsToStart.length; i++) {
+            let event = eventsToStart[i];
+            event(eventData);
+        }
+        return this;
+    }
+
+    listen(eventName, callback) {
+        if (typeof this.listeners[eventName] === 'undefined') this.listeners[eventName] = [];
+        this.listeners[eventName].push(callback);
+        return this;
+    }
+}
+
+window.Events = new EventManager();
+
 const Vue = require("vue");
 const axios = require("axios");
 
@@ -18,8 +43,17 @@ let axiosInstance = axios.create({
         'baseURL': window.baseUrl('')
     }
 });
+axiosInstance.interceptors.request.use(resp => {
+    return resp;
+}, err => {
+    if (typeof err.response === "undefined" || typeof err.response.data === "undefined") return Promise.reject(err);
+    if (typeof err.response.data.error !== "undefined") window.Events.emit('error', err.response.data.error);
+    if (typeof err.response.data.message !== "undefined") window.Events.emit('error', err.response.data.message);
+});
 window.$http = axiosInstance;
+
 Vue.prototype.$http = axiosInstance;
+Vue.prototype.$events = window.Events;
 
 
 // AngularJS - Create application and load components
@@ -37,31 +71,6 @@ const Translations = require("./translations");
 let translator = new Translations(window.translations);
 window.trans = translator.get.bind(translator);
 
-// Global Event System
-class EventManager {
-    constructor() {
-        this.listeners = {};
-    }
-
-    emit(eventName, eventData) {
-        if (typeof this.listeners[eventName] === 'undefined') return this;
-        let eventsToStart = this.listeners[eventName];
-        for (let i = 0; i < eventsToStart.length; i++) {
-            let event = eventsToStart[i];
-            event(eventData);
-        }
-        return this;
-    }
-
-    listen(eventName, callback) {
-        if (typeof this.listeners[eventName] === 'undefined') this.listeners[eventName] = [];
-        this.listeners[eventName].push(callback);
-        return this;
-    }
-}
-
-window.Events = new EventManager();
-Vue.prototype.$events = window.Events;
 
 require("./vues/vues");
 require("./components");
diff --git a/resources/assets/js/vues/attachment-manager.js b/resources/assets/js/vues/attachment-manager.js
new file mode 100644 (file)
index 0000000..635622b
--- /dev/null
@@ -0,0 +1,138 @@
+const draggable = require('vuedraggable');
+const dropzone = require('./components/dropzone');
+
+function mounted() {
+    this.pageId = this.$el.getAttribute('page-id');
+    this.file = this.newFile();
+
+    this.$http.get(window.baseUrl(`/attachments/get/page/${this.pageId}`)).then(resp => {
+        this.files = resp.data;
+    }).catch(err => {
+        this.checkValidationErrors('get', err);
+    });
+}
+
+let data = {
+    pageId: null,
+    files: [],
+    fileToEdit: null,
+    file: {},
+    tab: 'list',
+    editTab: 'file',
+    errors: {link: {}, edit: {}, delete: {}}
+};
+
+const components = {dropzone, draggable};
+
+let methods = {
+
+    newFile() {
+        return {page_id: this.pageId};
+    },
+
+    getFileUrl(file) {
+        return window.baseUrl(`/attachments/${file.id}`);
+    },
+
+    fileSortUpdate() {
+        this.$http.put(window.baseUrl(`/attachments/sort/page/${this.pageId}`), {files: this.files}).then(resp => {
+            this.$events.emit('success', resp.data.message);
+        }).catch(err => {
+            this.checkValidationErrors('sort', err);
+        });
+    },
+
+    startEdit(file) {
+        this.fileToEdit = Object.assign({}, file);
+        this.fileToEdit.link = file.external ? file.path : '';
+        this.editTab = file.external ? 'link' : 'file';
+    },
+
+    deleteFile(file) {
+        if (!file.deleting) return file.deleting = true;
+
+        this.$http.delete(window.baseUrl(`/attachments/${file.id}`)).then(resp => {
+            this.$events.emit('success', resp.data.message);
+            this.files.splice(this.files.indexOf(file), 1);
+        }).catch(err => {
+            this.checkValidationErrors('delete', err)
+        });
+    },
+
+    uploadSuccess(upload) {
+        this.files.push(upload.data);
+        this.$events.emit('success', trans('entities.attachments_file_uploaded'));
+    },
+
+    uploadSuccessUpdate(upload) {
+        let fileIndex = this.filesIndex(upload.data);
+        if (fileIndex === -1) {
+            this.files.push(upload.data)
+        } else {
+            this.files.splice(fileIndex, 1, upload.data);
+        }
+
+        if (this.fileToEdit && this.fileToEdit.id === upload.data.id) {
+            this.fileToEdit = Object.assign({}, upload.data);
+        }
+        this.$events.emit('success', trans('entities.attachments_file_updated'));
+    },
+
+    checkValidationErrors(groupName, err) {
+        console.error(err);
+        if (typeof err.response.data === "undefined" && typeof err.response.data.validation === "undefined") return;
+        this.errors[groupName] = err.response.data.validation;
+        console.log(this.errors[groupName]);
+    },
+
+    getUploadUrl(file) {
+        let url = window.baseUrl(`/attachments/upload`);
+        if (typeof file !== 'undefined') url += `/${file.id}`;
+        return url;
+    },
+
+    cancelEdit() {
+        this.fileToEdit = null;
+    },
+
+    attachNewLink(file) {
+        file.uploaded_to = this.pageId;
+        this.$http.post(window.baseUrl('/attachments/link'), file).then(resp => {
+            this.files.push(resp.data);
+            this.file = this.newFile();
+            this.$events.emit('success', trans('entities.attachments_link_attached'));
+        }).catch(err => {
+            this.checkValidationErrors('link', err);
+        });
+    },
+
+    updateFile(file) {
+        $http.put(window.baseUrl(`/attachments/${file.id}`), file).then(resp => {
+            let search = this.filesIndex(resp.data);
+            if (search === -1) {
+                this.files.push(resp.data);
+            } else {
+                this.files.splice(search, 1, resp.data);
+            }
+
+            if (this.fileToEdit && !file.external) this.fileToEdit.link = '';
+            this.fileToEdit = false;
+
+            this.$events.emit('success', trans('entities.attachments_updated_success'));
+        }).catch(err => {
+            this.checkValidationErrors('edit', err);
+        });
+    },
+
+    filesIndex(file) {
+        for (let i = 0, len = this.files.length; i < len; i++) {
+            if (this.files[i].id === file.id) return i;
+        }
+        return -1;
+    }
+
+};
+
+module.exports = {
+    data, methods, mounted, components,
+};
\ No newline at end of file
index 9e3fa013eaadef83e6343421b183a83d0812795b..12ccc970d3917a2253b4a1d39513605b9494e5e9 100644 (file)
@@ -127,8 +127,6 @@ const methods = {
                     message += errors[key].join('\n');
                 });
                 this.$events.emit('error', message);
-            } else if (error.response.status === 403) {
-                this.$events.emit('error', error.response.data.error);
             }
         });
     },
@@ -144,8 +142,6 @@ const methods = {
         }).catch(error=> {
             if (error.response.status === 400) {
                 this.dependantPages = error.response.data;
-            } else if (error.response.status === 403) {
-                this.$events.emit('error', error.response.data.error);
             }
         });
     },
index a3f6ec8e540c678587133d19a05c197cde37bbf1..5f6f7d7a74e9348ca720af26fce9f73ca4c947e3 100644 (file)
@@ -10,14 +10,15 @@ let vueMapping = {
     'code-editor': require('./code-editor'),
     'image-manager': require('./image-manager'),
     'tag-manager': require('./tag-manager'),
+    'attachment-manager': require('./attachment-manager'),
 };
 
 window.vues = {};
 
-Object.keys(vueMapping).forEach(id => {
-    if (exists(id)) {
-        let config = vueMapping[id];
-        config.el = '#' + id;
-        window.vues[id] = new Vue(config);
-    }
-});
\ No newline at end of file
+let ids = Object.keys(vueMapping);
+for (let i = 0, len = ids.length; i < len; i++) {
+    if (!exists(ids[i])) continue;
+    let config = vueMapping[ids[i]];
+    config.el = '#' + ids[i];
+    window.vues[ids[i]] = new Vue(config);
+}
\ No newline at end of file
index 8092caa07992fd82c848d59fd8022dffe827e78a..525b4f8f1976a279bb4a4b7c6981374d039ecaa5 100644 (file)
@@ -512,7 +512,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
 }
 
 
-[tab-container] .nav-tabs {
+.tab-container .nav-tabs {
   text-align: left;
   border-bottom: 1px solid #DDD;
   margin-bottom: $-m;
index ea517fee3974f717887b51d6b025c834f5c5da6b..31ac92f602d5d1a09da73f5cd4acbbf31bf9cbeb 100644 (file)
@@ -59,18 +59,9 @@ table.list-table {
   }
 }
 
-table.file-table {
-  @extend .no-style;
-  td {
-    padding: $-xs;
-  }
-  .ui-sortable-helper {
-    display: table;
-  }
-}
-
 .fake-table {
   display: table;
+  width: 100%;
   > div {
     display: table-row-group;
   }
index 3bc03a17f3f62db0305208b5e8b94fd3ce7492fc..bd60af89a8fbf8cf8f93c144da001731263039f6 100644 (file)
@@ -14,8 +14,8 @@
         <div class="padded tags">
             <p class="muted small">{!! nl2br(e(trans('entities.tags_explain'))) !!}</p>
 
-            <draggable class="fake-table no-style tag-table" :options="{handle: '.handle'}" :list="tags" element="div" style="width: 100%;">
-                <transition-group name="test" tag="div">
+            <draggable class="fake-table no-style tag-table" :options="{handle: '.handle'}" :list="tags" element="div">
+                <transition-group tag="div">
                     <div v-for="(tag, i) in tags" :key="tag.key">
                         <div width="20" class="handle" ><i class="zmdi zmdi-menu"></i></div>
                         <div>
     </div>
 
     @if(userCan('attachment-create-all'))
-        <div toolbox-tab-content="files" ng-controller="PageAttachmentController" page-id="{{ $page->id or 0 }}">
+        <div toolbox-tab-content="files" id="attachment-manager" page-id="{{ $page->id or 0 }}">
             <h4>{{ trans('entities.attachments') }}</h4>
             <div class="padded files">
 
-                <div id="file-list" ng-show="!editFile">
+                <div id="file-list" v-show="!fileToEdit">
                     <p class="muted small">{{ trans('entities.attachments_explain') }} <span class="secondary">{{ trans('entities.attachments_explain_instant_save') }}</span></p>
 
-                    <div tab-container>
+                    <div class="tab-container">
                         <div class="nav-tabs">
-                            <div tab-button="list" class="tab-item">{{ trans('entities.attachments_items') }}</div>
-                            <div tab-button="file" class="tab-item">{{ trans('entities.attachments_upload') }}</div>
-                            <div tab-button="link" class="tab-item">{{ trans('entities.attachments_link') }}</div>
+                            <div @click="tab = 'list'" :class="{selected: tab === 'list'}" class="tab-item">{{ trans('entities.attachments_items') }}</div>
+                            <div @click="tab = 'file'" :class="{selected: tab === 'file'}" class="tab-item">{{ trans('entities.attachments_upload') }}</div>
+                            <div @click="tab = 'link'" :class="{selected: tab === 'link'}" class="tab-item">{{ trans('entities.attachments_link') }}</div>
                         </div>
-                        <div tab-content="list">
-                            <table class="file-table" style="width: 100%;">
-                                <tbody ui-sortable="sortOptions" ng-model="files" >
-                                <tr ng-repeat="file in files track by $index">
-                                    <td width="20" ><i class="handle zmdi zmdi-menu"></i></td>
-                                    <td>
-                                        <a ng-href="@{{getFileUrl(file)}}" target="_blank" ng-bind="file.name"></a>
-                                        <div ng-if="file.deleting">
+                        <div v-show="tab === 'list'">
+                            <draggable class="fake-table no-style " style="width: 100%;" :options="{handle: '.handle'}" @change="fileSortUpdate" :list="files" element="div">
+                                <transition-group tag="div">
+                                <div v-for="(file, index) in files" :key="file.id">
+                                    <div width="20" ><i class="handle zmdi zmdi-menu"></i></div>
+                                    <div>
+                                        <a :href="getFileUrl(file)" target="_blank" v-text="file.name"></a>
+                                        <div v-if="file.deleting">
                                             <span class="neg small">{{ trans('entities.attachments_delete_confirm') }}</span>
                                             <br>
-                                            <span class="text-primary small" ng-click="file.deleting=false;">{{ trans('common.cancel') }}</span>
+                                            <span class="text-primary small" @click="file.deleting = false;">{{ trans('common.cancel') }}</span>
                                         </div>
-                                    </td>
-                                    <td width="10" ng-click="startEdit(file)" class="text-center text-primary" style="padding: 0;"><i class="zmdi zmdi-edit"></i></td>
-                                    <td width="5"></td>
-                                    <td width="10" ng-click="deleteFile(file)" class="text-center text-neg" style="padding: 0;"><i class="zmdi zmdi-close"></i></td>
-                                </tr>
-                                </tbody>
-                            </table>
-                            <p class="small muted" ng-if="files.length == 0">
+                                    </div>
+                                    <div width="10" @click="startEdit(file)" class="text-center text-primary" style="padding: 0;"><i class="zmdi zmdi-edit"></i></div>
+                                    <div width="5"></div>
+                                    <div width="10" @click="deleteFile(file)" class="text-center text-neg" style="padding: 0;"><i class="zmdi zmdi-close"></i></div>
+                                </div>
+                                </transition-group>
+                            </draggable>
+                            <p class="small muted" v-if="files.length === 0">
                                 {{ trans('entities.attachments_no_files') }}
                             </p>
                         </div>
-                        <div tab-content="file">
-                            <drop-zone message="{{ trans('entities.attachments_dropzone') }}" upload-url="@{{getUploadUrl()}}" uploaded-to="@{{uploadedTo}}" event-success="uploadSuccess"></drop-zone>
+                        <div v-show="tab === 'file'">
+                            <dropzone placeholder="{{ trans('entities.attachments_dropzone') }}" :upload-url="getUploadUrl()" :uploaded-to="pageId" @success="uploadSuccess"></dropzone>
                         </div>
-                        <div tab-content="link" sub-form="attachLinkSubmit(file)">
+                        <div v-show="tab === 'link'" @keypress.enter.prevent="attachNewLink(file)">
                             <p class="muted small">{{ trans('entities.attachments_explain_link') }}</p>
                             <div class="form-group">
                                 <label for="attachment-via-link">{{ trans('entities.attachments_link_name') }}</label>
-                                <input placeholder="{{ trans('entities.attachments_link_name') }}" ng-model="file.name">
-                                <p class="small neg" ng-repeat="error in errors.link.name" ng-bind="error"></p>
+                                <input type="text" placeholder="{{ trans('entities.attachments_link_name') }}" v-model="file.name">
+                                <p class="small neg" v-for="error in errors.link.name" v-text="error"></p>
                             </div>
                             <div class="form-group">
                                 <label for="attachment-via-link">{{ trans('entities.attachments_link_url') }}</label>
-                                <input placeholder="{{ trans('entities.attachments_link_url_hint') }}" ng-model="file.link">
-                                <p class="small neg" ng-repeat="error in errors.link.link" ng-bind="error"></p>
+                                <input type="text"  placeholder="{{ trans('entities.attachments_link_url_hint') }}" v-model="file.link">
+                                <p class="small neg" v-for="error in errors.link.link" v-text="error"></p>
                             </div>
-                            <button class="button pos">{{ trans('entities.attach') }}</button>
+                            <button @click.prevent="attachNewLink(file)" class="button pos">{{ trans('entities.attach') }}</button>
 
                         </div>
                     </div>
 
                 </div>
 
-                <div id="file-edit" ng-if="editFile" sub-form="updateFile(editFile)">
+                <div id="file-edit" v-if="fileToEdit" @keypress.enter.prevent="updateFile(fileToEdit)">
                     <h5>{{ trans('entities.attachments_edit_file') }}</h5>
 
                     <div class="form-group">
                         <label for="attachment-name-edit">{{ trans('entities.attachments_edit_file_name') }}</label>
-                        <input type="text" id="attachment-name-edit" placeholder="{{ trans('entities.attachments_edit_file_name') }}" ng-model="editFile.name">
-                        <p class="small neg" ng-repeat="error in errors.edit.name" ng-bind="error"></p>
+                        <input type="text" id="attachment-name-edit" placeholder="{{ trans('entities.attachments_edit_file_name') }}" v-model="fileToEdit.name">
+                        <p class="small neg" v-for="error in errors.edit.name" v-text="error"></p>
                     </div>
 
-                    <div tab-container="@{{ editFile.external ? 'link' : 'file' }}">
+                    <div class="tab-container">
                         <div class="nav-tabs">
-                            <div tab-button="file" class="tab-item">{{ trans('entities.attachments_upload') }}</div>
-                            <div tab-button="link" class="tab-item">{{ trans('entities.attachments_set_link') }}</div>
+                            <div @click="editTab = 'file'" :class="{selected: editTab === 'file'}" class="tab-item">{{ trans('entities.attachments_upload') }}</div>
+                            <div @click="editTab = 'link'" :class="{selected: editTab === 'link'}" class="tab-item">{{ trans('entities.attachments_set_link') }}</div>
                         </div>
-                        <div tab-content="file">
-                            <drop-zone upload-url="@{{getUploadUrl(editFile)}}" uploaded-to="@{{uploadedTo}}" placeholder="{{ trans('entities.attachments_edit_drop_upload') }}" event-success="uploadSuccessUpdate"></drop-zone>
+                        <div v-if="editTab === 'file'">
+                            <dropzone :upload-url="getUploadUrl(fileToEdit)" :uploaded-to="pageId" placeholder="{{ trans('entities.attachments_edit_drop_upload') }}" @success="uploadSuccessUpdate"></dropzone>
                             <br>
                         </div>
-                        <div tab-content="link">
+                        <div v-if="editTab === 'link'">
                             <div class="form-group">
                                 <label for="attachment-link-edit">{{ trans('entities.attachments_link_url') }}</label>
-                                <input id="attachment-link-edit" placeholder="{{ trans('entities.attachment_link') }}" ng-model="editFile.link">
-                                <p class="small neg" ng-repeat="error in errors.edit.link" ng-bind="error"></p>
+                                <input type="text" id="attachment-link-edit" placeholder="{{ trans('entities.attachment_link') }}" v-model="fileToEdit.link">
+                                <p class="small neg" v-for="error in errors.edit.link" v-text="error"></p>
                             </div>
                         </div>
                     </div>
 
-                    <button type="button" class="button" ng-click="cancelEdit()">{{ trans('common.back') }}</button>
-                    <button class="button pos">{{ trans('common.save') }}</button>
+                    <button type="button" class="button" @click="cancelEdit">{{ trans('common.back') }}</button>
+                    <button @click.enter.prevent="updateFile(fileToEdit)" class="button pos">{{ trans('common.save') }}</button>
                 </div>
 
             </div>