Started migration of attachment manager from vue

- Created new dropzone component.
- Added standard component event system using custom DOM events.
- Added tabs component.
- Added ajax-delete-row component.
This commit is contained in:
Dan Brown 2020-06-30 22:12:45 +01:00
parent 8dc9689c6d
commit 14b6cd1091
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
15 changed files with 315 additions and 72 deletions

View 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')]);
}

View 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;

View 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]);
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View 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
*/

View 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()});
}
});
}
}

View File

@ -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;

View 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.',

View File

@ -126,6 +126,10 @@ body.flexbox {
flex: 1;
}
.justify-flex-end {
justify-content: flex-end;
}
/**
* Display and float utilities

View File

@ -66,4 +66,6 @@
</div>
</div>
@include('pages.attachment-manager', ['page' => \BookStack\Entities\Page::first()])
@stop

View File

@ -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>

View File

@ -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>

View File

@ -1,101 +1,91 @@
<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>