From c615fb96c98aadf79d52bca2170faa83335dd4b2 Mon Sep 17 00:00:00 2001 From: David Sevilla Martin Date: Sun, 15 Mar 2020 09:37:51 -0400 Subject: [PATCH] forum: add IndexPage and WelcomeHero components + $.fn.slideUp() --- js/src/common/utils/patchZepto.ts | 16 + .../forum/components/DiscussionListItem.tsx | 2 +- js/src/forum/components/IndexPage.tsx | 374 +++++++++++++++++- js/src/forum/components/WelcomeHero.tsx | 38 ++ 4 files changed, 423 insertions(+), 7 deletions(-) create mode 100644 js/src/forum/components/WelcomeHero.tsx diff --git a/js/src/common/utils/patchZepto.ts b/js/src/common/utils/patchZepto.ts index 72a304ae3..2c15788c4 100644 --- a/js/src/common/utils/patchZepto.ts +++ b/js/src/common/utils/patchZepto.ts @@ -70,6 +70,22 @@ $.fn.animatedScrollTop = function(to, duration = $.fx.speeds._default, callback) return this; }; +// add basic $().slideUp() function +$.fn.slideUp = function(duration = $.fx.speeds._default, easing?, callback?) { + this.css({ overflow: 'hidden', height: this.height() }); + + this.animate( + { + height: 0, + }, + duration, + easing, + callback + ); + + return this; +}; + // required for compatibility with jquery plugins // ex: bootstrap plugins $.fn.extend = $.extend.bind($); diff --git a/js/src/forum/components/DiscussionListItem.tsx b/js/src/forum/components/DiscussionListItem.tsx index cc358b140..94913ead3 100644 --- a/js/src/forum/components/DiscussionListItem.tsx +++ b/js/src/forum/components/DiscussionListItem.tsx @@ -146,7 +146,7 @@ export default class DiscussionListItem { + if (app.cache.discussionList!.props.params[key] !== params[key]) { + app.cache.discussionList = null; + return true; + } + }); + } + + if (!app.cache.discussionList) { + app.cache.discussionList = new DiscussionList({ params, oninit: vnode => (app.cache.discussionList = vnode.state) }); + } + + app.history.push('index', app.translator.transText('core.forum.header.back_to_index_tooltip')); + + this.bodyClass = 'App--index'; + } + + onremove(vnode) { + super.onremove(vnode); + + // Save the scroll position so we can restore it when we return to the + // discussion list. + app.cache.scrollTop = $(window).scrollTop(); } view() { + if (!app.cache.discussionList) return; + + const discussionList = m(DiscussionList, app.cache.discussionList.props); + return ( -
-

hi

+
+ {this.hero()} +
+
+ +
+
+
    {listItems(this.viewItems().toArray())}
+
    {listItems(this.actionItems().toArray())}
