From 26965fa08f8a5e834b01f743109c835a62724706 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 25 Mar 2016 14:41:15 +0000 Subject: [PATCH 01/18] Added a markdown editor --- app/Http/Controllers/PageController.php | 5 +- app/Page.php | 2 +- app/PageRevision.php | 2 +- config/app.php | 2 + ...2016_03_25_123157_add_markdown_support.php | 39 ++++++++++++ package.json | 1 + .../fonts/roboto-mono-v4-latin-regular.woff | Bin 0 -> 19592 bytes .../fonts/roboto-mono-v4-latin-regular.woff2 | Bin 0 -> 16108 bytes readme.md | 1 + resources/assets/js/controllers.js | 45 +++++++++----- resources/assets/js/directives.js | 32 ++++++++++ resources/assets/sass/_fonts.scss | 11 ++++ resources/assets/sass/_forms.scss | 56 ++++++++++++++++++ resources/assets/sass/_text.scss | 6 ++ resources/views/pages/form.blade.php | 31 ++++++++-- 15 files changed, 210 insertions(+), 23 deletions(-) create mode 100644 database/migrations/2016_03_25_123157_add_markdown_support.php create mode 100644 public/fonts/roboto-mono-v4-latin-regular.woff create mode 100644 public/fonts/roboto-mono-v4-latin-regular.woff2 diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index e2b10d3d3..c3d8e396c 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -164,6 +164,7 @@ class PageController extends Controller $draft = $this->pageRepo->getUserPageDraft($page, $this->currentUser->id); $page->name = $draft->name; $page->html = $draft->html; + $page->markdown = $draft->markdown; $page->isDraft = true; $warnings [] = $this->pageRepo->getUserPageDraftMessage($draft); } @@ -204,9 +205,9 @@ class PageController extends Controller $page = $this->pageRepo->getById($pageId, true); $this->checkOwnablePermission('page-update', $page); if ($page->draft) { - $draft = $this->pageRepo->updateDraftPage($page, $request->only(['name', 'html'])); + $draft = $this->pageRepo->updateDraftPage($page, $request->only(['name', 'html', 'markdown'])); } else { - $draft = $this->pageRepo->saveUpdateDraft($page, $request->only(['name', 'html'])); + $draft = $this->pageRepo->saveUpdateDraft($page, $request->only(['name', 'html', 'markdown'])); } $updateTime = $draft->updated_at->format('H:i'); return response()->json(['status' => 'success', 'message' => 'Draft saved at ' . $updateTime]); diff --git a/app/Page.php b/app/Page.php index 84e37d519..d2a303f61 100644 --- a/app/Page.php +++ b/app/Page.php @@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Model; class Page extends Entity { - protected $fillable = ['name', 'html', 'priority']; + protected $fillable = ['name', 'html', 'priority', 'markdown']; protected $simpleAttributes = ['name', 'id', 'slug']; diff --git a/app/PageRevision.php b/app/PageRevision.php index f1b4bc587..c258913ff 100644 --- a/app/PageRevision.php +++ b/app/PageRevision.php @@ -4,7 +4,7 @@ use Illuminate\Database\Eloquent\Model; class PageRevision extends Model { - protected $fillable = ['name', 'html', 'text']; + protected $fillable = ['name', 'html', 'text', 'markdown']; /** * Get the user that created the page revision diff --git a/config/app.php b/config/app.php index 650ad1d07..d305af3c0 100644 --- a/config/app.php +++ b/config/app.php @@ -5,6 +5,8 @@ return [ 'env' => env('APP_ENV', 'production'), + 'editor' => env('APP_EDITOR', 'html'), + /* |-------------------------------------------------------------------------- | Application Debug Mode diff --git a/database/migrations/2016_03_25_123157_add_markdown_support.php b/database/migrations/2016_03_25_123157_add_markdown_support.php new file mode 100644 index 000000000..45efe5a09 --- /dev/null +++ b/database/migrations/2016_03_25_123157_add_markdown_support.php @@ -0,0 +1,39 @@ +longText('markdown'); + }); + + Schema::table('page_revisions', function (Blueprint $table) { + $table->longText('markdown'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('pages', function (Blueprint $table) { + $table->dropColumn('markdown'); + }); + + Schema::table('page_revisions', function (Blueprint $table) { + $table->dropColumn('markdown'); + }); + } +} diff --git a/package.json b/package.json index a1fb06b1c..7d1aa1a6a 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "bootstrap-sass": "^3.0.0", "dropzone": "^4.0.1", "laravel-elixir": "^3.4.0", + "marked": "^0.3.5", "zeroclipboard": "^2.2.0" } } diff --git a/public/fonts/roboto-mono-v4-latin-regular.woff b/public/fonts/roboto-mono-v4-latin-regular.woff new file mode 100644 index 0000000000000000000000000000000000000000..8cb9e6fd8840b38960ce4348be2652ce5f41bcef GIT binary patch literal 19592 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 literal 0 HcmV?d00001 diff --git a/public/fonts/roboto-mono-v4-latin-regular.woff2 b/public/fonts/roboto-mono-v4-latin-regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..1f6598111497129d63a60d571c0dfa83296f619b GIT binary patch literal 16108 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 yy# { var newTitle = $('#name').val(); - var newHtml = $scope.editorHtml; + var newHtml = $scope.editContent; if (newTitle !== currentContent.title || newHtml !== currentContent.html) { currentContent.html = newHtml; currentContent.title = newTitle; - saveDraft(newTitle, newHtml); + saveDraft(); } + }, 1000 * autosaveFrequency); } @@ -272,20 +284,24 @@ module.exports = function (ngApp, events) { * @param title * @param html */ - function saveDraft(title, html) { - $http.put('/ajax/page/' + pageId + '/save-draft', { - name: title, - html: html - }).then((responseData) => { + function saveDraft() { + var data = { + name: $('#name').val(), + html: isMarkdown ? $sce.getTrustedHtml($scope.displayContent) : $scope.editContent + }; + + if (isMarkdown) data.markdown = $scope.editContent; + + console.log(data.markdown); + + $http.put('/ajax/page/' + pageId + '/save-draft', data).then((responseData) => { $scope.draftText = responseData.data.message; if (!$scope.isNewPageDraft) $scope.isUpdateDraft = true; }); } $scope.forceDraftSave = function() { - var newTitle = $('#name').val(); - var newHtml = $scope.editorHtml; - saveDraft(newTitle, newHtml); + saveDraft(); }; /** @@ -298,6 +314,7 @@ module.exports = function (ngApp, events) { $scope.draftText = 'Editing Page'; $scope.isUpdateDraft = false; $scope.$broadcast('html-update', responseData.data.html); + $scope.$broadcast('markdown-update', responseData.data.markdown || responseData.data.html); $('#name').val(responseData.data.name); $timeout(() => { startAutoSave(); diff --git a/resources/assets/js/directives.js b/resources/assets/js/directives.js index 71b35fb42..316e5dcb4 100644 --- a/resources/assets/js/directives.js +++ b/resources/assets/js/directives.js @@ -1,5 +1,6 @@ "use strict"; var DropZone = require('dropzone'); +var markdown = require( "marked" ); var toggleSwitchTemplate = require('./components/toggle-switch.html'); var imagePickerTemplate = require('./components/image-picker.html'); @@ -202,5 +203,36 @@ module.exports = function (ngApp, events) { } }]) + ngApp.directive('markdownEditor', ['$timeout', function($timeout) { + return { + restrict: 'A', + scope: { + mdModel: '=', + mdChange: '=' + }, + link: function (scope, element, attrs) { + + // Set initial model content + var content = element.val(); + scope.mdModel = content; + scope.mdChange(markdown(content)); + + element.on('change input', (e) => { + content = element.val(); + $timeout(() => { + scope.mdModel = content; + scope.mdChange(markdown(content)); + }); + }); + + scope.$on('markdown-update', (event, value) => { + element.val(value); + scope.mdModel= value; + scope.mdChange(markdown(value)); + }); + + } + } + }]) }; \ No newline at end of file diff --git a/resources/assets/sass/_fonts.scss b/resources/assets/sass/_fonts.scss index 0dc8c95b2..8cf677779 100644 --- a/resources/assets/sass/_fonts.scss +++ b/resources/assets/sass/_fonts.scss @@ -93,4 +93,15 @@ url('/fonts/roboto-regular-webfont.svg#robotoregular') format('svg'); font-weight: normal; font-style: normal; +} + +/* roboto-mono-regular - latin */ +// https://google-webfonts-helper.herokuapp.com +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 400; + src: local('Roboto Mono'), local('RobotoMono-Regular'), + url('/fonts/roboto-mono-v4-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+ */ + url('/fonts/roboto-mono-v4-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } \ No newline at end of file diff --git a/resources/assets/sass/_forms.scss b/resources/assets/sass/_forms.scss index 037dad94a..5351f06e7 100644 --- a/resources/assets/sass/_forms.scss +++ b/resources/assets/sass/_forms.scss @@ -26,6 +26,58 @@ display: none; } +#markdown-editor { + position: relative; + z-index: 5; + textarea { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 400; + padding: $-xs $-m; + color: #444; + border-radius: 0; + max-height: 100%; + flex: 1; + border: 0; + &:focus { + outline: 0; + } + } + .markdown-display, .markdown-editor-wrap { + flex: 1; + padding-top: 28px; + position: relative; + border: 1px solid #DDD; + &:before { + display: block; + position: absolute; + top: 0; + left: 0; + width: 100%; + padding: $-xs $-m; + font-family: 'Roboto Mono'; + font-size: 11px; + line-height: 1; + border-bottom: 1px solid #DDD; + background-color: #EEE; + } + } + .markdown-editor-wrap { + display: flex; + &:before { + content: 'Editor'; + } + } + .markdown-display { + padding: 0 $-m; + padding-top: 28px; + margin-left: -1px; + &:before { + content: 'Preview'; + } + } +} + label { display: block; line-height: 1.4em; @@ -160,6 +212,10 @@ input:checked + .toggle-switch { width: 100%; } +div[editor-type="markdown"] .title-input.page-title input[type="text"] { + max-width: 100%; +} + .search-box { max-width: 100%; position: relative; diff --git a/resources/assets/sass/_text.scss b/resources/assets/sass/_text.scss index 721eeb238..1a55cf868 100644 --- a/resources/assets/sass/_text.scss +++ b/resources/assets/sass/_text.scss @@ -157,6 +157,12 @@ span.code { @extend .code-base; padding: 1px $-xs; } + +pre code { + background-color: transparent; + border: 0; + font-size: 1em; +} /* * Text colors */ diff --git a/resources/views/pages/form.blade.php b/resources/views/pages/form.blade.php index d8dc19ec2..2118d23b2 100644 --- a/resources/views/pages/form.blade.php +++ b/resources/views/pages/form.blade.php @@ -1,5 +1,5 @@ -
+
{{ csrf_field() }}
@@ -42,10 +42,31 @@
- - @if($errors->has('html')) -
{{ $errors->first('html') }}
+ @if(config('app.editor') === 'html') + + @if($errors->has('html')) +
{{ $errors->first('html') }}
+ @endif + @endif + + @if(config('app.editor') === 'markdown') +
+ +
+ +
+ +
+
+ + + + @if($errors->has('markdown')) +
{{ $errors->first('markdown') }}
+ @endif + @endif
\ No newline at end of file From ef874712bbaa8f380c96948dcfdc56d741c65924 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 25 Mar 2016 15:17:04 +0000 Subject: [PATCH 02/18] Cut readme down and added useful links Remove a lot of the instructions/config info since much of it is now on the BookStack docs site. --- readme.md | 145 +++--------------------------------------------------- 1 file changed, 7 insertions(+), 138 deletions(-) diff --git a/readme.md b/readme.md index 0730e3de3..62893aea3 100644 --- a/readme.md +++ b/readme.md @@ -1,149 +1,18 @@ # BookStack -A platform to create documentation/wiki content. General information about BookStack can be found at https://www.bookstackapp.com/ +A platform for storing and organising information and documentation. General information and documentation for BookStack can be found at https://www.bookstackapp.com/. -1. [Requirements](#requirements) -2. [Installation](#installation) - - [Server Rewrite Rules](#url-rewrite-rules) -3. [Updating](#updating-bookstack) -4. [Social Authentication](#social-authentication) - - [Google](#google) - - [GitHub](#github) -5. [LDAP Authentication](#ldap-authentication) -6. [Testing](#testing) -7. [License](#license) -8. [Attribution](#attribution) - - -## Requirements - -BookStack has similar requirements to Laravel: - -* PHP >= 5.5.9, Will need to be usable from the command line. -* PHP Extensions: `OpenSSL`, `PDO`, `MBstring`, `Tokenizer`, `GD` -* MySQL >= 5.6 -* Git (Not strictly required but helps manage updates) -* [Composer](https://getcomposer.org/) - -## Installation - -Ensure the above requirements are met before installing. Currently BookStack requires its own domain/subdomain and will not work in a site subdirectory. - -This project currently uses the `release` branch of this repository as a stable channel for providing updates. - -The installation is currently somewhat complicated and will be made simpler in future releases. Some PHP/Laravel experience will currently benefit. - -1. Clone the release branch of this repository into a folder. - -``` -git clone https://github.com/ssddanbrown/BookStack.git --branch release --single-branch -``` - -2. `cd` into the application folder and run `composer install`. -3. Copy the `.env.example` file to `.env` and fill with your own database and mail details. -4. Ensure the `storage`, `bootstrap/cache` & `public/uploads` folders are writable by the web server. -5. In the application root, Run `php artisan key:generate` to generate a unique application key. -6. If not using apache or if `.htaccess` files are disabled you will have to create some URL rewrite rules as shown below. -7. Run `php artisan migrate` to update the database. -8. Done! You can now login using the default admin details `admin@admin.com` with a password of `password`. It is recommended to change these details directly after first logging in. - -#### URL Rewrite rules - -**Apache** -``` -Options +FollowSymLinks -RewriteEngine On - -RewriteCond %{REQUEST_FILENAME} !-d -RewriteCond %{REQUEST_FILENAME} !-f -RewriteRule ^ index.php [L] -``` - -**Nginx** -``` -location / { - try_files $uri $uri/ /index.php?$query_string; -} -``` -## Updating BookStack - -To update BookStack you can run the following command in the root directory of the application: -``` -git pull origin release && composer install && php artisan migrate -``` -This command will update the repository that was created in the installation, install the PHP dependencies using `composer` then run the database migrations. - -## Social Authentication - -BookStack currently supports login via both Google and GitHub. Once enabled options for these services will show up in the login, registration and user profile pages. By default these services are disabled. To enable them you will have to create an application on the external services to obtain the require application id's and secrets. Here are instructions to do this for the current supported services: - -### Google - -1. Open the [Google Developers Console](https://console.developers.google.com/). -2. Create a new project (May have to wait a short while for it to be created). -3. Select 'Enable and manage APIs'. -4. Enable the 'Google+ API'. -5. In 'Credentials' choose the 'OAuth consent screen' tab and enter a product name ('BookStack' or your custom set name). -6. Back in the 'Credentials' tab click 'New credentials' > 'OAuth client ID'. -7. Choose an application type of 'Web application' and enter the following urls under 'Authorized redirect URIs', changing `https://example.com` to your own domain where BookStack is hosted: - - `https://example.com/login/service/google/callback` - - `https://example.com/register/service/google/callback` -8. Click 'Create' and your app_id and secret will be displayed. Replace the false value on both the `GOOGLE_APP_ID` & `GOOGLE_APP_SECRET` variables in the '.env' file in the BookStack root directory with your own app_id and secret. -9. Set the 'APP_URL' environment variable to be the same domain as you entered in step 7. So, in this example, it will be `https://example.com`. -10. All done! Users should now be able to link to their social accounts in their account profile pages and also register/login using their Google accounts. - -### Github - -1. While logged in, open up your [GitHub developer applications](https://github.com/settings/developers). -2. Click 'Register new application'. -3. Enter an application name ('BookStack' or your custom set name), A link to your app instance under 'Homepage URL' and an 'Authorization callback URL' of the url that your BookStack instance is hosted on then click 'Register application'. -4. A 'Client ID' and a 'Client Secret' value will be shown. Add these two values to the to the `GITHUB_APP_ID` and `GITHUB_APP_SECRET` variables, replacing the default false value, in the '.env' file found in the BookStack root folder. -5. Set the 'APP_URL' environment variable to be the same domain as you entered in step 3. -6. All done! Users should now be able to link to their social accounts in their account profile pages and also register/login using their Github account. - -## LDAP Authentication - -BookStack can be configured to allow LDAP based user login. While LDAP login is enabled you cannot log in with the standard user/password login and new user registration is disabled. BookStack will only use the LDAP server for getting user details and for authentication. Data on the LDAP server is not currently editable through BookStack. - -When a LDAP user logs into BookStack for the first time their BookStack profile will be created and they will be given the default role set under the 'Default user role after registration' option in the application settings. - -To set up LDAP-based authentication add or modify the following variables in your `.env` file: - -``` -# General auth -AUTH_METHOD=ldap - -# The LDAP host, Adding a port is optional -LDAP_SERVER=ldap://example.com:389 - -# The base DN from where users will be searched within. -LDAP_BASE_DN=ou=People,dc=example,dc=com - -# The full DN and password of the user used to search the server -# Can both be left as false to bind anonymously -LDAP_DN=false -LDAP_PASS=false - -# A filter to use when searching for users -# The user-provided user-name used to replace any occurrences of '${user}' -LDAP_USER_FILTER=(&(uid=${user})) - -# Set the LDAP version to use when connecting to the server. -LDAP_VERSION=false -``` - -You will also need to have the php-ldap extension installed on your system. It's recommended to change your `APP_DEBUG` variable to `true` while setting up LDAP to make any errors visible. Remember to change this back after LDAP is functioning. - -A user in BookStack will be linked to a LDAP user via a 'uid'. If a LDAP user uid changes it can be updated in BookStack by an admin by changing the 'External Authentication ID' field on the user's profile. - -You may find that you cannot log in with your initial Admin account after changing the `AUTH_METHOD` to `ldap`. To get around this set the `AUTH_METHOD` to `standard`, login with your admin account then change it back to `ldap`. You get then edit your profile and add your LDAP uid under the 'External Authentication ID' field. You will then be able to login in with that ID. +* [Installation Instructions](https://www.bookstackapp.com/docs/admin/installation) +* [Documentation](https://www.bookstackapp.com/docs) +* [Demo Instance](https://demo.bookstackapp.com) *(Login username: `admin@example.com`. Password: `password`)* +* [BookStack Blog](https://www.bookstackapp.com/blog) ## Development & Testing All development on BookStack is currently done on the master branch. When it's time for a release the master branch is merged into release with built & minified CSS & JS then tagged at it's version. Here are the current development requirements: -* [Node.js](https://nodejs.org/en/) **Development Only** -* [Gulp](http://gulpjs.com/) **Development Only** +* [Node.js](https://nodejs.org/en/) +* [Gulp](http://gulpjs.com/) SASS is used to help the CSS development and the JavaScript is run through browserify/babel to allow for writing ES6 code. Both of these are done using gulp. From efb49019d484f2455897d2249e16ccd0b2ad5e88 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 29 Mar 2016 18:25:54 +0100 Subject: [PATCH 03/18] Integrated the markdown editor with the image manager --- resources/assets/js/directives.js | 50 ++++++++++++++++++++++++++-- resources/assets/sass/_forms.scss | 44 ++++++++++++------------ resources/views/pages/form.blade.php | 18 ++++++++-- 3 files changed, 83 insertions(+), 29 deletions(-) diff --git a/resources/assets/js/directives.js b/resources/assets/js/directives.js index 316e5dcb4..de87950dc 100644 --- a/resources/assets/js/directives.js +++ b/resources/assets/js/directives.js @@ -1,6 +1,6 @@ "use strict"; var DropZone = require('dropzone'); -var markdown = require( "marked" ); +var markdown = require('marked'); var toggleSwitchTemplate = require('./components/toggle-switch.html'); var imagePickerTemplate = require('./components/image-picker.html'); @@ -201,9 +201,9 @@ module.exports = function (ngApp, events) { tinymce.init(scope.tinymce); } } - }]) + }]); - ngApp.directive('markdownEditor', ['$timeout', function($timeout) { + ngApp.directive('markdownInput', ['$timeout', function($timeout) { return { restrict: 'A', scope: { @@ -231,6 +231,50 @@ module.exports = function (ngApp, events) { scope.mdChange(markdown(value)); }); + } + } + }]); + + ngApp.directive('markdownEditor', ['$timeout', function($timeout) { + return { + restrict: 'A', + link: function (scope, element, attrs) { + + // Elements + var input = element.find('textarea[markdown-input]'); + var insertImage = element.find('button[data-action="insertImage"]'); + + var currentCaretPos = 0; + + input.blur((event) => { + currentCaretPos = input[0].selectionStart; + }); + + // Insert image shortcut + input.keydown((event) => { + if (event.which === 73 && event.ctrlKey && event.shiftKey) { + event.preventDefault(); + var caretPos = input[0].selectionStart; + var currentContent = input.val(); + var mdImageText = "![](http://)"; + input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos)); + input.focus(); + input[0].selectionStart = caretPos + ("![](".length); + input[0].selectionEnd = caretPos + ('![](http://'.length); + } + }); + + // Insert image from image manager + insertImage.click((event) => { + window.ImageManager.showExternal((image) => { + var caretPos = currentCaretPos; + var currentContent = input.val(); + var mdImageText = "![" + image.name + "](" + image.url + ")"; + input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos)); + input.change(); + }); + }); + } } }]) diff --git a/resources/assets/sass/_forms.scss b/resources/assets/sass/_forms.scss index 5351f06e7..4da0c39ad 100644 --- a/resources/assets/sass/_forms.scss +++ b/resources/assets/sass/_forms.scss @@ -39,44 +39,42 @@ max-height: 100%; flex: 1; border: 0; + width: 100%; &:focus { outline: 0; } } .markdown-display, .markdown-editor-wrap { flex: 1; - padding-top: 28px; position: relative; - border: 1px solid #DDD; - &:before { - display: block; - position: absolute; - top: 0; - left: 0; - width: 100%; - padding: $-xs $-m; - font-family: 'Roboto Mono'; - font-size: 11px; - line-height: 1; - border-bottom: 1px solid #DDD; - background-color: #EEE; - } } .markdown-editor-wrap { display: flex; - &:before { - content: 'Editor'; - } + flex-direction: column; + border: 1px solid #DDD; } .markdown-display { - padding: 0 $-m; - padding-top: 28px; + padding: 0 $-m 0; margin-left: -1px; - &:before { - content: 'Preview'; - } + overflow-y: scroll; } } +.editor-toolbar { + width: 100%; + padding: $-xs $-m; + font-family: 'Roboto Mono'; + font-size: 11px; + line-height: 1.6; + border-bottom: 1px solid #DDD; + background-color: #EEE; + flex: none; + &:after { + content: ''; + display: block; + clear: both; + } +} + label { display: block; diff --git a/resources/views/pages/form.blade.php b/resources/views/pages/form.blade.php index 2118d23b2..6b16cb870 100644 --- a/resources/views/pages/form.blade.php +++ b/resources/views/pages/form.blade.php @@ -51,14 +51,26 @@ @endif @if(config('app.editor') === 'markdown') -
+
-
-
+
+
+
Preview
+
+
+
+
From e1994ef2cf6552516888422063cf859586f0de14 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 29 Mar 2016 19:26:13 +0100 Subject: [PATCH 04/18] Added editor control in admin settings & Fixed some markdown editor bugs Also updated the setting system with a more sane approach to handling default values. (Now done via the setting-defaults config file) --- app/Repos/PageRepo.php | 4 +++ app/Services/SettingService.php | 13 ++++++++- config/setting-defaults.php | 10 +++++++ ...2016_03_25_123157_add_markdown_support.php | 4 +-- resources/assets/js/controllers.js | 6 ++-- resources/views/pages/form.blade.php | 6 ++-- resources/views/settings/index.blade.php | 28 ++++++++++++------- 7 files changed, 53 insertions(+), 18 deletions(-) create mode 100644 config/setting-defaults.php diff --git a/app/Repos/PageRepo.php b/app/Repos/PageRepo.php index 4c3512fa7..9a7502754 100644 --- a/app/Repos/PageRepo.php +++ b/app/Repos/PageRepo.php @@ -312,6 +312,7 @@ class PageRepo extends EntityRepo $page->fill($input); $page->html = $this->formatHtml($input['html']); $page->text = strip_tags($page->html); + if (setting('app-editor') !== 'markdown') $page->markdown = ''; $page->updated_by = $userId; $page->save(); @@ -348,6 +349,7 @@ class PageRepo extends EntityRepo public function saveRevision(Page $page) { $revision = $this->pageRevision->fill($page->toArray()); + if (setting('app-editor') !== 'markdown') $revision->markdown = ''; $revision->page_id = $page->id; $revision->slug = $page->slug; $revision->book_slug = $page->book->slug; @@ -386,6 +388,8 @@ class PageRepo extends EntityRepo } $draft->fill($data); + if (setting('app-editor') !== 'markdown') $draft->markdown = ''; + $draft->save(); return $draft; } diff --git a/app/Services/SettingService.php b/app/Services/SettingService.php index bcc7eae31..bf5fa918e 100644 --- a/app/Services/SettingService.php +++ b/app/Services/SettingService.php @@ -44,28 +44,39 @@ class SettingService /** * Gets a setting value from the cache or database. + * Looks at the system defaults if not cached or in database. * @param $key * @param $default * @return mixed */ protected function getValueFromStore($key, $default) { + // Check for an overriding value $overrideValue = $this->getOverrideValue($key); if ($overrideValue !== null) return $overrideValue; + // Check the cache $cacheKey = $this->cachePrefix . $key; if ($this->cache->has($cacheKey)) { return $this->cache->get($cacheKey); } + // Check the database $settingObject = $this->getSettingObjectByKey($key); - if ($settingObject !== null) { $value = $settingObject->value; $this->cache->forever($cacheKey, $value); return $value; } + // Check the defaults set in the app config. + $configPrefix = 'setting-defaults.' . $key; + if (config()->has($configPrefix)) { + $value = config($configPrefix); + $this->cache->forever($cacheKey, $value); + return $value; + } + return $default; } diff --git a/config/setting-defaults.php b/config/setting-defaults.php new file mode 100644 index 000000000..17bae1848 --- /dev/null +++ b/config/setting-defaults.php @@ -0,0 +1,10 @@ + 'wysiwyg' + +]; \ No newline at end of file diff --git a/database/migrations/2016_03_25_123157_add_markdown_support.php b/database/migrations/2016_03_25_123157_add_markdown_support.php index 45efe5a09..2daa32cfb 100644 --- a/database/migrations/2016_03_25_123157_add_markdown_support.php +++ b/database/migrations/2016_03_25_123157_add_markdown_support.php @@ -13,11 +13,11 @@ class AddMarkdownSupport extends Migration public function up() { Schema::table('pages', function (Blueprint $table) { - $table->longText('markdown'); + $table->longText('markdown')->default(''); }); Schema::table('page_revisions', function (Blueprint $table) { - $table->longText('markdown'); + $table->longText('markdown')->default(''); }); } diff --git a/resources/assets/js/controllers.js b/resources/assets/js/controllers.js index 09187c0c2..dbd2e1ae6 100644 --- a/resources/assets/js/controllers.js +++ b/resources/assets/js/controllers.js @@ -258,6 +258,10 @@ module.exports = function (ngApp, events) { } } + if (!isMarkdown) { + $scope.editorChange = function() {}; + } + /** * Start the AutoSave loop, Checks for content change * before performing the costly AJAX request. @@ -292,8 +296,6 @@ module.exports = function (ngApp, events) { if (isMarkdown) data.markdown = $scope.editContent; - console.log(data.markdown); - $http.put('/ajax/page/' + pageId + '/save-draft', data).then((responseData) => { $scope.draftText = responseData.data.message; if (!$scope.isNewPageDraft) $scope.isUpdateDraft = true; diff --git a/resources/views/pages/form.blade.php b/resources/views/pages/form.blade.php index 6b16cb870..fe3d28cbc 100644 --- a/resources/views/pages/form.blade.php +++ b/resources/views/pages/form.blade.php @@ -1,5 +1,5 @@ -
+
{{ csrf_field() }}
@@ -42,7 +42,7 @@
- @if(config('app.editor') === 'html') + @if(setting('app-editor') === 'wysiwyg') @if($errors->has('html')) @@ -50,7 +50,7 @@ @endif @endif - @if(config('app.editor') === 'markdown') + @if(setting('app-editor') === 'markdown')
diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php index f94623256..eb580bb8b 100644 --- a/resources/views/settings/index.blade.php +++ b/resources/views/settings/index.blade.php @@ -17,29 +17,37 @@
- +
- +

For performance reasons, all images are public by default, This option adds a random, hard-to-guess characters in front of image names. Ensure directory indexes are not enabled to prevent easy access.

- + +
+
+ +

Select which editor will be used by all users to edit pages.

+

This image should be 43px in height.
Large images will be scaled down.

- +

This should be a hex value.
Leave empty to reset to the default color.

- - + +
@@ -53,14 +61,14 @@
- +
+
From dc2978824ee9a783402e4c28934965f5dcffeb0a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 29 Mar 2016 20:13:23 +0100 Subject: [PATCH 05/18] Added basic system tests for markdown editor, Added extra test helpers Added test helpers for checking if an element exists / does not exist on a page. Also fixed markdown editor bugs found while creating tests. --- resources/assets/sass/_forms.scss | 3 ++ resources/views/pages/form.blade.php | 4 ++- tests/Entity/MarkdownTest.php | 51 ++++++++++++++++++++++++++++ tests/TestCase.php | 24 +++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tests/Entity/MarkdownTest.php diff --git a/resources/assets/sass/_forms.scss b/resources/assets/sass/_forms.scss index 4da0c39ad..4a505c5f8 100644 --- a/resources/assets/sass/_forms.scss +++ b/resources/assets/sass/_forms.scss @@ -57,6 +57,9 @@ padding: 0 $-m 0; margin-left: -1px; overflow-y: scroll; + .page-content { + margin: 0 auto; + } } } .editor-toolbar { diff --git a/resources/views/pages/form.blade.php b/resources/views/pages/form.blade.php index fe3d28cbc..7ce9dbfe5 100644 --- a/resources/views/pages/form.blade.php +++ b/resources/views/pages/form.blade.php @@ -68,7 +68,9 @@
Preview
-
+
+
+
diff --git a/tests/Entity/MarkdownTest.php b/tests/Entity/MarkdownTest.php new file mode 100644 index 000000000..eaf4d62c3 --- /dev/null +++ b/tests/Entity/MarkdownTest.php @@ -0,0 +1,51 @@ +page = \BookStack\Page::first(); + } + + protected function setMarkdownEditor() + { + $this->setSettings(['app-editor' => 'markdown']); + } + + public function test_default_editor_is_wysiwyg() + { + $this->assertEquals(setting('app-editor'), 'wysiwyg'); + $this->asAdmin()->visit($this->page->getUrl() . '/edit') + ->pageHasElement('#html-editor'); + } + + public function test_markdown_setting_shows_markdown_editor() + { + $this->setMarkdownEditor(); + $this->asAdmin()->visit($this->page->getUrl() . '/edit') + ->pageNotHasElement('#html-editor') + ->pageHasElement('#markdown-editor'); + } + + public function test_markdown_content_given_to_editor() + { + $this->setMarkdownEditor(); + $mdContent = '# hello. This is a test'; + $this->page->markdown = $mdContent; + $this->page->save(); + $this->asAdmin()->visit($this->page->getUrl() . '/edit') + ->seeInField('markdown', $mdContent); + } + + public function test_html_content_given_to_editor_if_no_markdown() + { + $this->setMarkdownEditor(); + $this->asAdmin()->visit($this->page->getUrl() . '/edit') + ->seeInField('markdown', $this->page->html); + } + +} \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php index 567dc93ec..f46d73e04 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -170,4 +170,28 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase $this->visit($link->link()->getUri()); return $this; } + + /** + * Check if the page contains the given element. + * @param string $selector + * @return bool + */ + protected function pageHasElement($selector) + { + $elements = $this->crawler->filter($selector); + $this->assertTrue(count($elements) > 0, "The page does not contain an element matching " . $selector); + return $this; + } + + /** + * Check if the page contains the given element. + * @param string $selector + * @return bool + */ + protected function pageNotHasElement($selector) + { + $elements = $this->crawler->filter($selector); + $this->assertFalse(count($elements) > 0, "The page contains " . count($elements) . " elements matching " . $selector); + return $this; + } } From 412eed19c34f04b88a2fc9f3c80787417479e4e7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 3 Apr 2016 10:23:16 +0100 Subject: [PATCH 06/18] Removed old input checks on entity permission checkboxes Old input check potentialy causing issues (#89) and is not needed on the pages which it shows. --- resources/views/form/restriction-checkbox.blade.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/resources/views/form/restriction-checkbox.blade.php b/resources/views/form/restriction-checkbox.blade.php index a4449ccb8..5a8662b56 100644 --- a/resources/views/form/restriction-checkbox.blade.php +++ b/resources/views/form/restriction-checkbox.blade.php @@ -1,7 +1,6 @@ \ No newline at end of file From c5960f9b6a75c8fb74289f005bb231ff942df413 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 3 Apr 2016 11:00:14 +0100 Subject: [PATCH 07/18] Added Redis cache/session support --- composer.json | 3 ++- composer.lock | 54 +++++++++++++++++++++++++++++++++++++++++++-- config/cache.php | 7 +++--- config/database.php | 28 ++++++++++++++--------- 4 files changed, 74 insertions(+), 18 deletions(-) diff --git a/composer.json b/composer.json index 4a4c554a7..8f375a279 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,8 @@ "barryvdh/laravel-ide-helper": "^2.1", "barryvdh/laravel-debugbar": "^2.0", "league/flysystem-aws-s3-v3": "^1.0", - "barryvdh/laravel-dompdf": "0.6.*" + "barryvdh/laravel-dompdf": "0.6.*", + "predis/predis": "^1.0" }, "require-dev": { "fzaninotto/faker": "~1.4", diff --git a/composer.lock b/composer.lock index 9951362c1..ec7a9486b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "523e654de96df9259fa5dfcb583d6e3e", - "content-hash": "74b5601c253aab71cf55e0885f31ae7f", + "hash": "eb7c71e9ed116d3fd2a1d0af07f9f134", + "content-hash": "17d2d7fc5fed682f2a290d6588538035", "packages": [ { "name": "aws/aws-sdk-php", @@ -1759,6 +1759,56 @@ ], "time": "2015-02-03 12:10:50" }, + { + "name": "predis/predis", + "version": "v1.0.3", + "source": { + "type": "git", + "url": "https://github.com/nrk/predis.git", + "reference": "84060b9034d756b4d79641667d7f9efe1aeb8e04" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nrk/predis/zipball/84060b9034d756b4d79641667d7f9efe1aeb8e04", + "reference": "84060b9034d756b4d79641667d7f9efe1aeb8e04", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "suggest": { + "ext-curl": "Allows access to Webdis when paired with phpiredis", + "ext-phpiredis": "Allows faster serialization and deserialization of the Redis protocol" + }, + "type": "library", + "autoload": { + "psr-4": { + "Predis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniele Alessandri", + "email": "suppakilla@gmail.com", + "homepage": "http://clorophilla.net" + } + ], + "description": "Flexible and feature-complete PHP client library for Redis", + "homepage": "http://github.com/nrk/predis", + "keywords": [ + "nosql", + "predis", + "redis" + ], + "time": "2015-07-30 18:34:15" + }, { "name": "psr/http-message", "version": "1.0", diff --git a/config/cache.php b/config/cache.php index 076a0299f..0a9568322 100644 --- a/config/cache.php +++ b/config/cache.php @@ -6,9 +6,8 @@ if (env('CACHE_DRIVER') === 'memcached') { $memcachedServers = explode(',', trim(env('MEMCACHED_SERVERS', '127.0.0.1:11211:100'), ',')); foreach ($memcachedServers as $index => $memcachedServer) { $memcachedServerDetails = explode(':', $memcachedServer); - $components = count($memcachedServerDetails); - if ($components < 2) $memcachedServerDetails[] = '11211'; - if ($components < 3) $memcachedServerDetails[] = '100'; + if (count($memcachedServerDetails) < 2) $memcachedServerDetails[] = '11211'; + if (count($memcachedServerDetails) < 3) $memcachedServerDetails[] = '100'; $memcachedServers[$index] = array_combine($memcachedServerKeys, $memcachedServerDetails); } } @@ -83,6 +82,6 @@ return [ | */ - 'prefix' => 'laravel', + 'prefix' => env('CACHE_PREFIX', 'bookstack'), ]; diff --git a/config/database.php b/config/database.php index 9650de117..20d461fc9 100644 --- a/config/database.php +++ b/config/database.php @@ -1,5 +1,21 @@ env('REDIS_CLUSTER', false) + ]; + foreach ($redisServers as $index => $redisServer) { + $redisServerName = ($index === 0) ? 'default' : 'redis-server-' . $index; + $redisServerDetails = explode(':', $redisServer); + if (count($redisServerDetails) < 2) $redisServerDetails[] = '6379'; + if (count($redisServerDetails) < 3) $redisServerDetails[] = '0'; + $redisConfig[$redisServerName] = array_combine($redisServerKeys, $redisServerDetails); + } +} + return [ /* @@ -123,16 +139,6 @@ return [ | */ - 'redis' => [ - - 'cluster' => false, - - 'default' => [ - 'host' => '127.0.0.1', - 'port' => 6379, - 'database' => 0, - ], - - ], + 'redis' => $redisConfig, ]; From 4caa61fe96537a88636af4cc6c8893b5b91aaaef Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 3 Apr 2016 11:16:49 +0100 Subject: [PATCH 08/18] Added a friendlier error for LDAP new user mismatches --- app/Exceptions/AuthException.php | 4 ++++ app/Http/Controllers/Auth/AuthController.php | 10 ++++++++++ 2 files changed, 14 insertions(+) create mode 100644 app/Exceptions/AuthException.php diff --git a/app/Exceptions/AuthException.php b/app/Exceptions/AuthException.php new file mode 100644 index 000000000..c20bb62a0 --- /dev/null +++ b/app/Exceptions/AuthException.php @@ -0,0 +1,4 @@ +exists) { + + // Check for users with same email already + $alreadyUser = $user->newQuery()->where('email', '=', $user->email)->count() > 0; + if ($alreadyUser) { + throw new AuthException('A user with the email ' . $user->email . ' already exists but with different credentials.'); + } + $user->save(); $this->userRepo->attachDefaultRole($user); auth()->login($user); From 2bb8c3d91402d2465b4ddb70832055a8b47dab0d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 3 Apr 2016 12:16:54 +0100 Subject: [PATCH 09/18] Made email confirmations work with LDAP auth The email_confirmed user field now actually indicates if an email is confirmed rather than defaulting to true if not checked. This ensures toggleing the 'Require email confirmation' setting actually makes all currently unconfirmed users confirm thier emails. --- app/Http/Controllers/Auth/AuthController.php | 3 --- app/Http/Middleware/Authenticate.php | 5 +---- app/Providers/LdapUserProvider.php | 2 +- app/Repos/UserRepo.php | 3 ++- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/app/Http/Controllers/Auth/AuthController.php b/app/Http/Controllers/Auth/AuthController.php index ef44b2aef..beb191d62 100644 --- a/app/Http/Controllers/Auth/AuthController.php +++ b/app/Http/Controllers/Auth/AuthController.php @@ -194,14 +194,11 @@ class AuthController extends Controller } if (setting('registration-confirmation') || setting('registration-restrict')) { - $newUser->email_confirmed = false; $newUser->save(); $this->emailConfirmationService->sendConfirmation($newUser); return redirect('/register/confirm'); } - $newUser->email_confirmed = true; - auth()->login($newUser); session()->flash('success', 'Thanks for signing up! You are now registered and signed in.'); return redirect($this->redirectPath()); diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php index 81392fe6e..599f40c84 100644 --- a/app/Http/Middleware/Authenticate.php +++ b/app/Http/Middleware/Authenticate.php @@ -11,14 +11,12 @@ class Authenticate { /** * The Guard implementation. - * * @var Guard */ protected $auth; /** * Create a new filter instance. - * * @param Guard $auth */ public function __construct(Guard $auth) @@ -28,14 +26,13 @@ class Authenticate /** * Handle an incoming request. - * * @param \Illuminate\Http\Request $request * @param \Closure $next * @return mixed */ public function handle($request, Closure $next) { - if(auth()->check() && auth()->user()->email_confirmed == false) { + if ($this->auth->check() && setting('registration-confirmation') && !$this->auth->user()->email_confirmed) { return redirect()->guest('/register/confirm/awaiting'); } diff --git a/app/Providers/LdapUserProvider.php b/app/Providers/LdapUserProvider.php index 30fa739c2..a15257aec 100644 --- a/app/Providers/LdapUserProvider.php +++ b/app/Providers/LdapUserProvider.php @@ -115,7 +115,7 @@ class LdapUserProvider implements UserProvider $model->name = $userDetails['name']; $model->external_auth_id = $userDetails['uid']; $model->email = $userDetails['email']; - $model->email_confirmed = true; + $model->email_confirmed = false; return $model; } diff --git a/app/Repos/UserRepo.php b/app/Repos/UserRepo.php index d5a4b1503..9b5c8d7e7 100644 --- a/app/Repos/UserRepo.php +++ b/app/Repos/UserRepo.php @@ -106,7 +106,8 @@ class UserRepo return $this->user->forceCreate([ 'name' => $data['name'], 'email' => $data['email'], - 'password' => bcrypt($data['password']) + 'password' => bcrypt($data['password']), + 'email_confirmed' => false ]); } From d099885fd1af7b97e7945376ea6971f139cffd33 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 3 Apr 2016 12:19:44 +0100 Subject: [PATCH 10/18] Fixed some label casing and fixed incorrect notifications Fixed the book & chapter permission update notification stating the 'page permissions' have been updated. --- app/Http/Controllers/BookController.php | 2 +- app/Http/Controllers/ChapterController.php | 2 +- resources/views/settings/index.blade.php | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php index 5b2b510c9..3390b41c0 100644 --- a/app/Http/Controllers/BookController.php +++ b/app/Http/Controllers/BookController.php @@ -267,7 +267,7 @@ class BookController extends Controller $book = $this->bookRepo->getBySlug($bookSlug); $this->checkOwnablePermission('restrictions-manage', $book); $this->bookRepo->updateRestrictionsFromRequest($request, $book); - session()->flash('success', 'Page Restrictions Updated'); + session()->flash('success', 'Book Restrictions Updated'); return redirect($book->getUrl()); } } diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php index 6b8a2f18f..4641ddbdb 100644 --- a/app/Http/Controllers/ChapterController.php +++ b/app/Http/Controllers/ChapterController.php @@ -187,7 +187,7 @@ class ChapterController extends Controller $chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id); $this->checkOwnablePermission('restrictions-manage', $chapter); $this->chapterRepo->updateRestrictionsFromRequest($request, $chapter); - session()->flash('success', 'Page Restrictions Updated'); + session()->flash('success', 'Chapter Restrictions Updated'); return redirect($chapter->getUrl()); } } diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php index eb580bb8b..7e38154d5 100644 --- a/resources/views/settings/index.blade.php +++ b/resources/views/settings/index.blade.php @@ -29,7 +29,7 @@
- +

Select which editor will be used by all users to edit pages.

From 8e614ecb6e02f539e615baf954dfc0f44fb955c4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 3 Apr 2016 12:34:10 +0100 Subject: [PATCH 11/18] Updated tests to match recent email confirmation changes --- tests/Auth/LdapTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Auth/LdapTest.php b/tests/Auth/LdapTest.php index 13036e5dc..b52b6ffe1 100644 --- a/tests/Auth/LdapTest.php +++ b/tests/Auth/LdapTest.php @@ -43,7 +43,7 @@ class LdapTest extends \TestCase ->press('Sign In') ->seePageIs('/') ->see($this->mockUser->name) - ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => 1, 'external_auth_id' => $this->mockUser->name]); + ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name]); } public function test_login_works_when_no_uid_provided_by_ldap_server() @@ -67,7 +67,7 @@ class LdapTest extends \TestCase ->press('Sign In') ->seePageIs('/') ->see($this->mockUser->name) - ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => 1, 'external_auth_id' => $ldapDn]); + ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $ldapDn]); } public function test_initial_incorrect_details() From cbff2c6035aa6204092b7b93061b1c941fb95eb8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 3 Apr 2016 14:59:54 +0100 Subject: [PATCH 12/18] Added uploaded to book/page filters & search in image manager Also refactored tab styles which affected the settings area. Closes #41 --- app/Http/Controllers/ImageController.php | 46 +++++++++-- app/Http/routes.php | 2 + app/Repos/ImageRepo.php | 80 ++++++++++++++++--- resources/assets/js/controllers.js | 78 +++++++++++++++++- resources/assets/sass/_header.scss | 5 +- resources/assets/sass/_image-manager.scss | 7 +- resources/assets/sass/styles.scss | 25 ++++++ .../views/partials/custom-styles.blade.php | 2 +- .../views/partials/image-manager.blade.php | 14 ++++ resources/views/settings/navbar.blade.php | 2 +- 10 files changed, 234 insertions(+), 27 deletions(-) diff --git a/app/Http/Controllers/ImageController.php b/app/Http/Controllers/ImageController.php index f9d65c48b..2e5d5f303 100644 --- a/app/Http/Controllers/ImageController.php +++ b/app/Http/Controllers/ImageController.php @@ -1,14 +1,9 @@ -json($imgData); } + /** + * Search through images within a particular type. + * @param $type + * @param int $page + * @param Request $request + * @return mixed + */ + public function searchByType($type, $page = 0, Request $request) + { + $this->validate($request, [ + 'term' => 'required|string' + ]); + + $searchTerm = $request->get('term'); + $imgData = $this->imageRepo->searchPaginatedByType($type, $page,24, $searchTerm); + return response()->json($imgData); + } + /** * Get all images for a user. * @param int $page @@ -55,6 +68,27 @@ class ImageController extends Controller return response()->json($imgData); } + /** + * Get gallery images with a specific filter such as book or page + * @param $filter + * @param int $page + * @param Request $request + */ + public function getGalleryFiltered($filter, $page = 0, Request $request) + { + $this->validate($request, [ + 'page_id' => 'required|integer' + ]); + + $validFilters = collect(['page', 'book']); + if (!$validFilters->contains($filter)) return response('Invalid filter', 500); + + $pageId = $request->get('page_id'); + $imgData = $this->imageRepo->getGalleryFiltered($page, 24, strtolower($filter), $pageId); + + return response()->json($imgData); + } + /** * Handles image uploads for use on pages. * @param string $type diff --git a/app/Http/routes.php b/app/Http/routes.php index eca37347c..9565b7576 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -75,6 +75,8 @@ Route::group(['middleware' => 'auth'], function () { Route::post('/{type}/upload', 'ImageController@uploadByType'); Route::get('/{type}/all', 'ImageController@getAllByType'); Route::get('/{type}/all/{page}', 'ImageController@getAllByType'); + Route::get('/{type}/search/{page}', 'ImageController@searchByType'); + Route::get('/gallery/{filter}/{page}', 'ImageController@getGalleryFiltered'); Route::delete('/{imageId}', 'ImageController@destroy'); }); diff --git a/app/Repos/ImageRepo.php b/app/Repos/ImageRepo.php index 2e2624a6e..8dd4d346d 100644 --- a/app/Repos/ImageRepo.php +++ b/app/Repos/ImageRepo.php @@ -2,6 +2,7 @@ use BookStack\Image; +use BookStack\Page; use BookStack\Services\ImageService; use BookStack\Services\RestrictionService; use Setting; @@ -13,18 +14,21 @@ class ImageRepo protected $image; protected $imageService; protected $restictionService; + protected $page; /** * ImageRepo constructor. * @param Image $image * @param ImageService $imageService * @param RestrictionService $restrictionService + * @param Page $page */ - public function __construct(Image $image, ImageService $imageService, RestrictionService $restrictionService) + public function __construct(Image $image, ImageService $imageService, RestrictionService $restrictionService, Page $page) { $this->image = $image; $this->imageService = $imageService; $this->restictionService = $restrictionService; + $this->page = $page; } @@ -38,6 +42,31 @@ class ImageRepo return $this->image->findOrFail($id); } + /** + * Execute a paginated query, returning in a standard format. + * Also runs the query through the restriction system. + * @param $query + * @param int $page + * @param int $pageSize + * @return array + */ + private function returnPaginated($query, $page = 0, $pageSize = 24) + { + $images = $this->restictionService->filterRelatedPages($query, 'images', 'uploaded_to'); + $images = $images->orderBy('created_at', 'desc')->skip($pageSize * $page)->take($pageSize + 1)->get(); + $hasMore = count($images) > $pageSize; + + $returnImages = $images->take(24); + $returnImages->each(function ($image) { + $this->loadThumbs($image); + }); + + return [ + 'images' => $returnImages, + 'hasMore' => $hasMore + ]; + } + /** * Gets a load images paginated, filtered by image type. * @param string $type @@ -54,19 +83,46 @@ class ImageRepo $images = $images->where('created_by', '=', $userFilter); } - $images = $this->restictionService->filterRelatedPages($images, 'images', 'uploaded_to'); - $images = $images->orderBy('created_at', 'desc')->skip($pageSize * $page)->take($pageSize + 1)->get(); - $hasMore = count($images) > $pageSize; + return $this->returnPaginated($images, $page, $pageSize); + } - $returnImages = $images->take(24); - $returnImages->each(function ($image) { - $this->loadThumbs($image); - }); + /** + * Search for images by query, of a particular type. + * @param string $type + * @param int $page + * @param int $pageSize + * @param string $searchTerm + * @return array + */ + public function searchPaginatedByType($type, $page = 0, $pageSize = 24, $searchTerm) + { + $images = $this->image->where('type', '=', strtolower($type))->where('name', 'LIKE', '%' . $searchTerm . '%'); + return $this->returnPaginated($images, $page, $pageSize); + } - return [ - 'images' => $returnImages, - 'hasMore' => $hasMore - ]; + /** + * Get gallery images with a particular filter criteria such as + * being within the current book or page. + * @param int $pagination + * @param int $pageSize + * @param $filter + * @param $pageId + * @return array + */ + public function getGalleryFiltered($pagination = 0, $pageSize = 24, $filter, $pageId) + { + $images = $this->image->where('type', '=', 'gallery'); + + $page = $this->page->findOrFail($pageId); + + if ($filter === 'page') { + $images = $images->where('uploaded_to', '=', $page->id); + } elseif ($filter === 'book') { + $validPageIds = $page->book->pages->pluck('id')->toArray(); + $images = $images->whereIn('uploaded_to', $validPageIds); + } + + return $this->returnPaginated($images, $pagination, $pageSize); } /** diff --git a/resources/assets/js/controllers.js b/resources/assets/js/controllers.js index dbd2e1ae6..83e58ee4b 100644 --- a/resources/assets/js/controllers.js +++ b/resources/assets/js/controllers.js @@ -14,20 +14,40 @@ module.exports = function (ngApp, events) { $scope.imageUpdateSuccess = false; $scope.imageDeleteSuccess = false; $scope.uploadedTo = $attrs.uploadedTo; + $scope.view = 'all'; + + $scope.searching = false; + $scope.searchTerm = ''; var page = 0; var previousClickTime = 0; + var previousClickImage = 0; var dataLoaded = false; var callback = false; + var preSearchImages = []; + var preSearchHasMore = false; + /** - * Simple returns the appropriate upload url depending on the image type set. + * Used by dropzone to get the endpoint to upload to. * @returns {string} */ $scope.getUploadUrl = function () { return '/images/' + $scope.imageType + '/upload'; }; + /** + * Cancel the current search operation. + */ + function cancelSearch() { + $scope.searching = false; + $scope.searchTerm = ''; + $scope.images = preSearchImages; + $scope.hasMore = preSearchHasMore; + } + $scope.cancelSearch = cancelSearch; + + /** * Runs on image upload, Adds an image to local list of images * and shows a success message to the user. @@ -59,7 +79,7 @@ module.exports = function (ngApp, events) { var currentTime = Date.now(); var timeDiff = currentTime - previousClickTime; - if (timeDiff < dblClickTime) { + if (timeDiff < dblClickTime && image.id === previousClickImage) { // If double click callbackAndHide(image); } else { @@ -68,6 +88,7 @@ module.exports = function (ngApp, events) { $scope.dependantPages = false; } previousClickTime = currentTime; + previousClickImage = image.id; }; /** @@ -110,20 +131,69 @@ module.exports = function (ngApp, events) { $scope.showing = false; }; + var baseUrl = '/images/' + $scope.imageType + '/all/' + /** * Fetch the list image data from the server. */ function fetchData() { - var url = '/images/' + $scope.imageType + '/all/' + page; + var url = baseUrl + page + '?'; + var components = {}; + if ($scope.uploadedTo) components['page_id'] = $scope.uploadedTo; + if ($scope.searching) components['term'] = $scope.searchTerm; + + + var urlQueryString = Object.keys(components).map((key) => { + return key + '=' + encodeURIComponent(components[key]); + }).join('&'); + url += urlQueryString; + $http.get(url).then((response) => { $scope.images = $scope.images.concat(response.data.images); $scope.hasMore = response.data.hasMore; page++; }); } - $scope.fetchData = fetchData; + /** + * Start a search operation + * @param searchTerm + */ + $scope.searchImages = function() { + + if ($scope.searchTerm === '') { + cancelSearch(); + return; + } + + if (!$scope.searching) { + preSearchImages = $scope.images; + preSearchHasMore = $scope.hasMore; + } + + $scope.searching = true; + $scope.images = []; + $scope.hasMore = false; + page = 0; + baseUrl = '/images/' + $scope.imageType + '/search/'; + fetchData(); + }; + + /** + * Set the current image listing view. + * @param viewName + */ + $scope.setView = function(viewName) { + cancelSearch(); + $scope.images = []; + $scope.hasMore = false; + page = 0; + $scope.view = viewName; + baseUrl = '/images/' + $scope.imageType + '/' + viewName + '/'; + fetchData(); + } + /** * Save the details of an image. * @param event diff --git a/resources/assets/sass/_header.scss b/resources/assets/sass/_header.scss index aa7c2f471..e0b1a99cb 100644 --- a/resources/assets/sass/_header.scss +++ b/resources/assets/sass/_header.scss @@ -189,12 +189,13 @@ form.search-box { } } -.setting-nav { +.nav-tabs { text-align: center; - a { + a, .tab-item { padding: $-m; display: inline-block; color: #666; + cursor: pointer; &.selected { border-bottom: 2px solid $primary; } diff --git a/resources/assets/sass/_image-manager.scss b/resources/assets/sass/_image-manager.scss index 8b18d24f3..73b3b59d6 100644 --- a/resources/assets/sass/_image-manager.scss +++ b/resources/assets/sass/_image-manager.scss @@ -120,7 +120,6 @@ .image-manager-list { overflow-y: scroll; flex: 1; - border-top: 1px solid #ddd; } .image-manager-content { @@ -128,6 +127,12 @@ flex-direction: column; height: 100%; flex: 1; + .container { + width: 100%; + } + .full-tab { + text-align: center; + } } // Dropzone diff --git a/resources/assets/sass/styles.scss b/resources/assets/sass/styles.scss index 7c7821242..d8453b9ed 100644 --- a/resources/assets/sass/styles.scss +++ b/resources/assets/sass/styles.scss @@ -176,4 +176,29 @@ $btt-size: 40px; position: relative; top: -5px; } +} + +.contained-search-box { + display: flex; + input, button { + border-radius: 0; + border: 1px solid #DDD; + margin-left: -1px; + } + input { + flex: 5; + &:focus, &:active { + outline: 0; + } + } + button { + width: 60px; + } + button i { + padding: 0; + } + button.cancel.active { + background-color: $negative; + color: #EEE; + } } \ No newline at end of file diff --git a/resources/views/partials/custom-styles.blade.php b/resources/views/partials/custom-styles.blade.php index 011f06654..de324d284 100644 --- a/resources/views/partials/custom-styles.blade.php +++ b/resources/views/partials/custom-styles.blade.php @@ -12,7 +12,7 @@ .button-base:hover, .button:hover, input[type="button"]:hover, input[type="submit"]:hover, .button:focus { background-color: {{ Setting::get('app-color') }}; } - .setting-nav a.selected { + .nav-tabs a.selected, .nav-tabs .tab-item.selected { border-bottom-color: {{ Setting::get('app-color') }}; } p.primary:hover, p .primary:hover, span.primary:hover, .text-primary:hover, a, a:hover, a:focus { diff --git a/resources/views/partials/image-manager.blade.php b/resources/views/partials/image-manager.blade.php index a394975d8..69928e119 100644 --- a/resources/views/partials/image-manager.blade.php +++ b/resources/views/partials/image-manager.blade.php @@ -3,6 +3,20 @@
+
+ +
+
+ +
-
+