Added AJAX-based search to books, Fixes #15

This commit is contained in:
Dan Brown 2015-09-01 15:35:11 +01:00
parent 9a82d27548
commit 03f5f9e9b9
20 changed files with 385 additions and 214 deletions

View File

@ -69,16 +69,21 @@ class Entity extends Model
* Perform a full-text search on this entity. * Perform a full-text search on this entity.
* @param string[] $fieldsToSearch * @param string[] $fieldsToSearch
* @param string[] $terms * @param string[] $terms
* @param string[] array $wheres
* @return mixed * @return mixed
*/ */
public static function fullTextSearch($fieldsToSearch, $terms) public static function fullTextSearch($fieldsToSearch, $terms, $wheres = [])
{ {
$termString = ''; $termString = '';
foreach ($terms as $term) { foreach ($terms as $term) {
$termString .= $term . '* '; $termString .= $term . '* ';
} }
$fields = implode(',', $fieldsToSearch); $fields = implode(',', $fieldsToSearch);
return static::whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString])->get(); $search = static::whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString]);
foreach ($wheres as $whereTerm) {
$search->where($whereTerm[0], $whereTerm[1], $whereTerm[2]);
}
return $search->get();
} }
} }

View File

@ -48,5 +48,23 @@ class SearchController extends Controller
return view('search/all', ['pages' => $pages, 'books' => $books, 'chapters' => $chapters, 'searchTerm' => $searchTerm]); return view('search/all', ['pages' => $pages, 'books' => $books, 'chapters' => $chapters, 'searchTerm' => $searchTerm]);
} }
/**
* Searches all entities within a book.
* @param Request $request
* @param integer $bookId
* @return \Illuminate\View\View
* @internal param string $searchTerm
*/
public function searchBook(Request $request, $bookId)
{
if (!$request->has('term')) {
return redirect()->back();
}
$searchTerm = $request->get('term');
$whereTerm = [['book_id', '=', $bookId]];
$pages = $this->pageRepo->getBySearch($searchTerm, $whereTerm);
$chapters = $this->chapterRepo->getBySearch($searchTerm, $whereTerm);
return view('search/book', ['pages' => $pages, 'chapters' => $chapters, 'searchTerm' => $searchTerm]);
}
} }

View File

