diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..4f9f4c480 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,11 @@ +### For Feature Requests +Desired Feature: + +### For Bug Reports +PHP Version: + +MySQL Version: + +Expected Behavior: + +Actual Behavior: diff --git a/.travis.yml b/.travis.yml index 83e9e10f5..e2eb5f511 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,8 +6,6 @@ php: cache: directories: - - vendor - - node_modules - $HOME/.composer/cache addons: @@ -17,19 +15,17 @@ addons: - mysql-client-core-5.6 - mysql-client-5.6 -before_install: - - npm install -g npm@latest - before_script: - mysql -u root -e 'create database `bookstack-test`;' - composer config -g github-oauth.github.com $GITHUB_ACCESS_TOKEN - phpenv config-rm xdebug.ini - composer self-update + - composer dump-autoload --no-interaction - composer install --prefer-dist --no-interaction - - npm install - - ./node_modules/.bin/gulp + - php artisan clear-compiled -n + - php artisan optimize -n - php artisan migrate --force -n --database=mysql_testing - php artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing script: - - vendor/bin/phpunit \ No newline at end of file + - phpunit \ No newline at end of file diff --git a/app/Entity.php b/app/Entity.php index 1342c2997..496d20a33 100644 --- a/app/Entity.php +++ b/app/Entity.php @@ -167,7 +167,8 @@ class Entity extends Ownable foreach ($terms as $key => $term) { $term = htmlentities($term, ENT_QUOTES); $term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term); - if (preg_match('/\s/', $term)) { + if (preg_match('/".*?"/', $term)) { + $term = str_replace('"', '', $term); $exactTerms[] = '%' . $term . '%'; $term = '"' . $term . '"'; } else { @@ -206,5 +207,5 @@ class Entity extends Ownable return $search->orderBy($orderBy, 'desc'); } - + } diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 40dd1ec10..57e807db0 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -47,19 +47,44 @@ class Handler extends ExceptionHandler { // Handle notify exceptions which will redirect to the // specified location then show a notification message. - if ($e instanceof NotifyException) { - session()->flash('error', $e->message); + if ($this->isExceptionType($e, NotifyException::class)) { + session()->flash('error', $this->getOriginalMessage($e)); return redirect($e->redirectLocation); } // Handle pretty exceptions which will show a friendly application-fitting page // Which will include the basic message to point the user roughly to the cause. - if (($e instanceof PrettyException || $e->getPrevious() instanceof PrettyException) && !config('app.debug')) { - $message = ($e instanceof PrettyException) ? $e->getMessage() : $e->getPrevious()->getMessage(); + if ($this->isExceptionType($e, PrettyException::class) && !config('app.debug')) { + $message = $this->getOriginalMessage($e); $code = ($e->getCode() === 0) ? 500 : $e->getCode(); return response()->view('errors/' . $code, ['message' => $message], $code); } return parent::render($request, $e); } + + /** + * Check the exception chain to compare against the original exception type. + * @param Exception $e + * @param $type + * @return bool + */ + protected function isExceptionType(Exception $e, $type) { + do { + if (is_a($e, $type)) return true; + } while ($e = $e->getPrevious()); + return false; + } + + /** + * Get original exception message. + * @param Exception $e + * @return string + */ + protected function getOriginalMessage(Exception $e) { + do { + $message = $e->getMessage(); + } while ($e = $e->getPrevious()); + return $message; + } } diff --git a/app/Exceptions/PrettyException.php b/app/Exceptions/PrettyException.php index d92acf831..889252006 100644 --- a/app/Exceptions/PrettyException.php +++ b/app/Exceptions/PrettyException.php @@ -1,5 +1,3 @@ getUrl()); } - $sortedBooks = []; // Sort pages and chapters + $sortedBooks = []; + $updatedModels = collect(); $sortMap = json_decode($request->get('sort-tree')); $defaultBookId = $book->id; - foreach ($sortMap as $index => $bookChild) { - $id = $bookChild->id; + + // Loop through contents of provided map and update entities accordingly + foreach ($sortMap as $bookChild) { + $priority = $bookChild->sort; + $id = intval($bookChild->id); $isPage = $bookChild->type == 'page'; - $bookId = $this->bookRepo->exists($bookChild->book) ? $bookChild->book : $defaultBookId; + $bookId = $this->bookRepo->exists($bookChild->book) ? intval($bookChild->book) : $defaultBookId; + $chapterId = ($isPage && $bookChild->parentChapter === false) ? 0 : intval($bookChild->parentChapter); $model = $isPage ? $this->pageRepo->getById($id) : $this->chapterRepo->getById($id); - $isPage ? $this->pageRepo->changeBook($bookId, $model) : $this->chapterRepo->changeBook($bookId, $model); - $model->priority = $index; - if ($isPage) { - $model->chapter_id = ($bookChild->parentChapter === false) ? 0 : $bookChild->parentChapter; + + // Update models only if there's a change in parent chain or ordering. + if ($model->priority !== $priority || $model->book_id !== $bookId || ($isPage && $model->chapter_id !== $chapterId)) { + $isPage ? $this->pageRepo->changeBook($bookId, $model) : $this->chapterRepo->changeBook($bookId, $model); + $model->priority = $priority; + if ($isPage) $model->chapter_id = $chapterId; + $model->save(); + $updatedModels->push($model); } - $model->save(); + + // Store involved books to be sorted later if (!in_array($bookId, $sortedBooks)) { $sortedBooks[] = $bookId; } @@ -203,10 +212,12 @@ class BookController extends Controller // Add activity for books foreach ($sortedBooks as $bookId) { $updatedBook = $this->bookRepo->getById($bookId); - $this->bookRepo->updateBookPermissions($updatedBook); Activity::add($updatedBook, 'book_sort', $updatedBook->id); } + // Update permissions on changed models + $this->bookRepo->buildJointPermissions($updatedModels); + return redirect($book->getUrl()); } diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php index 3c9050bf6..03ec2c110 100644 --- a/app/Http/Controllers/ChapterController.php +++ b/app/Http/Controllers/ChapterController.php @@ -204,7 +204,7 @@ class ChapterController extends Controller return redirect()->back(); } - $this->chapterRepo->changeBook($parent->id, $chapter); + $this->chapterRepo->changeBook($parent->id, $chapter, true); Activity::add($chapter, 'chapter_move', $chapter->book->id); session()->flash('success', sprintf('Chapter moved to "%s"', $parent->name)); diff --git a/app/PageRevision.php b/app/PageRevision.php index dae74cd0f..1ffd63dbd 100644 --- a/app/PageRevision.php +++ b/app/PageRevision.php @@ -3,7 +3,7 @@ class PageRevision extends Model { - protected $fillable = ['name', 'html', 'text', 'markdown']; + protected $fillable = ['name', 'html', 'text', 'markdown', 'summary']; /** * Get the user that created the page revision diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f214c9141..4665bf6c7 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -1,10 +1,6 @@ -delete(); } - /** - * Alias method to update the book jointPermissions in the PermissionService. - * @param Book $book - */ - public function updateBookPermissions(Book $book) - { - $this->permissionService->buildJointPermissionsForEntity($book); - } - /** * Get the next child element priority. * @param Book $book diff --git a/app/Repos/ChapterRepo.php b/app/Repos/ChapterRepo.php index 1a8cbdf0f..c12a9f0f2 100644 --- a/app/Repos/ChapterRepo.php +++ b/app/Repos/ChapterRepo.php @@ -195,11 +195,12 @@ class ChapterRepo extends EntityRepo /** * Changes the book relation of this chapter. - * @param $bookId + * @param $bookId * @param Chapter $chapter + * @param bool $rebuildPermissions * @return Chapter */ - public function changeBook($bookId, Chapter $chapter) + public function changeBook($bookId, Chapter $chapter, $rebuildPermissions = false) { $chapter->book_id = $bookId; // Update related activity @@ -213,9 +214,12 @@ class ChapterRepo extends EntityRepo foreach ($chapter->pages as $page) { $this->pageRepo->changeBook($bookId, $page); } - // Update permissions - $chapter->load('book'); - $this->permissionService->buildJointPermissionsForEntity($chapter->book); + + // Update permissions if applicable + if ($rebuildPermissions) { + $chapter->load('book'); + $this->permissionService->buildJointPermissionsForEntity($chapter->book); + } return $chapter; } diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php index 012a64967..c94601738 100644 --- a/app/Repos/EntityRepo.php +++ b/app/Repos/EntityRepo.php @@ -6,6 +6,7 @@ use BookStack\Entity; use BookStack\Page; use BookStack\Services\PermissionService; use BookStack\User; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; class EntityRepo @@ -168,15 +169,16 @@ class EntityRepo * @param $termString * @return array */ - protected function prepareSearchTerms($termString) + public function prepareSearchTerms($termString) { $termString = $this->cleanSearchTermString($termString); - preg_match_all('/"(.*?)"/', $termString, $matches); + preg_match_all('/(".*?")/', $termString, $matches); + $terms = []; if (count($matches[1]) > 0) { - $terms = $matches[1]; + foreach ($matches[1] as $match) { + $terms[] = $match; + } $termString = trim(preg_replace('/"(.*?)"/', '', $termString)); - } else { - $terms = []; } if (!empty($termString)) $terms = array_merge($terms, explode(' ', $termString)); return $terms; @@ -259,6 +261,15 @@ class EntityRepo return $query; } + /** + * Alias method to update the book jointPermissions in the PermissionService. + * @param Collection $collection collection on entities + */ + public function buildJointPermissions(Collection $collection) + { + $this->permissionService->buildJointPermissionsForEntities($collection); + } + } diff --git a/app/Repos/PageRepo.php b/app/Repos/PageRepo.php index 3698e5efb..235246f82 100644 --- a/app/Repos/PageRepo.php +++ b/app/Repos/PageRepo.php @@ -157,6 +157,8 @@ class PageRepo extends EntityRepo $draftPage->draft = false; $draftPage->save(); + $this->saveRevision($draftPage, 'Initial Publish'); + return $draftPage; } @@ -308,10 +310,9 @@ class PageRepo extends EntityRepo */ public function updatePage(Page $page, $book_id, $input) { - // Save a revision before updating - if ($page->html !== $input['html'] || $page->name !== $input['name']) { - $this->saveRevision($page); - } + // Hold the old details to compare later + $oldHtml = $page->html; + $oldName = $page->name; // Prevent slug being updated if no name change if ($page->name !== $input['name']) { @@ -335,6 +336,11 @@ class PageRepo extends EntityRepo // Remove all update drafts for this user & page. $this->userUpdateDraftsQuery($page, $userId)->delete(); + // Save a revision after updating + if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $input['summary'] !== null) { + $this->saveRevision($page, $input['summary']); + } + return $page; } @@ -360,9 +366,10 @@ class PageRepo extends EntityRepo /** * Saves a page revision into the system. * @param Page $page + * @param null|string $summary * @return $this */ - public function saveRevision(Page $page) + public function saveRevision(Page $page, $summary = null) { $revision = $this->pageRevision->fill($page->toArray()); if (setting('app-editor') !== 'markdown') $revision->markdown = ''; @@ -372,6 +379,7 @@ class PageRepo extends EntityRepo $revision->created_by = auth()->user()->id; $revision->created_at = $page->updated_at; $revision->type = 'version'; + $revision->summary = $summary; $revision->save(); // Clear old revisions if ($this->pageRevision->where('page_id', '=', $page->id)->count() > 50) { diff --git a/app/Services/ExportService.php b/app/Services/ExportService.php index 0497681e9..14084d320 100644 --- a/app/Services/ExportService.php +++ b/app/Services/ExportService.php @@ -48,11 +48,13 @@ class ExportService foreach ($imageTagsOutput[0] as $index => $imgMatch) { $oldImgString = $imgMatch; $srcString = $imageTagsOutput[2][$index]; - if (strpos(trim($srcString), 'http') !== 0) { - $pathString = public_path($srcString); + $isLocal = strpos(trim($srcString), 'http') !== 0; + if ($isLocal) { + $pathString = public_path(trim($srcString, '/')); } else { $pathString = $srcString; } + if ($isLocal && !file_exists($pathString)) continue; $imageContent = file_get_contents($pathString); $imageEncoded = 'data:image/' . pathinfo($pathString, PATHINFO_EXTENSION) . ';base64,' . base64_encode($imageContent); $newImageString = str_replace($srcString, $imageEncoded, $oldImgString); diff --git a/app/Services/ImageService.php b/app/Services/ImageService.php index d9bd61e9f..4401cb230 100644 --- a/app/Services/ImageService.php +++ b/app/Services/ImageService.php @@ -95,6 +95,7 @@ class ImageService try { $storage->put($fullPath, $imageData); + $storage->setVisibility($fullPath, 'public'); } catch (Exception $e) { throw new ImageUploadException('Image Path ' . $fullPath . ' is not writable by the server.'); } @@ -167,6 +168,7 @@ class ImageService $thumbData = (string)$thumb->encode(); $storage->put($thumbFilePath, $thumbData); + $storage->setVisibility($thumbFilePath, 'public'); $this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 72); return $this->getPublicUrl($thumbFilePath); @@ -257,9 +259,15 @@ class ImageService $storageUrl = config('filesystems.url'); // Get the standard public s3 url if s3 is set as storage type + // Uses the nice, short URL if bucket name has no periods in otherwise the longer + // region-based url will be used to prevent http issues. if ($storageUrl == false && config('filesystems.default') === 's3') { $storageDetails = config('filesystems.disks.s3'); - $storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket']; + if (strpos($storageDetails['bucket'], '.') === false) { + $storageUrl = 'https://' . $storageDetails['bucket'] . '.s3.amazonaws.com'; + } else { + $storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket']; + } } $this->storageUrl = $storageUrl; @@ -269,4 +277,4 @@ class ImageService } -} \ No newline at end of file +} diff --git a/app/Services/PermissionService.php b/app/Services/PermissionService.php index 0fffe60f2..cee074cd7 100644 --- a/app/Services/PermissionService.php +++ b/app/Services/PermissionService.php @@ -8,7 +8,7 @@ use BookStack\Ownable; use BookStack\Page; use BookStack\Role; use BookStack\User; -use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Collection; class PermissionService { @@ -25,6 +25,8 @@ class PermissionService protected $jointPermission; protected $role; + protected $entityCache; + /** * PermissionService constructor. * @param JointPermission $jointPermission @@ -48,6 +50,57 @@ class PermissionService $this->page = $page; } + /** + * Prepare the local entity cache and ensure it's empty + */ + protected function readyEntityCache() + { + $this->entityCache = [ + 'books' => collect(), + 'chapters' => collect() + ]; + } + + /** + * Get a book via ID, Checks local cache + * @param $bookId + * @return Book + */ + protected function getBook($bookId) + { + if (isset($this->entityCache['books']) && $this->entityCache['books']->has($bookId)) { + return $this->entityCache['books']->get($bookId); + } + + $book = $this->book->find($bookId); + if ($book === null) $book = false; + if (isset($this->entityCache['books'])) { + $this->entityCache['books']->put($bookId, $book); + } + + return $book; + } + + /** + * Get a chapter via ID, Checks local cache + * @param $chapterId + * @return Book + */ + protected function getChapter($chapterId) + { + if (isset($this->entityCache['chapters']) && $this->entityCache['chapters']->has($chapterId)) { + return $this->entityCache['chapters']->get($chapterId); + } + + $chapter = $this->chapter->find($chapterId); + if ($chapter === null) $chapter = false; + if (isset($this->entityCache['chapters'])) { + $this->entityCache['chapters']->put($chapterId, $chapter); + } + + return $chapter; + } + /** * Get the roles for the current user; * @return array|bool @@ -76,6 +129,7 @@ class PermissionService public function buildJointPermissions() { $this->jointPermission->truncate(); + $this->readyEntityCache(); // Get all roles (Should be the most limited dimension) $roles = $this->role->with('permissions')->get(); @@ -97,7 +151,7 @@ class PermissionService } /** - * Create the entity jointPermissions for a particular entity. + * Rebuild the entity jointPermissions for a particular entity. * @param Entity $entity */ public function buildJointPermissionsForEntity(Entity $entity) @@ -116,6 +170,17 @@ class PermissionService $this->createManyJointPermissions($entities, $roles); } + /** + * Rebuild the entity jointPermissions for a collection of entities. + * @param Collection $entities + */ + public function buildJointPermissionsForEntities(Collection $entities) + { + $roles = $this->role->with('jointPermissions')->get(); + $this->deleteManyJointPermissionsForEntities($entities); + $this->createManyJointPermissions($entities, $roles); + } + /** * Build the entity jointPermissions for a particular role. * @param Role $role @@ -177,9 +242,14 @@ class PermissionService */ protected function deleteManyJointPermissionsForEntities($entities) { + $query = $this->jointPermission->newQuery(); foreach ($entities as $entity) { - $entity->jointPermissions()->delete(); + $query->orWhere(function($query) use ($entity) { + $query->where('entity_id', '=', $entity->id) + ->where('entity_type', '=', $entity->getMorphClass()); + }); } + $query->delete(); } /** @@ -189,6 +259,7 @@ class PermissionService */ protected function createManyJointPermissions($entities, $roles) { + $this->readyEntityCache(); $jointPermissions = []; foreach ($entities as $entity) { foreach ($roles as $role) { @@ -248,8 +319,9 @@ class PermissionService } elseif ($entity->isA('chapter')) { if (!$entity->restricted) { - $hasExplicitAccessToBook = $entity->book->hasActiveRestriction($role->id, $restrictionAction); - $hasPermissiveAccessToBook = !$entity->book->restricted; + $book = $this->getBook($entity->book_id); + $hasExplicitAccessToBook = $book->hasActiveRestriction($role->id, $restrictionAction); + $hasPermissiveAccessToBook = !$book->restricted; return $this->createJointPermissionDataArray($entity, $role, $action, ($hasExplicitAccessToBook || ($roleHasPermission && $hasPermissiveAccessToBook)), ($hasExplicitAccessToBook || ($roleHasPermissionOwn && $hasPermissiveAccessToBook))); @@ -261,11 +333,14 @@ class PermissionService } elseif ($entity->isA('page')) { if (!$entity->restricted) { - $hasExplicitAccessToBook = $entity->book->hasActiveRestriction($role->id, $restrictionAction); - $hasPermissiveAccessToBook = !$entity->book->restricted; - $hasExplicitAccessToChapter = $entity->chapter && $entity->chapter->hasActiveRestriction($role->id, $restrictionAction); - $hasPermissiveAccessToChapter = $entity->chapter && !$entity->chapter->restricted; - $acknowledgeChapter = ($entity->chapter && $entity->chapter->restricted); + $book = $this->getBook($entity->book_id); + $hasExplicitAccessToBook = $book->hasActiveRestriction($role->id, $restrictionAction); + $hasPermissiveAccessToBook = !$book->restricted; + + $chapter = $this->getChapter($entity->chapter_id); + $hasExplicitAccessToChapter = $chapter && $chapter->hasActiveRestriction($role->id, $restrictionAction); + $hasPermissiveAccessToChapter = $chapter && !$chapter->restricted; + $acknowledgeChapter = ($chapter && $chapter->restricted); $hasExplicitAccessToParents = $acknowledgeChapter ? $hasExplicitAccessToChapter : $hasExplicitAccessToBook; $hasPermissiveAccessToParents = $acknowledgeChapter ? $hasPermissiveAccessToChapter : $hasPermissiveAccessToBook; diff --git a/app/Services/SocialAuthService.php b/app/Services/SocialAuthService.php index 7e1bd4246..b28a97ea4 100644 --- a/app/Services/SocialAuthService.php +++ b/app/Services/SocialAuthService.php @@ -158,7 +158,7 @@ class SocialAuthService $driver = trim(strtolower($socialDriver)); if (!in_array($driver, $this->validSocialDrivers)) abort(404, 'Social Driver Not Found'); - if (!$this->checkDriverConfigured($driver)) throw new SocialDriverNotConfigured; + if (!$this->checkDriverConfigured($driver)) throw new SocialDriverNotConfigured("Your {$driver} social settings are not configured correctly."); return $driver; } diff --git a/app/helpers.php b/app/helpers.php index e4f9b1b10..b8abb1006 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -2,33 +2,38 @@ use BookStack\Ownable; -if (!function_exists('versioned_asset')) { - /** - * Get the path to a versioned file. - * - * @param string $file - * @return string - * - * @throws \InvalidArgumentException - */ - function versioned_asset($file) - { - static $manifest = null; +/** + * Get the path to a versioned file. + * + * @param string $file + * @return string + * @throws Exception + */ +function versioned_asset($file = '') +{ + // Don't require css and JS assets for testing + if (config('app.env') === 'testing') return ''; - if (is_null($manifest)) { - $manifest = json_decode(file_get_contents(public_path('build/manifest.json')), true); + static $manifest = null; + $manifestPath = 'build/manifest.json'; + + if (is_null($manifest) && file_exists($manifestPath)) { + $manifest = json_decode(file_get_contents(public_path($manifestPath)), true); + } else if (!file_exists($manifestPath)) { + if (config('app.env') !== 'production') { + $path = public_path($manifestPath); + $error = "No {$path} file found, Ensure you have built the css/js assets using gulp."; + } else { + $error = "No {$manifestPath} file found, Ensure you are using the release version of BookStack"; } - - if (isset($manifest[$file])) { - return baseUrl($manifest[$file]); - } - - if (file_exists(public_path($file))) { - return baseUrl($file); - } - - throw new InvalidArgumentException("File {$file} not defined in asset manifest."); + throw new \Exception($error); } + + if (isset($manifest[$file])) { + return baseUrl($manifest[$file]); + } + + throw new InvalidArgumentException("File {$file} not defined in asset manifest."); } /** diff --git a/database/migrations/2016_07_07_181521_add_summary_to_page_revisions.php b/database/migrations/2016_07_07_181521_add_summary_to_page_revisions.php new file mode 100644 index 000000000..c618877ef --- /dev/null +++ b/database/migrations/2016_07_07_181521_add_summary_to_page_revisions.php @@ -0,0 +1,31 @@ +string('summary')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('page_revisions', function ($table) { + $table->dropColumn('summary'); + }); + } +} diff --git a/phpunit.xml b/phpunit.xml index 2150a5aa3..a2b26d413 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -28,7 +28,7 @@ - + diff --git a/resources/assets/js/controllers.js b/resources/assets/js/controllers.js index 9067f6ca4..fcaba2914 100644 --- a/resources/assets/js/controllers.js +++ b/resources/assets/js/controllers.js @@ -69,7 +69,7 @@ module.exports = function (ngApp, events) { */ function callbackAndHide(returnData) { if (callback) callback(returnData); - $scope.showing = false; + $scope.hide(); } /** @@ -109,6 +109,7 @@ module.exports = function (ngApp, events) { function show(doneCallback) { callback = doneCallback; $scope.showing = true; + $('#image-manager').find('.overlay').css('display', 'flex').hide().fadeIn(240); // Get initial images if they have not yet been loaded in. if (!dataLoaded) { fetchData(); @@ -131,6 +132,7 @@ module.exports = function (ngApp, events) { */ $scope.hide = function () { $scope.showing = false; + $('#image-manager').find('.overlay').fadeOut(240); }; var baseUrl = window.baseUrl('/images/' + $scope.imageType + '/all/'); @@ -357,8 +359,6 @@ module.exports = function (ngApp, events) { /** * Save a draft update into the system via an AJAX request. - * @param title - * @param html */ function saveDraft() { var data = { @@ -373,9 +373,17 @@ module.exports = function (ngApp, events) { var updateTime = moment.utc(moment.unix(responseData.data.timestamp)).toDate(); $scope.draftText = responseData.data.message + moment(updateTime).format('HH:mm'); if (!$scope.isNewPageDraft) $scope.isUpdateDraft = true; + showDraftSaveNotification(); }); } + function showDraftSaveNotification() { + $scope.draftUpdated = true; + $timeout(() => { + $scope.draftUpdated = false; + }, 2000) + } + $scope.forceDraftSave = function() { saveDraft(); }; diff --git a/resources/assets/js/directives.js b/resources/assets/js/directives.js index 897707af5..933bbf5ff 100644 --- a/resources/assets/js/directives.js +++ b/resources/assets/js/directives.js @@ -158,9 +158,22 @@ module.exports = function (ngApp, events) { return { restrict: 'A', link: function (scope, element, attrs) { - var menu = element.find('ul'); + const menu = element.find('ul'); element.find('[dropdown-toggle]').on('click', function () { menu.show().addClass('anim menuIn'); + let inputs = menu.find('input'); + let hasInput = inputs.length > 0; + if (hasInput) { + inputs.first().focus(); + element.on('keypress', 'input', event => { + if (event.keyCode === 13) { + event.preventDefault(); + menu.hide(); + menu.removeClass('anim menuIn'); + return false; + } + }); + } element.mouseleave(function () { menu.hide(); menu.removeClass('anim menuIn'); @@ -258,8 +271,6 @@ module.exports = function (ngApp, events) { scope.mdModel = content; scope.mdChange(markdown(content)); - console.log('test'); - element.on('change input', (event) => { content = element.val(); $timeout(() => { @@ -291,6 +302,7 @@ module.exports = function (ngApp, events) { const input = element.find('[markdown-input] textarea').first(); const display = element.find('.markdown-display').first(); const insertImage = element.find('button[data-action="insertImage"]'); + const insertEntityLink = element.find('button[data-action="insertEntityLink"]') let currentCaretPos = 0; @@ -342,6 +354,13 @@ module.exports = function (ngApp, events) { input[0].selectionEnd = caretPos + ('![](http://'.length); return; } + + // Insert entity link shortcut + if (event.which === 75 && event.ctrlKey && event.shiftKey) { + showLinkSelector(); + return; + } + // Pass key presses to controller via event scope.$emit('editor-keydown', event); }); @@ -351,12 +370,109 @@ module.exports = function (ngApp, events) { window.ImageManager.showExternal(image => { let caretPos = currentCaretPos; let currentContent = input.val(); - let mdImageText = "![" + image.name + "](" + image.url + ")"; + let mdImageText = "![" + image.name + "](" + image.thumbs.display + ")"; input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos)); input.change(); }); }); + function showLinkSelector() { + window.showEntityLinkSelector((entity) => { + let selectionStart = currentCaretPos; + let selectionEnd = input[0].selectionEnd; + let textSelected = (selectionEnd !== selectionStart); + let currentContent = input.val(); + + if (textSelected) { + let selectedText = currentContent.substring(selectionStart, selectionEnd); + let linkText = `[${selectedText}](${entity.link})`; + input.val(currentContent.substring(0, selectionStart) + linkText + currentContent.substring(selectionEnd)); + } else { + let linkText = ` [${entity.name}](${entity.link}) `; + input.val(currentContent.substring(0, selectionStart) + linkText + currentContent.substring(selectionStart)) + } + input.change(); + }); + } + insertEntityLink.click(showLinkSelector); + + // Upload and insert image on paste + function editorPaste(e) { + e = e.originalEvent; + if (!e.clipboardData) return + var items = e.clipboardData.items; + if (!items) return; + for (var i = 0; i < items.length; i++) { + uploadImage(items[i].getAsFile()); + } + } + + input.on('paste', editorPaste); + + // Handle image drop, Uploads images to BookStack. + function handleImageDrop(event) { + event.stopPropagation(); + event.preventDefault(); + let files = event.originalEvent.dataTransfer.files; + for (let i = 0; i < files.length; i++) { + uploadImage(files[i]); + } + } + + input.on('drop', handleImageDrop); + + // Handle image upload and add image into markdown content + function uploadImage(file) { + if (file.type.indexOf('image') !== 0) return; + var formData = new FormData(); + var ext = 'png'; + var xhr = new XMLHttpRequest(); + + if (file.name) { + var fileNameMatches = file.name.match(/\.(.+)$/); + if (fileNameMatches) { + ext = fileNameMatches[1]; + } + } + + // Insert image into markdown + let id = "image-" + Math.random().toString(16).slice(2); + let selectStart = input[0].selectionStart; + let selectEnd = input[0].selectionEnd; + let content = input[0].value; + let selectText = content.substring(selectStart, selectEnd); + let placeholderImage = window.baseUrl(`/loading.gif#upload${id}`); + let innerContent = ((selectEnd > selectStart) ? `![${selectText}]` : '![]') + `(${placeholderImage})`; + input[0].value = content.substring(0, selectStart) + innerContent + content.substring(selectEnd); + + input.focus(); + input[0].selectionStart = selectStart; + input[0].selectionEnd = selectStart; + + let remoteFilename = "image-" + Date.now() + "." + ext; + formData.append('file', file, remoteFilename); + formData.append('_token', document.querySelector('meta[name="token"]').getAttribute('content')); + + xhr.open('POST', window.baseUrl('/images/gallery/upload')); + xhr.onload = function () { + let selectStart = input[0].selectionStart; + if (xhr.status === 200 || xhr.status === 201) { + var result = JSON.parse(xhr.responseText); + input[0].value = input[0].value.replace(placeholderImage, result.thumbs.display); + input.change(); + } else { + console.log('An error occurred uploading the image'); + console.log(xhr.responseText); + input[0].value = input[0].value.replace(innerContent, ''); + input.change(); + } + input.focus(); + input[0].selectionStart = selectStart; + input[0].selectionEnd = selectStart; + }; + xhr.send(formData); + } + } } }]); @@ -587,6 +703,58 @@ module.exports = function (ngApp, events) { } }]); + ngApp.directive('entityLinkSelector', [function($http) { + return { + restict: 'A', + link: function(scope, element, attrs) { + + const selectButton = element.find('.entity-link-selector-confirm'); + let callback = false; + let entitySelection = null; + + // Handle entity selection change, Stores the selected entity locally + function entitySelectionChange(entity) { + entitySelection = entity; + if (entity === null) { + selectButton.attr('disabled', 'true'); + } else { + selectButton.removeAttr('disabled'); + } + } + events.listen('entity-select-change', entitySelectionChange); + + // Handle selection confirm button click + selectButton.click(event => { + hide(); + if (entitySelection !== null) callback(entitySelection); + }); + + // Show selector interface + function show() { + element.fadeIn(240); + } + + // Hide selector interface + function hide() { + element.fadeOut(240); + } + + // Listen to confirmation of entity selections (doubleclick) + events.listen('entity-select-confirm', entity => { + hide(); + callback(entity); + }); + + // Show entity selector, Accessible globally, and store the callback + window.showEntityLinkSelector = function(passedCallback) { + show(); + callback = passedCallback; + }; + + } + }; + }]); + ngApp.directive('entitySelector', ['$http', '$sce', function ($http, $sce) { return { @@ -600,26 +768,60 @@ module.exports = function (ngApp, events) { // Add input for forms const input = element.find('[entity-selector-input]').first(); + // Detect double click events + var lastClick = 0; + function isDoubleClick() { + let now = Date.now(); + let answer = now - lastClick < 300; + lastClick = now; + return answer; + } + // Listen to entity item clicks element.on('click', '.entity-list a', function(event) { event.preventDefault(); event.stopPropagation(); let item = $(this).closest('[data-entity-type]'); - itemSelect(item); + itemSelect(item, isDoubleClick()); }); element.on('click', '[data-entity-type]', function(event) { - itemSelect($(this)); + itemSelect($(this), isDoubleClick()); }); // Select entity action - function itemSelect(item) { + function itemSelect(item, doubleClick) { let entityType = item.attr('data-entity-type'); let entityId = item.attr('data-entity-id'); - let isSelected = !item.hasClass('selected'); + let isSelected = !item.hasClass('selected') || doubleClick; element.find('.selected').removeClass('selected').removeClass('primary-background'); if (isSelected) item.addClass('selected').addClass('primary-background'); let newVal = isSelected ? `${entityType}:${entityId}` : ''; input.val(newVal); + + if (!isSelected) { + events.emit('entity-select-change', null); + } + + if (!doubleClick && !isSelected) return; + + let link = item.find('.entity-list-item-link').attr('href'); + let name = item.find('.entity-list-item-name').text(); + + if (doubleClick) { + events.emit('entity-select-confirm', { + id: Number(entityId), + name: name, + link: link + }); + } + + if (isSelected) { + events.emit('entity-select-change', { + id: Number(entityId), + name: name, + link: link + }); + } } // Get search url with correct types diff --git a/resources/assets/js/global.js b/resources/assets/js/global.js index 1c300ad26..9ca335ee7 100644 --- a/resources/assets/js/global.js +++ b/resources/assets/js/global.js @@ -18,9 +18,12 @@ window.baseUrl = function(path) { var ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize', 'ui.sortable']); // Global Event System -var Events = { - listeners: {}, - emit: function (eventName, eventData) { +class EventManager { + constructor() { + this.listeners = {}; + } + + emit(eventName, eventData) { if (typeof this.listeners[eventName] === 'undefined') return this; var eventsToStart = this.listeners[eventName]; for (let i = 0; i < eventsToStart.length; i++) { @@ -28,33 +31,35 @@ var Events = { event(eventData); } return this; - }, - listen: function (eventName, callback) { + } + + listen(eventName, callback) { if (typeof this.listeners[eventName] === 'undefined') this.listeners[eventName] = []; this.listeners[eventName].push(callback); return this; } }; -window.Events = Events; +window.Events = new EventManager(); -var services = require('./services')(ngApp, Events); -var directives = require('./directives')(ngApp, Events); -var controllers = require('./controllers')(ngApp, Events); +var services = require('./services')(ngApp, window.Events); +var directives = require('./directives')(ngApp, window.Events); +var controllers = require('./controllers')(ngApp, window.Events); //Global jQuery Config & Extensions // Smooth scrolling jQuery.fn.smoothScrollTo = function () { if (this.length === 0) return; - $('body').animate({ + let scrollElem = document.documentElement.scrollTop === 0 ? document.body : document.documentElement; + $(scrollElem).animate({ scrollTop: this.offset().top - 60 // Adjust to change final scroll position top margin }, 800); // Adjust to change animations speed (ms) return this; }; // Making contains text expression not worry about casing -$.expr[":"].contains = $.expr.createPseudo(function (arg) { +jQuery.expr[":"].contains = $.expr.createPseudo(function (arg) { return function (elem) { return $(elem).text().toUpperCase().indexOf(arg.toUpperCase()) >= 0; }; @@ -104,13 +109,14 @@ $(function () { var scrollTop = document.getElementById('back-to-top'); var scrollTopBreakpoint = 1200; window.addEventListener('scroll', function() { - if (!scrollTopShowing && document.body.scrollTop > scrollTopBreakpoint) { + let scrollTopPos = document.documentElement.scrollTop || document.body.scrollTop || 0; + if (!scrollTopShowing && scrollTopPos > scrollTopBreakpoint) { scrollTop.style.display = 'block'; scrollTopShowing = true; setTimeout(() => { scrollTop.style.opacity = 0.4; }, 1); - } else if (scrollTopShowing && document.body.scrollTop < scrollTopBreakpoint) { + } else if (scrollTopShowing && scrollTopPos < scrollTopBreakpoint) { scrollTop.style.opacity = 0; scrollTopShowing = false; setTimeout(() => { @@ -124,6 +130,27 @@ $(function () { $('.entity-list.compact').find('p').not('.empty-text').slideToggle(240); }); + // Popup close + $('.popup-close').click(function() { + $(this).closest('.overlay').fadeOut(240); + }); + $('.overlay').click(function(event) { + if (!$(event.target).hasClass('overlay')) return; + $(this).fadeOut(240); + }); + + // Prevent markdown display link click redirect + $('.markdown-display').on('click', 'a', function(event) { + event.preventDefault(); + window.open($(this).attr('href')); + }); + + // Detect IE for css + if(navigator.userAgent.indexOf('MSIE')!==-1 + || navigator.appVersion.indexOf('Trident/') > 0 + || navigator.userAgent.indexOf('Safari') !== -1){ + $('body').addClass('flexbox-support'); + } }); diff --git a/resources/assets/js/pages/page-form.js b/resources/assets/js/pages/page-form.js index f8b314e9c..101aa8a38 100644 --- a/resources/assets/js/pages/page-form.js +++ b/resources/assets/js/pages/page-form.js @@ -1,3 +1,65 @@ +"use strict"; + +/** + * Handle pasting images from clipboard. + * @param e - event + * @param editor - editor instance + */ +function editorPaste(e, editor) { + if (!e.clipboardData) return + let items = e.clipboardData.items; + if (!items) return; + for (let i = 0; i < items.length; i++) { + if (items[i].type.indexOf("image") === -1) return + + let file = items[i].getAsFile(); + let formData = new FormData(); + let ext = 'png'; + let xhr = new XMLHttpRequest(); + + if (file.name) { + let fileNameMatches = file.name.match(/\.(.+)$/); + if (fileNameMatches) { + ext = fileNameMatches[1]; + } + } + + let id = "image-" + Math.random().toString(16).slice(2); + let loadingImage = window.baseUrl('/loading.gif'); + editor.execCommand('mceInsertContent', false, ``); + + let remoteFilename = "image-" + Date.now() + "." + ext; + formData.append('file', file, remoteFilename); + formData.append('_token', document.querySelector('meta[name="token"]').getAttribute('content')); + + xhr.open('POST', window.baseUrl('/images/gallery/upload')); + xhr.onload = function () { + if (xhr.status === 200 || xhr.status === 201) { + let result = JSON.parse(xhr.responseText); + editor.dom.setAttrib(id, 'src', result.thumbs.display); + } else { + console.log('An error occurred uploading the image', xhr.responseText); + editor.dom.remove(id); + } + }; + xhr.send(formData); + + } +} + +function registerEditorShortcuts(editor) { + // Headers + for (let i = 1; i < 5; i++) { + editor.addShortcut('ctrl+' + i, '', ['FormatBlock', false, 'h' + i]); + } + + // Other block shortcuts + editor.addShortcut('ctrl+q', '', ['FormatBlock', false, 'blockquote']); + editor.addShortcut('ctrl+d', '', ['FormatBlock', false, 'p']); + editor.addShortcut('ctrl+e', '', ['FormatBlock', false, 'pre']); + editor.addShortcut('ctrl+s', '', ['FormatBlock', false, 'code']); +} + var mceOptions = module.exports = { selector: '#html-editor', content_css: [ @@ -6,6 +68,8 @@ var mceOptions = module.exports = { ], body_class: 'page-content', relative_urls: false, + remove_script_host: false, + document_base_url: window.baseUrl('/'), statusbar: false, menubar: false, paste_data_images: false, @@ -38,23 +102,41 @@ var mceOptions = module.exports = { alignright: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-right'}, }, file_browser_callback: function (field_name, url, type, win) { - window.ImageManager.showExternal(function (image) { - win.document.getElementById(field_name).value = image.url; - if ("createEvent" in document) { - var evt = document.createEvent("HTMLEvents"); - evt.initEvent("change", false, true); - win.document.getElementById(field_name).dispatchEvent(evt); - } else { - win.document.getElementById(field_name).fireEvent("onchange"); - } - var html = ''; - html += '' + image.name + ''; - html += ''; - win.tinyMCE.activeEditor.execCommand('mceInsertContent', false, html); - }); + + if (type === 'file') { + window.showEntityLinkSelector(function(entity) { + let originalField = win.document.getElementById(field_name); + originalField.value = entity.link; + $(originalField).closest('.mce-form').find('input').eq(2).val(entity.name); + }); + } + + if (type === 'image') { + // Show image manager + window.ImageManager.showExternal(function (image) { + + // Set popover link input to image url then fire change event + // to ensure the new value sticks + win.document.getElementById(field_name).value = image.url; + if ("createEvent" in document) { + let evt = document.createEvent("HTMLEvents"); + evt.initEvent("change", false, true); + win.document.getElementById(field_name).dispatchEvent(evt); + } else { + win.document.getElementById(field_name).fireEvent("onchange"); + } + + // Replace the actively selected content with the linked image + let html = ``; + html += `${image.name}`; + html += ''; + win.tinyMCE.activeEditor.execCommand('mceInsertContent', false, html); + }); + } + }, paste_preprocess: function (plugin, args) { - var content = args.content; + let content = args.content; if (content.indexOf(''; - html += '' + image.name + ''; + let html = ``; + html += `${image.name}`; html += ''; editor.execCommand('mceInsertContent', false, html); }); @@ -122,49 +207,8 @@ var mceOptions = module.exports = { }); // Paste image-uploads - editor.on('paste', function (e) { - if (e.clipboardData) { - var items = e.clipboardData.items; - if (items) { - for (var i = 0; i < items.length; i++) { - if (items[i].type.indexOf("image") !== -1) { - - var file = items[i].getAsFile(); - var formData = new FormData(); - var ext = 'png'; - var xhr = new XMLHttpRequest(); - - if (file.name) { - var fileNameMatches = file.name.match(/\.(.+)$/); - if (fileNameMatches) { - ext = fileNameMatches[1]; - } - } - - var id = "image-" + Math.random().toString(16).slice(2); - editor.execCommand('mceInsertContent', false, ''); - - var remoteFilename = "image-" + Date.now() + "." + ext; - formData.append('file', file, remoteFilename); - formData.append('_token', document.querySelector('meta[name="token"]').getAttribute('content')); - - xhr.open('POST', window.baseUrl('/images/gallery/upload')); - xhr.onload = function () { - if (xhr.status === 200 || xhr.status === 201) { - var result = JSON.parse(xhr.responseText); - editor.dom.setAttrib(id, 'src', result.url); - } else { - console.log('An error occured uploading the image'); - console.log(xhr.responseText); - editor.dom.remove(id); - } - }; - xhr.send(formData); - } - } - } - - } + editor.on('paste', function(event) { + editorPaste(event, editor); }); } }; \ No newline at end of file diff --git a/resources/assets/sass/_buttons.scss b/resources/assets/sass/_buttons.scss index 5bdb0cf28..5de889673 100644 --- a/resources/assets/sass/_buttons.scss +++ b/resources/assets/sass/_buttons.scss @@ -100,3 +100,13 @@ $button-border-radius: 2px; } } +.button[disabled] { + background-color: #BBB; + cursor: default; + &:hover { + background-color: #BBB; + cursor: default; + box-shadow: none; + } +} + diff --git a/resources/assets/sass/_image-manager.scss b/resources/assets/sass/_components.scss similarity index 86% rename from resources/assets/sass/_image-manager.scss rename to resources/assets/sass/_components.scss index 73b3b59d6..ccb69b44e 100644 --- a/resources/assets/sass/_image-manager.scss +++ b/resources/assets/sass/_components.scss @@ -1,5 +1,5 @@ .overlay { - background-color: rgba(0, 0, 0, 0.2); + background-color: rgba(0, 0, 0, 0.333); position: fixed; z-index: 95536; width: 100%; @@ -10,26 +10,76 @@ left: 0; right: 0; bottom: 0; + display: flex; + align-items: center; + justify-content: center; + display: none; } -.image-manager-body { +.popup-body-wrap { + display: flex; +} + +.popup-body { background-color: #FFF; max-height: 90%; - width: 90%; - height: 90%; + width: 1200px; + height: auto; margin: 2% 5%; border-radius: 4px; box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.3); overflow: hidden; - position: fixed; - top: 0; - bottom: 0; - left: 0; z-index: 999; display: flex; - h1, h2, h3 { - font-weight: 300; + flex-direction: column; + &.small { + margin: 2% auto; + width: 800px; + max-width: 90%; } + &:before { + display: flex; + align-self: flex-start; + } +} + +//body.ie .popup-body { +// min-height: 100%; +//} + +.corner-button { + position: absolute; + top: 0; + right: 0; + margin: 0; + height: 40px; + border-radius: 0; + box-shadow: none; +} + +.popup-header, .popup-footer { + display: block !important; + position: relative; + height: 40px; + flex: none !important; + .popup-title { + color: #FFF; + padding: 8px $-m; + } +} +body.flexbox-support #entity-selector-wrap .popup-body .form-group { + height: 444px; + min-height: 444px; +} +#entity-selector-wrap .popup-body .form-group { + margin: 0; +} +//body.ie #entity-selector-wrap .popup-body .form-group { +// min-height: 60vh; +//} + +.image-manager-body { + min-height: 70vh; } #image-manager .dropzone-container { @@ -37,12 +87,6 @@ border: 3px dashed #DDD; } -.image-manager-bottom { - position: absolute; - bottom: 0; - right: 0; -} - .image-manager-list .image { display: block; position: relative; @@ -103,18 +147,13 @@ .image-manager-sidebar { width: 300px; - height: 100%; margin-left: 1px; - padding: 0 $-l; + padding: $-m $-l; + overflow-y: auto; border-left: 1px solid #DDD; -} - -.image-manager-close { - position: absolute; - top: 0; - right: 0; - margin: 0; - border-radius: 0; + .dropzone-container { + margin-top: $-m; + } } .image-manager-list { @@ -125,7 +164,6 @@ .image-manager-content { display: flex; flex-direction: column; - height: 100%; flex: 1; .container { width: 100%; @@ -141,12 +179,13 @@ * Copyright (c) 2012 Matias Meno */ .dz-message { - font-size: 1.4em; + font-size: 1.2em; + line-height: 1.1; font-style: italic; color: #aaa; text-align: center; cursor: pointer; - padding: $-xl $-m; + padding: $-l $-m; transition: all ease-in-out 120ms; } diff --git a/resources/assets/sass/_grid.scss b/resources/assets/sass/_grid.scss index 2fe1ad113..231c12d4d 100644 --- a/resources/assets/sass/_grid.scss +++ b/resources/assets/sass/_grid.scss @@ -25,6 +25,14 @@ body.flexbox { } } +.flex-child > div { + flex: 1; +} + +//body.ie .flex-child > div { +// flex: 1 0 0px; +//} + /** Rules for all columns */ div[class^="col-"] img { max-width: 100%; @@ -39,6 +47,9 @@ div[class^="col-"] img { &.fluid { max-width: 100%; } + &.medium { + max-width: 992px; + } &.small { max-width: 840px; } diff --git a/resources/assets/sass/_header.scss b/resources/assets/sass/_header.scss index e0b1a99cb..12bd17076 100644 --- a/resources/assets/sass/_header.scss +++ b/resources/assets/sass/_header.scss @@ -155,6 +155,7 @@ form.search-box { text-decoration: none; } } + } .faded span.faded-text { diff --git a/resources/assets/sass/_lists.scss b/resources/assets/sass/_lists.scss index 08f00677e..2658c4689 100644 --- a/resources/assets/sass/_lists.scss +++ b/resources/assets/sass/_lists.scss @@ -375,6 +375,9 @@ ul.pagination { .text-muted { color: #999; } + li.padded { + padding: $-xs $-m; + } a { display: block; padding: $-xs $-m; @@ -384,10 +387,10 @@ ul.pagination { background-color: #EEE; } i { - margin-right: $-m; + margin-right: $-s; padding-right: 0; - display: inline; - width: 22px; + display: inline-block; + width: 16px; } } li.border-bottom { diff --git a/resources/assets/sass/_pages.scss b/resources/assets/sass/_pages.scss index 03faef344..9787571e2 100644 --- a/resources/assets/sass/_pages.scss +++ b/resources/assets/sass/_pages.scss @@ -20,6 +20,16 @@ } } +.draft-notification { + pointer-events: none; + transform: scale(0); + transition: transform ease-in-out 120ms; + transform-origin: 50% 50%; + &.visible { + transform: scale(1); + } +} + .page-style.editor { padding: 0 !important; } @@ -238,7 +248,7 @@ } .tag-display { - margin: $-xl $-xs; + margin: $-xl $-m; border: 1px solid #DDD; min-width: 180px; max-width: 320px; diff --git a/resources/assets/sass/styles.scss b/resources/assets/sass/styles.scss index a6c364018..7d33bd0a6 100644 --- a/resources/assets/sass/styles.scss +++ b/resources/assets/sass/styles.scss @@ -12,7 +12,7 @@ @import "animations"; @import "tinymce"; @import "highlightjs"; -@import "image-manager"; +@import "components"; @import "header"; @import "lists"; @import "pages"; @@ -72,7 +72,7 @@ body.dragging, body.dragging * { border-radius: 3px; box-shadow: $bs-med; z-index: 999999; - display: table; + display: block; cursor: pointer; max-width: 480px; i, span { diff --git a/resources/views/books/list-item.blade.php b/resources/views/books/list-item.blade.php index 945eb9015..2eefdfbf5 100644 --- a/resources/views/books/list-item.blade.php +++ b/resources/views/books/list-item.blade.php @@ -1,5 +1,5 @@
-

