1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-07-31 15:24:31 +03:00

Merge branch 'master' into attachment_drag_drop

This commit is contained in:
Dan Brown
2020-09-13 16:33:31 +01:00
47 changed files with 923 additions and 3784 deletions

View File

@ -1,12 +1,106 @@
const componentMapping = {};
import addRemoveRows from "./add-remove-rows.js"
import ajaxDeleteRow from "./ajax-delete-row.js"
import ajaxForm from "./ajax-form.js"
import attachments from "./attachments.js"
import autoSuggest from "./auto-suggest.js"
import backToTop from "./back-to-top.js"
import bookSort from "./book-sort.js"
import breadcrumbListing from "./breadcrumb-listing.js"
import chapterToggle from "./chapter-toggle.js"
import codeEditor from "./code-editor.js"
import codeHighlighter from "./code-highlighter.js"
import collapsible from "./collapsible.js"
import customCheckbox from "./custom-checkbox.js"
import detailsHighlighter from "./details-highlighter.js"
import dropdown from "./dropdown.js"
import dropzone from "./dropzone.js"
import editorToolbox from "./editor-toolbox.js"
import entityPermissionsEditor from "./entity-permissions-editor.js"
import entitySearch from "./entity-search.js"
import entitySelector from "./entity-selector.js"
import entitySelectorPopup from "./entity-selector-popup.js"
import eventEmitSelect from "./event-emit-select.js"
import expandToggle from "./expand-toggle.js"
import headerMobileToggle from "./header-mobile-toggle.js"
import homepageControl from "./homepage-control.js"
import imageManager from "./image-manager.js"
import imagePicker from "./image-picker.js"
import index from "./index.js"
import listSortControl from "./list-sort-control.js"
import markdownEditor from "./markdown-editor.js"
import newUserPassword from "./new-user-password.js"
import notification from "./notification.js"
import optionalInput from "./optional-input.js"
import pageComments from "./page-comments.js"
import pageDisplay from "./page-display.js"
import pageEditor from "./page-editor.js"
import pagePicker from "./page-picker.js"
import permissionsTable from "./permissions-table.js"
import popup from "./popup.js"
import settingAppColorPicker from "./setting-app-color-picker.js"
import settingColorPicker from "./setting-color-picker.js"
import shelfSort from "./shelf-sort.js"
import sidebar from "./sidebar.js"
import sortableList from "./sortable-list.js"
import tabs from "./tabs.js"
import tagManager from "./tag-manager.js"
import templateManager from "./template-manager.js"
import toggleSwitch from "./toggle-switch.js"
import triLayout from "./tri-layout.js"
import wysiwygEditor from "./wysiwyg-editor.js"
const definitionFiles = require.context('./', false, /\.js$/);
for (const fileName of definitionFiles.keys()) {
const name = fileName.replace('./', '').split('.')[0];
if (name !== 'index') {
componentMapping[name] = definitionFiles(fileName).default;
}
}
const componentMapping = {
"add-remove-rows": addRemoveRows,
"ajax-delete-row": ajaxDeleteRow,
"ajax-form": ajaxForm,
"attachments": attachments,
"auto-suggest": autoSuggest,
"back-to-top": backToTop,
"book-sort": bookSort,
"breadcrumb-listing": breadcrumbListing,
"chapter-toggle": chapterToggle,
"code-editor": codeEditor,
"code-highlighter": codeHighlighter,
"collapsible": collapsible,
"custom-checkbox": customCheckbox,
"details-highlighter": detailsHighlighter,
"dropdown": dropdown,
"dropzone": dropzone,
"editor-toolbox": editorToolbox,
"entity-permissions-editor": entityPermissionsEditor,
"entity-search": entitySearch,
"entity-selector": entitySelector,
"entity-selector-popup": entitySelectorPopup,
"event-emit-select": eventEmitSelect,
"expand-toggle": expandToggle,
"header-mobile-toggle": headerMobileToggle,
"homepage-control": homepageControl,
"image-manager": imageManager,
"image-picker": imagePicker,
"index": index,
"list-sort-control": listSortControl,
"markdown-editor": markdownEditor,
"new-user-password": newUserPassword,
"notification": notification,
"optional-input": optionalInput,
"page-comments": pageComments,
"page-display": pageDisplay,
"page-editor": pageEditor,
"page-picker": pagePicker,
"permissions-table": permissionsTable,
"popup": popup,
"setting-app-color-picker": settingAppColorPicker,
"setting-color-picker": settingColorPicker,
"shelf-sort": shelfSort,
"sidebar": sidebar,
"sortable-list": sortableList,
"tabs": tabs,
"tag-manager": tagManager,
"template-manager": templateManager,
"toggle-switch": toggleSwitch,
"tri-layout": triLayout,
"wysiwyg-editor": wysiwygEditor,
};
window.components = {};

