]> BookStack Code Mirror - bookstack/commitdiff
Started migration of attachment manager from vue
authorDan Brown <redacted>
Tue, 30 Jun 2020 21:12:45 +0000 (22:12 +0100)
committerDan Brown <redacted>
Tue, 30 Jun 2020 21:12:45 +0000 (22:12 +0100)
- Created new dropzone component.
- Added standard component event system using custom DOM events.
- Added tabs component.
- Added ajax-delete-row component.

15 files changed:
app/Http/Controllers/AttachmentController.php
app/Uploads/Attachment.php
app/Uploads/AttachmentService.php
resources/js/components/ajax-delete-row.js [new file with mode: 0644]
resources/js/components/attachments.js [new file with mode: 0644]
resources/js/components/dropzone.js [new file with mode: 0644]
resources/js/components/index.js
resources/js/components/sortable-list.js
resources/js/components/tabs.js [new file with mode: 0644]
resources/lang/en/entities.php
resources/sass/_layout.scss
resources/views/common/home.blade.php
resources/views/components/dropzone.blade.php [new file with mode: 0644]
resources/views/pages/attachment-list.blade.php [new file with mode: 0644]
resources/views/pages/attachment-manager.blade.php

index 8f5da49ed83c10c5979b9e4b8eaa74ac324e14b9..f209d6a9418d7aba98437f71033957a08dbe39c1 100644 (file)
@@ -152,7 +152,9 @@ class AttachmentController extends Controller
     {
         $page = $this->pageRepo->getById($pageId);
         $this->checkOwnablePermission('page-view', $page);
-        return response()->json($page->attachments);
+        return view('pages.attachment-list', [
+            'attachments' => $page->attachments->all(),
+        ]);
     }
 
     /**
@@ -163,14 +165,13 @@ class AttachmentController extends Controller
     public function sortForPage(Request $request, int $pageId)
     {
         $this->validate($request, [
-            'files' => 'required|array',
-            'files.*.id' => 'required|integer',
+            'order' => 'required|array',
         ]);
         $page = $this->pageRepo->getById($pageId);
         $this->checkOwnablePermission('page-update', $page);
 
-        $attachments = $request->get('files');
-        $this->attachmentService->updateFileOrderWithinPage($attachments, $pageId);
+        $attachmentOrder = $request->get('order');
+        $this->attachmentService->updateFileOrderWithinPage($attachmentOrder, $pageId);
         return response()->json(['message' => trans('entities.attachments_order_updated')]);
     }
 
index 3f0b447df7388a573602396d052bce2e5c5bca7a..6e55003a96b5bbf1dceb4731bcce6440aff5a30d 100644 (file)
@@ -30,9 +30,8 @@ class Attachment extends Ownable
 
     /**
      * Get the url of this file.
-     * @return string
      */
