diff options
Diffstat (limited to 'ui')
36 files changed, 1380 insertions, 324 deletions
diff --git a/ui/assets/css/main.css b/ui/assets/css/main.css index dcc55f19..2703d550 100644 --- a/ui/assets/css/main.css +++ b/ui/assets/css/main.css @@ -163,3 +163,10 @@ hr { flex: 1; } +.img-blur { + filter: blur(10px); + -webkit-filter: blur(10px); + -moz-filter: blur(10px); + -o-filter: blur(10px); + -ms-filter: blur(10px); +} diff --git a/ui/assets/css/toastify.css b/ui/assets/css/toastify.css new file mode 100644 index 00000000..8804e229 --- /dev/null +++ b/ui/assets/css/toastify.css @@ -0,0 +1,78 @@ +/*! + * Toastify js 1.6.2 + * https://github.com/apvarun/toastify-js + * @license MIT licensed + * + * Copyright (C) 2018 Varun A P + */ + +.toastify { + padding: 12px 20px; + color: #ffffff; + display: inline-block; + box-shadow: 0 3px 6px -1px rgba(0, 0, 0, 0.12), 0 10px 36px -4px rgba(77, 96, 232, 0.3); + background: -webkit-linear-gradient(315deg, #73a5ff, #5477f5); + background: linear-gradient(135deg, #73a5ff, #5477f5); + position: fixed; + opacity: 0; + transition: all 0.4s cubic-bezier(0.215, 0.61, 0.355, 1); + border-radius: 2px; + cursor: pointer; + text-decoration: none; + max-width: calc(50% - 20px); + z-index: 2147483647; +} + +.toastify.on { + opacity: 1; +} + +.toast-close { + opacity: 0.4; + padding: 0 5px; +} + +.toastify-right { + right: 15px; +} + +.toastify-left { + left: 15px; +} + +.toastify-top { + top: -150px; +} + +.toastify-bottom { + bottom: -150px; +} + +.toastify-rounded { + border-radius: 25px; +} + +.toastify-avatar { + width: 1.5em; + height: 1.5em; + margin: 0 5px; + border-radius: 2px; +} + +.toastify-center { + margin-left: auto; + margin-right: auto; + left: 0; + right: 0; + max-width: fit-content; +} + +@media only screen and (max-width: 360px) { + .toastify-right, .toastify-left { + margin-left: auto; + margin-right: auto; + left: 0; + right: 0; + max-width: fit-content; + } +} diff --git a/ui/package.json b/ui/package.json index a2767e4a..41f47088 100644 --- a/ui/package.json +++ b/ui/package.json @@ -36,6 +36,7 @@ "prettier": "^1.18.2", "rxjs": "^6.4.0", "terser": "^4.6.0", + "toastify-js": "^1.6.2", "tributejs": "^4.1.1", "twemoji": "^12.1.2", "ws": "^7.0.0" @@ -57,7 +58,7 @@ "engineStrict": true, "husky": { "hooks": { - "pre-commit": "cargo clippy --manifest-path ../server/Cargo.toml --all-targets --all-features -- -D warnings && lint-staged" + "pre-commit": "ts-node translation_report.ts && git add ../README.md && cargo clippy --manifest-path ../server/Cargo.toml --all-targets --all-features -- -D warnings && lint-staged" } }, "lint-staged": { diff --git a/ui/src/components/comment-form.tsx b/ui/src/components/comment-form.tsx index dddcbe72..b8ea0a5a 100644 --- a/ui/src/components/comment-form.tsx +++ b/ui/src/components/comment-form.tsx @@ -16,6 +16,7 @@ import { mdToHtml, randomStr, markdownHelpUrl, + toast, } from '../utils'; import { WebSocketService, UserService } from '../services'; import autosize from 'autosize'; @@ -293,7 +294,7 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> { .catch(error => { i.state.imageLoading = false; i.setState(i.state); - alert(error); + toast(error, 'danger'); }); } diff --git a/ui/src/components/comment-node.tsx b/ui/src/components/comment-node.tsx index 21a82954..046fc88d 100644 --- a/ui/src/components/comment-node.tsx +++ b/ui/src/components/comment-node.tsx @@ -47,8 +47,8 @@ interface CommentNodeState { showConfirmAppointAsAdmin: boolean; collapsed: boolean; viewSource: boolean; - my_vote: number; - score: number; + upvoteLoading: boolean; + downvoteLoading: boolean; } interface CommentNodeProps { @@ -78,8 +78,8 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { showConfirmTransferCommunity: false, showConfirmAppointAsMod: false, showConfirmAppointAsAdmin: false, - my_vote: this.props.node.comment.my_vote, - score: this.props.node.comment.score, + upvoteLoading: this.props.node.comment.upvoteLoading, + downvoteLoading: this.props.node.comment.downvoteLoading, }; constructor(props: any, context: any) { @@ -87,8 +87,20 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { this.state = this.emptyState; this.handleReplyCancel = this.handleReplyCancel.bind(this); - this.handleCommentLike = this.handleCommentLike.bind(this); - this.handleCommentDisLike = this.handleCommentDisLike.bind(this); + this.handleCommentUpvote = this.handleCommentUpvote.bind(this); + this.handleCommentDownvote = this.handleCommentDownvote.bind(this); + } + + componentWillReceiveProps(nextProps: CommentNodeProps) { + if ( + nextProps.node.comment.upvoteLoading !== this.state.upvoteLoading || + nextProps.node.comment.downvoteLoading !== this.state.downvoteLoading + ) { + this.setState({ + upvoteLoading: false, + downvoteLoading: false, + }); + } } render() { @@ -107,26 +119,40 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { <button disabled={!UserService.Instance.user} className={`btn p-0 ${ - this.state.my_vote == 1 ? 'text-info' : 'text-muted' + node.comment.my_vote == 1 ? 'text-info' : 'text-muted' }`} - onClick={linkEvent(node, this.handleCommentLike)} + onClick={linkEvent(node, this.handleCommentUpvote)} > - <svg class="icon upvote"> - <use xlinkHref="#icon-arrow-up"></use> - </svg> + {this.state.upvoteLoading ? ( + <svg class="icon icon-spinner spin upvote"> + <use xlinkHref="#icon-spinner"></use> + </svg> + ) : ( + <svg class="icon upvote"> + <use xlinkHref="#icon-arrow-up"></use> + </svg> + )} </button> - <div class={`font-weight-bold text-muted`}>{this.state.score}</div> + <div class={`font-weight-bold text-muted`}> + {node.comment.score} + </div> {WebSocketService.Instance.site.enable_downvotes && ( <button disabled={!UserService.Instance.user} className={`btn p-0 ${ - this.state.my_vote == -1 ? 'text-danger' : 'text-muted' + node.comment.my_vote == -1 ? 'text-danger' : 'text-muted' }`} - onClick={linkEvent(node, this.handleCommentDisLike)} + onClick={linkEvent(node, this.handleCommentDownvote)} > - <svg class="icon downvote"> - <use xlinkHref="#icon-arrow-down"></use> - </svg> + {this.state.downvoteLoading ? ( + <svg class="icon icon-spinner spin downvote"> + <use xlinkHref="#icon-spinner"></use> + </svg> + ) : ( + <svg class="icon downvote"> + <use xlinkHref="#icon-arrow-down"></use> + </svg> + )} </button> )} </div> @@ -267,6 +293,16 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { </li> </> )} + {!this.myComment && ( + <li className="list-inline-item"> + <Link + class="text-muted" + to={`/create_private_message?recipient_id=${node.comment.creator_id}`} + > + {i18n.t('message').toLowerCase()} + </Link> + </li> + )} <li className="list-inline-item">•</li> <li className="list-inline-item"> <span @@ -724,41 +760,26 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { this.setState(this.state); } - handleCommentLike(i: CommentNodeI) { - this.state.my_vote = i.comment.my_vote == 1 ? 0 : 1; - let add = 1; - if (i.comment.my_vote == 1) { - add = -1; - } else if (i.comment.my_vote == -1) { - add = 2; - } - - this.state.score = i.comment.score + add; - this.setState(this.state); - + handleCommentUpvote(i: CommentNodeI) { + this.setState({ + upvoteLoading: true, + }); let form: CommentLikeForm = { comment_id: i.comment.id, post_id: i.comment.post_id, - score: this.state.my_vote, + score: i.comment.my_vote == 1 ? 0 : 1, }; WebSocketService.Instance.likeComment(form); } - handleCommentDisLike(i: CommentNodeI) { - this.state.my_vote = i.comment.my_vote == -1 ? 0 : -1; - let add = -1; - if (i.comment.my_vote == 1) { - add = -2; - } else if (i.comment.my_vote == -1) { - add = 1; - } - this.state.score = i.comment.score + add; - this.setState(this.state); - + handleCommentDownvote(i: CommentNodeI) { + this.setState({ + downvoteLoading: true, + }); let form: CommentLikeForm = { comment_id: i.comment.id, post_id: i.comment.post_id, - score: this.state.my_vote, + score: i.comment.my_vote == -1 ? 0 : -1, }; WebSocketService.Instance.likeComment(form); } diff --git a/ui/src/components/communities.tsx b/ui/src/components/communities.tsx index ebcbc345..867cfd81 100644 --- a/ui/src/components/communities.tsx +++ b/ui/src/components/communities.tsx @@ -13,12 +13,14 @@ import { WebSocketJsonResponse, } from '../interfaces'; import { WebSocketService } from '../services'; -import { wsJsonToRes } from '../utils'; +import { wsJsonToRes, toast } from '../utils'; import { i18n } from '../i18next'; import { T } from 'inferno-i18next'; declare const Sortable: any; +const communityLimit = 100; + interface CommunitiesState { communities: Array<Community>; page: number; @@ -174,12 +176,14 @@ export class Communities extends Component<any, CommunitiesState> { <T i18nKey="prev">#</T> </button> )} - <button - class="btn btn-sm btn-secondary" - onClick={linkEvent(this, this.nextPage)} - > - <T i18nKey="next">#</T> - </button> + {this.state.communities.length == communityLimit && ( + <button + class="btn btn-sm btn-secondary" + onClick={linkEvent(this, this.nextPage)} + > + <T i18nKey="next">#</T> + </button> + )} </div> ); } @@ -221,7 +225,7 @@ export class Communities extends Component<any, CommunitiesState> { refetch() { let listCommunitiesForm: ListCommunitiesForm = { sort: SortType[SortType.TopAll], - limit: 100, + limit: communityLimit, page: this.state.page, }; @@ -232,7 +236,7 @@ export class Communities extends Component<any, CommunitiesState> { console.log(msg); let res = wsJsonToRes(msg); if (res.error) { - alert(i18n.t(res.error)); + toast(i18n.t(msg.error), 'danger'); return; } else if (res.op == UserOperation.ListCommunities) { let data = res.data as ListCommunitiesResponse; diff --git a/ui/src/components/community-form.tsx b/ui/src/components/community-form.tsx index 14cd8e4f..4dc7bfcb 100644 --- a/ui/src/components/community-form.tsx +++ b/ui/src/components/community-form.tsx @@ -8,9 +8,10 @@ import { ListCategoriesResponse, CommunityResponse, GetSiteResponse, + WebSocketJsonResponse, } from '../interfaces'; import { WebSocketService } from '../services'; -import { wsJsonToRes, capitalizeFirstLetter } from '../utils'; +import { wsJsonToRes, capitalizeFirstLetter, toast } from '../utils'; import autosize from 'autosize'; import { i18n } from '../i18next'; import { T } from 'inferno-i18next'; @@ -243,7 +244,7 @@ export class CommunityForm extends Component< let res = wsJsonToRes(msg); console.log(msg); if (res.error) { - alert(i18n.t(res.error)); + toast(i18n.t(msg.error), 'danger'); this.state.loading = false; this.setState(this.state); return; diff --git a/ui/src/components/community.tsx b/ui/src/components/community.tsx index 357fe260..9d02dd86 100644 --- a/ui/src/components/community.tsx +++ b/ui/src/components/community.tsx @@ -25,6 +25,7 @@ import { routeSortTypeToEnum, fetchLimit, postRefetchSeconds, + toast, } from '../utils'; import { T } from 'inferno-i18next'; import { i18n } from '../i18next'; @@ -148,7 +149,7 @@ export class Community extends Component<any, State> { )} </h5> {this.selects()} - {this.state.posts && <PostListings posts={this.state.posts} />} + <PostListings posts={this.state.posts} /> {this.paginator()} </div> <div class="col-12 col-md-4"> @@ -193,12 +194,14 @@ export class Community extends Component<any, State> { <T i18nKey="prev">#</T> </button> )} - <button - class="btn btn-sm btn-secondary" - onClick={linkEvent(this, this.nextPage)} - > - <T i18nKey="next">#</T> - </button> + {this.state.posts.length == fetchLimit && ( + <button + class="btn btn-sm btn-secondary" + onClick={linkEvent(this, this.nextPage)} + > + <T i18nKey="next">#</T> + </button> + )} </div> ); } @@ -256,7 +259,7 @@ export class Community extends Component<any, State> { console.log(msg); let res = wsJsonToRes(msg); if (res.error) { - alert(i18n.t(res.error)); + toast(i18n.t(msg.error), 'danger'); this.context.router.history.push('/'); return; } else if (res.op == UserOperation.GetCommunity) { @@ -279,12 +282,6 @@ export class Community extends Component<any, State> { this.setState(this.state); } else if (res.op == UserOperation.GetPosts) { let data = res.data as GetPostsResponse; - - // TODO rework this - // This is needed to refresh the view - this.state.posts = undefined; - this.setState(this.state); - this.state.posts = data.posts; this.state.loading = false; this.setState(this.state); diff --git a/ui/src/components/create-post.tsx b/ui/src/components/create-post.tsx index eeb9bc6c..ad013d09 100644 --- a/ui/src/components/create-post.tsx +++ b/ui/src/components/create-post.tsx @@ -35,7 +35,7 @@ export class CreatePost extends Component<any, any> { get params(): PostFormParams { let urlParams = new URLSearchParams(this.props.location.search); let params: PostFormParams = { - name: urlParams.get('name'), + name: urlParams.get('title'), community: urlParams.get('community') || this.prevCommunityName, body: urlParams.get('body'), url: urlParams.get('url'), diff --git a/ui/src/components/create-private-message.tsx b/ui/src/components/create-private-message.tsx new file mode 100644 index 00000000..7160bc52 --- /dev/null +++ b/ui/src/components/create-private-message.tsx @@ -0,0 +1,53 @@ +import { Component } from 'inferno'; +import { PrivateMessageForm } from './private-message-form'; +import { WebSocketService } from '../services'; +import { PrivateMessageFormParams } from '../interfaces'; +import { toast } from '../utils'; +import { i18n } from '../i18next'; + +export class CreatePrivateMessage extends Component<any, any> { + constructor(props: any, context: any) { + super(props, context); + this.handlePrivateMessageCreate = this.handlePrivateMessageCreate.bind( + this + ); + } + + componentDidMount() { + document.title = `${i18n.t('create_private_message')} - ${ + WebSocketService.Instance.site.name + }`; + } + + render() { + return ( + <div class="container"> + <div class="row"> + <div class="col-12 col-lg-6 offset-lg-3 mb-4"> + <h5>{i18n.t('create_private_message')}</h5> + <PrivateMessageForm + onCreate={this.handlePrivateMessageCreate} + params={this.params} + /> + </div> + </div> + </div> + ); + } + + get params(): PrivateMessageFormParams { + let urlParams = new URLSearchParams(this.props.location.search); + let params: PrivateMessageFormParams = { + recipient_id: Number(urlParams.get('recipient_id')), + }; + + return params; + } + + handlePrivateMessageCreate() { + toast(i18n.t('message_sent')); + + // Navigate to the front + this.props.history.push(`/`); + } +} diff --git a/ui/src/components/footer.tsx b/ui/src/components/footer.tsx index 8aa05072..5451da2c 100644 --- a/ui/src/components/footer.tsx +++ b/ui/src/components/footer.tsx @@ -29,7 +29,7 @@ export class Footer extends Component<any, any> { </li> <li class="nav-item"> <Link class="nav-link" to="/sponsors"> - <T i18nKey="sponsors">#</T> + <T i18nKey="donate">#</T> </Link> </li> <li class="nav-item"> diff --git a/ui/src/components/inbox.tsx b/ui/src/components/inbox.tsx index 4aa9cebe..5c3ff6d2 100644 --- a/ui/src/components/inbox.tsx +++ b/ui/src/components/inbox.tsx @@ -13,10 +13,15 @@ import { UserMentionResponse, CommentResponse, WebSocketJsonResponse, + PrivateMessage as PrivateMessageI, + GetPrivateMessagesForm, + PrivateMessagesResponse, + PrivateMessageResponse, } from '../interfaces'; import { WebSocketService, UserService } from '../services'; -import { wsJsonToRes, fetchLimit } from '../utils'; +import { wsJsonToRes, fetchLimit, isCommentType, toast } from '../utils'; import { CommentNodes } from './comment-nodes'; +import { PrivateMessage } from './private-message'; import { SortSelect } from './sort-select'; import { i18n } from '../i18next'; import { T } from 'inferno-i18next'; @@ -27,9 +32,10 @@ enum UnreadOrAll { } enum UnreadType { - Both, + All, Replies, Mentions, + Messages, } interface InboxState { @@ -37,6 +43,7 @@ interface InboxState { unreadType: UnreadType; replies: Array<Comment>; mentions: Array<Comment>; + messages: Array<PrivateMessageI>; sort: SortType; page: number; } @@ -45,9 +52,10 @@ export class Inbox extends Component<any, InboxState> { private subscription: Subscription; private emptyState: InboxState = { unreadOrAll: UnreadOrAll.Unread, - unreadType: UnreadType.Both, + unreadType: UnreadType.All, replies: [], mentions: [], + messages: [], sort: SortType.New, page: 1, }; @@ -104,7 +112,10 @@ export class Inbox extends Component<any, InboxState> { </a> </small> </h5> - {this.state.replies.length + this.state.mentions.length > 0 && + {this.state.replies.length + + this.state.mentions.length + + this.state.messages.length > + 0 && this.state.unreadOrAll == UnreadOrAll.Unread && ( <ul class="list-inline mb-1 text-muted small font-weight-bold"> <li className="list-inline-item"> @@ -115,9 +126,10 @@ export class Inbox extends Component<any, InboxState> { </ul> )} {this.selects()} - {this.state.unreadType == UnreadType.Both && this.both()} + {this.state.unreadType == UnreadType.All && this.all()} {this.state.unreadType == UnreadType.Replies && this.replies()} {this.state.unreadType == UnreadType.Mentions && this.mentions()} + {this.state.unreadType == UnreadType.Messages && this.messages()} {this.paginator()} </div> </div> @@ -151,8 +163,8 @@ export class Inbox extends Component<any, InboxState> { <option disabled> <T i18nKey="type">#</T> </option> - <option value={UnreadType.Both}> - <T i18nKey="both">#</T> + <option value={UnreadType.All}> + <T i18nKey="all">#</T> </option> <option value={UnreadType.Replies}> <T i18nKey="replies">#</T> @@ -160,6 +172,9 @@ export class Inbox extends Component<any, InboxState> { <option value={UnreadType.Mentions}> <T i18nKey="mentions">#</T> </option> + <option value={UnreadType.Messages}> + <T i18nKey="messages">#</T> + </option> </select> <SortSelect sort={this.state.sort} @@ -170,33 +185,25 @@ export class Inbox extends Component<any, InboxState> { ); } - both() { - let combined: Array<{ - type_: string; - data: Comment; - }> = []; - let replies = this.state.replies.map(e => { - return { type_: 'replies', data: e }; - }); - let mentions = this.state.mentions.map(e => { - return { type_: 'mentions', data: e }; - }); + all() { + let combined: Array<Comment | PrivateMessageI> = []; - combined.push(...replies); - combined.push(...mentions); + combined.push(...this.state.replies); + combined.push(...this.state.mentions); + combined.push(...this.state.messages); // Sort it - if (this.state.sort == SortType.New) { - combined.sort((a, b) => b.data.published.localeCompare(a.data.published)); - } else { - combined.sort((a, b) => b.data.score - a.data.score); - } + combined.sort((a, b) => b.published.localeCompare(a.published)); return ( <div> - {combined.map(i => ( - <CommentNodes nodes={[{ comment: i.data }]} noIndent markable /> - ))} + {combined.map(i => + isCommentType(i) ? ( + <CommentNodes nodes={[{ comment: i }]} noIndent markable /> + ) : ( + <PrivateMessage privateMessage={i} /> + ) + )} </div> ); } @@ -221,6 +228,16 @@ export class Inbox extends Component<any, InboxState> { ); } + messages() { + return ( + <div> + {this.state.messages.map(message => ( + <PrivateMessage privateMessage={message} /> + ))} + </div> + ); + } + paginator() { return ( <div class="mt-2"> @@ -284,6 +301,13 @@ export class Inbox extends Component<any, InboxState> { limit: fetchLimit, }; WebSocketService.Instance.getUserMentions(userMentionsForm); + + let privateMessagesForm: GetPrivateMessagesForm = { + unread_only: this.state.unreadOrAll == UnreadOrAll.Unread, + page: this.state.page, + limit: fetchLimit, + }; + WebSocketService.Instance.getPrivateMessages(privateMessagesForm); } handleSortChange(val: SortType) { @@ -301,7 +325,7 @@ export class Inbox extends Component<any, InboxState> { console.log(msg); let res = wsJsonToRes(msg); if (res.error) { - alert(i18n.t(res.error)); + toast(i18n.t(msg.error), 'danger'); return; } else if (res.op == UserOperation.GetReplies) { let data = res.data as GetRepliesResponse; @@ -315,9 +339,37 @@ export class Inbox extends Component<any, InboxState> { this.sendUnreadCount(); window.scrollTo(0, 0); this.setState(this.state); + } else if (res.op == UserOperation.GetPrivateMessages) { + let data = res.data as PrivateMessagesResponse; + this.state.messages = data.messages; + this.sendUnreadCount(); + window.scrollTo(0, 0); + this.setState(this.state); + } else if (res.op == UserOperation.EditPrivateMessage) { + let data = res.data as PrivateMessageResponse; + let found: PrivateMessageI = this.state.messages.find( + m => m.id === data.message.id + ); + found.content = data.message.content; + found.updated = data.message.updated; + found.deleted = data.message.deleted; + // If youre in the unread view, just remove it from the list + if (this.state.unreadOrAll == UnreadOrAll.Unread && data.message.read) { + this.state.messages = this.state.messages.filter( + |