diff --git a/js/src/common/Model.ts b/js/src/common/Model.ts index 5547aa336..8075173bf 100644 --- a/js/src/common/Model.ts +++ b/js/src/common/Model.ts @@ -45,7 +45,7 @@ export default class Model { * Get the model's ID. * @final */ - id(): number { + id(): string|number { return this.data.id; } diff --git a/js/src/common/components/LinkButton.tsx b/js/src/common/components/LinkButton.tsx index 1eca85fae..821abab5c 100644 --- a/js/src/common/components/LinkButton.tsx +++ b/js/src/common/components/LinkButton.tsx @@ -21,13 +21,13 @@ interface LinkButtonProps extends ButtonProps { export default class LinkButton extends Button { static initProps(props: LinkButtonProps) { props.active = this.isActive(props); - props.oncreate = props.oncreate || m.route; + props.oncreate = props.oncreate; } view(vnode) { const vdom = super.view(vnode); - vdom.tag = 'a'; + vdom.tag = m.route.Link; return vdom; } diff --git a/js/src/common/utils/humanTime.ts b/js/src/common/utils/humanTime.ts new file mode 100644 index 000000000..746bc2cc5 --- /dev/null +++ b/js/src/common/utils/humanTime.ts @@ -0,0 +1,33 @@ +/** + * The `humanTime` utility converts a date to a localized, human-readable time- + * ago string. + */ +export default function humanTime(time: Date): string { + let m = dayjs(time); + const now = dayjs(); + + // To prevent showing things like "in a few seconds" due to small offsets + // between client and server time, we always reset future dates to the + // current time. This will result in "just now" being shown instead. + if (m.isAfter(now)) { + m = now; + } + + const day = 864e5; + const diff = m.diff(dayjs()); + let ago = null; + + // If this date was more than a month ago, we'll show the name of the month + // in the string. If it wasn't this year, we'll show the year as well. + if (diff < -30 * day) { + if (m.year() === dayjs().year()) { + ago = m.format('D MMM'); + } else { + ago = m.format('MMM \'YY'); + } + } else { + ago = m.fromNow(); + } + + return ago; +}; diff --git a/js/src/forum/Forum.ts b/js/src/forum/Forum.ts index ec63769d9..e4cb4db29 100644 --- a/js/src/forum/Forum.ts +++ b/js/src/forum/Forum.ts @@ -1,14 +1,21 @@ import Application from '../common/Application'; import History from './utils/History'; -import IndexPage from './components/IndexPage'; import HeaderPrimary from './components/HeaderPrimary'; import HeaderSecondary from './components/HeaderSecondary'; +import IndexPage from './components/IndexPage'; +import PostsUserPage from './components/PostsUserPage'; + export default class Forum extends Application { routes = { - 'index': { path: '/', component: IndexPage.component() }, - 'index.filter': {path: '/:filter', component: IndexPage.component() }, + 'index': { path: '/', component: new IndexPage() }, + 'index.filter': { path: '/:filter', component: new IndexPage() }, + + 'user': { path: '/u/:username', component: new PostsUserPage() }, + 'user.posts': { path: '/u/:username', component: new PostsUserPage() }, + 'user.discussions': { path: '/u/:username', component: new PostsUserPage() }, + 'settings': { path: '/u/:username', component: new PostsUserPage() }, }; /** diff --git a/js/src/forum/components/AvatarEditor.tsx b/js/src/forum/components/AvatarEditor.tsx new file mode 100644 index 000000000..921ebd24f --- /dev/null +++ b/js/src/forum/components/AvatarEditor.tsx @@ -0,0 +1,221 @@ +import Component, {ComponentProps} from '../../common/Component'; +import avatar from '../../common/helpers/avatar'; +import icon from '../../common/helpers/icon'; +import listItems from '../../common/helpers/listItems'; +import ItemList from '../../common/utils/ItemList'; +import Button from '../../common/components/Button'; +import LoadingIndicator from '../../common/components/LoadingIndicator'; +import User from '../../common/models/User'; + +export interface AvatarEditorProps extends ComponentProps { + user: User; +} + +/** + * The `AvatarEditor` component displays a user's avatar along with a dropdown + * menu which allows the user to upload/remove the avatar. + */ +export default class AvatarEditor extends Component { + /** + * Whether or not an avatar upload is in progress. + */ + loading = false; + + /** + * Whether or not an image has been dragged over the dropzone. + */ + isDraggedOver = false; + + static initProps(props) { + super.initProps(props); + + props.className = props.className || ''; + } + + view() { + const user = this.props.user; + + return ( +
+ {avatar(user)} + + {this.loading ? LoadingIndicator.component() : (user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle'))} + +
    + {listItems(this.controlItems().toArray())} +
+
+ ); + } + + /** + * Get the items in the edit avatar dropdown menu. + * + * @return {ItemList} + */ + controlItems() { + const items = new ItemList(); + + items.add('upload', + Button.component({ + icon: 'fas fa-upload', + children: app.translator.trans('core.forum.user.avatar_upload_button'), + onclick: this.openPicker.bind(this) + }) + ); + + items.add('remove', + Button.component({ + icon: 'fas fa-times', + children: app.translator.trans('core.forum.user.avatar_remove_button'), + onclick: this.remove.bind(this) + }) + ); + + return items; + } + + /** + * Enable dragover style + * + * @param {Event} e + */ + enableDragover(e) { + e.preventDefault(); + e.stopPropagation(); + this.isDraggedOver = true; + } + + /** + * Disable dragover style + * + * @param {Event} e + */ + disableDragover(e) { + e.preventDefault(); + e.stopPropagation(); + this.isDraggedOver = false; + } + + /** + * Upload avatar when file is dropped into dropzone. + * + * @param {Event} e + */ + dropUpload(e) { + e.preventDefault(); + e.stopPropagation(); + this.isDraggedOver = false; + this.upload(e.dataTransfer.files[0]); + } + + /** + * If the user doesn't have an avatar, there's no point in showing the + * controls dropdown, because only one option would be viable: uploading. + * Thus, when the avatar editor's dropdown toggle button is clicked, we prompt + * the user to upload an avatar immediately. + * + * @param {Event} e + */ + quickUpload(e) { + if (!this.props.user.avatarUrl()) { + e.preventDefault(); + e.stopPropagation(); + this.openPicker(); + } + } + + /** + * Upload avatar using file picker + */ + openPicker() { + if (this.loading) return; + + // Create a hidden HTML input element and click on it so the user can select + // an avatar file. Once they have, we will upload it via the API. + const user = this.props.user; + const $input = $(''); + + $input.appendTo('body').hide().click().on('change', e => { + this.upload($(e.target)[0].files[0]); + }); + } + + /** + * Upload avatar + * + * @param {File} file + */ + upload(file) { + if (this.loading) return; + + const user = this.props.user; + const data = new FormData(); + data.append('avatar', file); + + this.loading = true; + m.redraw(); + + app.request({ + method: 'POST', + url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar', + serialize: raw => raw, + data + }).then( + this.success.bind(this), + this.failure.bind(this) + ); + } + + /** + * Remove the user's avatar. + */ + remove() { + const user = this.props.user; + + this.loading = true; + m.redraw(); + + app.request({ + method: 'DELETE', + url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar' + }).then( + this.success.bind(this), + this.failure.bind(this) + ); + } + + /** + * After a successful upload/removal, push the updated user data into the + * store, and force a recomputation of the user's avatar color. + * + * @param {Object} response + * @protected + */ + success(response) { + app.store.pushPayload(response); + delete this.props.user.avatarColor; + + this.loading = false; + m.redraw(); + } + + /** + * If avatar upload/removal fails, stop loading. + * + * @param {Object} response + * @protected + */ + failure(response) { + this.loading = false; + m.redraw(); + } +} diff --git a/js/src/forum/components/Page.tsx b/js/src/forum/components/Page.tsx new file mode 100644 index 000000000..0ea54d260 --- /dev/null +++ b/js/src/forum/components/Page.tsx @@ -0,0 +1,35 @@ +import Component from '../../common/Component'; + +/** + * The `Page` component + */ +export default abstract class Page extends Component { + /** + * A class name to apply to the body while the route is active. + */ + bodyClass: string = ''; + + oninit(vnode) { + super.oninit(vnode); + + if (this.bodyClass) { + $('#app').addClass(this.bodyClass); + } + } + + oncreate(vnode) { + super.oncreate(vnode); + + app.previous = app.current; + app.current = this; + + app.drawer.hide(); + app.modal.close(); + } + + onremove(vnode) { + super.onremove(vnode); + + $('#app').removeClass(this.bodyClass); + } +} diff --git a/js/src/forum/components/PostsUserPage.tsx b/js/src/forum/components/PostsUserPage.tsx new file mode 100644 index 000000000..d94c2e9bf --- /dev/null +++ b/js/src/forum/components/PostsUserPage.tsx @@ -0,0 +1,144 @@ +import UserPage from './UserPage'; +import LoadingIndicator from '../../common/components/LoadingIndicator'; +import Button from '../../common/components/Button'; +// import Placeholder from '../../common/components/Placeholder'; +// import CommentPost from './CommentPost'; +import Post from "../../common/models/Post"; + +/** + * The `PostsUserPage` component shows a user's activity feed inside of their + * profile. + */ +export default class PostsUserPage extends UserPage { + /** + * Whether or not the activity feed is currently loading. + */ + loading = true; + + /** + * Whether or not there are any more activity items that can be loaded. + */ + moreResults = false; + + /** + * The Post models in the feed. + */ + posts: Post[] = []; + + /** + * The number of activity items to load per request. + */ + loadLimit = 20; + + oninit(vnode) { + super.oninit(vnode); + + this.loadUser(m.route.param('username')); + } + + content() { + return

test

; + + if (this.posts.length === 0 && ! this.loading) { + return ( +
+ +
+ ); + } + + let footer; + + if (this.loading) { + footer = LoadingIndicator.component(); + } else if (this.moreResults) { + footer = ( +
+ {Button.component({ + children: app.translator.trans('core.forum.user.posts_load_more_button'), + className: 'Button', + onclick: this.loadMore.bind(this) + })} +
+ ); + } + + return ( +
+
    + {this.posts.map(post => ( +
  • +
    + {app.translator.trans('core.forum.user.in_discussion_text', {discussion: {post.discussion().title()}})} +
    + {CommentPost.component({post})} +
  • + ))} +
+
+ {footer} +
+
+ ); + } + + /** + * Initialize the component with a user, and trigger the loading of their + * activity feed. + */ + show(user) { + super.show(user); + + this.refresh(); + } + + /** + * Clear and reload the user's activity feed. + */ + refresh() { + this.loading = true; + this.posts = []; + + m.redraw(); + + this.loadResults().then(this.parseResults.bind(this)); + } + + /** + * Load a new page of the user's activity feed. + * + * @param offset The position to start getting results from. + */ + protected loadResults(offset?: number): Promise { + return app.store.find('posts', { + filter: { + user: this.user.id(), + type: 'comment' + }, + page: {offset, limit: this.loadLimit}, + sort: '-createdAt' + }); + } + + /** + * Load the next page of results. + */ + loadMore() { + this.loading = true; + this.loadResults(this.posts.length).then(this.parseResults.bind(this)); + } + + /** + * Parse results and append them to the activity feed. + */ + parseResults(results: Post[]): Post[] { + this.loading = false; + + [].push.apply(this.posts, results); + + this.moreResults = results.length >= this.loadLimit; + m.redraw(); + + return results; + } +} diff --git a/js/src/forum/components/SessionDropdown.tsx b/js/src/forum/components/SessionDropdown.tsx index 32af6090e..c0b0dd169 100644 --- a/js/src/forum/components/SessionDropdown.tsx +++ b/js/src/forum/components/SessionDropdown.tsx @@ -42,14 +42,14 @@ export default class SessionDropdown extends Dropdown { const items = new ItemList(); const user = app.session.user; - // items.add('profile', - // LinkButton.component({ - // icon: 'fas fa-user', - // children: app.translator.trans('core.forum.header.profile_button'), - // href: app.route.user(user) - // }), - // 100 - // ); + items.add('profile', + LinkButton.component({ + icon: 'fas fa-user', + children: app.translator.trans('core.forum.header.profile_button'), + href: app.route.user(user) + }), + 100 + ); // items.add('settings', // LinkButton.component({ diff --git a/js/src/forum/components/UserCard.tsx b/js/src/forum/components/UserCard.tsx new file mode 100644 index 000000000..64556bbeb --- /dev/null +++ b/js/src/forum/components/UserCard.tsx @@ -0,0 +1,100 @@ +import Component, {ComponentProps} from '../../common/Component'; +import humanTime from '../../common/utils/humanTime'; +import ItemList from '../../common/utils/ItemList'; +import UserControls from '../utils/UserControls'; +import avatar from '../../common/helpers/avatar'; +import username from '../../common/helpers/username'; +import icon from '../../common/helpers/icon'; +import Dropdown from '../../common/components/Dropdown'; +import AvatarEditor from './AvatarEditor'; +import listItems from '../../common/helpers/listItems'; +import User from "../../common/models/User"; + +export interface UserCardProps extends ComponentProps { + user: User; + editable: boolean; + controlsButtonClassName: string; +} + +/** + * The `UserCard` component displays a user's profile card. This is used both on + * the `UserPage` (in the hero) and in discussions, shown when hovering over a + * post author. + */ +export default class UserCard extends Component { + view() { + const user = this.props.user; + const controls = UserControls.controls(user, this).toArray(); + const color = user.color(); + const badges = user.badges().toArray(); + + return ( +
+
+ +
+ {controls.length ? Dropdown.component({ + children: controls, + className: 'UserCard-controls App-primaryControl', + menuClassName: 'Dropdown-menu--right', + buttonClassName: this.props.controlsButtonClassName, + label: app.translator.trans('core.forum.user_controls.button'), + icon: 'fas fa-ellipsis-v' + }) : ''} + +
+

+ {this.props.editable + ? [AvatarEditor.component({user, className: 'UserCard-avatar'}), username(user)] + : ( + +
{avatar(user)}
+ {username(user)} +
+ )} +

+ + {badges.length ? ( +
    + {listItems(badges)} +
+ ) : ''} + +
    + {listItems(this.infoItems().toArray())} +
+
+
+
+
+ ); + } + + /** + * Build an item list of tidbits of info to show on this user's profile. + * + * @return {ItemList} + */ + infoItems() { + const items = new ItemList(); + const user = this.props.user; + const lastSeenAt = user.lastSeenAt(); + + if (lastSeenAt) { + const online = user.isOnline(); + + items.add('lastSeen', ( + + {online + ? [icon('fas fa-circle'), ' ', app.translator.trans('core.forum.user.online_text')] + : [icon('far fa-clock'), ' ', humanTime(lastSeenAt)]} + + )); + } + + items.add('joined', app.translator.trans('core.forum.user.joined_date_text', {ago: humanTime(user.joinTime())})); + + return items; + } +} diff --git a/js/src/forum/components/UserPage.tsx b/js/src/forum/components/UserPage.tsx new file mode 100644 index 000000000..4de893a81 --- /dev/null +++ b/js/src/forum/components/UserPage.tsx @@ -0,0 +1,145 @@ +import Page from './Page'; +import ItemList from '../../common/utils/ItemList'; +import affixSidebar from '../utils/affixSidebar'; +import UserCard from './UserCard'; +import LoadingIndicator from '../../common/components/LoadingIndicator'; +import SelectDropdown from '../../common/components/SelectDropdown'; +import LinkButton from '../../common/components/LinkButton'; +import Separator from '../../common/components/Separator'; +import listItems from '../../common/helpers/listItems'; + +import User from '../../common/models/User'; + +/** + * The `UserPage` component shows a user's profile. It can be extended to show + * content inside of the content area. See `ActivityPage` and `SettingsPage` for + * examples. + */ +export default abstract class UserPage extends Page { + /** + * The user this page is for. + */ + user: User; + bodyClass: string = 'App--user'; + + view() { + return ( +
+ {this.user ? [ + UserCard.component({ + user: this.user, + className: 'Hero UserHero', + editable: this.user.canEdit() || this.user === app.session.user, + controlsButtonClassName: 'Button' + }), +
+
+ +
+ {this.content()} +
+
+
+ ] : [ + LoadingIndicator.component({lassName: 'LoadingIndicator--block'}) + ]} +
+ ); + } + + /** + * Get the content to display in the user page. + */ + abstract content(); + + + /** + * Initialize the component with a user, and trigger the loading of their + * activity feed. + */ + protected show(user: User) { + this.user = user; + + app.setTitle(user.displayName()); + + m.redraw(); + } + + /** + * Given a username, load the user's profile from the store, or make a request + * if we don't have it yet. Then initialize the profile page with that user. + */ + loadUser(username: string) { + const lowercaseUsername = username.toLowerCase(); + + app.store.all('users').some(user => { + if (user.username().toLowerCase() === lowercaseUsername && user.joinTime()) { + this.show(user); + return true; + } + }); + + if (!this.user) { + app.store.find('users', username).then(this.show.bind(this)); + } + } + + /** + * Build an item list for the content of the sidebar. + */ + sidebarItems() { + const items = new ItemList(); + + items.add('nav', + SelectDropdown.component({ + children: this.navItems().toArray(), + className: 'App-titleControl', + buttonClassName: 'Button' + }) + ); + + return items; + } + + /** + * Build an item list for the navigation in the sidebar. + */ + navItems() { + const items = new ItemList(); + const user = this.user; + + items.add('posts', + LinkButton.component({ + href: app.route('user.posts', {username: user.username()}), + children: [app.translator.trans('core.forum.user.posts_link'), {user.commentCount()}], + icon: 'far fa-comment' + }), + 100 + ); + + items.add('discussions', + LinkButton.component({ + href: app.route('user.discussions', {username: user.username()}), + children: [app.translator.trans('core.forum.user.discussions_link'), {user.discussionCount()}], + icon: 'fas fa-bars' + }), + 90 + ); + + if (app.session.user === user) { + items.add('separator', Separator.component(), -90); + items.add('settings', + LinkButton.component({ + href: app.route('settings'), + children: app.translator.trans('core.forum.user.settings_link'), + icon: 'fas fa-cog' + }), + -100 + ); + } + + return items; + } +} diff --git a/js/src/forum/components/UsersSearchSource.tsx b/js/src/forum/components/UsersSearchSource.tsx index 950306c52..4ac0c5e18 100644 --- a/js/src/forum/components/UsersSearchSource.tsx +++ b/js/src/forum/components/UsersSearchSource.tsx @@ -41,7 +41,7 @@ export default class UsersSearchSource extends SearchSource { return (
  • - + {avatar(user)} {name} diff --git a/js/src/forum/utils/UserControls.ts b/js/src/forum/utils/UserControls.ts new file mode 100644 index 000000000..053b540b6 --- /dev/null +++ b/js/src/forum/utils/UserControls.ts @@ -0,0 +1,96 @@ +import Button from '../../common/components/Button'; +import Separator from '../../common/components/Separator'; +// import EditUserModal from '../components/EditUserModal'; +import UserPage from '../components/UserPage'; +import ItemList from '../../common/utils/ItemList'; +import User from "../../common/models/User"; + +/** + * The `UserControls` utility constructs a list of buttons for a user which + * perform actions on it. + */ +export default { + /** + * Get a list of controls for a user. + * + * @param user + * @param context The parent component under which the controls menu will + * be displayed. + */ + controls(user: User, context: any): ItemList { + const items = new ItemList(); + + ['user', 'moderation', 'destructive'].forEach(section => { + const controls = this[section + 'Controls'](user, context).toArray(); + if (controls.length) { + controls.forEach(item => items.add(item.itemName, item)); + items.add(section + 'Separator', Separator.component()); + } + }); + + return items; + }, + + /** + * Get controls for a user pertaining to the current user (e.g. poke, follow). + */ + userControls(): ItemList { + return new ItemList(); + }, + + /** + * Get controls for a user pertaining to moderation (e.g. suspend, edit). + */ + moderationControls(user: User): ItemList { + const items = new ItemList(); + + if (user.canEdit()) { + items.add('edit', Button.component({ + icon: 'fas fa-pencil-alt', + children: app.translator.trans('core.forum.user_controls.edit_button'), + onclick: this.editAction.bind(user) + })); + } + + return items; + }, + + /** + * Get controls for a user which are destructive (e.g. delete). + */ + destructiveControls(user: User): ItemList { + const items = new ItemList(); + + if (user.id() !== '1' && user.canDelete()) { + items.add('delete', Button.component({ + icon: 'fas fa-times', + children: app.translator.trans('core.forum.user_controls.delete_button'), + onclick: this.deleteAction.bind(user) + })); + } + + return items; + }, + + /** + * Delete the user. + */ + deleteAction() { + if (confirm(app.translator.trans('core.forum.user_controls.delete_confirmation'))) { + this.delete().then(() => { + if (app.current instanceof UserPage && app.current.user === this) { + app.history.back(); + } else { + window.location.reload(); + } + }); + } + }, + + /** + * Edit the user. + */ + editAction() { + app.modal.show(new EditUserModal({user: this})); + } +}; diff --git a/js/src/forum/utils/affixSidebar.ts b/js/src/forum/utils/affixSidebar.ts new file mode 100644 index 000000000..14a2d3e85 --- /dev/null +++ b/js/src/forum/utils/affixSidebar.ts @@ -0,0 +1,17 @@ +/** + * Setup the sidebar DOM element to be affixed to the top of the viewport + * using hcSticky. + */ +export default function affixSidebar(vnode) { + const element = vnode.dom; + const $sidebar = $(element); + const $header = $('#header'); + const $affixElement = $sidebar.find('> ul')[0]; + + $(window).off('.affix'); + + new hcSticky($affixElement, { + stickTo: element, + top: $header.outerHeight(true) + parseInt($sidebar.css('margin-top'), 10), + }); +}