-    public function getUrl()
+    public function getUrl(): string
     {
         if ($this->external && strpos($this->path, 'http') !== 0) {
             return $this->path;
index ae4fb6e967160787e1c46dde0e442228386f9af9..02220771aaee631e1d7bd0643d865d9196932b3e 100644 (file)
@@ -109,14 +109,14 @@ class AttachmentService extends UploadService
     }
 
     /**
-     * Updates the file ordering for a listing of attached files.
-     * @param array $attachmentList
-     * @param $pageId
+     * Updates the ordering for a listing of attached files.
      */
-    public function updateFileOrderWithinPage($attachmentList, $pageId)
+    public function updateFileOrderWithinPage(array $attachmentOrder, string $pageId)
     {
-        foreach ($attachmentList as $index => $attachment) {
-            Attachment::where('uploaded_to', '=', $pageId)->where('id', '=', $attachment['id'])->update(['order' => $index]);
+        foreach ($attachmentOrder as $index => $attachmentId) {
+            Attachment::query()->where('uploaded_to', '=', $pageId)
+                ->where('id', '=', $attachmentId)
+                ->update(['order' => $index]);
         }
     }
 
diff --git a/resources/js/components/ajax-delete-row.js b/resources/js/components/ajax-delete-row.js
new file mode 100644 (file)
index 0000000..2feb3d5
--- /dev/null
@@ -0,0 +1,32 @@
+/**
+ * AjaxDelete
+ * @extends {Component}
+ */
+import {onSelect} from "../services/dom";
+
+class AjaxDeleteRow {
+    setup() {
+        this.row = this.$el;
+        this.url = this.$opts.url;
+        this.deleteButtons = this.$manyRefs.delete;
+
+        onSelect(this.deleteButtons, this.runDelete.bind(this));
+    }
+
+    runDelete() {
+        this.row.style.opacity = '0.7';
+        this.row.style.pointerEvents = 'none';
+
+        window.$http.delete(this.url).then(resp => {
+            if (typeof resp.data === 'object' && resp.data.message) {
+                window.$events.emit('success', resp.data.message);
+            }
+            this.row.remove();
+        }).catch(err => {
+            this.row.style.opacity = null;
+            this.row.style.pointerEvents = null;
+        });
+    }
+}
+
+export default AjaxDeleteRow;
\ No newline at end of file
diff --git a/resources/js/components/attachments.js b/resources/js/components/attachments.js
new file mode 100644 (file)
index 0000000..49ba8f3
--- /dev/null
@@ -0,0 +1,46 @@
+
+/**
+ * Attachments
+ * @extends {Component}
+ */
+class Attachments {
+
+    setup() {
+        this.container = this.$el;
+        this.pageId = this.$opts.pageId;
+        this.editContainer = this.$refs.editContainer;
+        this.mainTabs = this.$refs.mainTabs;
+        this.list = this.$refs.list;
+
+        this.setupListeners();
+    }
+
+    setupListeners() {
+        this.container.addEventListener('dropzone-success', event => {
+            this.mainTabs.components.tabs.show('items');
+            window.$http.get(`/attachments/get/page/${this.pageId}`).then(resp => {
+                this.list.innerHTML = resp.data;
+                window.components.init(this.list);
+            })
+        });
+
+        this.container.addEventListener('sortable-list-sort', event => {
+            this.updateOrder(event.detail.ids);
+        });
+
+        this.editContainer.addEventListener('keypress', event => {
+            if (event.key === 'Enter') {
+                // TODO - Update editing file
+            }
+        })
+    }
+
+    updateOrder(idOrder) {
+        window.$http.put(`/attachments/sort/page/${this.pageId}`, {order: idOrder}).then(resp => {
+            window.$events.emit('success', resp.data.message);
+        });
+    }
+
+}
+
+export default Attachments;
\ No newline at end of file
diff --git a/resources/js/components/dropzone.js b/resources/js/components/dropzone.js
new file mode 100644 (file)
index 0000000..4b12867
--- /dev/null
@@ -0,0 +1,69 @@
+import DropZoneLib from "dropzone";
+import {fadeOut} from "../services/animations";
+
+/**
+ * Dropzone
+ * @extends {Component}
+ */
+class Dropzone {
+    setup() {
+        this.container = this.$el;
+        this.url = this.$opts.url;
+
+        const _this = this;
+        this.dz = new DropZoneLib(this.container, {
+            addRemoveLinks: true,
+            dictRemoveFile: window.trans('components.image_upload_remove'),
+            timeout: Number(window.uploadTimeout) || 60000,
+            maxFilesize: Number(window.uploadLimit) || 256,
+            url: this.url,
+            withCredentials: true,
+            init() {
+                this.dz = this;
+                this.dz.on('sending', _this.onSending.bind(_this));
+                this.dz.on('success', _this.onSuccess.bind(_this));
+                this.dz.on('error', _this.onError.bind(_this));
+            }
+        });
+    }
+
+    onSending(file, xhr, data) {
+
+        const token = window.document.querySelector('meta[name=token]').getAttribute('content');
+        data.append('_token', token);
+
+        xhr.ontimeout = function (e) {
+            this.dz.emit('complete', file);
+            this.dz.emit('error', file, window.trans('errors.file_upload_timeout'));
+        }
+    }
+
+    onSuccess(file, data) {
+        this.container.dispatchEvent(new Event('dropzone'))
+        this.$emit('success', {file, data});
+        fadeOut(file.previewElement, 800, () => {
+            this.dz.removeFile(file);
+        });
+    }
+
+    onError(file, errorMessage, xhr) {
+        this.$emit('error', {file, errorMessage, xhr});
+
+        const setMessage = (message) => {
+            const messsageEl = file.previewElement.querySelector('[data-dz-errormessage]');
+            messsageEl.textContent = message;
+        }
+
+        if (xhr && xhr.status === 413) {
+            setMessage(window.trans('errors.server_upload_limit'))
+        } else if (errorMessage.file) {
+            setMessage(errorMessage.file);
+        }
+    }
+
+    removeAll() {
+        this.dz.removeAllFiles(true);
+    }
+}
+
+export default Dropzone;
\ No newline at end of file
index 68f97b2800d7b11fba5910b227d82ecc34774a89..4908dcd730937d7884a7aaead8b8157522d62ba9 100644 (file)
@@ -40,6 +40,14 @@ function initComponent(name, element) {
         instance.$refs = allRefs.refs;
         instance.$manyRefs = allRefs.manyRefs;
         instance.$opts = parseOpts(name, element);
+        instance.$emit = (eventName, data = {}) => {
+            data.from = instance;
+            const event = new CustomEvent(`${name}-${eventName}`, {
+                bubbles: true,
+                detail: data
+            });
+            instance.$el.dispatchEvent(event);
+        };
         if (typeof instance.setup === 'function') {
             instance.setup();
         }
@@ -158,4 +166,5 @@ export default initAll;
  * @property {Object<String, HTMLElement>} $refs
  * @property {Object<String, HTMLElement[]>} $manyRefs
  * @property {Object<String, String>} $opts
+ * @property {function(string, Object)} $emit
  */
\ No newline at end of file
index 6efcb4e84f4841a40b184bbed2aceb01a5f40840..d2b39ff95987006fe9e7dd8e5a8e0892ced2aefd 100644 (file)
@@ -9,9 +9,12 @@ class SortableList {
         this.container = this.$el;
         this.handleSelector = this.$opts.handleSelector;
 
-        new Sortable(this.container, {
+        const sortable = new Sortable(this.container, {
             handle: this.handleSelector,
             animation: 150,
+            onSort: () => {
+                this.$emit('sort', {ids: sortable.toArray()});
+            }
         });
     }
 }
diff --git a/resources/js/components/tabs.js b/resources/js/components/tabs.js
new file mode 100644 (file)
index 0000000..7121d70
--- /dev/null
@@ -0,0 +1,51 @@
+/**
+ * Tabs
+ * Works by matching 'tabToggle<Key>' with 'tabContent<Key>' sections.
+ * @extends {Component}
+ */
+import {onSelect} from "../services/dom";
+
+class Tabs {
+
+    setup() {
+        this.tabContentsByName = {};
+        this.tabButtonsByName = {};
+        this.allContents = [];
+        this.allButtons = [];
+
+        for (const [key, elems] of Object.entries(this.$manyRefs || {})) {
+            if (key.startsWith('toggle')) {
+                const cleanKey = key.replace('toggle', '').toLowerCase();
+                onSelect(elems, e => this.show(cleanKey));
+                this.allButtons.push(...elems);
+                this.tabButtonsByName[cleanKey] = elems;
+            }
+            if (key.startsWith('content')) {
+                const cleanKey = key.replace('content', '').toLowerCase();
+                this.tabContentsByName[cleanKey] = elems;
+                this.allContents.push(...elems);
+            }
+        }
+    }
+
+    show(key) {
+        this.allContents.forEach(c => {
+            c.classList.add('hidden');
+            c.classList.remove('selected');
+        });
+        this.allButtons.forEach(b => b.classList.remove('selected'));
+
+        const contents = this.tabContentsByName[key] || [];
+        const buttons = this.tabButtonsByName[key] || [];
+        if (contents.length > 0) {
+            contents.forEach(c => {
+                c.classList.remove('hidden')
+                c.classList.add('selected')
+            });
+            buttons.forEach(b => b.classList.add('selected'));
+        }
+    }
+
+}
+
+export default Tabs;
\ No newline at end of file
index bb5c0078dcb959274e464a02783a686733d23b88..ac17a10d4e9cd78a66e7f0023273989031930422 100644 (file)
@@ -256,7 +256,7 @@ return [
     'attachments_upload' => 'Upload File',
     'attachments_link' => 'Attach Link',
     'attachments_set_link' => 'Set Link',
-    'attachments_delete_confirm' => 'Click delete again to confirm you want to delete this attachment.',
+    'attachments_delete' => 'Are you sure you want to delete this attachment?',
     'attachments_dropzone' => 'Drop files or click here to attach a file',
     'attachments_no_files' => 'No files have been uploaded',
     'attachments_explain_link' => 'You can attach a link if you\'d prefer not to upload a file. This can be a link to another page or a link to a file in the cloud.',
index 226f5ccdb8f210abf45662253cc5f16b6ad1444a..b6968afc6f5479d256a907bdf459ec0748c8981b 100644 (file)
@@ -126,6 +126,10 @@ body.flexbox {
   flex: 1;
 }
 
+.justify-flex-end {
+  justify-content: flex-end;
+}
+
 
 /**
  * Display and float utilities
index 2631f1a57ed878b01ad5099f7a539e07169eba58..2464b019bf307be45ffeb7777f8b98495aa06633 100644 (file)
@@ -66,4 +66,6 @@
         </div>
     </div>
 
+    @include('pages.attachment-manager', ['page' => \BookStack\Entities\Page::first()])
+
 @stop
diff --git a/resources/views/components/dropzone.blade.php b/resources/views/components/dropzone.blade.php
new file mode 100644 (file)
index 0000000..22bf8af
--- /dev/null
@@ -0,0 +1,9 @@
+{{--
+@url - URL to upload to.
+@placeholder - Placeholder text
+--}}
+<div component="dropzone"
+     option:dropzone:url="{{ $url }}"
+     class="dropzone-container text-center">
+    <button type="button" class="dz-message">{{ $placeholder }}</button>
+</div>
\ No newline at end of file
diff --git a/resources/views/pages/attachment-list.blade.php b/resources/views/pages/attachment-list.blade.php
new file mode 100644 (file)
index 0000000..a980187
--- /dev/null
@@ -0,0 +1,28 @@
+<div component="sortable-list" option:sortable-list:handle-selector=".handle">
+    @foreach($attachments as $attachment)
+        <div component="ajax-delete-row"
+             option:ajax-delete-row:url="{{ url('/attachments/' . $attachment->id) }}"
+             data-id="{{ $attachment->id }}"
+             class="card drag-card">
+            <div class="handle">@icon('grip')</div>
+            <div class="py-s">
+                <a href="{{ $attachment->getUrl() }}" target="_blank">{{ $attachment->name }}</a>
+            </div>
+            <div class="flex-fill justify-flex-end">
+                <button type="button" class="drag-card-action text-center text-primary">@icon('edit')</button>
+                <div component="dropdown" class="flex-fill relative">
+                    <button refs="dropdown@toggle" type="button" class="drag-card-action text-center text-neg">@icon('close')</button>
+                    <div refs="dropdown@menu" class="dropdown-menu">
+                        <p class="text-neg small px-m mb-xs">{{ trans('entities.attachments_delete') }}</p>
+                        <button refs="ajax-delete-row@delete" type="button" class="text-primary small delete">{{ trans('common.confirm') }}</button>
+                    </div>
+                </div>
+            </div>
+        </div>
+    @endforeach
+    @if (count($attachments) === 0)
+        <p class="small text-muted">
+            {{ trans('entities.attachments_no_files') }}
+        </p>
+    @endif
+</div>
\ No newline at end of file
index dd00678043c065602adfb58e8960fe357bed9b28..a86cd70dae29b729924499697aad36528243369a 100644 (file)
-<div toolbox-tab-content="files" id="attachment-manager" page-id="{{ $page->id ?? 0 }}">
+<div style="display: block;" toolbox-tab-content="files"
+     component="attachments"
+     option:attachments:page-id="{{ $page->id ?? 0 }}">
 
     @exposeTranslations([
-    'entities.attachments_file_uploaded',
-    'entities.attachments_file_updated',
-    'entities.attachments_link_attached',
-    'entities.attachments_updated_success',
-    'errors.server_upload_limit',
-    'components.image_upload_remove',
-    'components.file_upload_timeout',
+        'entities.attachments_file_uploaded',
+        'entities.attachments_file_updated',
+        'entities.attachments_link_attached',
+        'entities.attachments_updated_success',
+        'errors.server_upload_limit',
+        'components.image_upload_remove',
+        'components.file_upload_timeout',
     ])
 
     <h4>{{ trans('entities.attachments') }}</h4>
     <div class="px-l files">
 
-        <div id="file-list" v-show="!fileToEdit">
+        <div id="file-list">
             <p class="text-muted small">{{ trans('entities.attachments_explain') }} <span class="text-warn">{{ trans('entities.attachments_explain_instant_save') }}</span></p>
 
-            <div class="tab-container">
+            <div component="tabs" refs="attachments@mainTabs" class="tab-container">
                 <div class="nav-tabs">
-                    <button type="button" @click="tab = 'list'" :class="{selected: tab === 'list'}"
-                            class="tab-item">{{ trans('entities.attachments_items') }}</button>
-                    <button type="button" @click="tab = 'file'" :class="{selected: tab === 'file'}"
-                            class="tab-item">{{ trans('entities.attachments_upload') }}</button>
-                    <button type="button" @click="tab = 'link'" :class="{selected: tab === 'link'}"
-                            class="tab-item">{{ trans('entities.attachments_link') }}</button>
+                    <button refs="tabs@toggleItems" type="button" class="selected tab-item">{{ trans('entities.attachments_items') }}</button>
+                    <button refs="tabs@toggleUpload" type="button" class="tab-item">{{ trans('entities.attachments_upload') }}</button>
+                    <button refs="tabs@toggleLinks" type="button" class="tab-item">{{ trans('entities.attachments_link') }}</button>
                 </div>
-                <div v-show="tab === 'list'">
-                    <draggable style="width: 100%;" :options="{handle: '.handle'}" @change="fileSortUpdate" :list="files" element="div">
-                        <div v-for="(file, index) in files" :key="file.id" class="card drag-card">
-                            <div class="handle">@icon('grip')</div>
-                            <div class="py-s">
-                                <a :href="getFileUrl(file)" target="_blank" v-text="file.name"></a>
-                                <div v-if="file.deleting">
-                                    <span class="text-neg small">{{ trans('entities.attachments_delete_confirm') }}</span>
-                                    <br>
-                                    <button type="button" class="text-primary small" @click="file.deleting = false;">{{ trans('common.cancel') }}</button>
-                                </div>
-                            </div>
-                            <button type="button" @click="startEdit(file)" class="drag-card-action text-center text-primary">@icon('edit')</button>
-                            <button type="button" @click="deleteFile(file)" class="drag-card-action text-center text-neg">@icon('close')</button>
-                        </div>
-                    </draggable>
-                    <p class="small text-muted" v-if="files.length === 0">
-                        {{ trans('entities.attachments_no_files') }}
-                    </p>
+                <div refs="tabs@contentItems attachments@list">
+                    @include('pages.attachment-list', ['attachments' => $page->attachments->all()])
                 </div>
-                <div v-show="tab === 'file'">
-                    <dropzone placeholder="{{ trans('entities.attachments_dropzone') }}" :upload-url="getUploadUrl()" :uploaded-to="pageId" @success="uploadSuccess"></dropzone>
+                <div refs="tabs@contentUpload" class="hiden">
+                    @include('components.dropzone', [
+                        'placeholder' => trans('entities.attachments_dropzone'),
+                        'url' =>  url('/attachments/upload?uploaded_to=' . $page->id)
+                    ])
                 </div>
-                <div v-show="tab === 'link'" @keypress.enter.prevent="attachNewLink(file)">
+                <div refs="tabs@contentLinks" class="hidden">
                     <p class="text-muted small">{{ trans('entities.attachments_explain_link') }}</p>
                     <div class="form-group">
-                        <label for="attachment-via-link">{{ trans('entities.attachments_link_name') }}</label>
-                        <input type="text" placeholder="{{ trans('entities.attachments_link_name') }}" v-model="file.name">
-                        <p class="small text-neg" v-for="error in errors.link.name" v-text="error"></p>
+                        <label for="attachment_link_name">{{ trans('entities.attachments_link_name') }}</label>
+                        <input name="attachment_link_name" id="attachment_link_name" type="text" placeholder="{{ trans('entities.attachments_link_name') }}">
+                        <p class="small text-neg"></p>
                     </div>
                     <div class="form-group">
-                        <label for="attachment-via-link">{{ trans('entities.attachments_link_url') }}</label>
-                        <input type="text"  placeholder="{{ trans('entities.attachments_link_url_hint') }}" v-model="file.link">
-                        <p class="small text-neg" v-for="error in errors.link.link" v-text="error"></p>
+                        <label for="attachment_link_url">{{ trans('entities.attachments_link_url') }}</label>
+                        <input name="attachment_link_url" id="attachment_link_url" type="text" placeholder="{{ trans('entities.attachments_link_url_hint') }}">
+                        <p class="small text-neg"></p>
                     </div>
-                    <button @click.prevent="attachNewLink(file)" class="button">{{ trans('entities.attach') }}</button>
-
+                    <button class="button">{{ trans('entities.attach') }}</button>
                 </div>
             </div>
 
         </div>
 
-        <div id="file-edit" v-if="fileToEdit" @keypress.enter.prevent="updateFile(fileToEdit)">
+        <div refs="attachments@editContainer" class="hidden">
             <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') }}" v-model="fileToEdit.name">
-                <p class="small text-neg" v-for="error in errors.edit.name" v-text="error"></p>
+                <input type="text" id="attachment-name-edit"
+                       name="attachment_name"
+                       placeholder="{{ trans('entities.attachments_edit_file_name') }}">
+                <p class="small text-neg"></p>
             </div>
 
-            <div class="tab-container">
+            <div component="tabs" class="tab-container">
                 <div class="nav-tabs">
-                    <button type="button" @click="editTab = 'file'" :class="{selected: editTab === 'file'}" class="tab-item">{{ trans('entities.attachments_upload') }}</button>
-                    <button type="button" @click="editTab = 'link'" :class="{selected: editTab === 'link'}" class="tab-item">{{ trans('entities.attachments_set_link') }}</button>
+                    <button refs="tabs@toggleFile" type="button" class="tab-item selected">{{ trans('entities.attachments_upload') }}</button>
+                    <button refs="tabs@toggleLink" type="button" class="tab-item">{{ trans('entities.attachments_set_link') }}</button>
                 </div>
-                <div v-if="editTab === 'file'">
+                <div refs="tabs@contentFile">
+                    @include('components.dropzone', [
+                        'placeholder' => trans('entities.attachments_edit_drop_upload'),
+                        'url' =>  url('/attachments')
+                    ])
                     <dropzone :upload-url="getUploadUrl(fileToEdit)" :uploaded-to="pageId" placeholder="{{ trans('entities.attachments_edit_drop_upload') }}" @success="uploadSuccessUpdate"></dropzone>
                     <br>
                 </div>
-                <div v-if="editTab === 'link'">
+                <div refs="tabs@contentLink" class="hidden">
                     <div class="form-group">
                         <label for="attachment-link-edit">{{ trans('entities.attachments_link_url') }}</label>
                         <input type="text" id="attachment-link-edit" placeholder="{{ trans('entities.attachment_link') }}" v-model="fileToEdit.link">
-                        <p class="small text-neg" v-for="error in errors.edit.link" v-text="error"></p>
+                        <p class="small text-neg"></p>
                     </div>
                 </div>
             </div>
 
-            <button type="button" class="button outline" @click="cancelEdit">{{ trans('common.back') }}</button>
-            <button @click.enter.prevent="updateFile(fileToEdit)" class="button">{{ trans('common.save') }}</button>
+            <button type="button" class="button outline">{{ trans('common.back') }}</button>
+            <button class="button">{{ trans('common.save') }}</button>
         </div>
 
     </div>