- Created new dropzone component.
- Added standard component event system using custom DOM events.
- Added tabs component.
- Added ajax-delete-row component.
{
$page = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-view', $page);
- return response()->json($page->attachments);
+ return view('pages.attachment-list', [
+ 'attachments' => $page->attachments->all(),
+ ]);
}
/**
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')]);
}
/**
* 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;
}
/**
- * 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]);
}
}
--- /dev/null
+/**
+ * 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
--- /dev/null
+
+/**
+ * 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
--- /dev/null
+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
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();
}
* @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
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()});
+ }
});
}
}
--- /dev/null
+/**
+ * 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
'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.',
flex: 1;
}
+.justify-flex-end {
+ justify-content: flex-end;
+}
+
/**
* Display and float utilities
</div>
</div>
+ @include('pages.attachment-manager', ['page' => \BookStack\Entities\Page::first()])
+
@stop
--- /dev/null
+{{--
+@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
--- /dev/null
+<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
-<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>