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