diff --git a/.travis.yml b/.travis.yml index ff387bd3d..83e9e10f5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,5 @@ +dist: trusty +sudo: required language: php php: - 7.0 @@ -5,15 +7,21 @@ php: cache: directories: - vendor + - node_modules + - $HOME/.composer/cache addons: - mariadb: '10.0' + apt: + packages: + - mysql-server-5.6 + - mysql-client-core-5.6 + - mysql-client-5.6 before_install: - npm install -g npm@latest before_script: - - mysql -e 'create database `bookstack-test`;' + - 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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..281814bb8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Dan Brown + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/Activity.php b/app/Activity.php index d43419c17..af386700a 100644 --- a/app/Activity.php +++ b/app/Activity.php @@ -44,7 +44,7 @@ class Activity extends Model * @return bool */ public function isSimilarTo($activityB) { - return [$this->key, $this->entitiy_type, $this->entitiy_id] === [$activityB->key, $activityB->entitiy_type, $activityB->entitiy_id]; + return [$this->key, $this->entity_type, $this->entity_id] === [$activityB->key, $activityB->entity_type, $activityB->entity_id]; } } diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php index 69e9488b9..3c9050bf6 100644 --- a/app/Http/Controllers/ChapterController.php +++ b/app/Http/Controllers/ChapterController.php @@ -59,7 +59,7 @@ class ChapterController extends Controller $input = $request->all(); $input['priority'] = $this->bookRepo->getNewPriority($book); - $chapter = $this->chapterRepo->createFromInput($request->all(), $book); + $chapter = $this->chapterRepo->createFromInput($input, $book); Activity::add($chapter, 'chapter_create', $book->id); return redirect($chapter->getUrl()); } @@ -154,6 +154,63 @@ class ChapterController extends Controller return redirect($book->getUrl()); } + /** + * Show the page for moving a chapter. + * @param $bookSlug + * @param $chapterSlug + * @return mixed + * @throws \BookStack\Exceptions\NotFoundException + */ + public function showMove($bookSlug, $chapterSlug) { + $book = $this->bookRepo->getBySlug($bookSlug); + $chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id); + $this->checkOwnablePermission('chapter-update', $chapter); + return view('chapters/move', [ + 'chapter' => $chapter, + 'book' => $book + ]); + } + + /** + * Perform the move action for a chapter. + * @param $bookSlug + * @param $chapterSlug + * @param Request $request + * @return mixed + * @throws \BookStack\Exceptions\NotFoundException + */ + public function move($bookSlug, $chapterSlug, Request $request) { + $book = $this->bookRepo->getBySlug($bookSlug); + $chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id); + $this->checkOwnablePermission('chapter-update', $chapter); + + $entitySelection = $request->get('entity_selection', null); + if ($entitySelection === null || $entitySelection === '') { + return redirect($chapter->getUrl()); + } + + $stringExploded = explode(':', $entitySelection); + $entityType = $stringExploded[0]; + $entityId = intval($stringExploded[1]); + + $parent = false; + + if ($entityType == 'book') { + $parent = $this->bookRepo->getById($entityId); + } + + if ($parent === false || $parent === null) { + session()->flash('The selected Book was not found'); + return redirect()->back(); + } + + $this->chapterRepo->changeBook($parent->id, $chapter); + Activity::add($chapter, 'chapter_move', $chapter->book->id); + session()->flash('success', sprintf('Chapter moved to "%s"', $parent->name)); + + return redirect($chapter->getUrl()); + } + /** * Show the Restrictions view. * @param $bookSlug diff --git a/app/Http/Controllers/ImageController.php b/app/Http/Controllers/ImageController.php index 2e5d5f303..621c23e85 100644 --- a/app/Http/Controllers/ImageController.php +++ b/app/Http/Controllers/ImageController.php @@ -51,9 +51,9 @@ class ImageController extends Controller $this->validate($request, [ 'term' => 'required|string' ]); - + $searchTerm = $request->get('term'); - $imgData = $this->imageRepo->searchPaginatedByType($type, $page,24, $searchTerm); + $imgData = $this->imageRepo->searchPaginatedByType($type, $page, 24, $searchTerm); return response()->json($imgData); } @@ -99,7 +99,7 @@ class ImageController extends Controller { $this->checkPermission('image-create-all'); $this->validate($request, [ - 'file' => 'image|mimes:jpeg,gif,png' + 'file' => 'is_image' ]); $imageUpload = $request->file('file'); diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index da9273743..f35834e62 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -92,7 +92,7 @@ class PageController extends Controller $draftPage = $this->pageRepo->getById($pageId, true); - $chapterId = $draftPage->chapter_id; + $chapterId = intval($draftPage->chapter_id); $parent = $chapterId !== 0 ? $this->chapterRepo->getById($chapterId) : $book; $this->checkOwnablePermission('page-create', $parent); @@ -221,8 +221,8 @@ class PageController extends Controller $updateTime = $draft->updated_at->timestamp; $utcUpdateTimestamp = $updateTime + Carbon::createFromTimestamp(0)->offset; return response()->json([ - 'status' => 'success', - 'message' => 'Draft saved at ', + 'status' => 'success', + 'message' => 'Draft saved at ', 'timestamp' => $utcUpdateTimestamp ]); } @@ -450,6 +450,67 @@ class PageController extends Controller ]); } + /** + * Show the view to choose a new parent to move a page into. + * @param $bookSlug + * @param $pageSlug + * @return mixed + * @throws NotFoundException + */ + public function showMove($bookSlug, $pageSlug) + { + $book = $this->bookRepo->getBySlug($bookSlug); + $page = $this->pageRepo->getBySlug($pageSlug, $book->id); + $this->checkOwnablePermission('page-update', $page); + return view('pages/move', [ + 'book' => $book, + 'page' => $page + ]); + } + + /** + * Does the action of moving the location of a page + * @param $bookSlug + * @param $pageSlug + * @param Request $request + * @return mixed + * @throws NotFoundException + */ + public function move($bookSlug, $pageSlug, Request $request) + { + $book = $this->bookRepo->getBySlug($bookSlug); + $page = $this->pageRepo->getBySlug($pageSlug, $book->id); + $this->checkOwnablePermission('page-update', $page); + + $entitySelection = $request->get('entity_selection', null); + if ($entitySelection === null || $entitySelection === '') { + return redirect($page->getUrl()); + } + + $stringExploded = explode(':', $entitySelection); + $entityType = $stringExploded[0]; + $entityId = intval($stringExploded[1]); + + $parent = false; + + if ($entityType == 'chapter') { + $parent = $this->chapterRepo->getById($entityId); + } else if ($entityType == 'book') { + $parent = $this->bookRepo->getById($entityId); + } + + if ($parent === false || $parent === null) { + session()->flash('The selected Book or Chapter was not found'); + return redirect()->back(); + } + + $this->pageRepo->changePageParent($page, $parent); + Activity::add($page, 'page_move', $page->book->id); + session()->flash('success', sprintf('Page moved to "%s"', $parent->name)); + + return redirect($page->getUrl()); + } + /** * Set the permissions for this page. * @param $bookSlug diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index e198dc767..58ad737c4 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -2,10 +2,10 @@ namespace BookStack\Http\Controllers; +use BookStack\Services\ViewService; use Illuminate\Http\Request; use BookStack\Http\Requests; -use BookStack\Http\Controllers\Controller; use BookStack\Repos\BookRepo; use BookStack\Repos\ChapterRepo; use BookStack\Repos\PageRepo; @@ -15,18 +15,21 @@ class SearchController extends Controller protected $pageRepo; protected $bookRepo; protected $chapterRepo; + protected $viewService; /** * SearchController constructor. - * @param $pageRepo - * @param $bookRepo - * @param $chapterRepo + * @param PageRepo $pageRepo + * @param BookRepo $bookRepo + * @param ChapterRepo $chapterRepo + * @param ViewService $viewService */ - public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo) + public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo, ViewService $viewService) { $this->pageRepo = $pageRepo; $this->bookRepo = $bookRepo; $this->chapterRepo = $chapterRepo; + $this->viewService = $viewService; parent::__construct(); } @@ -48,9 +51,9 @@ class SearchController extends Controller $chapters = $this->chapterRepo->getBySearch($searchTerm, [], 10, $paginationAppends); $this->setPageTitle('Search For ' . $searchTerm); return view('search/all', [ - 'pages' => $pages, - 'books' => $books, - 'chapters' => $chapters, + 'pages' => $pages, + 'books' => $books, + 'chapters' => $chapters, 'searchTerm' => $searchTerm ]); } @@ -69,8 +72,8 @@ class SearchController extends Controller $pages = $this->pageRepo->getBySearch($searchTerm, [], 20, $paginationAppends); $this->setPageTitle('Page Search For ' . $searchTerm); return view('search/entity-search-list', [ - 'entities' => $pages, - 'title' => 'Page Search Results', + 'entities' => $pages, + 'title' => 'Page Search Results', 'searchTerm' => $searchTerm ]); } @@ -89,8 +92,8 @@ class SearchController extends Controller $chapters = $this->chapterRepo->getBySearch($searchTerm, [], 20, $paginationAppends); $this->setPageTitle('Chapter Search For ' . $searchTerm); return view('search/entity-search-list', [ - 'entities' => $chapters, - 'title' => 'Chapter Search Results', + 'entities' => $chapters, + 'title' => 'Chapter Search Results', 'searchTerm' => $searchTerm ]); } @@ -109,8 +112,8 @@ class SearchController extends Controller $books = $this->bookRepo->getBySearch($searchTerm, 20, $paginationAppends); $this->setPageTitle('Book Search For ' . $searchTerm); return view('search/entity-search-list', [ - 'entities' => $books, - 'title' => 'Book Search Results', + 'entities' => $books, + 'title' => 'Book Search Results', 'searchTerm' => $searchTerm ]); } @@ -134,4 +137,35 @@ class SearchController extends Controller return view('search/book', ['pages' => $pages, 'chapters' => $chapters, 'searchTerm' => $searchTerm]); } + + /** + * Search for a list of entities and return a partial HTML response of matching entities. + * Returns the most popular entities if no search is provided. + * @param Request $request + * @return mixed + */ + public function searchEntitiesAjax(Request $request) + { + $entities = collect(); + $entityTypes = $request->has('types') ? collect(explode(',', $request->get('types'))) : collect(['page', 'chapter', 'book']); + $searchTerm = ($request->has('term') && trim($request->get('term')) !== '') ? $request->get('term') : false; + + // Search for entities otherwise show most popular + if ($searchTerm !== false) { + if ($entityTypes->contains('page')) $entities = $entities->merge($this->pageRepo->getBySearch($searchTerm)->items()); + if ($entityTypes->contains('chapter')) $entities = $entities->merge($this->chapterRepo->getBySearch($searchTerm)->items()); + if ($entityTypes->contains('book')) $entities = $entities->merge($this->bookRepo->getBySearch($searchTerm)->items()); + $entities = $entities->sortByDesc('title_relevance'); + } else { + $entityNames = $entityTypes->map(function ($type) { + return 'BookStack\\' . ucfirst($type); + })->toArray(); + $entities = $this->viewService->getPopular(20, 0, $entityNames); + } + + return view('search/entity-ajax-list', ['entities' => $entities]); + } + } + + diff --git a/app/Http/Controllers/TagController.php b/app/Http/Controllers/TagController.php index 1823b0dc8..c8a356541 100644 --- a/app/Http/Controllers/TagController.php +++ b/app/Http/Controllers/TagController.php @@ -55,7 +55,7 @@ class TagController extends Controller */ public function getNameSuggestions(Request $request) { - $searchTerm = $request->get('search'); + $searchTerm = $request->has('search') ? $request->get('search') : false; $suggestions = $this->tagRepo->getNameSuggestions($searchTerm); return response()->json($suggestions); } @@ -66,8 +66,9 @@ class TagController extends Controller */ public function getValueSuggestions(Request $request) { - $searchTerm = $request->get('search'); - $suggestions = $this->tagRepo->getValueSuggestions($searchTerm); + $searchTerm = $request->has('search') ? $request->get('search') : false; + $tagName = $request->has('name') ? $request->get('name') : false; + $suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName); return response()->json($suggestions); } diff --git a/app/Http/routes.php b/app/Http/routes.php index 9f226efd7..eb35f2a11 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -34,6 +34,8 @@ Route::group(['middleware' => 'auth'], function () { Route::get('/{bookSlug}/page/{pageSlug}/export/html', 'PageController@exportHtml'); Route::get('/{bookSlug}/page/{pageSlug}/export/plaintext', 'PageController@exportPlainText'); Route::get('/{bookSlug}/page/{pageSlug}/edit', 'PageController@edit'); + Route::get('/{bookSlug}/page/{pageSlug}/move', 'PageController@showMove'); + Route::put('/{bookSlug}/page/{pageSlug}/move', 'PageController@move'); Route::get('/{bookSlug}/page/{pageSlug}/delete', 'PageController@showDelete'); Route::get('/{bookSlug}/draft/{pageId}/delete', 'PageController@showDeleteDraft'); Route::get('/{bookSlug}/page/{pageSlug}/permissions', 'PageController@showRestrict'); @@ -53,6 +55,8 @@ Route::group(['middleware' => 'auth'], function () { Route::post('/{bookSlug}/chapter/create', 'ChapterController@store'); Route::get('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@show'); Route::put('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@update'); + Route::get('/{bookSlug}/chapter/{chapterSlug}/move', 'ChapterController@showMove'); + Route::put('/{bookSlug}/chapter/{chapterSlug}/move', 'ChapterController@move'); Route::get('/{bookSlug}/chapter/{chapterSlug}/edit', 'ChapterController@edit'); Route::get('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@showRestrict'); Route::put('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@restrict'); @@ -93,6 +97,8 @@ Route::group(['middleware' => 'auth'], function () { Route::post('/update/{entityType}/{entityId}', 'TagController@updateForEntity'); }); + Route::get('/ajax/search/entities', 'SearchController@searchEntitiesAjax'); + // Links Route::get('/link/{id}', 'PageController@redirectFromLink'); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8bcbcbdad..f214c9141 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -15,7 +15,12 @@ class AppServiceProvider extends ServiceProvider */ public function boot() { - // + // Custom validation methods + \Validator::extend('is_image', function($attribute, $value, $parameters, $validator) { + $imageMimes = ['image/png', 'image/bmp', 'image/gif', 'image/jpeg', 'image/jpg', 'image/tiff', 'image/webp']; + return in_array($value->getMimeType(), $imageMimes); + }); + } /** diff --git a/app/Repos/BookRepo.php b/app/Repos/BookRepo.php index b0530b4f5..a11ed2763 100644 --- a/app/Repos/BookRepo.php +++ b/app/Repos/BookRepo.php @@ -251,7 +251,10 @@ class BookRepo extends EntityRepo }]); $chapterQuery = $this->permissionService->enforceChapterRestrictions($chapterQuery, 'view'); $chapters = $chapterQuery->get(); - $children = $pages->merge($chapters); + $children = $pages->values(); + foreach ($chapters as $chapter) { + $children->push($chapter); + } $bookSlug = $book->slug; $children->each(function ($child) use ($bookSlug) { diff --git a/app/Repos/ChapterRepo.php b/app/Repos/ChapterRepo.php index 048e0a63b..3c518bde9 100644 --- a/app/Repos/ChapterRepo.php +++ b/app/Repos/ChapterRepo.php @@ -9,6 +9,18 @@ use BookStack\Chapter; class ChapterRepo extends EntityRepo { + protected $pageRepo; + + /** + * ChapterRepo constructor. + * @param $pageRepo + */ + public function __construct(PageRepo $pageRepo) + { + $this->pageRepo = $pageRepo; + parent::__construct(); + } + /** * Base query for getting chapters, Takes permissions into account. * @return mixed @@ -189,12 +201,21 @@ class ChapterRepo extends EntityRepo public function changeBook($bookId, Chapter $chapter) { $chapter->book_id = $bookId; + // Update related activity foreach ($chapter->activity as $activity) { $activity->book_id = $bookId; $activity->save(); } $chapter->slug = $this->findSuitableSlug($chapter->name, $bookId, $chapter->id); $chapter->save(); + // Update all child pages + foreach ($chapter->pages as $page) { + $this->pageRepo->changeBook($bookId, $page); + } + // Update permissions + $chapter->load('book'); + $this->permissionService->buildJointPermissionsForEntity($chapter->book); + return $chapter; } diff --git a/app/Repos/PageRepo.php b/app/Repos/PageRepo.php index 504c3fa3b..de050e1c7 100644 --- a/app/Repos/PageRepo.php +++ b/app/Repos/PageRepo.php @@ -3,6 +3,7 @@ use Activity; use BookStack\Book; use BookStack\Chapter; +use BookStack\Entity; use BookStack\Exceptions\NotFoundException; use Carbon\Carbon; use DOMDocument; @@ -572,6 +573,22 @@ class PageRepo extends EntityRepo return $page; } + + /** + * Change the page's parent to the given entity. + * @param Page $page + * @param Entity $parent + */ + public function changePageParent(Page $page, Entity $parent) + { + $book = $parent->isA('book') ? $parent : $parent->book; + $page->chapter_id = $parent->isA('chapter') ? $parent->id : 0; + $page->save(); + $page = $this->changeBook($book->id, $page); + $page->load('book'); + $this->permissionService->buildJointPermissionsForEntity($book); + } + /** * Gets a suitable slug for the resource * @param $name diff --git a/app/Repos/TagRepo.php b/app/Repos/TagRepo.php index 7d51d87f7..6d0857f8b 100644 --- a/app/Repos/TagRepo.php +++ b/app/Repos/TagRepo.php @@ -58,29 +58,48 @@ class TagRepo /** * Get tag name suggestions from scanning existing tag names. + * If no search term is given the 50 most popular tag names are provided. * @param $searchTerm * @return array */ - public function getNameSuggestions($searchTerm) + public function getNameSuggestions($searchTerm = false) { - if ($searchTerm === '') return []; - $query = $this->tag->where('name', 'LIKE', $searchTerm . '%')->groupBy('name')->orderBy('name', 'desc'); + $query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('name'); + + if ($searchTerm) { + $query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc'); + } else { + $query = $query->orderBy('count', 'desc')->take(50); + } + $query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type'); return $query->get(['name'])->pluck('name'); } /** * Get tag value suggestions from scanning existing tag values. + * If no search is given the 50 most popular values are provided. + * Passing a tagName will only find values for a tags with a particular name. * @param $searchTerm + * @param $tagName * @return array */ - public function getValueSuggestions($searchTerm) + public function getValueSuggestions($searchTerm = false, $tagName = false) { - if ($searchTerm === '') return []; - $query = $this->tag->where('value', 'LIKE', $searchTerm . '%')->groupBy('value')->orderBy('value', 'desc'); + $query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('value'); + + if ($searchTerm) { + $query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc'); + } else { + $query = $query->orderBy('count', 'desc')->take(50); + } + + if ($tagName !== false) $query = $query->where('name', '=', $tagName); + $query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type'); return $query->get(['value'])->pluck('value'); } + /** * Save an array of tags to an entity * @param Entity $entity diff --git a/app/Services/ActivityService.php b/app/Services/ActivityService.php index 90a3a6d82..f6fea33a1 100644 --- a/app/Services/ActivityService.php +++ b/app/Services/ActivityService.php @@ -90,7 +90,7 @@ class ActivityService { $activityList = $this->permissionService ->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type') - ->orderBy('created_at', 'desc')->skip($count * $page)->take($count)->get(); + ->orderBy('created_at', 'desc')->with('user', 'entity')->skip($count * $page)->take($count)->get(); return $this->filterSimilar($activityList); } diff --git a/app/Services/PermissionService.php b/app/Services/PermissionService.php index 218cb30a5..0fffe60f2 100644 --- a/app/Services/PermissionService.php +++ b/app/Services/PermissionService.php @@ -4,6 +4,7 @@ use BookStack\Book; use BookStack\Chapter; use BookStack\Entity; use BookStack\JointPermission; +use BookStack\Ownable; use BookStack\Page; use BookStack\Role; use BookStack\User; @@ -307,16 +308,16 @@ class PermissionService /** * Checks if an entity has a restriction set upon it. - * @param Entity $entity + * @param Ownable $ownable * @param $permission * @return bool */ - public function checkEntityUserAccess(Entity $entity, $permission) + public function checkOwnableUserAccess(Ownable $ownable, $permission) { if ($this->isAdmin) return true; $explodedPermission = explode('-', $permission); - $baseQuery = $entity->where('id', '=', $entity->id); + $baseQuery = $ownable->where('id', '=', $ownable->id); $action = end($explodedPermission); $this->currentAction = $action; @@ -327,7 +328,7 @@ class PermissionService $allPermission = $this->currentUser && $this->currentUser->can($permission . '-all'); $ownPermission = $this->currentUser && $this->currentUser->can($permission . '-own'); $this->currentAction = 'view'; - $isOwner = $this->currentUser && $this->currentUser->id === $entity->created_by; + $isOwner = $this->currentUser && $this->currentUser->id === $ownable->created_by; return ($allPermission || ($isOwner && $ownPermission)); } diff --git a/app/Services/ViewService.php b/app/Services/ViewService.php index 849a164cf..aac9831f7 100644 --- a/app/Services/ViewService.php +++ b/app/Services/ViewService.php @@ -50,7 +50,7 @@ class ViewService * Get the entities with the most views. * @param int $count * @param int $page - * @param bool|false $filterModel + * @param bool|false|array $filterModel */ public function getPopular($count = 10, $page = 0, $filterModel = false) { @@ -60,7 +60,11 @@ class ViewService ->groupBy('viewable_id', 'viewable_type') ->orderBy('view_count', 'desc'); - if ($filterModel) $query->where('viewable_type', '=', get_class($filterModel)); + if ($filterModel && is_array($filterModel)) { + $query->whereIn('viewable_type', $filterModel); + } else if ($filterModel) { + $query->where('viewable_type', '=', get_class($filterModel)); + }; return $query->with('viewable')->skip($skipCount)->take($count)->get()->pluck('viewable'); } diff --git a/app/helpers.php b/app/helpers.php index b8f61d94e..42e4c1894 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -1,5 +1,7 @@ user() && auth()->user()->can($permission); } // Check permission on ownable item - $permissionService = app('BookStack\Services\PermissionService'); - return $permissionService->checkEntityUserAccess($ownable, $permission); + $permissionService = app(\BookStack\Services\PermissionService::class); + return $permissionService->checkOwnableUserAccess($ownable, $permission); } /** diff --git a/config/setting-defaults.php b/config/setting-defaults.php index 17bae1848..6a55a0dc3 100644 --- a/config/setting-defaults.php +++ b/config/setting-defaults.php @@ -5,6 +5,8 @@ */ return [ - 'app-editor' => 'wysiwyg' + 'app-editor' => 'wysiwyg', + 'app-color' => '#0288D1', + 'app-color-light' => 'rgba(21, 101, 192, 0.15)' ]; \ No newline at end of file diff --git a/database/migrations/2015_07_12_114933_create_books_table.php b/database/migrations/2015_07_12_114933_create_books_table.php index 51fb55c48..4220809d5 100644 --- a/database/migrations/2015_07_12_114933_create_books_table.php +++ b/database/migrations/2015_07_12_114933_create_books_table.php @@ -12,7 +12,13 @@ class CreateBooksTable extends Migration */ public function up() { - Schema::create('books', function (Blueprint $table) { + $pdo = \DB::connection()->getPdo(); + $mysqlVersion = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION); + $requiresISAM = strpos($mysqlVersion, '5.5') === 0; + + Schema::create('books', function (Blueprint $table) use ($requiresISAM) { + if($requiresISAM) $table->engine = 'MyISAM'; + $table->increments('id'); $table->string('name'); $table->string('slug')->indexed(); diff --git a/database/migrations/2015_07_12_190027_create_pages_table.php b/database/migrations/2015_07_12_190027_create_pages_table.php index b3b2b9244..0a29d1087 100644 --- a/database/migrations/2015_07_12_190027_create_pages_table.php +++ b/database/migrations/2015_07_12_190027_create_pages_table.php @@ -12,7 +12,13 @@ class CreatePagesTable extends Migration */ public function up() { - Schema::create('pages', function (Blueprint $table) { + $pdo = \DB::connection()->getPdo(); + $mysqlVersion = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION); + $requiresISAM = strpos($mysqlVersion, '5.5') === 0; + + Schema::create('pages', function (Blueprint $table) use ($requiresISAM) { + if($requiresISAM) $table->engine = 'MyISAM'; + $table->increments('id'); $table->integer('book_id'); $table->integer('chapter_id'); diff --git a/database/migrations/2015_07_27_172342_create_chapters_table.php b/database/migrations/2015_07_27_172342_create_chapters_table.php index 7974759f2..3ec414480 100644 --- a/database/migrations/2015_07_27_172342_create_chapters_table.php +++ b/database/migrations/2015_07_27_172342_create_chapters_table.php @@ -12,7 +12,12 @@ class CreateChaptersTable extends Migration */ public function up() { - Schema::create('chapters', function (Blueprint $table) { + $pdo = \DB::connection()->getPdo(); + $mysqlVersion = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION); + $requiresISAM = strpos($mysqlVersion, '5.5') === 0; + + Schema::create('chapters', function (Blueprint $table) use ($requiresISAM) { + if($requiresISAM) $table->engine = 'MyISAM'; $table->increments('id'); $table->integer('book_id'); $table->string('slug')->indexed(); diff --git a/readme.md b/readme.md index 8a20d52d9..29ac44f5e 100644 --- a/readme.md +++ b/readme.md @@ -1,5 +1,7 @@ # BookStack +[](https://github.com/ssddanbrown/BookStack/releases/latest) +[](https://github.com/ssddanbrown/BookStack/blob/master/LICENSE) [](https://travis-ci.org/ssddanbrown/BookStack) A platform for storing and organising information and documentation. General information and documentation for BookStack can be found at https://www.bookstackapp.com/. diff --git a/resources/assets/js/controllers.js b/resources/assets/js/controllers.js index 8f434bf7e..406fd7e77 100644 --- a/resources/assets/js/controllers.js +++ b/resources/assets/js/controllers.js @@ -379,6 +379,15 @@ module.exports = function (ngApp, events) { saveDraft(); }; + // Listen to shortcuts coming via events + $scope.$on('editor-keydown', (event, data) => { + // Save shortcut (ctrl+s) + if (data.keyCode == 83 && (navigator.platform.match("Mac") ? data.metaKey : data.ctrlKey)) { + data.preventDefault(); + saveDraft(); + } + }); + /** * Discard the current draft and grab the current page * content from the system via an AJAX request. diff --git a/resources/assets/js/directives.js b/resources/assets/js/directives.js index 62557f976..0119ded42 100644 --- a/resources/assets/js/directives.js +++ b/resources/assets/js/directives.js @@ -149,7 +149,10 @@ module.exports = function (ngApp, events) { }; }]); - + /** + * Dropdown + * Provides some simple logic to create small dropdown menus + */ ngApp.directive('dropdown', [function () { return { restrict: 'A', @@ -166,7 +169,11 @@ module.exports = function (ngApp, events) { }; }]); - ngApp.directive('tinymce', ['$timeout', function($timeout) { + /** + * TinyMCE + * An angular wrapper around the tinyMCE editor. + */ + ngApp.directive('tinymce', ['$timeout', function ($timeout) { return { restrict: 'A', scope: { @@ -185,6 +192,10 @@ module.exports = function (ngApp, events) { scope.mceChange(content); }); + editor.on('keydown', (event) => { + scope.$emit('editor-keydown', event); + }); + editor.on('init', (e) => { scope.mceModel = editor.getContent(); }); @@ -200,8 +211,8 @@ module.exports = function (ngApp, events) { scope.tinymce.extraSetups.push(tinyMceSetup); // Custom tinyMCE plugins - tinymce.PluginManager.add('customhr', function(editor) { - editor.addCommand('InsertHorizontalRule', function() { + tinymce.PluginManager.add('customhr', function (editor) { + editor.addCommand('InsertHorizontalRule', function () { var hrElem = document.createElement('hr'); var cNode = editor.selection.getNode(); var parentNode = cNode.parentNode; @@ -227,7 +238,11 @@ module.exports = function (ngApp, events) { } }]); - ngApp.directive('markdownInput', ['$timeout', function($timeout) { + /** + * Markdown input + * Handles the logic for just the editor input field. + */ + ngApp.directive('markdownInput', ['$timeout', function ($timeout) { return { restrict: 'A', scope: { @@ -251,7 +266,7 @@ module.exports = function (ngApp, events) { scope.$on('markdown-update', (event, value) => { element.val(value); - scope.mdModel= value; + scope.mdModel = value; scope.mdChange(markdown(value)); }); @@ -259,23 +274,59 @@ module.exports = function (ngApp, events) { } }]); - ngApp.directive('markdownEditor', ['$timeout', function($timeout) { + /** + * Markdown Editor + * Handles all functionality of the markdown editor. + */ + 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"]'); + const input = element.find('textarea[markdown-input]'); + const display = element.find('.markdown-display').first(); + const insertImage = element.find('button[data-action="insertImage"]'); - var currentCaretPos = 0; + let currentCaretPos = 0; - input.blur((event) => { + input.blur(event => { currentCaretPos = input[0].selectionStart; }); - // Insert image shortcut - input.keydown((event) => { + // Scroll sync + let inputScrollHeight, + inputHeight, + displayScrollHeight, + displayHeight; + + function setScrollHeights() { + inputScrollHeight = input[0].scrollHeight; + inputHeight = input.height(); + displayScrollHeight = display[0].scrollHeight; + displayHeight = display.height(); + } + + setTimeout(() => { + setScrollHeights(); + }, 200); + window.addEventListener('resize', setScrollHeights); + let scrollDebounceTime = 800; + let lastScroll = 0; + input.on('scroll', event => { + let now = Date.now(); + if (now - lastScroll > scrollDebounceTime) { + setScrollHeights() + } + let scrollPercent = (input.scrollTop() / (inputScrollHeight - inputHeight)); + let displayScrollY = (displayScrollHeight - displayHeight) * scrollPercent; + display.scrollTop(displayScrollY); + lastScroll = now; + }); + + // Editor key-presses + input.keydown(event => { + // Insert image shortcut if (event.which === 73 && event.ctrlKey && event.shiftKey) { event.preventDefault(); var caretPos = input[0].selectionStart; @@ -285,12 +336,15 @@ module.exports = function (ngApp, events) { input.focus(); input[0].selectionStart = caretPos + ("; input[0].selectionEnd = caretPos + ('; + return; } + // Pass key presses to controller via event + scope.$emit('editor-keydown', event); }); // Insert image from image manager - insertImage.click((event) => { - window.ImageManager.showExternal((image) => { + insertImage.click(event => { + window.ImageManager.showExternal(image => { var caretPos = currentCaretPos; var currentContent = input.val(); var mdImageText = ""; @@ -302,11 +356,16 @@ module.exports = function (ngApp, events) { } } }]); - - ngApp.directive('toolbox', [function() { + + /** + * Page Editor Toolbox + * Controls all functionality for the sliding toolbox + * on the page edit view. + */ + ngApp.directive('toolbox', [function () { return { restrict: 'A', - link: function(scope, elem, attrs) { + link: function (scope, elem, attrs) { // Get common elements const $buttons = elem.find('[tab-button]'); @@ -317,7 +376,7 @@ module.exports = function (ngApp, events) { $toggle.click((e) => { elem.toggleClass('open'); }); - + // Set an active tab/content by name function setActive(tabName, openToolbox) { $buttons.removeClass('active'); @@ -331,7 +390,7 @@ module.exports = function (ngApp, events) { setActive($content.first().attr('tab-content'), false); // Handle tab button click - $buttons.click(function(e) { + $buttons.click(function (e) { let name = $(this).attr('tab-button'); setActive(name, true); }); @@ -339,11 +398,16 @@ module.exports = function (ngApp, events) { } }]); - ngApp.directive('autosuggestions', ['$http', function($http) { + /** + * Tag Autosuggestions + * Listens to child inputs and provides autosuggestions depending on field type + * and input. Suggestions provided by server. + */ + ngApp.directive('tagAutosuggestions', ['$http', function ($http) { return { restrict: 'A', - link: function(scope, elem, attrs) { - + link: function (scope, elem, attrs) { + // Local storage for quick caching. const localCache = {}; @@ -360,38 +424,49 @@ module.exports = function (ngApp, events) { let active = 0; // Listen to input events on autosuggest fields - elem.on('input', '[autosuggest]', function(event) { + elem.on('input focus', '[autosuggest]', function (event) { let $input = $(this); let val = $input.val(); let url = $input.attr('autosuggest'); - // No suggestions until at least 3 chars - if (val.length < 3) { - if (isShowing) { - $suggestionBox.hide(); - isShowing = false; + let type = $input.attr('autosuggest-type'); + + // Add name param to request if for a value + if (type.toLowerCase() === 'value') { + let $nameInput = $input.closest('tr').find('[autosuggest-type="name"]').first(); + let nameVal = $nameInput.val(); + if (nameVal !== '') { + url += '?name=' + encodeURIComponent(nameVal); } - return; - }; + } let suggestionPromise = getSuggestions(val.slice(0, 3), url); - suggestionPromise.then((suggestions) => { - if (val.length > 2) { - suggestions = suggestions.filter((item) => { - return item.toLowerCase().indexOf(val.toLowerCase()) !== -1; - }).slice(0, 4); - displaySuggestions($input, suggestions); - } + suggestionPromise.then(suggestions => { + if (val.length === 0) { + displaySuggestions($input, suggestions.slice(0, 6)); + } else { + suggestions = suggestions.filter(item => { + return item.toLowerCase().indexOf(val.toLowerCase()) !== -1; + }).slice(0, 4); + displaySuggestions($input, suggestions); + } }); }); // Hide autosuggestions when input loses focus. // Slight delay to allow clicks. - elem.on('blur', '[autosuggest]', function(event) { + let lastFocusTime = 0; + elem.on('blur', '[autosuggest]', function (event) { + let startTime = Date.now(); setTimeout(() => { - $suggestionBox.hide(); - isShowing = false; + if (lastFocusTime < startTime) { + $suggestionBox.hide(); + isShowing = false; + } }, 200) }); + elem.on('focus', '[autosuggest]', function (event) { + lastFocusTime = Date.now(); + }); elem.on('keydown', '[autosuggest]', function (event) { if (!isShowing) return; @@ -401,23 +476,25 @@ module.exports = function (ngApp, events) { // Down arrow if (event.keyCode === 40) { - let newActive = (active === suggestCount-1) ? 0 : active + 1; + let newActive = (active === suggestCount - 1) ? 0 : active + 1; changeActiveTo(newActive, suggestionElems); } // Up arrow else if (event.keyCode === 38) { - let newActive = (active === 0) ? suggestCount-1 : active - 1; + let newActive = (active === 0) ? suggestCount - 1 : active - 1; changeActiveTo(newActive, suggestionElems); } - // Enter key - else if (event.keyCode === 13) { + // Enter or tab key + else if ((event.keyCode === 13 || event.keyCode === 9) && !event.shiftKey) { let text = suggestionElems[active].textContent; currentInput[0].value = text; currentInput.focus(); $suggestionBox.hide(); isShowing = false; - event.preventDefault(); - return false; + if (event.keyCode === 13) { + event.preventDefault(); + return false; + } } }); @@ -430,6 +507,7 @@ module.exports = function (ngApp, events) { // Display suggestions on a field let prevSuggestions = []; + function displaySuggestions($input, suggestions) { // Hide if no suggestions @@ -466,7 +544,8 @@ module.exports = function (ngApp, events) { if (i === 0) { suggestion.className = 'active' active = 0; - }; + } + ; $suggestionBox[0].appendChild(suggestion); } @@ -484,17 +563,18 @@ module.exports = function (ngApp, events) { // Get suggestions & cache function getSuggestions(input, url) { - let searchUrl = url + '?search=' + encodeURIComponent(input); + let hasQuery = url.indexOf('?') !== -1; + let searchUrl = url + (hasQuery ? '&' : '?') + 'search=' + encodeURIComponent(input); // Get from local cache if exists - if (localCache[searchUrl]) { + if (typeof localCache[searchUrl] !== 'undefined') { return new Promise((resolve, reject) => { - resolve(localCache[input]); + resolve(localCache[searchUrl]); }); } - return $http.get(searchUrl).then((response) => { - localCache[input] = response.data; + return $http.get(searchUrl).then(response => { + localCache[searchUrl] = response.data; return response.data; }); } @@ -502,6 +582,67 @@ module.exports = function (ngApp, events) { } } }]); + + + ngApp.directive('entitySelector', ['$http', '$sce', function ($http, $sce) { + return { + restrict: 'A', + scope: true, + link: function (scope, element, attrs) { + scope.loading = true; + scope.entityResults = false; + scope.search = ''; + + // Add input for forms + const input = element.find('[entity-selector-input]').first(); + + // 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); + }); + element.on('click', '[data-entity-type]', function(event) { + itemSelect($(this)); + }); + + // Select entity action + function itemSelect(item) { + let entityType = item.attr('data-entity-type'); + let entityId = item.attr('data-entity-id'); + let isSelected = !item.hasClass('selected'); + element.find('.selected').removeClass('selected').removeClass('primary-background'); + if (isSelected) item.addClass('selected').addClass('primary-background'); + let newVal = isSelected ? `${entityType}:${entityId}` : ''; + input.val(newVal); + } + + // Get search url with correct types + function getSearchUrl() { + let types = (attrs.entityTypes) ? encodeURIComponent(attrs.entityTypes) : encodeURIComponent('page,book,chapter'); + return `/ajax/search/entities?types=${types}`; + } + + // Get initial contents + $http.get(getSearchUrl()).then(resp => { + scope.entityResults = $sce.trustAsHtml(resp.data); + scope.loading = false; + }); + + // Search when typing + scope.searchEntities = function() { + scope.loading = true; + input.val(''); + let url = getSearchUrl() + '&term=' + encodeURIComponent(scope.search); + $http.get(url).then(resp => { + scope.entityResults = $sce.trustAsHtml(resp.data); + scope.loading = false; + }); + }; + } + }; + }]); }; diff --git a/resources/assets/js/global.js b/resources/assets/js/global.js index d4fe7020b..44562abd0 100644 --- a/resources/assets/js/global.js +++ b/resources/assets/js/global.js @@ -112,16 +112,11 @@ $(function () { // Common jQuery actions $('[data-action="expand-entity-list-details"]').click(function() { - $('.entity-list.compact').find('p').slideToggle(240); + $('.entity-list.compact').find('p').not('.empty-text').slideToggle(240); }); }); - -function elemExists(selector) { - return document.querySelector(selector) !== null; -} - // Page specific items require('./pages/page-show'); diff --git a/resources/assets/js/pages/page-form.js b/resources/assets/js/pages/page-form.js index 5617fa5be..611d2e782 100644 --- a/resources/assets/js/pages/page-form.js +++ b/resources/assets/js/pages/page-form.js @@ -1,7 +1,8 @@ var mceOptions = module.exports = { selector: '#html-editor', content_css: [ - '/css/styles.css' + '/css/styles.css', + '/libs/material-design-iconic-font/css/material-design-iconic-font.min.css' ], body_class: 'page-content', relative_urls: false, @@ -19,11 +20,18 @@ var mceOptions = module.exports = { {title: "Header 1", format: "h1"}, {title: "Header 2", format: "h2"}, {title: "Header 3", format: "h3"}, - {title: "Paragraph", format: "p"}, + {title: "Paragraph", format: "p", exact: true, classes: ''}, {title: "Blockquote", format: "blockquote"}, {title: "Code Block", icon: "code", format: "pre"}, - {title: "Inline Code", icon: "code", inline: "code"} + {title: "Inline Code", icon: "code", inline: "code"}, + {title: "Callouts", items: [ + {title: "Success", block: 'p', exact: true, attributes : {'class' : 'callout success'}}, + {title: "Info", block: 'p', exact: true, attributes : {'class' : 'callout info'}}, + {title: "Warning", block: 'p', exact: true, attributes : {'class' : 'callout warning'}}, + {title: "Danger", block: 'p', exact: true, attributes : {'class' : 'callout danger'}} + ]} ], + style_formats_merge: false, formats: { alignleft: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-left'}, aligncenter: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-center'}, diff --git a/resources/assets/js/pages/page-show.js b/resources/assets/js/pages/page-show.js index 3ddece1b8..b037612be 100644 --- a/resources/assets/js/pages/page-show.js +++ b/resources/assets/js/pages/page-show.js @@ -74,15 +74,15 @@ window.setupPageShow = module.exports = function (pageId) { // Make the book-tree sidebar stick in view on scroll var $window = $(window); var $bookTree = $(".book-tree"); + var $bookTreeParent = $bookTree.parent(); // Check the page is scrollable and the content is taller than the tree var pageScrollable = ($(document).height() > $window.height()) && ($bookTree.height() < $('.page-content').height()); // Get current tree's width and header height var headerHeight = $("#header").height() + $(".toolbar").height(); var isFixed = $window.scrollTop() > headerHeight; - var bookTreeWidth = $bookTree.width(); // Function to fix the tree as a sidebar function stickTree() { - $bookTree.width(bookTreeWidth + 48 + 15); + $bookTree.width($bookTreeParent.width() + 15); $bookTree.addClass("fixed"); isFixed = true; } @@ -101,13 +101,27 @@ window.setupPageShow = module.exports = function (pageId) { unstickTree(); } } + // The event ran when the window scrolls + function windowScrollEvent() { + checkTreeStickiness(false); + } + // If the page is scrollable and the window is wide enough listen to scroll events // and evaluate tree stickiness. if (pageScrollable && $window.width() > 1000) { - $window.scroll(function() { - checkTreeStickiness(false); - }); + $window.on('scroll', windowScrollEvent); checkTreeStickiness(true); } + // Handle window resizing and switch between desktop/mobile views + $window.on('resize', event => { + if (pageScrollable && $window.width() > 1000) { + $window.on('scroll', windowScrollEvent); + checkTreeStickiness(true); + } else { + $window.off('scroll', windowScrollEvent); + unstickTree(); + } + }); + }; diff --git a/resources/assets/sass/_blocks.scss b/resources/assets/sass/_blocks.scss index bf23eb565..3c7f7490b 100644 --- a/resources/assets/sass/_blocks.scss +++ b/resources/assets/sass/_blocks.scss @@ -125,3 +125,51 @@ margin-right: $-xl; } } + + +/** + * Callouts + */ + +.callout { + border-left: 3px solid #BBB; + background-color: #EEE; + padding: $-s; + &:before { + font-family: 'Material-Design-Iconic-Font'; + padding-right: $-s; + display: inline-block; + } + &.success { + border-left-color: $positive; + background-color: lighten($positive, 45%); + color: darken($positive, 16%); + } + &.success:before { + content: '\f269'; + } + &.danger { + border-left-color: $negative; + background-color: lighten($negative, 34%); + color: darken($negative, 20%); + } + &.danger:before { + content: '\f1f2'; + } + &.info { + border-left-color: $info; + background-color: lighten($info, 50%); + color: darken($info, 16%); + } + &.info:before { + content: '\f1f8'; + } + &.warning { + border-left-color: $warning; + background-color: lighten($warning, 36%); + color: darken($warning, 16%); + } + &.warning:before { + content: '\f1f1'; + } +} \ No newline at end of file diff --git a/resources/assets/sass/_forms.scss b/resources/assets/sass/_forms.scss index da015ec7c..4e643dcda 100644 --- a/resources/assets/sass/_forms.scss +++ b/resources/assets/sass/_forms.scss @@ -20,6 +20,9 @@ &.disabled, &[disabled] { background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAMUlEQVQIW2NkwAGuXbv2nxGbHEhCS0uLEUMSJgHShCKJLIEiiS4Bl8QmAZbEJQGSBAC62BuJ+tt7zgAAAABJRU5ErkJggg==); } + &:focus { + outline: 0; + } } #html-editor { diff --git a/resources/assets/sass/_lists.scss b/resources/assets/sass/_lists.scss index 388d5753d..08f00677e 100644 --- a/resources/assets/sass/_lists.scss +++ b/resources/assets/sass/_lists.scss @@ -1,6 +1,7 @@ .page-list { h3 { - margin: $-l 0 $-m 0; + margin: $-l 0 $-xs 0; + font-size: 1.666em; } a.chapter { color: $color-chapter; @@ -8,7 +9,6 @@ .inset-list { display: none; overflow: hidden; - // padding-left: $-m; margin-bottom: $-l; } h4 { @@ -338,6 +338,10 @@ ul.pagination { padding-top: $-xs; margin: 0; } + > p.empty-text { + display: block; + font-size: $fs-m; + } hr { margin: 0; } diff --git a/resources/assets/sass/_pages.scss b/resources/assets/sass/_pages.scss index e61e0c823..49b701dda 100644 --- a/resources/assets/sass/_pages.scss +++ b/resources/assets/sass/_pages.scss @@ -48,7 +48,7 @@ max-width: 100%; height:auto; } - h1, h2, h3, h4, h5, h6 { + h1, h2, h3, h4, h5, h6, pre { clear: left; } hr { diff --git a/resources/assets/sass/_text.scss b/resources/assets/sass/_text.scss index 0095b91cb..cd81bb4e2 100644 --- a/resources/assets/sass/_text.scss +++ b/resources/assets/sass/_text.scss @@ -3,7 +3,7 @@ */ h1 { - font-size: 3.625em; + font-size: 3.425em; line-height: 1.22222222em; margin-top: 0.48888889em; margin-bottom: 0.48888889em; @@ -33,10 +33,10 @@ h1, h2, h3, h4 { display: block; color: #555; .subheader { - display: block; + //display: block; font-size: 0.5em; line-height: 1em; - color: lighten($text-dark, 16%); + color: lighten($text-dark, 32%); } } @@ -225,6 +225,15 @@ p.secondary, p .secondary, span.secondary, .text-secondary { color: $color-chapter; } } +.faded .text-book:hover { + color: $color-book !important; +} +.faded .text-chapter:hover { + color: $color-chapter !important; +} +.faded .text-page:hover { + color: $color-page !important; +} span.highlight { //background-color: rgba($primary, 0.2); diff --git a/resources/assets/sass/_variables.scss b/resources/assets/sass/_variables.scss index 4f8ea0f08..23bf2b219 100644 --- a/resources/assets/sass/_variables.scss +++ b/resources/assets/sass/_variables.scss @@ -38,6 +38,7 @@ $primary-dark: #0288D1; $secondary: #e27b41; $positive: #52A256; $negative: #E84F4F; +$info: $primary; $warning: $secondary; $primary-faded: rgba(21, 101, 192, 0.15); diff --git a/resources/assets/sass/styles.scss b/resources/assets/sass/styles.scss index 0a7da179b..a6c364018 100644 --- a/resources/assets/sass/styles.scss +++ b/resources/assets/sass/styles.scss @@ -207,3 +207,59 @@ $btt-size: 40px; color: #EEE; } } + +.entity-selector { + border: 1px solid #DDD; + border-radius: 3px; + overflow: hidden; + font-size: 0.8em; + input[type="text"] { + width: 100%; + display: block; + border-radius: 0; + border: 0; + border-bottom: 1px solid #DDD; + font-size: 16px; + padding: $-s $-m; + } + .entity-list { + overflow-y: scroll; + height: 400px; + background-color: #EEEEEE; + } + .loading { + height: 400px; + padding-top: $-l; + } + .entity-list > p { + text-align: center; + padding-top: $-l; + font-size: 1.333em; + } + .entity-list > div { + padding-left: $-m; + padding-right: $-m; + background-color: #FFF; + transition: all ease-in-out 120ms; + cursor: pointer; + } +} + +.entity-list-item.selected { + h3, i, p ,a, span { + color: #EEE; + } +} + + + + + + + + + + + + + diff --git a/resources/lang/en/activities.php b/resources/lang/en/activities.php index 8356d8302..56af4ca07 100644 --- a/resources/lang/en/activities.php +++ b/resources/lang/en/activities.php @@ -4,7 +4,7 @@ return [ /** * Activity text strings. - * Is used for all the text within activity logs. + * Is used for all the text within activity logs & notifications. */ // Pages @@ -16,6 +16,7 @@ return [ 'page_delete_notification' => 'Page Successfully Deleted', 'page_restore' => 'restored page', 'page_restore_notification' => 'Page Successfully Restored', + 'page_move' => 'moved page', // Chapters 'chapter_create' => 'created chapter', @@ -24,6 +25,7 @@ return [ 'chapter_update_notification' => 'Chapter Successfully Updated', 'chapter_delete' => 'deleted chapter', 'chapter_delete_notification' => 'Chapter Successfully Deleted', + 'chapter_move' => 'moved chapter', // Books 'book_create' => 'created book', diff --git a/resources/views/books/list-item.blade.php b/resources/views/books/list-item.blade.php index 5807bf461..945eb9015 100644 --- a/resources/views/books/list-item.blade.php +++ b/resources/views/books/list-item.blade.php @@ -1,4 +1,4 @@ -
{!! $book->searchSnippet !!}
diff --git a/resources/views/chapters/list-item.blade.php b/resources/views/chapters/list-item.blade.php index d5ac46338..1567557d2 100644 --- a/resources/views/chapters/list-item.blade.php +++ b/resources/views/chapters/list-item.blade.php @@ -1,5 +1,11 @@ -