1
0
mirror of https://github.com/flarum/core.git synced 2025-08-06 16:36:47 +02:00

Start work on user page, fix routes not working

This commit is contained in:
David Sevilla Martin
2019-10-22 18:44:29 -04:00
parent c6bcb79541
commit 6401e45b56
13 changed files with 813 additions and 15 deletions

View File

@@ -45,7 +45,7 @@ export default class Model {
* Get the model's ID.
* @final
*/
id(): number {
id(): string|number {
return this.data.id;
}

View File

@@ -21,13 +21,13 @@ interface LinkButtonProps extends ButtonProps {
export default class LinkButton extends Button<LinkButtonProps> {
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;
}

View 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;
};

View File

@@ -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() },
};
/**

View 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();
}
}

View 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);
}
}

View 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;
}
}

View File

@@ -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({

View 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;
}
}

View 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;
}
}

View File

@@ -41,7 +41,7 @@ export default class UsersSearchSource extends SearchSource {
return (
<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)}
{name}
</a>

View 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}));
}
};

View 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),
});
}