diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php index 537ea915b..d4642be78 100644 --- a/app/Http/Controllers/BookshelfController.php +++ b/app/Http/Controllers/BookshelfController.php @@ -64,7 +64,7 @@ class BookshelfController extends Controller public function create() { $this->checkPermission('bookshelf-create-all'); - $books = Book::visible()->orderBy('name')->get(['name', 'id', 'slug']); + $books = Book::visible()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']); $this->setPageTitle(trans('entities.shelves_create')); return view('shelves.create', ['books' => $books]); @@ -140,7 +140,7 @@ class BookshelfController extends Controller $this->checkOwnablePermission('bookshelf-update', $shelf); $shelfBookIds = $shelf->books()->get(['id'])->pluck('id'); - $books = Book::visible()->whereNotIn('id', $shelfBookIds)->orderBy('name')->get(['name', 'id', 'slug']); + $books = Book::visible()->whereNotIn('id', $shelfBookIds)->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']); $this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $shelf->getShortName()])); diff --git a/resources/icons/add-small.svg b/resources/icons/add-small.svg new file mode 100644 index 000000000..81aaf4f65 --- /dev/null +++ b/resources/icons/add-small.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M18 13.114h-4.886V18h-2.228v-4.886H6v-2.228h4.886V6h2.228v4.886H18Z" style="stroke-width:.857143"/></svg> \ No newline at end of file diff --git a/resources/icons/remove.svg b/resources/icons/remove.svg new file mode 100644 index 000000000..088c34a5d --- /dev/null +++ b/resources/icons/remove.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M17.5 7.91 16.09 6.5 12 10.59 7.91 6.5 6.5 7.91 10.59 12 6.5 16.09l1.41 1.41L12 13.41l4.09 4.09 1.41-1.41L13.41 12Z"/></svg> \ No newline at end of file diff --git a/resources/js/components/shelf-sort.js b/resources/js/components/shelf-sort.js index d10470bd7..e4aefc591 100644 --- a/resources/js/components/shelf-sort.js +++ b/resources/js/components/shelf-sort.js @@ -1,6 +1,30 @@ import Sortable from "sortablejs"; import {Component} from "./component"; +/** + * @type {Object<string, function(HTMLElement, HTMLElement, HTMLElement)>} + */ +const itemActions = { + move_up(item, shelfBooksList, allBooksList) { + const list = item.parentNode; + const index = Array.from(list.children).indexOf(item); + const newIndex = Math.max(index - 1, 0); + list.insertBefore(item, list.children[newIndex] || null); + }, + move_down(item, shelfBooksList, allBooksList) { + const list = item.parentNode; + const index = Array.from(list.children).indexOf(item); + const newIndex = Math.min(index + 2, list.children.length); + list.insertBefore(item, list.children[newIndex] || null); + }, + remove(item, shelfBooksList, allBooksList) { + allBooksList.appendChild(item); + }, + add(item, shelfBooksList, allBooksList) { + shelfBooksList.appendChild(item); + }, +}; + export class ShelfSort extends Component { setup() { @@ -9,6 +33,9 @@ export class ShelfSort extends Component { this.shelfBookList = this.$refs.shelfBookList; this.allBookList = this.$refs.allBookList; this.bookSearchInput = this.$refs.bookSearch; + this.sortButtonContainer = this.$refs.sortButtonContainer; + + this.lastSort = null; this.initSortable(); this.setupListeners(); @@ -29,16 +56,22 @@ export class ShelfSort extends Component { setupListeners() { this.elem.addEventListener('click', event => { - const sortItem = event.target.closest('.scroll-box-item'); - if (sortItem) { - event.preventDefault(); - this.sortItemClick(sortItem); + const sortItemAction = event.target.closest('.scroll-box-item button[data-action]'); + if (sortItemAction) { + this.sortItemActionClick(sortItemAction); } }); this.bookSearchInput.addEventListener('input', event => { this.filterBooksByName(this.bookSearchInput.value); }); + + this.sortButtonContainer.addEventListener('click' , event => { + const button = event.target.closest('button[data-sort]'); + if (button) { + this.sortShelfBooks(button.dataset.sort); + } + }); } /** @@ -62,15 +95,16 @@ export class ShelfSort extends Component { } /** - * Called when a sort item is clicked. - * @param {Element} sortItem + * Called when a sort item action button is clicked. + * @param {HTMLElement} sortItemAction */ - sortItemClick(sortItem) { - const lists = this.elem.querySelectorAll('.scroll-box'); - const newList = Array.from(lists).filter(list => sortItem.parentElement !== list); - if (newList.length > 0) { - newList[0].appendChild(sortItem); - } + sortItemActionClick(sortItemAction) { + const sortItem = sortItemAction.closest('.scroll-box-item'); + const action = sortItemAction.dataset.action; + + const actionFunction = itemActions[action]; + actionFunction(sortItem, this.shelfBookList, this.allBookList); + this.onChange(); } @@ -79,4 +113,27 @@ export class ShelfSort extends Component { this.input.value = shelfBookElems.map(elem => elem.getAttribute('data-id')).join(','); } + sortShelfBooks(sortProperty) { + const books = Array.from(this.shelfBookList.children); + const reverse = sortProperty === this.lastSort; + + books.sort((bookA, bookB) => { + const aProp = bookA.dataset[sortProperty].toLowerCase(); + const bProp = bookB.dataset[sortProperty].toLowerCase(); + + if (reverse) { + return aProp < bProp ? (aProp === bProp ? 0 : 1) : -1; + } + + return aProp < bProp ? (aProp === bProp ? 0 : -1) : 1; + }); + + for (const book of books) { + this.shelfBookList.append(book); + } + + this.lastSort = (this.lastSort === sortProperty) ? null : sortProperty; + this.onChange(); + } + } \ No newline at end of file diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 2150f6d07..825501364 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -1050,4 +1050,89 @@ $btt-size: 40px; vertical-align: top; line-height: 2; } +} + +// Sortable scroll boxes +.scroll-box { + list-style: none; + padding: 0; + margin: 0; + max-height: 280px; + overflow-y: scroll; + border: 1px solid; + @include lightDark(border-color, #DDD, #000); + border-radius: 3px; + min-height: 20px; + @include lightDark(background-color, #EEE, #000); +} +.scroll-box-item { + border-bottom: 1px solid; + border-top: 1px solid; + @include lightDark(border-color, #DDD, #000); + margin-top: -1px; + @include lightDark(background-color, #FFF, #222); + display: flex; + align-items: flex-start; + padding: 1px; + &:last-child { + border-bottom: 0; + } + &:hover { + cursor: pointer; + @include lightDark(background-color, #f8f8f8, #333); + } + .handle { + color: #AAA; + cursor: grab; + } + button { + opacity: .6; + } + .handle svg { + margin: 0; + } + > * { + padding: $-xs $-m; + } + .handle + * { + padding-left: 0; + } + &:hover .handle { + @include lightDark(color, #444, #FFF); + } + &:hover button { + opacity: 1; + } + a:hover { + text-decoration: none; + } +} + +input.scroll-box-search, .scroll-box-header-item { + font-size: 0.8rem; + border: 1px solid; + @include lightDark(border-color, #DDD, #000); + @include lightDark(background-color, #FFF, #222); + margin-bottom: -1px; + border-radius: 3px 3px 0 0; + width: 100%; + max-width: 100%; + height: auto; + line-height: 1.4; + color: #666; +} + +.scroll-box-search + .scroll-box, +.scroll-box-header-item + .scroll-box { + border-radius: 0 0 3px 3px; +} + +.scroll-box[refs="shelf-sort@shelf-book-list"] [data-action="add"] { + display: none; +} +.scroll-box[refs="shelf-sort@all-book-list"] [data-action="remove"], +.scroll-box[refs="shelf-sort@all-book-list"] [data-action="move_up"], +.scroll-box[refs="shelf-sort@all-book-list"] [data-action="move_down"], +{ + display: none; } \ No newline at end of file diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index 668cb5c85..0f4ec7041 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -198,71 +198,6 @@ $loadingSize: 10px; } } -.scroll-box { - max-height: 250px; - overflow-y: scroll; - border: 1px solid; - @include lightDark(border-color, #DDD, #000); - border-radius: 3px; - min-height: 20px; - @include lightDark(background-color, #EEE, #000); -} -.scroll-box-item { - border-bottom: 1px solid; - border-top: 1px solid; - @include lightDark(border-color, #DDD, #000); - margin-top: -1px; - @include lightDark(background-color, #FFF, #222); - display: flex; - padding: 1px; - &:last-child { - border-bottom: 0; - } - &:hover { - cursor: pointer; - @include lightDark(background-color, #f8f8f8, #333); - } - .handle { - color: #AAA; - cursor: grab; - } - .handle svg { - margin: 0; - } - > * { - padding: $-xs $-m; - } - .handle + * { - padding-left: 0; - } - &:hover .handle { - @include lightDark(color, #444, #FFF); - } - a:hover { - text-decoration: none; - } -} - -input.scroll-box-search, .scroll-box-header-item { - font-size: 0.8rem; - padding: $-xs $-m; - border: 1px solid; - @include lightDark(border-color, #DDD, #000); - @include lightDark(background-color, #FFF, #222); - margin-bottom: -1px; - border-radius: 3px 3px 0 0; - width: 100%; - max-width: 100%; - height: auto; - line-height: 1.4; - color: #666; -} - -.scroll-box-search + .scroll-box, -.scroll-box-header-item + .scroll-box { - border-radius: 0 0 3px 3px; -} - .fullscreen { border:0; position:fixed; diff --git a/resources/views/shelves/parts/form.blade.php b/resources/views/shelves/parts/form.blade.php index 364f8e0be..ad67cb85c 100644 --- a/resources/views/shelves/parts/form.blade.php +++ b/resources/views/shelves/parts/form.blade.php @@ -12,32 +12,45 @@ <div component="shelf-sort" class="grid half gap-xl"> <div class="form-group"> - <label for="books">{{ trans('entities.shelves_books') }}</label> + <label for="books" id="shelf-sort-books-label">{{ trans('entities.shelves_books') }}</label> <input refs="shelf-sort@input" type="hidden" name="books" value="{{ isset($shelf) ? $shelf->visibleBooks->implode('id', ',') : '' }}"> - <div class="scroll-box-header-item">{{ trans('entities.shelves_drag_books') }}</div> - <div refs="shelf-sort@shelf-book-list" class="scroll-box"> - @if (count($shelf->visibleBooks ?? []) > 0) - @foreach ($shelf->visibleBooks as $book) - <div data-id="{{ $book->id }}" class="scroll-box-item"> - <div class="handle">@icon('grip')</div> - <a href="{{ $book->getUrl() }}" class="text-book">@icon('book'){{ $book->name }}</a> - </div> - @endforeach - @endif + <div class="scroll-box-header-item flex-container-row items-center py-xs"> + <span class="px-m py-xs">{{ trans('entities.shelves_drag_books') }}</span> + <div class="dropdown-container ml-auto" component="dropdown"> + <button refs="dropdown@toggle" + type="button" + title="{{ trans('common.more') }}" + class="icon-button px-xs py-xxs mx-xs text-bigger" + aria-haspopup="true" + aria-expanded="false"> + @icon('more') + </button> + <div refs="dropdown@menu shelf-sort@sort-button-container" class="dropdown-menu" role="menu"> + <button type="button" class="text-item" data-sort="name">{{ trans('entities.books_sort_name') }}</button> + <button type="button" class="text-item" data-sort="created">{{ trans('entities.books_sort_created') }}</button> + <button type="button" class="text-item" data-sort="updated">{{ trans('entities.books_sort_updated') }}</button> + </div> + </div> </div> + <ul refs="shelf-sort@shelf-book-list" + aria-labelledby="shelf-sort-books-label" + class="scroll-box"> + @foreach (($shelf->visibleBooks ?? []) as $book) + @include('shelves.parts.shelf-sort-book-item', ['book' => $book]) + @endforeach + </ul> </div> <div class="form-group"> - <label for="books">{{ trans('entities.shelves_add_books') }}</label> + <label for="books" id="shelf-sort-all-books-label">{{ trans('entities.shelves_add_books') }}</label> <input type="text" refs="shelf-sort@book-search" class="scroll-box-search" placeholder="{{ trans('common.search') }}"> - <div refs="shelf-sort@all-book-list" class="scroll-box"> + <ul refs="shelf-sort@all-book-list" + aria-labelledby="shelf-sort-all-books-label" + class="scroll-box"> @foreach ($books as $book) - <div data-id="{{ $book->id }}" class="scroll-box-item"> - <div class="handle">@icon('grip')</div> - <a href="{{ $book->getUrl() }}" class="text-book">@icon('book'){{ $book->name }}</a> - </div> + @include('shelves.parts.shelf-sort-book-item', ['book' => $book]) @endforeach - </div> + </ul> </div> </div> diff --git a/resources/views/shelves/parts/shelf-sort-book-item.blade.php b/resources/views/shelves/parts/shelf-sort-book-item.blade.php new file mode 100644 index 000000000..795aee7be --- /dev/null +++ b/resources/views/shelves/parts/shelf-sort-book-item.blade.php @@ -0,0 +1,18 @@ +<li data-id="{{ $book->id }}" + data-name="{{ $book->name }}" + data-created="{{ $book->created_at->timestamp }}" + data-updated="{{ $book->updated_at->timestamp }}" + class="scroll-box-item"> + <div class="handle px-s">@icon('grip')</div> + <div class="text-book">@icon('book'){{ $book->name }}</div> + <div class="buttons flex-container-row items-center ml-auto px-xxs py-xs"> + <button type="button" data-action="move_up" class="icon-button p-xxs" + title="{{ trans('entities.books_sort_move_up') }}">@icon('chevron-up')</button> + <button type="button" data-action="move_down" class="icon-button p-xxs" + title="{{ trans('entities.books_sort_move_down') }}">@icon('chevron-down')</button> + <button type="button" data-action="remove" class="icon-button p-xxs" + title="{{ trans('common.remove') }}">@icon('remove')</button> + <button type="button" data-action="add" class="icon-button p-xxs" + title="{{ trans('common.add') }}">@icon('add-small')</button> + </div> +</li> \ No newline at end of file