{{$book->name}}

+

{{$book->name}}

@if(isset($book->searchSnippet))

{!! $book->searchSnippet !!}

@else diff --git a/resources/views/books/sort.blade.php b/resources/views/books/sort.blade.php index 3849dfcfc..984db0ce6 100644 --- a/resources/views/books/sort.blade.php +++ b/resources/views/books/sort.blade.php @@ -50,7 +50,7 @@ var sortableOptions = { group: 'serialization', onDrop: function($item, container, _super) { - var pageMap = buildPageMap(); + var pageMap = buildEntityMap(); $('#sort-tree-input').val(JSON.stringify(pageMap)); _super($item, container); }, @@ -74,29 +74,42 @@ $link.remove(); }); - function buildPageMap() { - var pageMap = []; + /** + * Build up a mapping of entities with their ordering and nesting. + * @returns {Array} + */ + function buildEntityMap() { + var entityMap = []; var $lists = $('.sort-list'); $lists.each(function(listIndex) { var list = $(this); var bookId = list.closest('[data-type="book"]').attr('data-id'); - var $childElements = list.find('[data-type="page"], [data-type="chapter"]'); - $childElements.each(function(childIndex) { + var $directChildren = list.find('> [data-type="page"], > [data-type="chapter"]'); + $directChildren.each(function(directChildIndex) { var $childElem = $(this); var type = $childElem.attr('data-type'); var parentChapter = false; - if(type === 'page' && $childElem.closest('[data-type="chapter"]').length === 1) { - parentChapter = $childElem.closest('[data-type="chapter"]').attr('data-id'); - } - pageMap.push({ - id: $childElem.attr('data-id'), + var childId = $childElem.attr('data-id'); + entityMap.push({ + id: childId, + sort: directChildIndex, parentChapter: parentChapter, type: type, book: bookId }); + $chapterChildren = $childElem.find('[data-type="page"]').each(function(pageIndex) { + var $chapterChild = $(this); + entityMap.push({ + id: $chapterChild.attr('data-id'), + sort: pageIndex, + parentChapter: childId, + type: 'page', + book: bookId + }); + }); }); }); - return pageMap; + return entityMap; } }); diff --git a/resources/views/chapters/list-item.blade.php b/resources/views/chapters/list-item.blade.php index 3677851df..35d3a7589 100644 --- a/resources/views/chapters/list-item.blade.php +++ b/resources/views/chapters/list-item.blade.php @@ -6,8 +6,8 @@   »   @endif - - {{ $chapter->name }} + + {{ $chapter->name }} @if(isset($chapter->searchSnippet)) diff --git a/resources/views/pages/edit.blade.php b/resources/views/pages/edit.blade.php index 9fe6a6a19..58df580a5 100644 --- a/resources/views/pages/edit.blade.php +++ b/resources/views/pages/edit.blade.php @@ -19,6 +19,14 @@
+ @include('partials/image-manager', ['imageType' => 'gallery', 'uploaded_to' => $page->id]) + @include('partials/entity-selector-popup') + + @stop \ No newline at end of file diff --git a/resources/views/pages/form.blade.php b/resources/views/pages/form.blade.php index 18a9868c7..0e0c3672e 100644 --- a/resources/views/pages/form.blade.php +++ b/resources/views/pages/form.blade.php @@ -13,8 +13,9 @@
-
+ - - +
@@ -62,6 +74,8 @@ Editor
+  |  +
diff --git a/resources/views/pages/list-item.blade.php b/resources/views/pages/list-item.blade.php index a95870db0..98243f6fa 100644 --- a/resources/views/pages/list-item.blade.php +++ b/resources/views/pages/list-item.blade.php @@ -1,6 +1,6 @@

- {{ $page->name }} + {{ $page->name }}

@if(isset($page->searchSnippet)) diff --git a/resources/views/pages/restrictions.blade.php b/resources/views/pages/restrictions.blade.php index 8eca486c3..bd88919df 100644 --- a/resources/views/pages/restrictions.blade.php +++ b/resources/views/pages/restrictions.blade.php @@ -16,7 +16,7 @@ @endif » - {{ $page->getShortName() }} + {{ $page->getShortName() }}
diff --git a/resources/views/pages/revisions.blade.php b/resources/views/pages/revisions.blade.php index 03fb23673..926affffc 100644 --- a/resources/views/pages/revisions.blade.php +++ b/resources/views/pages/revisions.blade.php @@ -5,45 +5,59 @@
-
+ +

Page Revisions For "{{ $page->name }}"

@if(count($page->revisions) > 0) - - - - + + + + + - @foreach($page->revisions as $revision) + @foreach($page->revisions as $index => $revision) - + - - - + + + + @if ($index !== 0) + + @else + + @endif @endforeach
NameCreated ByRevision DateActionsNameCreated ByRevision DateChangelogActions
{{$revision->name}}{{ $revision->name }} @if($revision->createdBy) - {{$revision->createdBy->name}} + {{ $revision->createdBy->name }} @endif @if($revision->createdBy) {{$revision->createdBy->name}} @else Deleted User @endif{{$revision->created_at->format('jS F, Y H:i:s')}}
({{$revision->created_at->diffForHumans()}})
- Preview -  |  - Restore - @if($revision->createdBy) {{ $revision->createdBy->name }} @else Deleted User @endif{{ $revision->created_at->format('jS F, Y H:i:s') }}
({{ $revision->created_at->diffForHumans() }})
{{ $revision->summary }} + Preview +  |  + Restore + Current Version
diff --git a/resources/views/pages/show.blade.php b/resources/views/pages/show.blade.php index 9d6b74a03..2e6e35476 100644 --- a/resources/views/pages/show.blade.php +++ b/resources/views/pages/show.blade.php @@ -58,7 +58,7 @@