View File

@ -1,16 +1,31 @@
import {scrollAndHighlightElement} from "../services/util";
/**
* @extends {Component}
*/
class PageComments {
constructor(elem) {
this.elem = elem;
this.pageId = Number(elem.getAttribute('page-id'));
setup() {
this.elem = this.$el;
this.pageId = Number(this.$opts.pageId);
// Element references
this.container = this.$refs.commentContainer;
this.formContainer = this.$refs.formContainer;
this.commentCountBar = this.$refs.commentCountBar;
this.addButtonContainer = this.$refs.addButtonContainer;
this.replyToRow = this.$refs.replyToRow;
// Translations
this.updatedText = this.$opts.updatedText;
this.deletedText = this.$opts.deletedText;
this.createdText = this.$opts.createdText;
this.countText = this.$opts.countText;
// Internal State
this.editingComment = null;
this.parentId = null;
this.container = elem.querySelector('[comment-container]');
this.formContainer = elem.querySelector('[comment-form-container]');
if (this.formContainer) {
this.form = this.formContainer.querySelector('form');
this.formInput = this.form.querySelector('textarea');
@ -32,13 +47,14 @@ class PageComments {
if (actionElem === null) return;
event.preventDefault();
let action = actionElem.getAttribute('action');
if (action === 'edit') this.editComment(actionElem.closest('[comment]'));
const action = actionElem.getAttribute('action');
const comment = actionElem.closest('[comment]');
if (action === 'edit') this.editComment(comment);
if (action === 'closeUpdateForm') this.closeUpdateForm();
if (action === 'delete') this.deleteComment(actionElem.closest('[comment]'));
if (action === 'delete') this.deleteComment(comment);
if (action === 'addComment') this.showForm();
if (action === 'hideForm') this.hideForm();
if (action === 'reply') this.setReply(actionElem.closest('[comment]'));
if (action === 'reply') this.setReply(comment);
if (action === 'remove-reply-to') this.removeReplyTo();
}
@ -69,14 +85,15 @@ class PageComments {
};
this.showLoading(form);
let commentId = this.editingComment.getAttribute('comment');
window.$http.put(`/ajax/comment/${commentId}`, reqData).then(resp => {
window.$http.put(`/comment/${commentId}`, reqData).then(resp => {
let newComment = document.createElement('div');
newComment.innerHTML = resp.data;
this.editingComment.innerHTML = newComment.children[0].innerHTML;
window.$events.emit('success', window.trans('entities.comment_updated_success'));
window.$events.success(this.updatedText);
window.components.init(this.editingComment);
this.closeUpdateForm();
this.editingComment = null;
}).catch(window.$events.showValidationErrors).then(() => {
this.hideLoading(form);
});
}
@ -84,9 +101,9 @@ class PageComments {
deleteComment(commentElem) {
let id = commentElem.getAttribute('comment');
this.showLoading(commentElem.querySelector('[comment-content]'));
window.$http.delete(`/ajax/comment/${id}`).then(resp => {
window.$http.delete(`/comment/${id}`).then(resp => {
commentElem.parentNode.removeChild(commentElem);
window.$events.emit('success', window.trans('entities.comment_deleted_success'));
window.$events.success(this.deletedText);
this.updateCount();
this.hideForm();
});
@ -101,21 +118,24 @@ class PageComments {
parent_id: this.parentId || null,
};
this.showLoading(this.form);
window.$http.post(`/ajax/page/${this.pageId}/comment`, reqData).then(resp => {
window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => {
let newComment = document.createElement('div');
newComment.innerHTML = resp.data;
let newElem = newComment.children[0];
this.container.appendChild(newElem);
window.components.init(newElem);
window.$events.emit('success', window.trans('entities.comment_created_success'));
window.$events.success(this.createdText);
this.resetForm();
this.updateCount();
}).catch(err => {
window.$events.showValidationErrors(err);
this.hideLoading(this.form);
});
}
updateCount() {
let count = this.container.children.length;
this.elem.querySelector('[comments-title]').textContent = window.trans_choice('entities.comment_count', count, {count});
this.elem.querySelector('[comments-title]').textContent = window.trans_plural(this.countText, count, {count});
}
resetForm() {
@ -129,7 +149,7 @@ class PageComments {
showForm() {
this.formContainer.style.display = 'block';
this.formContainer.parentNode.style.display = 'block';
this.elem.querySelector('[comment-add-button-container]').style.display = 'none';
this.addButtonContainer.style.display = 'none';
this.formInput.focus();
this.formInput.scrollIntoView({behavior: "smooth"});
}
@ -137,14 +157,12 @@ class PageComments {
hideForm() {
this.formContainer.style.display = 'none';
this.formContainer.parentNode.style.display = 'none';
const addButtonContainer = this.elem.querySelector('[comment-add-button-container]');
if (this.getCommentCount() > 0) {
this.elem.appendChild(addButtonContainer)
this.elem.appendChild(this.addButtonContainer)
} else {
const countBar = this.elem.querySelector('[comment-count-bar]');
countBar.appendChild(addButtonContainer);
this.commentCountBar.appendChild(this.addButtonContainer);
}
addButtonContainer.style.display = 'block';
this.addButtonContainer.style.display = 'block';
}
getCommentCount() {
@ -154,15 +172,15 @@ class PageComments {
setReply(commentElem) {
this.showForm();
this.parentId = Number(commentElem.getAttribute('local-id'));
this.elem.querySelector('[comment-form-reply-to]').style.display = 'block';
let replyLink = this.elem.querySelector('[comment-form-reply-to] a');
this.replyToRow.style.display = 'block';
const replyLink = this.replyToRow.querySelector('a');
replyLink.textContent = `#${this.parentId}`;
replyLink.href = `#comment${this.parentId}`;
}
removeReplyTo() {
this.parentId = null;
this.elem.querySelector('[comment-form-reply-to]').style.display = 'none';
this.replyToRow.style.display = 'none';
}
showLoading(formElem) {

View File

@ -7,11 +7,10 @@ window.baseUrl = function(path) {
};
// Set events and http services on window
import Events from "./services/events"
import events from "./services/events"
import httpInstance from "./services/http"
const eventManager = new Events();
window.$http = httpInstance;
window.$events = eventManager;
window.$events = events;
// Translation setup
// Creates a global function with name 'trans' to be used in the same way as Laravel's translation system
@ -19,6 +18,7 @@ import Translations from "./services/translations"
const translator = new Translations();
window.trans = translator.get.bind(translator);
window.trans_choice = translator.getPlural.bind(translator);
window.trans_plural = translator.parsePlural.bind(translator);
// Load Components
import components from "./components"

View File

@ -1,55 +1,66 @@
const listeners = {};
const stack = [];
/**
* Simple global events manager
* Emit a custom event for any handlers to pick-up.
* @param {String} eventName
* @param {*} eventData
*/
class Events {
constructor() {
this.listeners = {};
this.stack = [];
}
/**
* Emit a custom event for any handlers to pick-up.
* @param {String} eventName
* @param {*} eventData
* @returns {Events}
*/
emit(eventName, eventData) {
this.stack.push({name: eventName, data: 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 to a custom event and run the given callback when that event occurs.
* @param {String} eventName
* @param {Function} callback
* @returns {Events}
*/
listen(eventName, callback) {
if (typeof this.listeners[eventName] === 'undefined') this.listeners[eventName] = [];
this.listeners[eventName].push(callback);
return this;
}
/**
* Emit an event for public use.
* Sends the event via the native DOM event handling system.
* @param {Element} targetElement
* @param {String} eventName
* @param {Object} eventData
*/
emitPublic(targetElement, eventName, eventData) {
const event = new CustomEvent(eventName, {
detail: eventData,
bubbles: true
});
targetElement.dispatchEvent(event);
function emit(eventName, eventData) {
stack.push({name: eventName, data: eventData});
if (typeof listeners[eventName] === 'undefined') return this;
let eventsToStart = listeners[eventName];
for (let i = 0; i < eventsToStart.length; i++) {
let event = eventsToStart[i];
event(eventData);
}
}
export default Events;
/**
* Listen to a custom event and run the given callback when that event occurs.
* @param {String} eventName
* @param {Function} callback
* @returns {Events}
*/
function listen(eventName, callback) {
if (typeof listeners[eventName] === 'undefined') listeners[eventName] = [];
listeners[eventName].push(callback);
}
/**
* Emit an event for public use.
* Sends the event via the native DOM event handling system.
* @param {Element} targetElement
* @param {String} eventName
* @param {Object} eventData
*/
function emitPublic(targetElement, eventName, eventData) {
const event = new CustomEvent(eventName, {
detail: eventData,
bubbles: true
});
targetElement.dispatchEvent(event);
}
/**
* Notify of a http error.
* Check for standard scenarios such as validation errors and
* formats an error notification accordingly.
* @param {Error} error
*/
function showValidationErrors(error) {
if (!error.status) return;
if (error.status === 422 && error.data) {
const message = Object.values(error.data).flat().join('\n');
emit('error', message);
}
}
export default {
emit,
emitPublic,
listen,
success: (msg) => emit('success', msg),
error: (msg) => emit('error', msg),
showValidationErrors,
}

View File

@ -69,7 +69,10 @@ async function dataRequest(method, url, data = null) {
// Send data as JSON if a plain object
if (typeof data === 'object' && !(data instanceof FormData)) {
options.headers = {'Content-Type': 'application/json'};
options.headers = {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
};
options.body = JSON.stringify(data);
}

View File

@ -47,7 +47,19 @@ class Translator {
*/
getPlural(key, count, replacements) {
const text = this.getTransText(key);
const splitText = text.split('|');
return this.parsePlural(text, count, replacements);
}
/**
* Parse the given translation and find the correct plural option
* to use. Similar format at laravel's 'trans_choice' helper.
* @param {String} translation
* @param {Number} count
* @param {Object} replacements
* @returns {String}
*/
parsePlural(translation, count, replacements) {
const splitText = translation.split('|');
const exactCountRegex = /^{([0-9]+)}/;
const rangeRegex = /^\[([0-9]+),([0-9*]+)]/;
let result = null;

View File

@ -106,6 +106,7 @@ return [
'role_access_api' => 'Access system API',
'role_manage_settings' => 'Manage app settings',
'role_asset' => 'Asset Permissions',
'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.',
'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.',
'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.',
'role_all' => 'All',

View File

@ -130,7 +130,7 @@ p, ul, ol, pre, table, blockquote {
hr {
border: 0;
height: 1px;
@include lightDark(background, #eaeaea, #222);
@include lightDark(background, #eaeaea, #555);
margin-bottom: $-l;
&.faded {
background-image: linear-gradient(to right, #FFF, #e3e0e0 20%, #e3e0e0 80%, #FFF);

View File

@ -1,23 +1,23 @@
<section page-comments page-id="{{ $page->id }}" class="comments-list" aria-label="{{ trans('entities.comments') }}">
<section component="page-comments"
option:page-comments:page-id="{{ $page->id }}"
option:page-comments:updated-text="{{ trans('entities.comment_updated_success') }}"
option:page-comments:deleted-text="{{ trans('entities.comment_deleted_success') }}"
option:page-comments:created-text="{{ trans('entities.comment_created_success') }}"
option:page-comments:count-text="{{ trans('entities.comment_count') }}"
class="comments-list"
aria-label="{{ trans('entities.comments') }}">
@exposeTranslations([
'entities.comment_updated_success',
'entities.comment_deleted_success',
'entities.comment_created_success',
'entities.comment_count',
])
<div comment-count-bar class="grid half left-focus v-center no-row-gap">
<div refs="page-comments@commentCountBar" class="grid half left-focus v-center no-row-gap">
<h5 comments-title>{{ trans_choice('entities.comment_count', count($page->comments), ['count' => count($page->comments)]) }}</h5>
@if (count($page->comments) === 0 && userCan('comment-create-all'))
<div class="text-m-right" comment-add-button-container>
<div class="text-m-right" refs="page-comments@addButtonContainer">
<button type="button" action="addComment"
class="button outline">{{ trans('entities.comment_add') }}</button>
</div>
@endif
</div>
<div class="comment-container" comment-container>
<div refs="page-comments@commentContainer" class="comment-container">
@foreach($page->comments as $comment)
@include('comments.comment', ['comment' => $comment])
@endforeach
@ -27,7 +27,7 @@
@include('comments.create')
@if (count($page->comments) > 0)
<div class="text-right" comment-add-button-container>
<div refs="page-comments@addButtonContainer" class="text-right">
<button type="button" action="addComment"
class="button outline">{{ trans('entities.comment_add') }}</button>
</div>

View File

@ -1,6 +1,7 @@
<div class="comment-box" comment-box style="display:none;">
<div class="comment-box" style="display:none;">
<div class="header p-s">{{ trans('entities.comment_new') }}</div>
<div comment-form-reply-to class="reply-row primary-background-light text-muted px-s py-xs mb-s" style="display: none;">
<div refs="page-comments@replyToRow" class="reply-row primary-background-light text-muted px-s py-xs mb-s" style="display: none;">
<div class="grid left-focus v-center">
<div>
{!! trans('entities.comment_in_reply_to', ['commentId' => '<a href=""></a>']) !!}
@ -10,7 +11,8 @@
</div>
</div>
</div>
<div class="content px-s" comment-form-container>
<div refs="page-comments@formContainer" class="content px-s">
<form novalidate>
<div class="form-group description-input">
<textarea name="markdown" rows="3"
@ -26,4 +28,5 @@
</div>
</form>
</div>
</div>

View File

@ -3,10 +3,10 @@
@foreach($roles as $role)
<div>
@include('components.custom-checkbox', [
'name' => $name . '[' . str_replace('.', 'DOT', $role->name) . ']',
'name' => $name . '[' . strval($role->id) . ']',
'label' => $role->display_name,
'value' => $role->id,
'checked' => old($name . '.' . str_replace('.', 'DOT', $role->name)) || (!old('name') && isset($model) && $model->hasRole($role->name))
'checked' => old($name . '.' . strval($role->id)) || (!old('name') && isset($model) && $model->hasRole($role->id))
])
</div>
@endforeach

View File

@ -231,7 +231,8 @@
<label for="setting-registration-role">{{ trans('settings.reg_default_role') }}</label>
<select id="setting-registration-role" name="setting-registration-role" @if($errors->has('setting-registration-role')) class="neg" @endif>
@foreach(\BookStack\Auth\Role::all() as $role)
<option value="{{$role->id}}" data-role-name="{{ $role->name }}"
<option value="{{$role->id}}"
data-system-role-name="{{ $role->system_name ?? '' }}"
@if(setting('registration-role', \BookStack\Auth\Role::first()->id) == $role->id) selected @endif
>
{{ $role->display_name }}

View File

@ -19,7 +19,7 @@
@if($role->users->count() > 0)
<div class="form-group">
<p>{{ trans('settings.role_delete_users_assigned', ['userCount' => $role->users->count()]) }}</p>
@include('form.role-select', ['options' => $roles, 'name' => 'migration_role_id'])
@include('form.role-select', ['options' => $roles, 'name' => 'migrate_role_id'])
</div>
@endif

View File

@ -28,19 +28,23 @@
</div>
</div>
<div class="grid half" permissions-table>
<div>
<label class="setting-list-label">{{ trans('settings.role_system') }}</label>
<a href="#" permissions-table-toggle-all class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
</div>
<div class="toggle-switch-list">
<div>@include('settings.roles.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])</div>
<div>@include('settings.roles.checkbox', ['permission' => 'users-manage', 'label' => trans('settings.role_manage_users')])</div>
<div>@include('settings.roles.checkbox', ['permission' => 'user-roles-manage', 'label' => trans('settings.role_manage_roles')])</div>
<div>@include('settings.roles.checkbox', ['permission' => 'restrictions-manage-all', 'label' => trans('settings.role_manage_entity_permissions')])</div>
<div>@include('settings.roles.checkbox', ['permission' => 'restrictions-manage-own', 'label' => trans('settings.role_manage_own_entity_permissions')])</div>
<div>@include('settings.roles.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])</div>
<div>@include('settings.roles.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])</div>
<div permissions-table>
<label class="setting-list-label">{{ trans('settings.role_system') }}</label>
<a href="#" permissions-table-toggle-all class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
<div class="toggle-switch-list grid half mt-m">
<div>
<div>@include('settings.roles.checkbox', ['permission' => 'restrictions-manage-all', 'label' => trans('settings.role_manage_entity_permissions')])</div>
<div>@include('settings.roles.checkbox', ['permission' => 'restrictions-manage-own', 'label' => trans('settings.role_manage_own_entity_permissions')])</div>
<div>@include('settings.roles.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])</div>
<div>@include('settings.roles.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])</div>
</div>
<div>
<div>@include('settings.roles.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])</div>
<div>@include('settings.roles.checkbox', ['permission' => 'users-manage', 'label' => trans('settings.role_manage_users')])</div>
<div>@include('settings.roles.checkbox', ['permission' => 'user-roles-manage', 'label' => trans('settings.role_manage_roles')])</div>
<p class="text-warn text-small mt-s mb-none">{{ trans('settings.roles_system_warning') }}</p>
</div>
</div>
</div>