mirror of
https://github.com/flarum/core.git
synced 2025-08-07 17:07:19 +02:00
Start work on user page, fix routes not working
This commit is contained in:
@@ -45,7 +45,7 @@ export default class Model {
|
|||||||
* Get the model's ID.
|
* Get the model's ID.
|
||||||
* @final
|
* @final
|
||||||
*/
|
*/
|
||||||
id(): number {
|
id(): string|number {
|
||||||
return this.data.id;
|
return this.data.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -21,13 +21,13 @@ interface LinkButtonProps extends ButtonProps {
|
|||||||
export default class LinkButton extends Button<LinkButtonProps> {
|
export default class LinkButton extends Button<LinkButtonProps> {
|
||||||
static initProps(props: LinkButtonProps) {
|
static initProps(props: LinkButtonProps) {
|
||||||
props.active = this.isActive(props);
|
props.active = this.isActive(props);
|
||||||
props.oncreate = props.oncreate || m.route;
|
props.oncreate = props.oncreate;
|
||||||
}
|
}
|
||||||
|
|
||||||
view(vnode) {
|
view(vnode) {
|
||||||
const vdom = super.view(vnode);
|
const vdom = super.view(vnode);
|
||||||
|
|
||||||
vdom.tag = 'a';
|
vdom.tag = m.route.Link;
|
||||||
|
|
||||||
return vdom;
|
return vdom;
|
||||||
}
|
}
|
||||||
|
33
js/src/common/utils/humanTime.ts
Normal file
33
js/src/common/utils/humanTime.ts
Normal file
@@ -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;
|
||||||
|
};
|
@@ -1,14 +1,21 @@
|
|||||||
import Application from '../common/Application';
|
import Application from '../common/Application';
|
||||||
import History from './utils/History';
|
import History from './utils/History';
|
||||||
|
|
||||||
import IndexPage from './components/IndexPage';
|
|
||||||
import HeaderPrimary from './components/HeaderPrimary';
|
import HeaderPrimary from './components/HeaderPrimary';
|
||||||
import HeaderSecondary from './components/HeaderSecondary';
|
import HeaderSecondary from './components/HeaderSecondary';
|
||||||
|
|
||||||
|
import IndexPage from './components/IndexPage';
|
||||||
|
import PostsUserPage from './components/PostsUserPage';
|
||||||
|
|
||||||
export default class Forum extends Application {
|
export default class Forum extends Application {
|
||||||
routes = {
|
routes = {
|
||||||
'index': { path: '/', component: IndexPage.component() },
|
'index': { path: '/', component: new IndexPage() },
|
||||||
'index.filter': {path: '/:filter', component: IndexPage.component() },
|
'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() },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
221
js/src/forum/components/AvatarEditor.tsx
Normal file
221
js/src/forum/components/AvatarEditor.tsx
Normal file
@@ -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<AvatarEditorProps> {
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div className={'AvatarEditor Dropdown ' + this.props.className + (this.loading ? ' loading' : '') + (this.isDraggedOver ? ' dragover' : '')}>
|
||||||
|
{avatar(user)}
|
||||||
|
<a className={ user.avatarUrl() ? "Dropdown-toggle" : "Dropdown-toggle AvatarEditor--noAvatar" }
|
||||||
|
title={app.translator.trans('core.forum.user.avatar_upload_tooltip')}
|
||||||
|
data-toggle="dropdown"
|
||||||
|
onclick={this.quickUpload.bind(this)}
|
||||||
|
ondragover={this.enableDragover.bind(this)}
|
||||||
|
ondragenter={this.enableDragover.bind(this)}
|
||||||
|
ondragleave={this.disableDragover.bind(this)}
|
||||||
|
ondragend={this.disableDragover.bind(this)}
|
||||||
|
ondrop={this.dropUpload.bind(this)}>
|
||||||
|
{this.loading ? LoadingIndicator.component() : (user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle'))}
|
||||||
|
</a>
|
||||||
|
<ul className="Dropdown-menu Menu">
|
||||||
|
{listItems(this.controlItems().toArray())}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 type="file">');
|
||||||
|
|
||||||
|
$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();
|
||||||
|
}
|
||||||
|
}
|
35
js/src/forum/components/Page.tsx
Normal file
35
js/src/forum/components/Page.tsx
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
144
js/src/forum/components/PostsUserPage.tsx
Normal file
144
js/src/forum/components/PostsUserPage.tsx
Normal file
@@ -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 <p>test</p>;
|
||||||
|
|
||||||
|
if (this.posts.length === 0 && ! this.loading) {
|
||||||
|
return (
|
||||||
|
<div className="PostsUserPage">
|
||||||
|
<Placeholder text={app.translator.trans('core.forum.user.posts_empty_text')} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let footer;
|
||||||
|
|
||||||
|
if (this.loading) {
|
||||||
|
footer = LoadingIndicator.component();
|
||||||
|
} else if (this.moreResults) {
|
||||||
|
footer = (
|
||||||
|
<div className="PostsUserPage-loadMore">
|
||||||
|
{Button.component({
|
||||||
|
children: app.translator.trans('core.forum.user.posts_load_more_button'),
|
||||||
|
className: 'Button',
|
||||||
|
onclick: this.loadMore.bind(this)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="PostsUserPage">
|
||||||
|
<ul className="PostsUserPage-list">
|
||||||
|
{this.posts.map(post => (
|
||||||
|
<li>
|
||||||
|
<div className="PostsUserPage-discussion">
|
||||||
|
{app.translator.trans('core.forum.user.in_discussion_text', {discussion: <a href={app.route.post(post)} oncreate={m.route}>{post.discussion().title()}</a>})}
|
||||||
|
</div>
|
||||||
|
{CommentPost.component({post})}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="PostsUserPage-loadMore">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Post[]> {
|
||||||
|
return app.store.find<Post>('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;
|
||||||
|
}
|
||||||
|
}
|
@@ -42,14 +42,14 @@ export default class SessionDropdown extends Dropdown {
|
|||||||
const items = new ItemList();
|
const items = new ItemList();
|
||||||
const user = app.session.user;
|
const user = app.session.user;
|
||||||
|
|
||||||
// items.add('profile',
|
items.add('profile',
|
||||||
// LinkButton.component({
|
LinkButton.component({
|
||||||
// icon: 'fas fa-user',
|
icon: 'fas fa-user',
|
||||||
// children: app.translator.trans('core.forum.header.profile_button'),
|
children: app.translator.trans('core.forum.header.profile_button'),
|
||||||
// href: app.route.user(user)
|
href: app.route.user(user)
|
||||||
// }),
|
}),
|
||||||
// 100
|
100
|
||||||
// );
|
);
|
||||||
|
|
||||||
// items.add('settings',
|
// items.add('settings',
|
||||||
// LinkButton.component({
|
// LinkButton.component({
|
||||||
|
100
js/src/forum/components/UserCard.tsx
Normal file
100
js/src/forum/components/UserCard.tsx
Normal file
@@ -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<UserCardProps> {
|
||||||
|
view() {
|
||||||
|
const user = this.props.user;
|
||||||
|
const controls = UserControls.controls(user, this).toArray();
|
||||||
|
const color = user.color();
|
||||||
|
const badges = user.badges().toArray();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'UserCard ' + (this.props.className || '')}
|
||||||
|
style={color ? {backgroundColor: color} : ''}>
|
||||||
|
<div className="darkenBackground">
|
||||||
|
|
||||||
|
<div className="container">
|
||||||
|
{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'
|
||||||
|
}) : ''}
|
||||||
|
|
||||||
|
<div className="UserCard-profile">
|
||||||
|
<h2 className="UserCard-identity">
|
||||||
|
{this.props.editable
|
||||||
|
? [AvatarEditor.component({user, className: 'UserCard-avatar'}), username(user)]
|
||||||
|
: (
|
||||||
|
<a href={app.route.user(user)} oncreate={m.route}>
|
||||||
|
<div className="UserCard-avatar">{avatar(user)}</div>
|
||||||
|
{username(user)}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{badges.length ? (
|
||||||
|
<ul className="UserCard-badges badges">
|
||||||
|
{listItems(badges)}
|
||||||
|
</ul>
|
||||||
|
) : ''}
|
||||||
|
|
||||||
|
<ul className="UserCard-info">
|
||||||
|
{listItems(this.infoItems().toArray())}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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', (
|
||||||
|
<span className={'UserCard-lastSeen' + (online ? ' online' : '')}>
|
||||||
|
{online
|
||||||
|
? [icon('fas fa-circle'), ' ', app.translator.trans('core.forum.user.online_text')]
|
||||||
|
: [icon('far fa-clock'), ' ', humanTime(lastSeenAt)]}
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
items.add('joined', app.translator.trans('core.forum.user.joined_date_text', {ago: humanTime(user.joinTime())}));
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
145
js/src/forum/components/UserPage.tsx
Normal file
145
js/src/forum/components/UserPage.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="UserPage">
|
||||||
|
{this.user ? [
|
||||||
|
UserCard.component({
|
||||||
|
user: this.user,
|
||||||
|
className: 'Hero UserHero',
|
||||||
|
editable: this.user.canEdit() || this.user === app.session.user,
|
||||||
|
controlsButtonClassName: 'Button'
|
||||||
|
}),
|
||||||
|
<div className="container">
|
||||||
|
<div className="sideNavContainer">
|
||||||
|
<nav className="sideNav UserPage-nav" config={affixSidebar}>
|
||||||
|
<ul>{listItems(this.sidebarItems().toArray())}</ul>
|
||||||
|
</nav>
|
||||||
|
<div className="sideNavOffset UserPage-content">
|
||||||
|
{this.content()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
] : [
|
||||||
|
LoadingIndicator.component({lassName: 'LoadingIndicator--block'})
|
||||||
|
]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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'), <span className="Button-badge">{user.commentCount()}</span>],
|
||||||
|
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'), <span className="Button-badge">{user.discussionCount()}</span>],
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
@@ -41,7 +41,7 @@ export default class UsersSearchSource extends SearchSource {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="UserSearchResult" data-index={'users' + user.id()}>
|
<li className="UserSearchResult" data-index={'users' + user.id()}>
|
||||||
<a href={app.route.user(user)} config={m.route}>
|
<a href={app.route.user(user)} oncreate={m.route}>
|
||||||
{avatar(user)}
|
{avatar(user)}
|
||||||
{name}
|
{name}
|
||||||
</a>
|
</a>
|
||||||
|
96
js/src/forum/utils/UserControls.ts
Normal file
96
js/src/forum/utils/UserControls.ts
Normal file
@@ -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}));
|
||||||
|
}
|
||||||
|
};
|
17
js/src/forum/utils/affixSidebar.ts
Normal file
17
js/src/forum/utils/affixSidebar.ts
Normal file
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
Reference in New Issue
Block a user