From 920ad4f04f380df2daf5ed259a901506777dbbda Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 3 Jun 2015 18:10:56 +0930 Subject: [PATCH] Implement search on front end --- .../forum/src/components/discussion-list.js | 32 +- .../components/discussions-search-results.js | 36 ++ .../forum/src/components/header-secondary.js | 2 + .../js/forum/src/components/index-page.js | 378 ++++++++++++------ .../js/forum/src/components/post-preview.js | 3 +- .../js/forum/src/components/search-box.js | 222 ++++++++++ .../src/components/users-search-results.js | 22 + .../core/js/forum/src/initializers/boot.js | 5 +- framework/core/js/lib/helpers/highlight.js | 13 + framework/core/less/forum/app.less | 1 + framework/core/less/forum/index.less | 35 +- framework/core/less/lib/components.less | 8 + framework/core/less/lib/dropdowns.less | 16 +- framework/core/less/lib/forms.less | 44 +- framework/core/less/lib/layout.less | 8 +- framework/core/less/lib/search.less | 82 ++++ .../Api/Actions/Discussions/IndexAction.php | 4 +- .../Search/Discussions/DiscussionSearcher.php | 8 +- 18 files changed, 717 insertions(+), 202 deletions(-) create mode 100644 framework/core/js/forum/src/components/discussions-search-results.js create mode 100644 framework/core/js/forum/src/components/search-box.js create mode 100644 framework/core/js/forum/src/components/users-search-results.js create mode 100644 framework/core/js/lib/helpers/highlight.js create mode 100644 framework/core/less/lib/search.less diff --git a/framework/core/js/forum/src/components/discussion-list.js b/framework/core/js/forum/src/components/discussion-list.js index 3b8ab3bdb..3b091e20e 100644 --- a/framework/core/js/forum/src/components/discussion-list.js +++ b/framework/core/js/forum/src/components/discussion-list.js @@ -1,6 +1,7 @@ import Component from 'flarum/component'; import avatar from 'flarum/helpers/avatar'; import listItems from 'flarum/helpers/list-items'; +import highlight from 'flarum/helpers/highlight'; import humanTime from 'flarum/utils/human-time'; import ItemList from 'flarum/utils/item-list'; import abbreviateNumber from 'flarum/utils/abbreviate-number'; @@ -8,6 +9,7 @@ import ActionButton from 'flarum/components/action-button'; import DropdownButton from 'flarum/components/dropdown-button'; import LoadingIndicator from 'flarum/components/loading-indicator'; import TerminalPost from 'flarum/components/terminal-post'; +import PostPreview from 'flarum/components/post-preview'; import SubtreeRetainer from 'flarum/utils/subtree-retainer'; export default class DiscussionList extends Component { @@ -30,16 +32,26 @@ export default class DiscussionList extends Component { params[i] = this.props.params[i]; } params.sort = this.sortMap()[params.sort]; + if (params.q) { + params.include.push('relevantPosts', 'relevantPosts.discussion', 'relevantPosts.user'); + } return params; } + willBeRedrawn() { + this.subtrees.map(subtree => subtree.invalidate()); + } + sortMap() { - return { - recent: '-lastTime', - replies: '-commentsCount', - newest: '-startTime', - oldest: '+startTime' - }; + var map = {}; + if (this.props.params.q) { + map.relevance = ''; + } + map.recent = '-lastTime'; + map.replies = '-commentsCount'; + map.newest = '-startTime'; + map.oldest = '+startTime'; + return map; } refresh() { @@ -124,6 +136,7 @@ export default class DiscussionList extends Component { var isUnread = discussion.isUnread(); var displayUnread = this.countType() !== 'replies' && isUnread; var jumpTo = Math.min(discussion.lastPostNumber(), (discussion.readNumber() || 0) + 1); + var relevantPosts = this.props.params.q ? discussion.relevantPosts() : ''; var controls = discussion.controls(this).toArray(); @@ -152,13 +165,16 @@ export default class DiscussionList extends Component { ]), m('ul.badges', listItems(discussion.badges().toArray())), m('a.main', {href: app.route('discussion.near', {id: discussion.id(), slug: discussion.slug(), near: jumpTo}), config: m.route}, [ - m('h3.title', discussion.title()), + m('h3.title', highlight(discussion.title(), this.props.params.q)), m('ul.info', listItems(this.infoItems(discussion).toArray())) ]), m('span.count', {onclick: this.markAsRead.bind(this, discussion)}, [ abbreviateNumber(discussion[displayUnread ? 'unreadCount' : 'repliesCount']()), m('span.label', displayUnread ? 'unread' : 'replies') - ]) + ]), + (relevantPosts && relevantPosts.length) + ? m('div.relevant-posts', relevantPosts.map(post => PostPreview.component({post, highlight: this.props.params.q}))) + : '' ])) }) ]), diff --git a/framework/core/js/forum/src/components/discussions-search-results.js b/framework/core/js/forum/src/components/discussions-search-results.js new file mode 100644 index 000000000..f7009fe81 --- /dev/null +++ b/framework/core/js/forum/src/components/discussions-search-results.js @@ -0,0 +1,36 @@ +import highlight from 'flarum/helpers/highlight'; +import ActionButton from 'flarum/components/action-button'; + +export default class DiscussionsSearchResults { + constructor() { + this.results = {}; + } + + search(string) { + this.results[string] = []; + return app.store.find('discussions', {q: string, page: {limit: 3}, include: 'relevantPosts,relevantPosts.discussion'}).then(results => { + this.results[string] = results; + }); + } + + view(string) { + return [ + m('li.dropdown-header', 'Discussions'), + m('li', ActionButton.component({ + icon: 'search', + label: 'Search all discussions for "'+string+'"', + href: app.route('index', {q: string}), + config: m.route + })), + (this.results[string] && this.results[string].length) ? this.results[string].map(discussion => { + var post = discussion.relevantPosts()[0]; + return m('li.discussion-search-result', {'data-index': 'discussions'+discussion.id()}, + m('a', { href: app.route.discussion(discussion, post.number()), config: m.route }, + m('div.title', highlight(discussion.title(), string)), + m('div.excerpt', highlight(post.excerpt(), string)) + ) + ); + }) : '' + ]; + } +} diff --git a/framework/core/js/forum/src/components/header-secondary.js b/framework/core/js/forum/src/components/header-secondary.js index 900002e56..f64a05061 100644 --- a/framework/core/js/forum/src/components/header-secondary.js +++ b/framework/core/js/forum/src/components/header-secondary.js @@ -16,6 +16,8 @@ export default class HeaderSecondary extends Component { items() { var items = new ItemList(); + items.add('search', app.search.view()); + if (app.session.user()) { items.add('notifications', UserNotifications.component({ user: app.session.user() })) items.add('user', UserDropdown.component({ user: app.session.user() })); diff --git a/framework/core/js/forum/src/components/index-page.js b/framework/core/js/forum/src/components/index-page.js index 4b0f2838d..fbb82b025 100644 --- a/framework/core/js/forum/src/components/index-page.js +++ b/framework/core/js/forum/src/components/index-page.js @@ -17,12 +17,32 @@ import LoadingIndicator from 'flarum/components/loading-indicator'; import DropdownSelect from 'flarum/components/dropdown-select'; export default class IndexPage extends Component { + /** + * @param {Object} props + */ constructor(props) { super(props); + // If the user is returning from a discussion page, then take note of which + // discussion they have just visited. After the view is rendered, we will + // scroll down so that this discussion is in view. + if (app.current instanceof DiscussionPage) { + this.lastDiscussion = app.current.discussion(); + } + var params = this.params(); + if (app.cache.discussionList) { - app.cache.discussionList.subtrees.map(subtree => subtree.invalidate()); + // The discussion list component is stored in the app's cache so that it + // can persist across interfaces. Since we will soon be redrawing the + // discussion list from scratch, we need to invalidate the component's + // subtree cache to ensure that it re-constructs the view. + app.cache.discussionList.willBeRedrawn(); + + // Compare the requested parameters (sort, search query) to the ones that + // are currently present in the cached discussion list. If they differ, we + // will clear the cache and set up a new discussion list component with + // the new parameters. Object.keys(params).some(key => { if (app.cache.discussionList.props.params[key] !== params[key]) { app.cache.discussionList = null; @@ -30,151 +50,53 @@ export default class IndexPage extends Component { } }); } - if (!app.cache.discussionList) { - app.cache.discussionList = new DiscussionList({params}); - } - if (app.current instanceof DiscussionPage) { - this.lastDiscussion = app.current.discussion(); + if (!app.cache.discussionList) { + app.cache.discussionList = new DiscussionList({ params }); } app.history.push('index'); app.current = this; } - onunload() { - app.cache.scrollTop = $(window).scrollTop(); - app.composer.minimize(); - } - /** - Params that stick between filter changes - */ - stickyParams() { - return { - sort: m.route.param('sort'), - show: m.route.param('show'), - q: m.route.param('q') - } - } - - /** - Params which are passed to the DiscussionList - */ - params() { - var params = this.stickyParams(); - params.filter = m.route.param('filter'); - return params; - } - - reorder(sort) { - var params = this.params(); - if (sort === 'recent') { - delete params.sort; - } else { - params.sort = sort; - } - m.route(app.route(this.props.routeName, params)); - } - - /** - Render the component. - - @method view - @return void + * Render the component. + * + * @return {Object} */ view() { - var sortOptions = {}; - for (var i in app.cache.discussionList.sortMap()) { - sortOptions[i] = i.substr(0, 1).toUpperCase()+i.substr(1); - } - return m('div.index-area', {config: this.onload.bind(this)}, [ - WelcomeHero.component(), + this.hero(), m('div.container', [ m('nav.side-nav.index-nav', {config: this.affixSidebar}, [ m('ul', listItems(this.sidebarItems().toArray())) ]), m('div.offset-content.index-results', [ m('div.index-toolbar', [ - m('div.index-toolbar-view', [ - SelectInput.component({ - options: sortOptions, - value: m.route.param('sort'), - onchange: this.reorder.bind(this) - }), - ]), - m('div.index-toolbar-action', [ - app.session.user() ? ActionButton.component({ - title: 'Mark All as Read', - icon: 'check', - className: 'control-markAllAsRead btn btn-default btn-icon', - onclick: this.markAllAsRead.bind(this) - }) : '' - ]) + m('ul.index-toolbar-view', listItems(this.viewItems().toArray())), + m('ul.index-toolbar-action', listItems(this.actionItems().toArray())) ]), app.cache.discussionList.view() ]) ]) - ]) - } - - onload(element, isInitialized, context) { - if (isInitialized) { return; } - - this.element(element); - - $('body').addClass('index-page'); - context.onunload = function() { - $('body').removeClass('index-page'); - } - - - var heroHeight = this.$('.hero').css('height', '').outerHeight(); - var scrollTop = app.cache.scrollTop; - - $('.global-page').css('min-height', $(window).height() + heroHeight); - $(window).scrollTop(scrollTop - (app.cache.heroHeight - heroHeight)); - - app.cache.heroHeight = heroHeight; - - if (this.lastDiscussion) { - var $discussion = this.$('.discussion-summary[data-id='+this.lastDiscussion.id()+']'); - if ($discussion.length) { - var indexTop = $('#header').outerHeight(); - var discussionTop = $discussion.offset().top; - if (discussionTop < scrollTop + indexTop || discussionTop + $discussion.outerHeight() > scrollTop + $(window).height()) { - $(window).scrollTop(discussionTop - indexTop); - } - } - } - - app.setTitle(''); - } - - newDiscussion() { - if (app.session.user()) { - app.composer.load(new DiscussionComposer({ user: app.session.user() })); - app.composer.show(); - return true; - } else { - app.modal.show(new LoginModal({ - message: 'You must be logged in to do that.', - callback: this.newDiscussion.bind(this) - })); - } - } - - markAllAsRead() { - app.session.user().save({ readTime: new Date() }); + ]); } /** - Build an item list for the sidebar of the index page. By default this is a - "New Discussion" button, and then a DropdownSelect component containing a - list of navigation items (see this.navItems). + * Get the component to display as the hero. + * + * @return {Object} + */ + hero() { + return WelcomeHero.component(); + } - @return {ItemList} + /** + * Build an item list for the sidebar of the index page. By default this is a + * "New Discussion" button, and then a DropdownSelect component containing a + * list of navigation items (see this.navItems). + * + * @return {ItemList} */ sidebarItems() { var items = new ItemList(); @@ -200,14 +122,14 @@ export default class IndexPage extends Component { } /** - Build an item list for the navigation in the sidebar of the index page. By - default this is just the 'All Discussions' link. - - @return {ItemList} + * Build an item list for the navigation in the sidebar of the index page. By + * default this is just the 'All Discussions' link. + * + * @return {ItemList} */ navItems() { var items = new ItemList(); - var params = {sort: m.route.param('sort')}; + var params = this.stickyParams(); items.add('allDiscussions', IndexNavItem.component({ @@ -221,12 +143,182 @@ export default class IndexPage extends Component { } /** - Setup the sidebar DOM element to be affixed to the top of the viewport - using Bootstrap's affix plugin. + * Build an item list for the part of the toolbar which is concerned with how + * the results are displayed. By default this is just a select box to change + * the way discussions are sorted. + * + * @return {ItemList} + */ + viewItems() { + var items = new ItemList(); - @param {DOMElement} element - @param {Boolean} isInitialized - @return {void} + var sortOptions = {}; + for (var i in app.cache.discussionList.sortMap()) { + sortOptions[i] = i.substr(0, 1).toUpperCase()+i.substr(1); + } + + items.add('sort', + SelectInput.component({ + options: sortOptions, + value: this.params.sort, + onchange: this.reorder.bind(this) + }) + ); + + return items; + } + + /** + * Build an item list for the part of the toolbar which is about taking action + * on the results. By default this is just a "mark all as read" button. + * + * @return {ItemList} + */ + actionItems() { + var items = new ItemList(); + + if (app.session.user()) { + items.add('markAllAsRead', + ActionButton.component({ + title: 'Mark All as Read', + icon: 'check', + className: 'control-markAllAsRead btn btn-default btn-icon', + onclick: this.markAllAsRead.bind(this) + }) + ); + } + + return items; + } + + /** + * Return the current search query, if any. This is implemented to activate + * the search box in the header. + * + * @see module:flarum/components/search-box + * @return {String} + */ + searching() { + return this.params().q; + } + + /** + * Redirect to the index page without a search filter. This is called when the + * 'x' is clicked in the search box in the header. + * + * @see module:flarum/components/search-box + * @return void + */ + clearSearch() { + var params = this.params(); + delete params.q; + m.route(app.route('index', params)); + } + + /** + * Redirect to + * @param {[type]} sort [description] + * @return {[type]} + */ + reorder(sort) { + var params = this.params(); + if (sort === Object.keys(app.cache.discussionList.sortMap())[0]) { + delete params.sort; + } else { + params.sort = sort; + } + m.route(app.route(this.props.routeName, params)); + } + + /** + * Get URL parameters that stick between filter changes. + * + * @return {Object} + */ + stickyParams() { + return { + sort: m.route.param('sort'), + q: m.route.param('q') + } + } + + /** + * Get parameters to pass to the DiscussionList component. + * + * @return {Object} + */ + params() { + var params = this.stickyParams(); + + params.filter = m.route.param('filter'); + + return params; + } + + /** + * Initialize the DOM. + * + * @param {DOMElement} element + * @param {Boolean} isInitialized + * @param {Object} context + * @return {void} + */ + onload(element, isInitialized, context) { + if (isInitialized) return; + + this.element(element); + + $('body').addClass('index-page'); + context.onunload = function() { + $('body').removeClass('index-page'); + }; + + app.setTitle(''); + + // Work out the difference between the height of this hero and that of the + // previous hero. Maintain the same scroll position relative to the bottom + // of the hero so that the 'fixed' sidebar doesn't jump around. + var heroHeight = this.$('.hero').outerHeight(); + var scrollTop = app.cache.scrollTop; + + $('.global-page').css('min-height', $(window).height() + heroHeight); + $(window).scrollTop(scrollTop - (app.cache.heroHeight - heroHeight)); + + app.cache.heroHeight = heroHeight; + + // If we've just returned from a discussion page, then the constructor will + // have set the `lastDiscussion` property. If this is the case, we want to + // scroll down to that discussion so that it's in view. + if (this.lastDiscussion) { + var $discussion = this.$('.discussion-summary[data-id='+this.lastDiscussion.id()+']'); + if ($discussion.length) { + var indexTop = $('#header').outerHeight(); + var discussionTop = $discussion.offset().top; + if (discussionTop < scrollTop + indexTop || discussionTop + $discussion.outerHeight() > scrollTop + $(window).height()) { + $(window).scrollTop(discussionTop - indexTop); + } + } + } + } + + /** + * Mithril hook, called when the controller is destroyed. Save the scroll + * position, and minimize the composer. + * + * @return void + */ + onunload() { + app.cache.scrollTop = $(window).scrollTop(); + app.composer.minimize(); + } + + /** + * Setup the sidebar DOM element to be affixed to the top of the viewport + * using Bootstrap's affix plugin. + * + * @param {DOMElement} element + * @param {Boolean} isInitialized + * @return {void} */ affixSidebar(element, isInitialized) { if (isInitialized) { return; } @@ -243,4 +335,28 @@ export default class IndexPage extends Component { } }); } + + /** + * Initialize the composer for a new discussion. + * + * @todo return a promise + * @return void + */ + newDiscussion() { + if (app.session.user()) { + app.composer.load(new DiscussionComposer({ user: app.session.user() })); + app.composer.show(); + return true; + } + app.modal.show(new LoginModal({ onlogin: this.newDiscussion.bind(this) })); + } + + /** + * Mark all discussions as read. + * + * @return void + */ + markAllAsRead() { + app.session.user().save({ readTime: new Date() }); + } }; diff --git a/framework/core/js/forum/src/components/post-preview.js b/framework/core/js/forum/src/components/post-preview.js index dbc84c6e8..3318defc0 100644 --- a/framework/core/js/forum/src/components/post-preview.js +++ b/framework/core/js/forum/src/components/post-preview.js @@ -2,6 +2,7 @@ import Component from 'flarum/component'; import avatar from 'flarum/helpers/avatar'; import username from 'flarum/helpers/username'; import humanTime from 'flarum/helpers/human-time'; +import highlight from 'flarum/helpers/highlight'; export default class PostPreview extends Component { view() { @@ -16,7 +17,7 @@ export default class PostPreview extends Component { avatar(user), ' ', username(user), ' ', humanTime(post.time()), ' ', - post.excerpt() + highlight(post.excerpt(), this.props.highlight) ])); } } diff --git a/framework/core/js/forum/src/components/search-box.js b/framework/core/js/forum/src/components/search-box.js new file mode 100644 index 000000000..02d2b94aa --- /dev/null +++ b/framework/core/js/forum/src/components/search-box.js @@ -0,0 +1,222 @@ +import Component from 'flarum/component'; +import DiscussionPage from 'flarum/components/discussion-page'; +import IndexPage from 'flarum/components/index-page'; +import ActionButton from 'flarum/components/action-button'; +import LoadingIndicator from 'flarum/components/loading-indicator'; +import ItemList from 'flarum/utils/item-list'; +import classList from 'flarum/utils/class-list'; +import listItems from 'flarum/helpers/list-items'; +import icon from 'flarum/helpers/icon'; +import DiscussionsSearchResults from 'flarum/components/discussions-search-results'; +import UsersSearchResults from 'flarum/components/users-search-results'; + +/** + * A search box, which displays a menu of as-you-type results from a variety of + * sources. + * + * The search box will be 'activated' if the app's current controller implements + * a `searching` method that returns a truthy value. If this is the case, an 'x' + * button will be shown next to the search field, and clicking it will call the + * `clearSearch` method on the controller. + */ +export default class SearchBox extends Component { + constructor(props) { + super(props); + + this.value = m.prop(this.getCurrentSearch() || ''); + this.hasFocus = m.prop(false); + + this.sources = this.sourceItems().toArray(); + this.loadingSources = 0; + this.searched = []; + + /** + * The index of the currently-selected
  • in the results list. This can be + * a unique string (to account for the fact that an item's position may jump + * around as new results load), but otherwise it will be numeric (the + * sequential position within the list). + */ + this.index = m.prop(0); + } + + getCurrentSearch() { + return typeof app.current.searching === 'function' && app.current.searching(); + } + + view() { + var currentSearch = this.getCurrentSearch(); + + return m('div.search-box.dropdown', { + config: this.onload.bind(this), + className: classList({ + open: this.value() && this.hasFocus(), + active: !!currentSearch, + loading: !!this.loadingSources, + }) + }, + m('div.search-input', + m('input.form-control', { + placeholder: 'Search Forum', + value: this.value(), + oninput: m.withAttr('value', this.value), + onfocus: () => this.hasFocus(true), + onblur: () => this.hasFocus(false) + }), + this.loadingSources + ? LoadingIndicator.component({size: 'tiny', className: 'btn btn-icon btn-link'}) + : currentSearch + ? m('button.clear.btn.btn-icon.btn-link', {onclick: this.clear.bind(this)}, icon('times-circle')) + : '' + ), + m('ul.dropdown-menu.dropdown-menu-right.search-results', this.sources.map(source => source.view(this.value()))) + ); + } + + onload(element, isInitialized, context) { + this.element(element); + + // Highlight the item that is currently selected. + this.setIndex(this.getCurrentNumericIndex()); + + if (isInitialized) return; + + var self = this; + + this.$('.search-results') + .on('mousedown', e => e.preventDefault()) + .on('click', () => this.$('input').blur()) + + // Whenever the mouse is hovered over a search result, highlight it. + .on('mouseenter', '> li:not(.dropdown-header)', function(e) { + self.setIndex( + self.selectableItems().index(this) + ); + }); + + // Handle navigation key events on the search input. + this.$('input') + .on('keydown', e => { + switch (e.which) { + case 40: case 38: // Down/Up + this.setIndex(this.getCurrentNumericIndex() + (e.which === 40 ? 1 : -1), true); + e.preventDefault(); + break; + + case 13: // Return + this.$('input').blur(); + this.getItem(this.index()).find('a')[0].dispatchEvent(new Event('click')); + break; + + case 27: // Escape + this.clear(); + break; + } + }) + + // Handle input key events on the search input, triggering results to + // load. + .on('input focus', function(e) { + var value = this.value.toLowerCase(); + + if (value) { + clearTimeout(self.searchTimeout); + self.searchTimeout = setTimeout(() => { + if (self.searched.indexOf(value) === -1) { + if (value.length >= 3) { + self.sources.map(source => { + if (source.search) { + self.loadingSources++; + source.search(value).then(() => { + self.loadingSources--; + m.redraw(); + }); + } + }); + } + self.searched.push(value); + m.redraw(); + } + }, 500); + } + }); + } + + clear() { + this.value(''); + if (this.getCurrentSearch()) { + app.current.clearSearch(); + } else { + m.redraw(); + } + } + + sourceItems() { + var items = new ItemList(); + + items.add('discussions', new DiscussionsSearchResults()); + items.add('users', new UsersSearchResults()); + + return items; + } + + selectableItems() { + return this.$('.search-results > li:not(.dropdown-header)'); + } + + getCurrentNumericIndex() { + return this.selectableItems().index( + this.getItem(this.index()) + ); + } + + /** + * Get the
  • in the search results with the given index (numeric or named). + * + * @param {String} index + * @return {DOMElement} + */ + getItem(index) { + var $items = this.selectableItems(); + var $item = $items.filter('[data-index='+index+']'); + + if (!$item.length) { + $item = $items.eq(index); + } + + return $item; + } + + setIndex(index, scrollToItem) { + var $items = this.selectableItems(); + var $dropdown = $items.parent(); + + if (index < 0) { + index = $items.length - 1; + } else if (index >= $items.length) { + index = 0; + } + + var $item = $items.removeClass('active').eq(index).addClass('active'); + + this.index($item.attr('data-index') || index); + + if (scrollToItem) { + var dropdownScroll = $dropdown.scrollTop(); + var dropdownTop = $dropdown.offset().top; + var dropdownBottom = dropdownTop + $dropdown.outerHeight(); + var itemTop = $item.offset().top; + var itemBottom = itemTop + $item.outerHeight(); + + var scrollTop; + if (itemTop < dropdownTop) { + scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top')); + } else if (itemBottom > dropdownBottom) { + scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom')); + } + + if (typeof scrollTop !== 'undefined') { + $dropdown.stop(true).animate({scrollTop}, 100); + } + } + } +} diff --git a/framework/core/js/forum/src/components/users-search-results.js b/framework/core/js/forum/src/components/users-search-results.js new file mode 100644 index 000000000..a9bf956f1 --- /dev/null +++ b/framework/core/js/forum/src/components/users-search-results.js @@ -0,0 +1,22 @@ +import highlight from 'flarum/helpers/highlight'; +import avatar from 'flarum/helpers/avatar'; + +export default class UsersSearchResults { + search(string) { + return app.store.find('users', {q: string, page: {limit: 5}}); + } + + view(string) { + var results = app.store.all('users').filter(user => user.username().toLowerCase().substr(0, string.length) === string); + + return results.length ? [ + m('li.dropdown-header', 'Users'), + results.map(user => m('li.user-search-result', {'data-index': 'users'+user.id()}, + m('a', { + href: app.route.user(user), + config: m.route + }, avatar(user), highlight(user.username(), string)) + )) + ] : ''; + } +} diff --git a/framework/core/js/forum/src/initializers/boot.js b/framework/core/js/forum/src/initializers/boot.js index 5e3b55e20..80bf9edb0 100644 --- a/framework/core/js/forum/src/initializers/boot.js +++ b/framework/core/js/forum/src/initializers/boot.js @@ -11,8 +11,7 @@ import FooterSecondary from 'flarum/components/footer-secondary'; import Composer from 'flarum/components/composer'; import Modal from 'flarum/components/modal'; import Alerts from 'flarum/components/alerts'; -import SignupModal from 'flarum/components/signup-modal'; -import LoginModal from 'flarum/components/login-modal'; +import SearchBox from 'flarum/components/search-box'; export default function(app) { var id = id => document.getElementById(id); @@ -43,5 +42,7 @@ export default function(app) { m.route.mode = 'hash'; m.route(id('content'), '/', mapRoutes(app.routes)); + app.search = new SearchBox(); + new ScrollListener(top => $('body').toggleClass('scrolled', top > 0)).start(); } diff --git a/framework/core/js/lib/helpers/highlight.js b/framework/core/js/lib/helpers/highlight.js new file mode 100644 index 000000000..f01f5ba7e --- /dev/null +++ b/framework/core/js/lib/helpers/highlight.js @@ -0,0 +1,13 @@ +export default function(string, regexp) { + if (!regexp) { + return string; + } + + if (!(regexp instanceof RegExp)) { + regexp = new RegExp(regexp, 'gi'); + } + + return m.trust( + $('
    ').text(string).html().replace(regexp, '$&') + ); +} diff --git a/framework/core/less/forum/app.less b/framework/core/less/forum/app.less index 9fd60924b..d857852de 100644 --- a/framework/core/less/forum/app.less +++ b/framework/core/less/forum/app.less @@ -20,6 +20,7 @@ @import "@{lib-path}/modals.less"; @import "@{lib-path}/layout.less"; @import "@{lib-path}/side-nav.less"; +@import "@{lib-path}/search.less"; @import "composer.less"; @import "notifications.less"; diff --git a/framework/core/less/forum/index.less b/framework/core/less/forum/index.less index 207c29c6b..c1fc1ec2e 100644 --- a/framework/core/less/forum/index.less +++ b/framework/core/less/forum/index.less @@ -17,13 +17,13 @@ margin-bottom: 15px; } .index-toolbar-view { - display: inline-block; + &:extend(.list-inline); - & .control-show { - margin-right: 10px; - } + display: inline-block; } .index-toolbar-action { + &:extend(.list-inline); + float: right; } @@ -97,6 +97,9 @@ & .count strong { font-size: 18px; } + & .relevant-posts { + display: none; + } } } } @@ -238,6 +241,30 @@ cursor: pointer; } } + & .relevant-posts { + margin-bottom: 20px; + + & .post-preview { + background: @fl-body-secondary-color; + display: block; + padding: 10px 15px; + border-bottom: 2px dotted @fl-body-bg; + + & .avatar, & time { + display: none; + } + & .post-preview-content { + padding-left: 0; + } + &:first-child { + border-radius: @border-radius-base @border-radius-base 0 0; + } + &:hover { + background: darken(@fl-body-secondary-color, 2%); + text-decoration: none; + } + } + } } .load-more { text-align: center; diff --git a/framework/core/less/lib/components.less b/framework/core/less/lib/components.less index dd6500113..511186c3a 100644 --- a/framework/core/less/lib/components.less +++ b/framework/core/less/lib/components.less @@ -22,3 +22,11 @@ hr { border-top: 2px solid @fl-body-secondary-color; } + +mark { + background: #FFE300; + color: @fl-body-color; + padding: 1px; + border-radius: @border-radius-base; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.1); +} diff --git a/framework/core/less/lib/dropdowns.less b/framework/core/less/lib/dropdowns.less index 0751a826d..100ea61d7 100644 --- a/framework/core/less/lib/dropdowns.less +++ b/framework/core/less/lib/dropdowns.less @@ -44,9 +44,23 @@ } } & .divider { - margin: 10px 0; + margin: 8px 0; background-color: @fl-body-control-bg; } + & .dropdown-header { + padding: 10px 15px; + color: @fl-body-heading-color; + text-transform: uppercase; + font-size: 12px; + font-weight: bold; + margin-top: 8px; + border-top: 1px solid @fl-body-control-bg; + + &:first-child { + margin-top: -8px; + border-top: 0; + } + } } @media @tablet, @desktop, @desktop-hd { .dropdown-split { diff --git a/framework/core/less/lib/forms.less b/framework/core/less/lib/forms.less index dfcb61e7b..1f214c1a4 100644 --- a/framework/core/less/lib/forms.less +++ b/framework/core/less/lib/forms.less @@ -3,11 +3,14 @@ } .form-control { .box-shadow(none); + border-width: 2px; + &:focus, &.focus { background-color: #fff; color: @fl-body-color; .box-shadow(none); + border: 2px solid @fl-body-primary-color; } } legend { @@ -17,47 +20,6 @@ legend { margin-bottom: 10px; } -// Search inputs -// @todo Extract some of this into header-specific definitions -.search-input { - overflow: hidden; - - &:before { - .fa(); - content: @fa-var-search; - float: left; - margin-right: -36px; - width: 36px; - font-size: 14px; - text-align: center; - color: @fl-body-muted-color; - position: relative; - padding: @padding-base-vertical - 1 0; - line-height: @line-height-base; - pointer-events: none; - } - & .form-control { - float: left; - width: 225px; - padding-left: 36px; - padding-right: 36px; - .transition(~"all 0.4s"); - } - & .clear { - float: left; - margin-left: -36px; - vertical-align: top; - opacity: 0; - width: 36px !important; - .rotate(-180deg); - .transition(~"transform 0.2s, opacity 0.2s"); - } - &.clearable .clear { - opacity: 1; - .rotate(0deg); - } -} - // Select inputs .select-input { display: inline-block; diff --git a/framework/core/less/lib/layout.less b/framework/core/less/lib/layout.less index 2a45bfc24..1a80f240a 100644 --- a/framework/core/less/lib/layout.less +++ b/framework/core/less/lib/layout.less @@ -169,7 +169,7 @@ body { background: fadein(@fl-drawer-control-bg, 5%); } } - & .search-input:before { + & .search-input { color: @fl-drawer-control-color; } & .btn-default, & .btn-default:hover { @@ -311,12 +311,8 @@ body { .header-secondary { float: right; - & .search-input { + & .search-box { margin-right: 10px; - - &:focus { - width: 400px; - } } } } diff --git a/framework/core/less/lib/search.less b/framework/core/less/lib/search.less new file mode 100644 index 000000000..6eee72cab --- /dev/null +++ b/framework/core/less/lib/search.less @@ -0,0 +1,82 @@ +.search-box { + & input:focus, &.active input, & .search-results { + width: 400px; + } +} +.search-results { + max-height: 70vh; + overflow: auto; + + & > li > a { + white-space: normal; + + &:hover { + background: none; + } + } + + & mark { + background: none; + padding: 0; + font-weight: bold; + color: inherit; + box-shadow: none; + } +} + +.search-input { + overflow: hidden; + color: @fl-body-muted-color; + + &:before { + .fa(); + content: @fa-var-search; + float: left; + margin-right: -36px; + width: 36px; + font-size: 14px; + text-align: center; + position: relative; + padding: @padding-base-vertical - 1 0; + line-height: @line-height-base; + pointer-events: none; + } + & input { + float: left; + width: 225px; + padding-left: 36px; + padding-right: 36px; + .transition(~"all 0.4s"); + + .active & { + background: @fl-body-bg; + border: 2px solid @fl-body-secondary-color; + + &:focus { + &:extend(.form-control:focus); + } + } + } + & .btn { + float: left; + margin-left: -36px; + width: 36px !important; + outline: none; + } +} + +.discussion-search-result { + & .title { + margin-bottom: 3px; + } + & .excerpt { + color: @fl-body-muted-color; + font-size: 11px; + } +} +.user-search-result { + & .avatar { + .avatar-size(24px); + margin: -2px 10px -2px 0; + } +} diff --git a/framework/core/src/Api/Actions/Discussions/IndexAction.php b/framework/core/src/Api/Actions/Discussions/IndexAction.php index 65695afd7..c0cd6aaed 100644 --- a/framework/core/src/Api/Actions/Discussions/IndexAction.php +++ b/framework/core/src/Api/Actions/Discussions/IndexAction.php @@ -33,7 +33,9 @@ class IndexAction extends SerializeCollectionAction 'lastUser' => true, 'startPost' => false, 'lastPost' => false, - 'relevantPosts' => false + 'relevantPosts' => false, + 'relevantPosts.discussion' => false, + 'relevantPosts.user' => false ]; /** diff --git a/framework/core/src/Core/Search/Discussions/DiscussionSearcher.php b/framework/core/src/Core/Search/Discussions/DiscussionSearcher.php index d4b7d74c8..742ba664c 100644 --- a/framework/core/src/Core/Search/Discussions/DiscussionSearcher.php +++ b/framework/core/src/Core/Search/Discussions/DiscussionSearcher.php @@ -93,7 +93,7 @@ class DiscussionSearcher implements SearcherInterface } if (in_array('relevantPosts', $load) && count($this->relevantPosts)) { - $load = array_diff($load, ['relevantPosts']); + $load = array_diff($load, ['relevantPosts', 'relevantPosts.discussion', 'relevantPosts.user']); $postIds = []; foreach ($this->relevantPosts as $id => $posts) { @@ -104,12 +104,6 @@ class DiscussionSearcher implements SearcherInterface foreach ($discussions as $discussion) { $discussion->relevantPosts = $posts->filter(function ($post) use ($discussion) { return $post->discussion_id == $discussion->id; - }) - ->each(function ($post) { - $pos = strpos(strtolower($post->content), strtolower($this->fulltext)); - // TODO: make clipping more intelligent (full words only) - $start = max(0, $pos - 50); - $post->content = ($start > 0 ? '...' : '').str_limit(substr($post->content, $start), 300); }); } }