+
+ {discussionList} +
+
+
); } + + oncreate(vnode) { + super.oncreate(vnode); + + const $app = $('#app'); + + extend(vnode.dom, 'onunload', () => $app.css('min-height', '')); + + app.setTitle(''); + app.setTitleCount(0); + + // 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 sidebar doesn't jump around. + const oldHeroHeight = app.cache.heroHeight; + const heroHeight = (app.cache.heroHeight = this.$('.Hero').outerHeight() || 0); + const scrollTop = app.cache.scrollTop; + + $app.css('min-height', $(window).height() + heroHeight); + + // Scroll to the remembered position. We do this after a short delay so that + // it happens after the browser has done its own "back button" scrolling, + // which isn't right. https://github.com/flarum/core/issues/835 + const scroll = () => $(window).scrollTop(scrollTop - oldHeroHeight + heroHeight); + scroll(); + setTimeout(scroll, 1); + + // 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) { + const $discussion = this.$(`.DiscussionListItem[data-id="${this.lastDiscussion.id()}"]`); + + if ($discussion.length) { + const indexTop = $('#header').outerHeight(); + const indexBottom = $(window).height(); + const discussionTop = $discussion.offset().top; + const discussionBottom = discussionTop + $discussion.outerHeight(); + + if (discussionTop < scrollTop + indexTop || discussionBottom > scrollTop + indexBottom) { + $(window).scrollTop(discussionTop - indexTop); + } + } + } + } + + /** + * Get the component to display as the hero. + */ + hero() { + return ; + } + + /** + * 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. + */ + sidebarItems(): ItemList { + const items = new ItemList(); + const canStartDiscussion = app.forum.attribute('canStartDiscussion') || !app.session.user; + + items.add( + 'newDiscussion', + Button.component({ + children: app.translator.trans( + canStartDiscussion ? 'core.forum.index.start_discussion_button' : 'core.forum.index.cannot_start_discussion_button' + ), + icon: 'fas fa-edit', + className: 'Button Button--primary IndexPage-newDiscussion', + itemClassName: 'App-primaryControl', + onclick: this.newDiscussionAction.bind(this), + disabled: !canStartDiscussion, + }) + ); + + items.add( + 'nav', + SelectDropdown.component({ + children: this.navItems().toArray(), + buttonClassName: 'Button', + className: 'App-titleControl', + }) + ); + + return items; + } + + /** + * Build an item list for the navigation in the sidebar of the index page. By + * default this is just the 'All Discussions' link. + */ + navItems(): ItemList { + const items = new ItemList(); + const params = this.stickyParams(); + + items.add( + 'allDiscussions', + LinkButton.component({ + href: app.route('index', params), + children: app.translator.trans('core.forum.index.all_discussions_link'), + icon: 'far fa-comments', + }), + 100 + ); + + return items; + } + + /** + * 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. + */ + viewItems(): ItemList { + const items = new ItemList(); + const sortMap = app.cache.discussionList.sortMap(); + + const sortOptions = {}; + for (const i in sortMap) { + sortOptions[i] = app.translator.trans('core.forum.index_sort.' + i + '_button'); + } + + items.add( + 'sort', + Dropdown.component({ + buttonClassName: 'Button', + label: sortOptions[this.params().sort] || Object.keys(sortMap).map(key => sortOptions[key])[0], + children: Object.keys(sortOptions).map(value => { + const label = sortOptions[value]; + const active = (this.params().sort || Object.keys(sortMap)[0]) === value; + + return Button.component({ + children: label, + icon: active ? 'fas fa-check' : true, + onclick: this.changeSort.bind(this, value), + active: active, + }); + }), + }) + ); + + 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. + */ + actionItems(): ItemList { + const items = new ItemList(); + + items.add( + 'refresh', + Button.component({ + title: app.translator.trans('core.forum.index.refresh_tooltip'), + icon: 'fas fa-sync', + className: 'Button Button--icon', + onclick: () => { + app.cache.discussionList.refresh(); + if (app.session.user) { + app.store.find('users', app.session.user.id()); + m.redraw(); + } + }, + }) + ); + + if (app.session.user) { + items.add( + 'markAllAsRead', + Button.component({ + title: app.translator.trans('core.forum.index.mark_all_as_read_tooltip'), + icon: 'fas fa-check', + className: 'Button Button--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 Search + */ + searching(): string { + 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 Search + */ + clearSearch() { + const params = this.params(); + delete params.q; + + m.route.set(app.route(this.props.routeName, params)); + } + + /** + * Redirect to the index page using the given sort parameter. + */ + changeSort(sort: string) { + const 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(): Params { + return { + sort: m.route.param('sort'), + q: m.route.param('q'), + }; + } + + /** + * Get parameters to pass to the DiscussionList component. + * + * @return {Object} + */ + params() { + const params = this.stickyParams(); + + params.filter = m.route.param('filter'); + + return params; + } + + /** + * Open the composer for a new discussion or prompt the user to login. + * + * @return {Promise} + */ + newDiscussionAction(): Promise { + if (app.session.user) { + // const component = new DiscussionComposer({ user: app.session.user }); + + // app.composer.load(component); + // app.composer.show(); + + return Promise.resolve(); + } else { + app.modal.show(LogInModal); + + return Promise.reject(); + } + } + + /** + * Mark all discussions as read. + */ + markAllAsRead(): void { + const confirmation = confirm(app.translator.transText('core.forum.index.mark_all_as_read_confirmation')); + + if (confirmation) { + app.session.user.save({ markedAllAsReadAt: new Date() }); + } + } } diff --git a/js/src/forum/components/WelcomeHero.tsx b/js/src/forum/components/WelcomeHero.tsx new file mode 100644 index 000000000..5c9fe0c08 --- /dev/null +++ b/js/src/forum/components/WelcomeHero.tsx @@ -0,0 +1,38 @@ +import Component from '../../common/Component'; +import Button from '../../common/components/Button'; + +/** + * The `WelcomeHero` component displays a hero that welcomes the user to the + * forum. + */ +export default class WelcomeHero extends Component { + hidden = !!localStorage.getItem('welcomeHidden'); + + view() { + if (this.hidden) return
; + + const slideUp = () => this.$().slideUp(this.hide.bind(this)); + + return ( +
+
+
+
+ ); + } + + /** + * Hide the welcome hero. + */ + hide() { + localStorage.setItem('welcomeHidden', 'true'); + + this.hidden = true; + } +}