diff --git a/app/Entities/Book.php b/app/Entities/Book.php index 3bce3860c..e722c4b10 100644 --- a/app/Entities/Book.php +++ b/app/Entities/Book.php @@ -69,6 +69,15 @@ class Book extends Entity return $this->hasMany(Page::class); } + /** + * Get the direct child pages of this book. + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function directPages() + { + return $this->pages()->where('chapter_id', '=', '0'); + } + /** * Get all chapters within this book. * @return \Illuminate\Database\Eloquent\Relations\HasMany diff --git a/app/Entities/Repos/EntityRepo.php b/app/Entities/Repos/EntityRepo.php index e5fd35407..dd9ea8ebf 100644 --- a/app/Entities/Repos/EntityRepo.php +++ b/app/Entities/Repos/EntityRepo.php @@ -341,6 +341,18 @@ class EntityRepo return $this->permissionService->enforceEntityRestrictions('book', $bookshelf->books())->get(); } + /** + * Get the direct children of a book. + * @param Book $book + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getBookDirectChildren(Book $book) + { + $pages = $this->permissionService->enforceEntityRestrictions('page', $book->directPages())->get(); + $chapters = $this->permissionService->enforceEntityRestrictions('chapters', $book->chapters())->get(); + return collect()->concat($pages)->concat($chapters)->sortBy('priority')->sortByDesc('draft'); + } + /** * Get all child objects of a book. * Returns a sorted collection of Pages and Chapters. diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index d8f2dc4d7..1f4224c79 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -3,6 +3,7 @@ use BookStack\Actions\ViewService; use BookStack\Entities\Repos\EntityRepo; use BookStack\Entities\SearchService; +use BookStack\Exceptions\NotFoundException; use Illuminate\Http\Request; class SearchController extends Controller @@ -104,4 +105,45 @@ class SearchController extends Controller return view('search/entity-ajax-list', ['entities' => $entities]); } + + /** + * Search siblings items in the system. + * @param Request $request + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View|mixed + */ + public function searchSiblings(Request $request) + { + $type = $request->get('entity_type', null); + $id = $request->get('entity_id', null); + + $entity = $this->entityRepo->getById($type, $id); + if (!$entity) { + return $this->jsonError(trans('errors.entity_not_found'), 404); + } + + $entities = []; + + // Page in chapter + if ($entity->isA('page') && $entity->chapter) { + $entities = $this->entityRepo->getChapterChildren($entity->chapter); + } + + // Page in book or chapter + if (($entity->isA('page') && !$entity->chapter) || $entity->isA('chapter')) { + $entities = $this->entityRepo->getBookDirectChildren($entity->book); + } + + // Book in shelf + // TODO - When shelve tracking added, Update below if criteria + + // Book + if ($entity->isA('book')) { + $entities = $this->entityRepo->getAll('book'); + } + + // Shelve + // TODO - When shelve tracking added + + return view('partials.entity-list-basic', ['entities' => $entities, 'style' => 'compact']); + } } diff --git a/resources/assets/icons/books.svg b/resources/assets/icons/books.svg new file mode 100644 index 000000000..240201fb3 --- /dev/null +++ b/resources/assets/icons/books.svg @@ -0,0 +1,71 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + viewBox="0 0 24 24" + version="1.1" + id="svg6" + sodipodi:docname="books.svg" + inkscape:version="0.92.3 (2405546, 2018-03-11)"> + <metadata + id="metadata12"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + </cc:Work> + </rdf:RDF> + </metadata> + <defs + id="defs10" /> + <sodipodi:namedview + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1" + objecttolerance="10" + gridtolerance="10" + guidetolerance="10" + inkscape:pageopacity="0" + inkscape:pageshadow="2" + inkscape:window-width="2560" + inkscape:window-height="1413" + id="namedview8" + showgrid="false" + inkscape:zoom="19.666667" + inkscape:cx="13.076733" + inkscape:cy="8.7801453" + inkscape:window-x="0" + inkscape:window-y="27" + inkscape:window-maximized="1" + inkscape:current-layer="svg6" /> + <path + d="M0 0h24v24H0z" + fill="none" + id="path2" /> + <path + d="M 19.252119,1.707627 H 8.6631356 c -0.9706568,0 -1.7648305,0.7941737 -1.7648305,1.7648305 V 17.591101 c 0,0.970657 0.7941737,1.764831 1.7648305,1.764831 H 19.252119 c 0.970656,0 1.76483,-0.794174 1.76483,-1.764831 V 3.4724575 c 0,-0.9706568 -0.794174,-1.7648305 -1.76483,-1.7648305 z M 8.6631356,3.4724575 H 13.075212 V 10.531779 L 10.869173,9.2081571 8.6631356,10.531779 Z" + id="path4" + inkscape:connector-curvature="0" + style="stroke-width:0.88241524" /> + <g + id="g836" + transform="translate(30.610169,3.2033898)"> + <path + id="path822" + d="M 0,0 H 24 V 24 H 0 Z" + inkscape:connector-curvature="0" + style="fill:none" /> + <path + id="path824" + d="M -27.644068,3.4067797 V 17.40678 c 0,1.1 0.9,2 2,2 h 14 v -2 h -14 V 3.4067797 Z" + inkscape:connector-curvature="0" + sodipodi:nodetypes="cssccccc" /> + </g> +</svg> diff --git a/resources/assets/js/components/breadcrumb-listing.js b/resources/assets/js/components/breadcrumb-listing.js new file mode 100644 index 000000000..e4f4e5302 --- /dev/null +++ b/resources/assets/js/components/breadcrumb-listing.js @@ -0,0 +1,60 @@ + + +class BreadcrumbListing { + + constructor(elem) { + this.elem = elem; + this.searchInput = elem.querySelector('input'); + this.loadingElem = elem.querySelector('.loading-container'); + this.entityListElem = elem.querySelector('.breadcrumb-listing-entity-list'); + this.toggleElem = elem.querySelector('[dropdown-toggle]'); + + // this.loadingElem.style.display = 'none'; + const entityDescriptor = elem.getAttribute('breadcrumb-listing').split(':'); + this.entityType = entityDescriptor[0]; + this.entityId = Number(entityDescriptor[1]); + + this.toggleElem.addEventListener('click', this.onShow.bind(this)); + this.searchInput.addEventListener('input', this.onSearch.bind(this)); + } + + onShow() { + this.loadEntityView(); + } + + onSearch() { + const input = this.searchInput.value.toLowerCase().trim(); + const listItems = this.entityListElem.querySelectorAll('.entity-list-item'); + console.log(listItems); + for (let listItem of listItems) { + const match = !input || listItem.textContent.toLowerCase().includes(input); + console.log(match); + listItem.style.display = match ? 'flex' : 'none'; + } + } + + loadEntityView() { + this.toggleLoading(true); + + const params = { + 'entity_id': this.entityId, + 'entity_type': this.entityType, + }; + + window.$http.get('/search/entity/siblings', {params}).then(resp => { + this.entityListElem.innerHTML = resp.data; + }).catch(err => { + console.error(err); + }).then(() => { + this.toggleLoading(false); + this.onSearch(); + }); + } + + toggleLoading(show = false) { + this.loadingElem.style.display = show ? 'block' : 'none'; + } + +} + +export default BreadcrumbListing; \ No newline at end of file diff --git a/resources/assets/js/components/dropdown.js b/resources/assets/js/components/dropdown.js index dda42e868..400ddb576 100644 --- a/resources/assets/js/components/dropdown.js +++ b/resources/assets/js/components/dropdown.js @@ -6,7 +6,7 @@ class DropDown { constructor(elem) { this.container = elem; - this.menu = elem.querySelector('ul'); + this.menu = elem.querySelector('ul, [dropdown-menu]'); this.toggle = elem.querySelector('[dropdown-toggle]'); this.setupListeners(); } diff --git a/resources/assets/js/components/index.js b/resources/assets/js/components/index.js index 37e78e383..e2749797e 100644 --- a/resources/assets/js/components/index.js +++ b/resources/assets/js/components/index.js @@ -21,7 +21,7 @@ import homepageControl from "./homepage-control"; import headerMobileToggle from "./header-mobile-toggle"; import listSortControl from "./list-sort-control"; import triLayout from "./tri-layout"; - +import breadcrumbListing from "./breadcrumb-listing"; const componentMapping = { 'dropdown': dropdown, @@ -47,6 +47,7 @@ const componentMapping = { 'header-mobile-toggle': headerMobileToggle, 'list-sort-control': listSortControl, 'tri-layout': triLayout, + 'breadcrumb-listing': breadcrumbListing, }; window.components = {}; diff --git a/resources/assets/sass/_header.scss b/resources/assets/sass/_header.scss index a81c11e74..e8667e719 100644 --- a/resources/assets/sass/_header.scss +++ b/resources/assets/sass/_header.scss @@ -220,6 +220,50 @@ header .search-box { } } +.breadcrumb-listing { + position: relative; + .breadcrumb-listing-toggle { + padding: 6px; + border: 1px solid transparent; + border-radius: 4px; + &:hover { + border-color: #DDD; + } + } + .svg-icon { + margin-right: 0; + } +} + +.breadcrumb-listing-dropdown { + box-shadow: $bs-med; + overflow: hidden; + min-height: 100px; + width: 240px; + display: none; + position: absolute; + z-index: 80; + right: -$-m; + .breadcrumb-listing-search .svg-icon { + position: absolute; + left: $-s; + top: 11px; + fill: #888; + pointer-events: none; + } + .breadcrumb-listing-entity-list { + max-height: 400px; + overflow-y: scroll; + text-align: left; + } + input { + padding-left: $-xl; + border-radius: 0; + border: 0; + border-bottom: 1px solid #DDD; + } +} + .faded { a, button, span, span > div { color: #666; diff --git a/resources/assets/sass/_text.scss b/resources/assets/sass/_text.scss index 539ffef99..1a9afd794 100644 --- a/resources/assets/sass/_text.scss +++ b/resources/assets/sass/_text.scss @@ -340,10 +340,6 @@ span.sep { /** * Icons */ -i { - padding-right: $-xs; -} - .svg-icon { width: 1em; height: 1em; @@ -351,5 +347,6 @@ i { position: relative; bottom: -0.105em; margin-right: $-xs; + pointer-events: none; } diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php index 4eeba10cf..e106b3704 100644 --- a/resources/views/books/index.blade.php +++ b/resources/views/books/index.blade.php @@ -38,13 +38,13 @@ <div class="actions mb-xl"> <h5>{{ trans('common.actions') }}</h5> <div class="icon-list text-primary"> + @include('partials.view-toggle', ['view' => $view, 'type' => 'book']) @if($currentUser->can('book-create-all')) <a href="{{ baseUrl("/create-book") }}" class="icon-list-item"> <span>@icon('add')</span> <span>{{ trans('entities.books_create') }}</span> </a> @endif - @include('partials.view-toggle', ['view' => $view, 'type' => 'book']) </div> </div> diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php index d29954228..28983b22c 100644 --- a/resources/views/books/show.blade.php +++ b/resources/views/books/show.blade.php @@ -8,6 +8,12 @@ @section('body') + <div class="mb-s"> + @include('partials.breadcrumbs', ['crumbs' => [ + $book, + ]]) + </div> + <div class="content-wrap card"> <h1 class="break-text" v-pre>{{$book->name}}</h1> <div class="book-content" v-show="!searching"> diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php index 75e4a3be0..f1661a146 100644 --- a/resources/views/common/header.blade.php +++ b/resources/views/common/header.blade.php @@ -30,7 +30,7 @@ @if(userCan('bookshelf-view-all') || userCan('bookshelf-view-own')) <a href="{{ baseUrl('/shelves') }}">@icon('bookshelf'){{ trans('entities.shelves') }}</a> @endif - <a href="{{ baseUrl('/books') }}">@icon('book'){{ trans('entities.books') }}</a> + <a href="{{ baseUrl('/books') }}">@icon('books'){{ trans('entities.books') }}</a> @if(signedInUser() && userCan('settings-manage')) <a href="{{ baseUrl('/settings') }}">@icon('settings'){{ trans('settings.settings') }}</a> @endif diff --git a/resources/views/partials/breadcrumb-listing.blade.php b/resources/views/partials/breadcrumb-listing.blade.php new file mode 100644 index 000000000..3dea32023 --- /dev/null +++ b/resources/views/partials/breadcrumb-listing.blade.php @@ -0,0 +1,13 @@ +<div class="breadcrumb-listing" dropdown breadcrumb-listing="{{ $entity->getType() }}:{{ $entity->id }}"> + <div class="breadcrumb-listing-toggle" dropdown-toggle> + <div class="separator">@icon('chevron-right')</div> + </div> + <div dropdown-menu class="breadcrumb-listing-dropdown card"> + <div class="breadcrumb-listing-search"> + @icon('search') + <input autocomplete="off" type="text" name="entity-search"> + </div> + @include('partials.loading-icon') + <div class="breadcrumb-listing-entity-list px-m"></div> + </div> +</div> \ No newline at end of file diff --git a/resources/views/partials/breadcrumbs.blade.php b/resources/views/partials/breadcrumbs.blade.php index d9c1b4681..890a6783e 100644 --- a/resources/views/partials/breadcrumbs.blade.php +++ b/resources/views/partials/breadcrumbs.blade.php @@ -1,10 +1,22 @@ <div class="breadcrumbs text-center"> <?php $breadcrumbCount = 0; ?> + + {{--Show top level item--}} + @if (count($crumbs) > 0 && $crumbs[0] instanceof \BookStack\Entities\Book) + <a href="{{ baseUrl('/books') }}" class="icon-list-item"> + <span>@icon('books')</span> + <span>{{ trans('entities.books') }}</span> + </a> + <?php $breadcrumbCount++; ?> + @endif + @foreach($crumbs as $key => $crumb) + <?php $isEntity = ($crumb instanceof \BookStack\Entities\Entity); ?> + @if (is_null($crumb)) <?php continue; ?> @endif - @if ($breadcrumbCount !== 0) + @if ($breadcrumbCount !== 0 && !$isEntity) <div class="separator">@icon('chevron-right')</div> @endif @@ -17,10 +29,15 @@ <span>@icon($crumb['icon'])</span> <span>{{ $crumb['text'] }}</span> </a> - @elseif($crumb instanceof \BookStack\Entities\Entity) + @elseif($isEntity && userCan('view', $crumb)) + @if($breadcrumbCount > 0) + @include('partials.breadcrumb-listing', ['entity' => $crumb]) + @endif <a href="{{ $crumb->getUrl() }}" class="text-{{$crumb->getType()}} icon-list-item"> <span>@icon($crumb->getType())</span> - <span>{{ $crumb->getShortName() }}</span> + <span> + {{ $crumb->getShortName() }} + </span> </a> @endif <?php $breadcrumbCount++; ?> diff --git a/resources/views/partials/entity-list-basic.blade.php b/resources/views/partials/entity-list-basic.blade.php new file mode 100644 index 000000000..dc5c3f333 --- /dev/null +++ b/resources/views/partials/entity-list-basic.blade.php @@ -0,0 +1,11 @@ +<div class="entity-list {{ $style ?? '' }}"> + @if(count($entities) > 0) + @foreach($entities as $index => $entity) + @include('partials.entity-list-item-basic', ['entity' => $entity]) + @endforeach + @else + <p class="text-muted empty-text"> + {{ $emptyText ?? trans('common.no_items') }} + </p> + @endif +</div> \ No newline at end of file diff --git a/resources/views/tri-layout.blade.php b/resources/views/tri-layout.blade.php index a77bb5d75..5f3d381df 100644 --- a/resources/views/tri-layout.blade.php +++ b/resources/views/tri-layout.blade.php @@ -7,6 +7,7 @@ {{--<div class="toolbar px-xl">--}} {{--@yield('toolbar')--}} {{--</div>--}} + {{--TODO - Cleanup toolbar usage--}} <div class="tri-layout-container mt-m" tri-layout @yield('container-attrs') > diff --git a/routes/web.php b/routes/web.php index 8f6bb51e8..41da967d9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -154,6 +154,7 @@ Route::group(['middleware' => 'auth'], function () { Route::get('/search', 'SearchController@search'); Route::get('/search/book/{bookId}', 'SearchController@searchBook'); Route::get('/search/chapter/{bookId}', 'SearchController@searchChapter'); + Route::get('/search/entity/siblings', 'SearchController@searchSiblings'); // Other Pages Route::get('/', 'HomeController@index');