@ -66,6 +66,7 @@ Route::group(['middleware' => 'auth'], function () {
// Search // Search
Route::get('/search/all', 'SearchController@searchAll'); Route::get('/search/all', 'SearchController@searchAll');
Route::get('/search/book/{bookId}', 'SearchController@searchBook');
// Other Pages // Other Pages
Route::get('/', 'HomeController@index'); Route::get('/', 'HomeController@index');

View File

@ -67,10 +67,10 @@ class ChapterRepo
return $slug; return $slug;
} }
public function getBySearch($term) public function getBySearch($term, $whereTerms = [])
{ {
$terms = explode(' ', preg_quote(trim($term))); $terms = explode(' ', preg_quote(trim($term)));
$chapters = $this->chapter->fullTextSearch(['name', 'description'], $terms); $chapters = $this->chapter->fullTextSearch(['name', 'description'], $terms, $whereTerms);
$words = join('|', $terms); $words = join('|', $terms);
foreach ($chapters as $chapter) { foreach ($chapters as $chapter) {
//highlight //highlight

View File

@ -59,10 +59,10 @@ class PageRepo
$page->delete(); $page->delete();
} }
public function getBySearch($term) public function getBySearch($term, $whereTerms = [])
{ {
$terms = explode(' ', preg_quote(trim($term))); $terms = explode(' ', preg_quote(trim($term)));
$pages = $this->page->fullTextSearch(['name', 'text'], $terms); $pages = $this->page->fullTextSearch(['name', 'text'], $terms, $whereTerms);
// Add highlights to page text. // Add highlights to page text.
$words = join('|', $terms); $words = join('|', $terms);

View File

@ -14,4 +14,6 @@ var elixir = require('laravel-elixir');
elixir(function(mix) { elixir(function(mix) {
mix.sass('styles.scss'); mix.sass('styles.scss');
mix.scripts('image-manager.js', 'public/js/image-manager.js'); mix.scripts('image-manager.js', 'public/js/image-manager.js');
mix.scripts('book-sidebar.js', 'public/js/book-sidebar.js');
mix.scripts('jquery-extensions.js', 'public/js/jquery-extensions.js');
}); });

View File

@ -0,0 +1,31 @@
var bookDashboard = new Vue({
el: '#book-dashboard',
data: {
searching: false,
searchTerm: '',
searchResults: ''
},
methods: {
searchBook: function (e) {
e.preventDefault();
var term = this.searchTerm;
if (term.length == 0) return;
this.searching = true;
this.searchResults = '';
var searchUrl = this.$$.form.getAttribute('action');
searchUrl += '?term=' + encodeURIComponent(term);
this.$http.get(searchUrl, function (data) {
this.$set('searchResults', data);
});
},
checkSearchForm: function (e) {
if (this.searchTerm.length < 1) {
this.searching = false;
}
},
clearSearch: function(e) {
this.searching = false;
this.searchTerm = '';
}
}
});

View File

@ -1,33 +1,6 @@
jQuery.fn.showSuccess = function(message) {
var elem = $(this);
var success = $('<div class="text-pos" style="display:none;"><i class="zmdi zmdi-check-circle"></i>'+message+'</div>');
elem.after(success);
success.slideDown(400, function() {
setTimeout(function() {success.slideUp(400, function() {
success.remove();
})}, 2000);
});
};
jQuery.fn.showFailure = function(messageMap) { window.ImageManager = new Vue({
var elem = $(this);
$.each(messageMap, function(key, messages) {
var input = elem.find('[name="'+key+'"]').last();
var fail = $('<div class="text-neg" style="display:none;"><i class="zmdi zmdi-alert-circle"></i>'+messages.join("\n")+'</div>');
input.after(fail);
fail.slideDown(400, function() {
setTimeout(function() {fail.slideUp(400, function() {
fail.remove();
})}, 2000);
});
});
};
(function() {
var ImageManager = new Vue({
el: '#image-manager', el: '#image-manager',
@ -52,7 +25,7 @@ jQuery.fn.showFailure = function(messageMap) {
methods: { methods: {
fetchData: function () { fetchData: function () {
var _this = this; var _this = this;
$.getJSON('/images/all/' + _this.page, function(data) { this.$http.get('/images/all/' + _this.page, function (data) {
_this.images = _this.images.concat(data.images); _this.images = _this.images.concat(data.images);
_this.hasMore = data.hasMore; _this.hasMore = data.hasMore;
_this.page++; _this.page++;
@ -147,8 +120,3 @@ jQuery.fn.showFailure = function(messageMap) {
} }
}); });
window.ImageManager = ImageManager;
})();

View File

@ -0,0 +1,43 @@
jQuery.fn.smoothScrollTo = function() {
if(this.length === 0) return;
$('body').animate({
scrollTop: this.offset().top - 60 // Adjust to change final scroll position top margin
}, 800); // Adjust to change animations speed (ms)
return this;
};
$.expr[":"].contains = $.expr.createPseudo(function(arg) {
return function( elem ) {
return $(elem).text().toUpperCase().indexOf(arg.toUpperCase()) >= 0;
};
});
jQuery.fn.showSuccess = function (message) {
var elem = $(this);
var success = $('<div class="text-pos" style="display:none;"><i class="zmdi zmdi-check-circle"></i>' + message + '</div>');
elem.after(success);
success.slideDown(400, function () {
setTimeout(function () {
success.slideUp(400, function () {
success.remove();
})
}, 2000);
});
};
jQuery.fn.showFailure = function (messageMap) {
var elem = $(this);
$.each(messageMap, function (key, messages) {
var input = elem.find('[name="' + key + '"]').last();
var fail = $('<div class="text-neg" style="display:none;"><i class="zmdi zmdi-alert-circle"></i>' + messages.join("\n") + '</div>');
input.after(fail);
fail.slideDown(400, function () {
setTimeout(function () {
fail.slideUp(400, function () {
fail.remove();
})
}, 2000);
});
});
};

View File

@ -16,6 +16,26 @@
} }
} }
.anim.searchResult {
opacity: 0;
transform: translate3d(580px, 0, 0);
animation-name: searchResult;
animation-duration: 220ms;
animation-fill-mode: forwards;
animation-timing-function: cubic-bezier(.62,.28,.23,.99);
}
@keyframes searchResult {
0% {
opacity: 0;
transform: translate3d(400px, 0, 0);
}
100% {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
.anim.notification { .anim.notification {
transform: translate3d(580px, 0, 0); transform: translate3d(580px, 0, 0);
animation-name: notification; animation-name: notification;

View File

@ -90,11 +90,28 @@ input[type="text"], input[type="number"], input[type="email"], input[type="searc
} }
} }
.description-input textarea { .description-input textarea {
@extend .inline-input-style; @extend .inline-input-style;
font-size: $fs-m; font-size: $fs-m;
color: #666; color: #666;
width: 100%; width: 100%;
} }
.search-box {
button {
background-color: transparent;
border: none;
color: $primary;
padding: 0;
margin: 0;
cursor: pointer;
margin-left: $-s;
}
button[type="submit"] {
margin-left: -$-l;
}
input {
padding-right: $-l;
width: 300px;
}
}

View File

@ -57,19 +57,25 @@ header {
} }
form.search-box { form.search-box {
padding-top: $-l *0.9; margin-top: $-l *0.9;
display: inline-block; display: inline-block;
position: relative;
input { input {
background-color: transparent; background-color: transparent;
border-radius: 0; border-radius: 0;
border: none; border: none;
border-bottom: 2px solid #EEE; border-bottom: 2px solid #EEE;
color: #EEE; color: #EEE;
padding-left: $-l; padding-right: $-l;
outline: 0; outline: 0;
} }
i { a {
margin-right: -$-l; vertical-align: top;
margin-left: -$-l;
color: #FFF;
top: 0;
display: inline-block;
position: absolute;
} }
} }
@ -121,6 +127,10 @@ body.flexbox {
padding: $-l $-l $-l 0; padding: $-l $-l $-l 0;
vertical-align: top; vertical-align: top;
line-height: 1; line-height: 1;
&:hover {
color: #FFF;
text-decoration: none;
}
} }
.page-title input { .page-title input {
@ -538,3 +548,12 @@ ul.dropdown {
border-bottom: 1px solid #DDD; border-bottom: 1px solid #DDD;
} }
} }
.search-results > h3 a {
font-size: 0.66em;
color: $primary;
padding-left: $-m;
i {
padding-right: $-s;
}
}

View File

@ -14,24 +14,12 @@
<!-- Scripts --> <!-- Scripts -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script> <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>
<script src="/js/jquery-extensions.js"></script>
<script src="/bower/bootstrap/dist/js/bootstrap.js"></script> <script src="/bower/bootstrap/dist/js/bootstrap.js"></script>
<script src="/bower/jquery-sortable/source/js/jquery-sortable.js"></script> <script src="/bower/jquery-sortable/source/js/jquery-sortable.js"></script>
<script src="/bower/dropzone/dist/min/dropzone.min.js"></script> <script src="/bower/dropzone/dist/min/dropzone.min.js"></script>
<script src="/bower/vue/dist/vue.min.js"></script> <script src="/bower/vue/dist/vue.min.js"></script>
<script> <script src="/bower/vue-resource/dist/vue-resource.min.js"></script>
$.fn.smoothScrollTo = function() {
if(this.length === 0) return;
$('body').animate({
scrollTop: this.offset().top - 60 // Adjust to change final scroll position top margin
}, 800); // Adjust to change animations speed (ms)
return this;
};
$.expr[":"].contains = $.expr.createPseudo(function(arg) {
return function( elem ) {
return $(elem).text().toUpperCase().indexOf(arg.toUpperCase()) >= 0;
};
});
</script>
@yield('head') @yield('head')
</head> </head>
@ -57,8 +45,8 @@
</div> </div>
<div class="col-md-3 text-right"> <div class="col-md-3 text-right">
<form action="/search/all" method="GET" class="search-box"> <form action="/search/all" method="GET" class="search-box">
<i class="zmdi zmdi-search"></i>
<input type="text" name="term" tabindex="2" value="{{ isset($searchTerm) ? $searchTerm : '' }}"> <input type="text" name="term" tabindex="2" value="{{ isset($searchTerm) ? $searchTerm : '' }}">
<a onclick="$(this).closest('form').submit();"><i class="zmdi zmdi-search"></i></a>
</form> </form>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">

View File

@ -10,8 +10,8 @@
<form action="{{$book->getUrl()}}" method="POST"> <form action="{{$book->getUrl()}}" method="POST">
{!! csrf_field() !!} {!! csrf_field() !!}
<input type="hidden" name="_method" value="DELETE"> <input type="hidden" name="_method" value="DELETE">
<button type="submit" class="button neg">Confirm</button>
<a href="{{$book->getUrl()}}" class="button">Cancel</a> <a href="{{$book->getUrl()}}" class="button">Cancel</a>
<button type="submit" class="button neg">Confirm</button>
</form> </form>
</div> </div>

View File

@ -27,11 +27,12 @@
</div> </div>
<div class="container"> <div class="container" id="book-dashboard">
<div class="row"> <div class="row">
<div class="col-md-7"> <div class="col-md-7">
<h1>{{$book->name}}</h1> <h1>{{$book->name}}</h1>
<div class="book-content anim fadeIn" v-if="!searching">
<p class="text-muted">{{$book->description}}</p> <p class="text-muted">{{$book->description}}</p>
<div class="page-list"> <div class="page-list">
@ -67,24 +68,39 @@
</p> </p>
<hr> <hr>
@endif @endif
</div>
<p class="text-muted small"> <p class="text-muted small">
Created {{$book->created_at->diffForHumans()}} @if($book->createdBy) by {{$book->createdBy->name}} @endif Created {{$book->created_at->diffForHumans()}} @if($book->createdBy) by {{$book->createdBy->name}} @endif
<br> <br>
Last Updated {{$book->updated_at->diffForHumans()}} @if($book->createdBy) by {{$book->updatedBy->name}} @endif Last Updated {{$book->updated_at->diffForHumans()}} @if($book->createdBy) by {{$book->updatedBy->name}} @endif
</p> </p>
</div>
</div>
<div class="search-results" v-if="searching">
<h3 class="text-muted">Search Results <a v-if="searching" v-on="click: clearSearch" class="text-small"><i class="zmdi zmdi-close"></i>Clear Search</a></h3>
<div v-html="searchResults"></div>
</div>
</div> </div>
<div class="col-md-4 col-md-offset-1"> <div class="col-md-4 col-md-offset-1">
<div class="margin-top large"><br></div> <div class="margin-top large"></div>
{{--<h3>Search This Book</h3>--}}
<div class="search-box">
<form v-on="submit: searchBook, input: checkSearchForm" v-el="form" action="/search/book/{{ $book->id }}">
{!! csrf_field() !!}
<input v-model="searchTerm" type="text" name="term" placeholder="Search This Book">
<button type="submit"><i class="zmdi zmdi-search"></i></button>
<button v-if="searching" v-on="click: clearSearch" type="button primary"><i class="zmdi zmdi-close"></i></button>
</form>
</div>
<div class="activity anim fadeIn">
<h3>Recent Activity</h3> <h3>Recent Activity</h3>
@include('partials/activity-list', ['activity' => Activity::entityActivity($book, 20, 0)]) @include('partials/activity-list', ['activity' => Activity::entityActivity($book, 20, 0)])
</div> </div>
</div> </div>
</div> </div>
</div>
@ -99,4 +115,6 @@
}); });
</script> </script>
<script src="/js/book-sidebar.js"></script>
@stop @stop

View File

@ -11,7 +11,7 @@
<form action="{{$chapter->getUrl()}}" method="POST"> <form action="{{$chapter->getUrl()}}" method="POST">
{!! csrf_field() !!} {!! csrf_field() !!}
<input type="hidden" name="_method" value="DELETE"> <input type="hidden" name="_method" value="DELETE">
<a href="{{$chapter->getUrl()}}" class="button muted">Cancel</a> <a href="{{$chapter->getUrl()}}" class="button primary">Cancel</a>
<button type="submit" class="button neg">Confirm</button> <button type="submit" class="button neg">Confirm</button>
</form> </form>
</div> </div>

View File

@ -4,12 +4,12 @@
<div class="page-content"> <div class="page-content">
<h1>Delete Page</h1> <h1>Delete Page</h1>
<p>Are you sure you want to delete this page?</p> <p class="text-neg">Are you sure you want to delete this page?</p>
<form action="{{$page->getUrl()}}" method="POST"> <form action="{{$page->getUrl()}}" method="POST">
{!! csrf_field() !!} {!! csrf_field() !!}
<input type="hidden" name="_method" value="DELETE"> <input type="hidden" name="_method" value="DELETE">
<a href="{{$page->getUrl()}}" class="button muted">Cancel</a> <a href="{{$page->getUrl()}}" class="button primary">Cancel</a>
<button type="submit" class="button neg">Confirm</button> <button type="submit" class="button neg">Confirm</button>
</form> </form>
</div> </div>

View File

@ -44,7 +44,7 @@
</div> </div>
</div> </div>
<div class="col-md-9"> <div class="col-md-9">
<div class="page-content"> <div class="page-content anim fadeIn">
@include('pages/page-display') @include('pages/page-display')
<hr> <hr>
<p class="text-muted small"> <p class="text-muted small">

View File

@ -2,7 +2,7 @@
@section('content') @section('content')
<div class="container"> <div class="container anim fadeIn">
<h1>Search Results&nbsp;&nbsp;&nbsp; <span class="text-muted">{{$searchTerm}}</span></h1> <h1>Search Results&nbsp;&nbsp;&nbsp; <span class="text-muted">{{$searchTerm}}</span></h1>

View File

@ -0,0 +1,41 @@
<div class="page-list">
@if(count($pages) > 0)
@foreach($pages as $page)
<div class="book-child anim searchResult">
<h3>
<a href="{{$page->getUrl() . '#' . $searchTerm}}" class="page">
<i class="zmdi zmdi-file-text"></i>{{$page->name}}
</a>
</h3>
<p class="text-muted">
{!! $page->searchSnippet !!}
</p>
<hr>
</div>
@endforeach
@else
<p class="text-muted">No pages matched this search</p>
@endif
</div>
@if(count($chapters) > 0)
<div class="page-list">
@foreach($chapters as $chapter)
<div class="book-child anim searchResult">
<h3>
<a href="{{$chapter->getUrl()}}" class="text-chapter">
<i class="zmdi zmdi-collection-bookmark"></i>{{$chapter->name}}
</a>
</h3>
<p class="text-muted">
{!! $chapter->searchSnippet !!}
</p>
<hr>
</div>
@endforeach
</div>
@endif