mirror of
https://github.com/flarum/framework.git
synced 2024-12-12 06:03:39 +08:00
Implement search on front end
This commit is contained in:
parent
f1a7e8c115
commit
920ad4f04f
|
@ -1,6 +1,7 @@
|
||||||
import Component from 'flarum/component';
|
import Component from 'flarum/component';
|
||||||
import avatar from 'flarum/helpers/avatar';
|
import avatar from 'flarum/helpers/avatar';
|
||||||
import listItems from 'flarum/helpers/list-items';
|
import listItems from 'flarum/helpers/list-items';
|
||||||
|
import highlight from 'flarum/helpers/highlight';
|
||||||
import humanTime from 'flarum/utils/human-time';
|
import humanTime from 'flarum/utils/human-time';
|
||||||
import ItemList from 'flarum/utils/item-list';
|
import ItemList from 'flarum/utils/item-list';
|
||||||
import abbreviateNumber from 'flarum/utils/abbreviate-number';
|
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 DropdownButton from 'flarum/components/dropdown-button';
|
||||||
import LoadingIndicator from 'flarum/components/loading-indicator';
|
import LoadingIndicator from 'flarum/components/loading-indicator';
|
||||||
import TerminalPost from 'flarum/components/terminal-post';
|
import TerminalPost from 'flarum/components/terminal-post';
|
||||||
|
import PostPreview from 'flarum/components/post-preview';
|
||||||
import SubtreeRetainer from 'flarum/utils/subtree-retainer';
|
import SubtreeRetainer from 'flarum/utils/subtree-retainer';
|
||||||
|
|
||||||
export default class DiscussionList extends Component {
|
export default class DiscussionList extends Component {
|
||||||
|
@ -30,16 +32,26 @@ export default class DiscussionList extends Component {
|
||||||
params[i] = this.props.params[i];
|
params[i] = this.props.params[i];
|
||||||
}
|
}
|
||||||
params.sort = this.sortMap()[params.sort];
|
params.sort = this.sortMap()[params.sort];
|
||||||
|
if (params.q) {
|
||||||
|
params.include.push('relevantPosts', 'relevantPosts.discussion', 'relevantPosts.user');
|
||||||
|
}
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
willBeRedrawn() {
|
||||||
|
this.subtrees.map(subtree => subtree.invalidate());
|
||||||
|
}
|
||||||
|
|
||||||
sortMap() {
|
sortMap() {
|
||||||
return {
|
var map = {};
|
||||||
recent: '-lastTime',
|
if (this.props.params.q) {
|
||||||
replies: '-commentsCount',
|
map.relevance = '';
|
||||||
newest: '-startTime',
|
}
|
||||||
oldest: '+startTime'
|
map.recent = '-lastTime';
|
||||||
};
|
map.replies = '-commentsCount';
|
||||||
|
map.newest = '-startTime';
|
||||||
|
map.oldest = '+startTime';
|
||||||
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh() {
|
refresh() {
|
||||||
|
@ -124,6 +136,7 @@ export default class DiscussionList extends Component {
|
||||||
var isUnread = discussion.isUnread();
|
var isUnread = discussion.isUnread();
|
||||||
var displayUnread = this.countType() !== 'replies' && isUnread;
|
var displayUnread = this.countType() !== 'replies' && isUnread;
|
||||||
var jumpTo = Math.min(discussion.lastPostNumber(), (discussion.readNumber() || 0) + 1);
|
var jumpTo = Math.min(discussion.lastPostNumber(), (discussion.readNumber() || 0) + 1);
|
||||||
|
var relevantPosts = this.props.params.q ? discussion.relevantPosts() : '';
|
||||||
|
|
||||||
var controls = discussion.controls(this).toArray();
|
var controls = discussion.controls(this).toArray();
|
||||||
|
|
||||||
|
@ -152,13 +165,16 @@ export default class DiscussionList extends Component {
|
||||||
]),
|
]),
|
||||||
m('ul.badges', listItems(discussion.badges().toArray())),
|
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('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('ul.info', listItems(this.infoItems(discussion).toArray()))
|
||||||
]),
|
]),
|
||||||
m('span.count', {onclick: this.markAsRead.bind(this, discussion)}, [
|
m('span.count', {onclick: this.markAsRead.bind(this, discussion)}, [
|
||||||
abbreviateNumber(discussion[displayUnread ? 'unreadCount' : 'repliesCount']()),
|
abbreviateNumber(discussion[displayUnread ? 'unreadCount' : 'repliesCount']()),
|
||||||
m('span.label', displayUnread ? 'unread' : 'replies')
|
m('span.label', displayUnread ? 'unread' : 'replies')
|
||||||
])
|
]),
|
||||||
|
(relevantPosts && relevantPosts.length)
|
||||||
|
? m('div.relevant-posts', relevantPosts.map(post => PostPreview.component({post, highlight: this.props.params.q})))
|
||||||
|
: ''
|
||||||
]))
|
]))
|
||||||
})
|
})
|
||||||
]),
|
]),
|
||||||
|
|
|
@ -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))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}) : ''
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,6 +16,8 @@ export default class HeaderSecondary extends Component {
|
||||||
items() {
|
items() {
|
||||||
var items = new ItemList();
|
var items = new ItemList();
|
||||||
|
|
||||||
|
items.add('search', app.search.view());
|
||||||
|
|
||||||
if (app.session.user()) {
|
if (app.session.user()) {
|
||||||
items.add('notifications', UserNotifications.component({ user: app.session.user() }))
|
items.add('notifications', UserNotifications.component({ user: app.session.user() }))
|
||||||
items.add('user', UserDropdown.component({ user: app.session.user() }));
|
items.add('user', UserDropdown.component({ user: app.session.user() }));
|
||||||
|
|
|
@ -17,12 +17,32 @@ import LoadingIndicator from 'flarum/components/loading-indicator';
|
||||||
import DropdownSelect from 'flarum/components/dropdown-select';
|
import DropdownSelect from 'flarum/components/dropdown-select';
|
||||||
|
|
||||||
export default class IndexPage extends Component {
|
export default class IndexPage extends Component {
|
||||||
|
/**
|
||||||
|
* @param {Object} props
|
||||||
|
*/
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(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();
|
var params = this.params();
|
||||||
|
|
||||||
if (app.cache.discussionList) {
|
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 => {
|
Object.keys(params).some(key => {
|
||||||
if (app.cache.discussionList.props.params[key] !== params[key]) {
|
if (app.cache.discussionList.props.params[key] !== params[key]) {
|
||||||
app.cache.discussionList = null;
|
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) {
|
if (!app.cache.discussionList) {
|
||||||
this.lastDiscussion = app.current.discussion();
|
app.cache.discussionList = new DiscussionList({ params });
|
||||||
}
|
}
|
||||||
|
|
||||||
app.history.push('index');
|
app.history.push('index');
|
||||||
app.current = this;
|
app.current = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
onunload() {
|
|
||||||
app.cache.scrollTop = $(window).scrollTop();
|
|
||||||
app.composer.minimize();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Params that stick between filter changes
|
* Render the component.
|
||||||
*/
|
*
|
||||||
stickyParams() {
|
* @return {Object}
|
||||||
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
|
|
||||||
*/
|
*/
|
||||||
view() {
|
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)}, [
|
return m('div.index-area', {config: this.onload.bind(this)}, [
|
||||||
WelcomeHero.component(),
|
this.hero(),
|
||||||
m('div.container', [
|
m('div.container', [
|
||||||
m('nav.side-nav.index-nav', {config: this.affixSidebar}, [
|
m('nav.side-nav.index-nav', {config: this.affixSidebar}, [
|
||||||
m('ul', listItems(this.sidebarItems().toArray()))
|
m('ul', listItems(this.sidebarItems().toArray()))
|
||||||
]),
|
]),
|
||||||
m('div.offset-content.index-results', [
|
m('div.offset-content.index-results', [
|
||||||
m('div.index-toolbar', [
|
m('div.index-toolbar', [
|
||||||
m('div.index-toolbar-view', [
|
m('ul.index-toolbar-view', listItems(this.viewItems().toArray())),
|
||||||
SelectInput.component({
|
m('ul.index-toolbar-action', listItems(this.actionItems().toArray()))
|
||||||
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)
|
|
||||||
}) : ''
|
|
||||||
])
|
|
||||||
]),
|
]),
|
||||||
app.cache.discussionList.view()
|
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
|
* Get the component to display as the hero.
|
||||||
"New Discussion" button, and then a DropdownSelect component containing a
|
*
|
||||||
list of navigation items (see this.navItems).
|
* @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() {
|
sidebarItems() {
|
||||||
var items = new ItemList();
|
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
|
* Build an item list for the navigation in the sidebar of the index page. By
|
||||||
default this is just the 'All Discussions' link.
|
* default this is just the 'All Discussions' link.
|
||||||
|
*
|
||||||
@return {ItemList}
|
* @return {ItemList}
|
||||||
*/
|
*/
|
||||||
navItems() {
|
navItems() {
|
||||||
var items = new ItemList();
|
var items = new ItemList();
|
||||||
var params = {sort: m.route.param('sort')};
|
var params = this.stickyParams();
|
||||||
|
|
||||||
items.add('allDiscussions',
|
items.add('allDiscussions',
|
||||||
IndexNavItem.component({
|
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
|
* Build an item list for the part of the toolbar which is concerned with how
|
||||||
using Bootstrap's affix plugin.
|
* 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
|
var sortOptions = {};
|
||||||
@param {Boolean} isInitialized
|
for (var i in app.cache.discussionList.sortMap()) {
|
||||||
@return {void}
|
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) {
|
affixSidebar(element, isInitialized) {
|
||||||
if (isInitialized) { return; }
|
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() });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,6 +2,7 @@ import Component from 'flarum/component';
|
||||||
import avatar from 'flarum/helpers/avatar';
|
import avatar from 'flarum/helpers/avatar';
|
||||||
import username from 'flarum/helpers/username';
|
import username from 'flarum/helpers/username';
|
||||||
import humanTime from 'flarum/helpers/human-time';
|
import humanTime from 'flarum/helpers/human-time';
|
||||||
|
import highlight from 'flarum/helpers/highlight';
|
||||||
|
|
||||||
export default class PostPreview extends Component {
|
export default class PostPreview extends Component {
|
||||||
view() {
|
view() {
|
||||||
|
@ -16,7 +17,7 @@ export default class PostPreview extends Component {
|
||||||
avatar(user), ' ',
|
avatar(user), ' ',
|
||||||
username(user), ' ',
|
username(user), ' ',
|
||||||
humanTime(post.time()), ' ',
|
humanTime(post.time()), ' ',
|
||||||
post.excerpt()
|
highlight(post.excerpt(), this.props.highlight)
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
222
framework/core/js/forum/src/components/search-box.js
Normal file
222
framework/core/js/forum/src/components/search-box.js
Normal file
|
@ -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 <li> 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 <li> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))
|
||||||
|
))
|
||||||
|
] : '';
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,8 +11,7 @@ import FooterSecondary from 'flarum/components/footer-secondary';
|
||||||
import Composer from 'flarum/components/composer';
|
import Composer from 'flarum/components/composer';
|
||||||
import Modal from 'flarum/components/modal';
|
import Modal from 'flarum/components/modal';
|
||||||
import Alerts from 'flarum/components/alerts';
|
import Alerts from 'flarum/components/alerts';
|
||||||
import SignupModal from 'flarum/components/signup-modal';
|
import SearchBox from 'flarum/components/search-box';
|
||||||
import LoginModal from 'flarum/components/login-modal';
|
|
||||||
|
|
||||||
export default function(app) {
|
export default function(app) {
|
||||||
var id = id => document.getElementById(id);
|
var id = id => document.getElementById(id);
|
||||||
|
@ -43,5 +42,7 @@ export default function(app) {
|
||||||
m.route.mode = 'hash';
|
m.route.mode = 'hash';
|
||||||
m.route(id('content'), '/', mapRoutes(app.routes));
|
m.route(id('content'), '/', mapRoutes(app.routes));
|
||||||
|
|
||||||
|
app.search = new SearchBox();
|
||||||
|
|
||||||
new ScrollListener(top => $('body').toggleClass('scrolled', top > 0)).start();
|
new ScrollListener(top => $('body').toggleClass('scrolled', top > 0)).start();
|
||||||
}
|
}
|
||||||
|
|
13
framework/core/js/lib/helpers/highlight.js
Normal file
13
framework/core/js/lib/helpers/highlight.js
Normal file
|
@ -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(
|
||||||
|
$('<div/>').text(string).html().replace(regexp, '<mark>$&</mark>')
|
||||||
|
);
|
||||||
|
}
|
|
@ -20,6 +20,7 @@
|
||||||
@import "@{lib-path}/modals.less";
|
@import "@{lib-path}/modals.less";
|
||||||
@import "@{lib-path}/layout.less";
|
@import "@{lib-path}/layout.less";
|
||||||
@import "@{lib-path}/side-nav.less";
|
@import "@{lib-path}/side-nav.less";
|
||||||
|
@import "@{lib-path}/search.less";
|
||||||
|
|
||||||
@import "composer.less";
|
@import "composer.less";
|
||||||
@import "notifications.less";
|
@import "notifications.less";
|
||||||
|
|
|
@ -17,13 +17,13 @@
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
.index-toolbar-view {
|
.index-toolbar-view {
|
||||||
display: inline-block;
|
&:extend(.list-inline);
|
||||||
|
|
||||||
& .control-show {
|
display: inline-block;
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.index-toolbar-action {
|
.index-toolbar-action {
|
||||||
|
&:extend(.list-inline);
|
||||||
|
|
||||||
float: right;
|
float: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -97,6 +97,9 @@
|
||||||
& .count strong {
|
& .count strong {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
& .relevant-posts {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -238,6 +241,30 @@
|
||||||
cursor: pointer;
|
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 {
|
.load-more {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
@ -22,3 +22,11 @@
|
||||||
hr {
|
hr {
|
||||||
border-top: 2px solid @fl-body-secondary-color;
|
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);
|
||||||
|
}
|
||||||
|
|
|
@ -44,9 +44,23 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
& .divider {
|
& .divider {
|
||||||
margin: 10px 0;
|
margin: 8px 0;
|
||||||
background-color: @fl-body-control-bg;
|
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 {
|
@media @tablet, @desktop, @desktop-hd {
|
||||||
.dropdown-split {
|
.dropdown-split {
|
||||||
|
|
|
@ -3,11 +3,14 @@
|
||||||
}
|
}
|
||||||
.form-control {
|
.form-control {
|
||||||
.box-shadow(none);
|
.box-shadow(none);
|
||||||
|
border-width: 2px;
|
||||||
|
|
||||||
&:focus,
|
&:focus,
|
||||||
&.focus {
|
&.focus {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
color: @fl-body-color;
|
color: @fl-body-color;
|
||||||
.box-shadow(none);
|
.box-shadow(none);
|
||||||
|
border: 2px solid @fl-body-primary-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
legend {
|
legend {
|
||||||
|
@ -17,47 +20,6 @@ legend {
|
||||||
margin-bottom: 10px;
|
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 inputs
|
||||||
.select-input {
|
.select-input {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
@ -169,7 +169,7 @@ body {
|
||||||
background: fadein(@fl-drawer-control-bg, 5%);
|
background: fadein(@fl-drawer-control-bg, 5%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
& .search-input:before {
|
& .search-input {
|
||||||
color: @fl-drawer-control-color;
|
color: @fl-drawer-control-color;
|
||||||
}
|
}
|
||||||
& .btn-default, & .btn-default:hover {
|
& .btn-default, & .btn-default:hover {
|
||||||
|
@ -311,12 +311,8 @@ body {
|
||||||
.header-secondary {
|
.header-secondary {
|
||||||
float: right;
|
float: right;
|
||||||
|
|
||||||
& .search-input {
|
& .search-box {
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
|
|
||||||
&:focus {
|
|
||||||
width: 400px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
82
framework/core/less/lib/search.less
Normal file
82
framework/core/less/lib/search.less
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -33,7 +33,9 @@ class IndexAction extends SerializeCollectionAction
|
||||||
'lastUser' => true,
|
'lastUser' => true,
|
||||||
'startPost' => false,
|
'startPost' => false,
|
||||||
'lastPost' => false,
|
'lastPost' => false,
|
||||||
'relevantPosts' => false
|
'relevantPosts' => false,
|
||||||
|
'relevantPosts.discussion' => false,
|
||||||
|
'relevantPosts.user' => false
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -93,7 +93,7 @@ class DiscussionSearcher implements SearcherInterface
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array('relevantPosts', $load) && count($this->relevantPosts)) {
|
if (in_array('relevantPosts', $load) && count($this->relevantPosts)) {
|
||||||
$load = array_diff($load, ['relevantPosts']);
|
$load = array_diff($load, ['relevantPosts', 'relevantPosts.discussion', 'relevantPosts.user']);
|
||||||
|
|
||||||
$postIds = [];
|
$postIds = [];
|
||||||
foreach ($this->relevantPosts as $id => $posts) {
|
foreach ($this->relevantPosts as $id => $posts) {
|
||||||
|
@ -104,12 +104,6 @@ class DiscussionSearcher implements SearcherInterface
|
||||||
foreach ($discussions as $discussion) {
|
foreach ($discussions as $discussion) {
|
||||||
$discussion->relevantPosts = $posts->filter(function ($post) use ($discussion) {
|
$discussion->relevantPosts = $posts->filter(function ($post) use ($discussion) {
|
||||||
return $post->discussion_id == $discussion->id;
|
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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user