diff options
author | Dessalines <tyhou13@gmx.com> | 2020-04-14 16:07:20 -0400 |
---|---|---|
committer | Dessalines <tyhou13@gmx.com> | 2020-04-14 16:07:20 -0400 |
commit | 1336b4ed6023e7fcf0fd40be63569966ee4b1b45 (patch) | |
tree | e62e4422e0ba0430ea6d060f01d20dee22d6e564 /ui | |
parent | f040dac647d50c97e3f9ab8058563a7fe0f29261 (diff) | |
parent | 641e4c5d96d9d152bc75318b3ea08f789d920b2b (diff) |
Merge branch 'dev' into federation
Diffstat (limited to 'ui')
28 files changed, 1085 insertions, 335 deletions
diff --git a/ui/assets/css/main.css b/ui/assets/css/main.css index 1c8206e3..bf249e5b 100644 --- a/ui/assets/css/main.css +++ b/ui/assets/css/main.css @@ -156,7 +156,7 @@ hr { } .emoji { - height: 1.2em !important; + max-height: 1.2em !important; } .text-wrap-truncate { diff --git a/ui/package.json b/ui/package.json index 7d946614..d2eb1de9 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,19 +14,20 @@ }, "keywords": [], "dependencies": { + "@joeattardi/emoji-button": "^2.12.1", "@types/autosize": "^3.0.6", - "@types/js-cookie": "^2.2.5", + "@types/js-cookie": "^2.2.6", "@types/jwt-decode": "^2.2.1", "@types/markdown-it": "^0.0.9", "@types/markdown-it-container": "^2.0.2", - "@types/node": "^13.9.2", + "@types/node": "^13.11.1", "autosize": "^4.0.2", "bootswatch": "^4.3.1", - "classcat": "^1.1.3", + "classcat": "^4.0.2", "dotenv": "^8.2.0", "emoji-short-name": "^1.0.0", - "husky": "^4.2.3", - "i18next": "^19.3.3", + "husky": "^4.2.5", + "i18next": "^19.4.1", "inferno": "^7.4.2", "inferno-i18next": "nimbusec-oss/inferno-i18next", "inferno-router": "^7.4.2", @@ -37,26 +38,26 @@ "markdown-it-emoji": "^1.4.0", "mobius1-selectr": "^2.4.13", "moment": "^2.24.0", - "prettier": "^1.18.2", + "prettier": "^2.0.4", "reconnecting-websocket": "^4.4.0", - "rxjs": "^6.4.0", - "terser": "^4.6.7", - "tippy.js": "^6.1.0", + "rxjs": "^6.5.5", + "terser": "^4.6.11", + "tippy.js": "^6.1.1", "toastify-js": "^1.7.0", - "tributejs": "^5.1.2", + "tributejs": "^5.1.3", "twemoji": "^12.1.2", "ws": "^7.2.3" }, "devDependencies": { "eslint": "^6.5.1", "eslint-plugin-inferno": "^7.14.3", - "eslint-plugin-jane": "^7.2.0", + "eslint-plugin-jane": "^7.2.1", "fuse-box": "^3.1.3", - "lint-staged": "^10.0.8", - "sortpack": "^2.1.2", - "ts-node": "^8.7.0", - "ts-transform-classcat": "^0.0.2", - "ts-transform-inferno": "^4.0.2", + "lint-staged": "^10.1.3", + "sortpack": "^2.1.4", + "ts-node": "^8.8.2", + "ts-transform-classcat": "^1.0.0", + "ts-transform-inferno": "^4.0.3", "typescript": "^3.8.3" }, "engines": { diff --git a/ui/src/components/admin-settings.tsx b/ui/src/components/admin-settings.tsx new file mode 100644 index 00000000..56af7114 --- /dev/null +++ b/ui/src/components/admin-settings.tsx @@ -0,0 +1,241 @@ +import { Component, linkEvent } from 'inferno'; +import { Subscription } from 'rxjs'; +import { retryWhen, delay, take } from 'rxjs/operators'; +import { + UserOperation, + SiteResponse, + GetSiteResponse, + SiteConfigForm, + GetSiteConfigResponse, + WebSocketJsonResponse, +} from '../interfaces'; +import { WebSocketService } from '../services'; +import { wsJsonToRes, capitalizeFirstLetter, toast, randomStr } from '../utils'; +import autosize from 'autosize'; +import { SiteForm } from './site-form'; +import { UserListing } from './user-listing'; +import { i18n } from '../i18next'; + +interface AdminSettingsState { + siteRes: GetSiteResponse; + siteConfigRes: GetSiteConfigResponse; + siteConfigForm: SiteConfigForm; + loading: boolean; + siteConfigLoading: boolean; +} + +export class AdminSettings extends Component<any, AdminSettingsState> { + private siteConfigTextAreaId = `site-config-${randomStr()}`; + private subscription: Subscription; + private emptyState: AdminSettingsState = { + siteRes: { + site: { + id: null, + name: null, + creator_id: null, + creator_name: null, + published: null, + number_of_users: null, + number_of_posts: null, + number_of_comments: null, + number_of_communities: null, + enable_downvotes: null, + open_registration: null, + enable_nsfw: null, + }, + admins: [], + banned: [], + online: null, + }, + siteConfigForm: { + config_hjson: null, + auth: null, + }, + siteConfigRes: { + config_hjson: null, + }, + loading: true, + siteConfigLoading: null, + }; + + constructor(props: any, context: any) { + super(props, context); + + this.state = this.emptyState; + + this.subscription = WebSocketService.Instance.subject + .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10)))) + .subscribe( + msg => this.parseMessage(msg), + err => console.error(err), + () => console.log('complete') + ); + + WebSocketService.Instance.getSite(); + WebSocketService.Instance.getSiteConfig(); + } + + componentWillUnmount() { + this.subscription.unsubscribe(); + } + + render() { + return ( + <div class="container"> + {this.state.loading ? ( + <h5> + <svg class="icon icon-spinner spin"> + <use xlinkHref="#icon-spinner"></use> + </svg> + </h5> + ) : ( + <div class="row"> + <div class="col-12 col-md-6"> + <SiteForm site={this.state.siteRes.site} /> + {this.admins()} + {this.bannedUsers()} + </div> + <div class="col-12 col-md-6">{this.adminSettings()}</div> + </div> + )} + </div> + ); + } + + admins() { + return ( + <> + <h5>{capitalizeFirstLetter(i18n.t('admins'))}</h5> + <ul class="list-unstyled"> + {this.state.siteRes.admins.map(admin => ( + <li class="list-inline-item"> + <UserListing + user={{ + name: admin.name, + avatar: admin.avatar, + }} + /> + </li> + ))} + </ul> + </> + ); + } + + bannedUsers() { + return ( + <> + <h5>{i18n.t('banned_users')}</h5> + <ul class="list-unstyled"> + {this.state.siteRes.banned.map(banned => ( + <li class="list-inline-item"> + <UserListing + user={{ + name: banned.name, + avatar: banned.avatar, + }} + /> + </li> + ))} + </ul> + </> + ); + } + + adminSettings() { + return ( + <div> + <h5>{i18n.t('admin_settings')}</h5> + <form onSubmit={linkEvent(this, this.handleSiteConfigSubmit)}> + <div class="form-group row"> + <label + class="col-12 col-form-label" + htmlFor={this.siteConfigTextAreaId} + > + {i18n.t('site_config')} + </label> + <div class="col-12"> + <textarea + id={this.siteConfigTextAreaId} + value={this.state.siteConfigForm.config_hjson} + onInput={linkEvent(this, this.handleSiteConfigHjsonChange)} + class="form-control text-monospace" + rows={3} + /> + </div> + </div> + <div class="form-group row"> + <div class="col-12"> + <button type="submit" class="btn btn-secondary mr-2"> + {this.state.siteConfigLoading ? ( + <svg class="icon icon-spinner spin"> + <use xlinkHref="#icon-spinner"></use> + </svg> + ) : ( + capitalizeFirstLetter(i18n.t('save')) + )} + </button> + </div> + </div> + </form> + </div> + ); + } + + handleSiteConfigSubmit(i: AdminSettings, event: any) { + event.preventDefault(); + i.state.siteConfigLoading = true; + WebSocketService.Instance.saveSiteConfig(i.state.siteConfigForm); + i.setState(i.state); + } + + handleSiteConfigHjsonChange(i: AdminSettings, event: any) { + i.state.siteConfigForm.config_hjson = event.target.value; + i.setState(i.state); + } + + parseMessage(msg: WebSocketJsonResponse) { + console.log(msg); + let res = wsJsonToRes(msg); + if (msg.error) { + toast(i18n.t(msg.error), 'danger'); + this.context.router.history.push('/'); + this.state.loading = false; + this.setState(this.state); + return; + } else if (msg.reconnect) { + } else if (res.op == UserOperation.GetSite) { + let data = res.data as GetSiteResponse; + + // This means it hasn't been set up yet + if (!data.site) { + this.context.router.history.push('/setup'); + } + this.state.siteRes = data; + this.setState(this.state); + document.title = `${i18n.t('admin_settings')} - ${ + this.state.siteRes.site.name + }`; + } else if (res.op == UserOperation.EditSite) { + let data = res.data as SiteResponse; + this.state.siteRes.site = data.site; + this.setState(this.state); + toast(i18n.t('site_saved')); + } else if (res.op == UserOperation.GetSiteConfig) { + let data = res.data as GetSiteConfigResponse; + this.state.siteConfigRes = data; + this.state.loading = false; + this.state.siteConfigForm.config_hjson = this.state.siteConfigRes.config_hjson; + this.setState(this.state); + var textarea: any = document.getElementById(this.siteConfigTextAreaId); + autosize(textarea); + } else if (res.op == UserOperation.SaveSiteConfig) { + let data = res.data as GetSiteConfigResponse; + this.state.siteConfigRes = data; + this.state.siteConfigForm.config_hjson = this.state.siteConfigRes.config_hjson; + this.state.siteConfigLoading = false; + toast(i18n.t('site_saved')); + this.setState(this.state); + } + } +} diff --git a/ui/src/components/comment-form.tsx b/ui/src/components/comment-form.tsx index ae3e7cfc..b3c1a9a1 100644 --- a/ui/src/components/comment-form.tsx +++ b/ui/src/components/comment-form.tsx @@ -17,10 +17,12 @@ import { toast, setupTribute, wsJsonToRes, + emojiPicker, } from '../utils'; import { WebSocketService, UserService } from '../services'; import autosize from 'autosize'; import Tribute from 'tributejs/src/Tribute.js'; +import emojiShortName from 'emoji-short-name'; import { i18n } from '../i18next'; interface CommentFormProps { @@ -69,6 +71,8 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> { super(props, context); this.tribute = setupTribute(); + this.setupEmojiPicker(); + this.state = this.emptyState; if (this.props.node) { @@ -158,8 +162,9 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> { </button> {this.state.commentForm.content && ( <button - className={`btn btn-sm mr-2 btn-secondary ${this.state - .previewMode && 'active'}`} + className={`btn btn-sm mr-2 btn-secondary ${ + this.state.previewMode && 'active' + }`} onClick={linkEvent(this, this.handlePreviewToggle)} > {i18n.t('preview')} @@ -209,6 +214,15 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> { <use xlinkHref="#icon-spinner"></use> </svg> )} + <span + onClick={linkEvent(this, this.handleEmojiPickerClick)} + class="pointer unselectable d-inline-block mr-3 float-right text-muted font-weight-bold" + data-tippy-content={i18n.t('emoji_picker')} + > + <svg class="icon icon-inline"> + <use xlinkHref="#icon-smile"></use> + </svg> + </span> </div> </div> </form> @@ -216,6 +230,20 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> { ); } + setupEmojiPicker() { + emojiPicker.on('emoji', twemojiHtmlStr => { + if (this.state.commentForm.content == null) { + this.state.commentForm.content = ''; + } + var el = document.createElement('div'); + el.innerHTML = twemojiHtmlStr; + let nativeUnicode = (el.childNodes[0] as HTMLElement).getAttribute('alt'); + let shortName = `:${emojiShortName[nativeUnicode]}:`; + this.state.commentForm.content += shortName; + this.setState(this.state); + }); + } + handleFinished() { this.state.previewMode = false; this.state.loading = false; @@ -242,6 +270,10 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> { i.setState(i.state); } + handleEmojiPickerClick(_i: CommentForm, event: any) { + emojiPicker.togglePicker(event.target); + } + handleCommentContentChange(i: CommentForm, event: any) { i.state.commentForm.content = event.target.value; i.setState(i.state); diff --git a/ui/src/components/comment-node.tsx b/ui/src/components/comment-node.tsx index 39f29b5f..69a78f50 100644 --- a/ui/src/components/comment-node.tsx +++ b/ui/src/components/comment-node.tsx @@ -24,8 +24,6 @@ import { getUnixTime, canMod, isMod, - pictshareAvatarThumbnail, - showAvatars, setupTippy, colorList, } from '../utils'; @@ -33,6 +31,7 @@ import moment from 'moment'; import { MomentTime } from './moment-time'; import { CommentForm } from './comment-form'; import { CommentNodes } from './comment-nodes'; +import { UserListing } from './user-listing'; import { i18n } from '../i18next'; interface CommentNodeState { @@ -143,25 +142,21 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { } > <div - class={`${!this.props.noIndent && + class={`${ + !this.props.noIndent && this.props.node.comment.parent_id && - 'ml-2'}`} + 'ml-2' + }`} > <div class="d-flex flex-wrap align-items-center mb-1 mt-1 text-muted small"> - <Link - className="mr-2 text-body font-weight-bold" - to={`/u/${node.comment.creator_name}`} - > - {node.comment.creator_avatar && showAvatars() && ( - <img - height="32" - width="32" - src={pictshareAvatarThumbnail(node.comment.creator_avatar)} - class="rounded-circle mr-1" - /> - )} - <span>{node.comment.creator_name}</span> - </Link> + <span class="mr-2"> + <UserListing + user={{ + name: node.comment.creator_name, + avatar: node.comment.creator_avatar, + }} + /> + </span> {this.isMod && ( <div className="badge badge-light d-none d-sm-inline mr-2"> {i18n.t('mod')} @@ -191,7 +186,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { </> )} <div - className="mr-lg-4 flex-grow-1 flex-lg-grow-0 unselectable pointer mr-2" + className="mr-lg-4 flex-grow-1 flex-lg-grow-0 unselectable pointer mx-2" onClick={linkEvent(this, this.handleCommentCollapse)} > {this.state.collapsed ? ( @@ -256,8 +251,9 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { this.loadingIcon ) : ( <svg - class={`icon icon-inline ${node.comment.read && - 'text-success'}`} + class={`icon icon-inline ${ + node.comment.read && 'text-success' + }`} > <use xlinkHref="#icon-check"></use> </svg> @@ -309,8 +305,9 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { this.loadingIcon ) : ( <svg - class={`icon icon-inline ${node.comment.saved && - 'text-warning'}`} + class={`icon icon-inline ${ + node.comment.saved && 'text-warning' + }`} > <use xlinkHref="#icon-star"></use> </svg> @@ -357,8 +354,9 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { data-tippy-content={i18n.t('view_source')} > <svg - class={`icon icon-inline ${this.state - .viewSource && 'text-success'}`} + class={`icon icon-inline ${ + this.state.viewSource && 'text-success' + }`} > <use xlinkHref="#icon-file-text"></use> </svg> @@ -387,8 +385,9 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { } > <svg - class={`icon icon-inline ${node.comment - .deleted && 'text-danger'}`} + class={`icon icon-inline ${ + node.comment.deleted && 'text-danger' + }`} > <use xlinkHref="#icon-trash"></use> </svg> diff --git a/ui/src/components/main.tsx b/ui/src/components/main.tsx index 38003312..366d3be8 100644 --- a/ui/src/components/main.tsx +++ b/ui/src/components/main.tsx @@ -33,13 +33,12 @@ import { SortSelect } from './sort-select'; import { ListingTypeSelect } from './listing-type-select'; import { DataTypeSelect } from './data-type-select'; import { SiteForm } from './site-form'; +import { UserListing } from './user-listing'; import { wsJsonToRes, repoUrl, mdToHtml, fetchLimit, - pictshareAvatarThumbnail, - showAvatars, toast, getListingTypeFromProps, getPageFromProps, @@ -316,20 +315,12 @@ export class Main extends Component<any, MainState> { <li class="list-inline-item">{i18n.t('admins')}:</li> {this.state.siteRes.admins.map(admin => ( <li class="list-inline-item"> - <Link - class="text-body font-weight-bold" - to={`/u/${admin.name}`} - > - {admin.avatar && showAvatars() && ( - <img - height="32" - width="32" - src={pictshareAvatarThumbnail(admin.avatar)} - class="rounded-circle mr-1" - /> - )} - <span>{admin.name}</span> - </Link> + <UserListing + user={{ + name: admin.name, + avatar: admin.avatar, + }} + /> </li> ))} </ul> @@ -619,6 +610,7 @@ export class Main extends Component<any, MainState> { this.state.siteRes.site = data.site; this.state.showEditSite = false; this.setState(this.state); + toast(i18n.t('site_saved')); } else if (res.op == UserOperation.GetPosts) { let data = res.data as GetPostsResponse; this.state.posts = data.posts; diff --git a/ui/src/components/navbar.tsx b/ui/src/components/navbar.tsx index d7f3b5a8..f1936be1 100644 --- a/ui/src/components/navbar.tsx +++ b/ui/src/components/navbar.tsx @@ -16,6 +16,7 @@ import { Comment, CommentResponse, PrivateMessage, + UserView, PrivateMessageResponse, WebSocketJsonResponse, } from '../interfaces'; @@ -40,6 +41,7 @@ interface NavbarState { messages: Array<PrivateMessage>; unreadCount: number; siteName: string; + admins: Array<UserView>; } export class Navbar extends Component<any, NavbarState> { @@ -53,6 +55,7 @@ export class Navbar extends Component<any, NavbarState> { messages: [], expanded: false, siteName: undefined, + admins: [], }; constructor(props: any, context: any) { @@ -179,6 +182,19 @@ export class Navbar extends Component<any, NavbarState> { </li> </ul> <ul class="navbar-nav ml-auto"> + {this.canAdmin && ( + <li className="nav-item mt-1"> + <Link + class="nav-link" + to={`/admin`} + title={i18n.t('admin_settings')} + > + <svg class="icon"> + <use xlinkHref="#icon-settings"></use> + </svg> + </Link> + </li> + )} {this.state.isLoggedIn ? ( <> <li className="nav-item mt-1"> @@ -298,7 +314,10 @@ export class Navbar extends Component<any, NavbarState> { if (data.site && !this.state.siteName) { this.state.siteName = data.site.name; + this.state.admins = data.admins; WebSocketService.Instance.site = data.site; + WebSocketService.Instance.admins = data.admins; + this.setState(this.state); } } @@ -353,9 +372,16 @@ export class Navbar extends Component<any, NavbarState> { ); } + get canAdmin(): boolean { + return ( + UserService.Instance.user && + this.state.admins.map(a => a.id).includes(UserService.Instance.user.id) + ); + } + requestNotificationPermission() { if (UserService.Instance.user) { - document.addEventListener('DOMContentLoaded', function() { + document.addEventListener('DOMContentLoaded', function () { if (!Notification) { toast(i18n.t('notifications_error'), 'danger'); return; diff --git a/ui/src/components/post-form.tsx b/ui/src/components/post-form.tsx index 47920b9b..4dbc8b23 100644 --- a/ui/src/components/post-form.tsx +++ b/ui/src/components/post-form.tsx @@ -34,9 +34,11 @@ import { randomStr, setupTribute, setupTippy, + emojiPicker, } from '../utils'; import autosize from 'autosize'; import Tribute from 'tributejs/src/Tribute.js'; +import emojiShortName from 'emoji-short-name'; import Selectr from 'mobius1-selectr'; import { i18n } from '../i18next'; @@ -92,6 +94,8 @@ export class PostForm extends Component<PostFormProps, PostFormState> { this.fetchPageTitle = debounce(this.fetchPageTitle).bind(this); this.tribute = setupTribute(); + this.setupEmojiPicker(); + this.state = this.emptyState; if (this.props.post) { @@ -190,8 +194,9 @@ export class PostForm extends Component<PostFormProps, PostFormState> { <form> <label htmlFor="file-upload" - className={`${UserService.Instance.user && - 'pointer'} d-inline-block float-right text-muted h6 font-weight-bold`} + className={`${ + UserService.Instance.user && 'pointer' + } d-inline-block float-right text-muted font-weight-bold`} data-tippy-content={i18n.t('upload_image')} > <svg class="icon icon-inline"> @@ -284,8 +289,9 @@ export class PostForm extends Component<PostFormProps, PostFormState> { )} {this.state.postForm.body && ( <button - className={`mt-1 mr-2 btn btn-sm btn-secondary ${this.state - .previewMode && 'active'}`} + className={`mt-1 mr-2 btn btn-sm btn-secondary ${ + this.state.previewMode && 'active' + }`} onClick={linkEvent(this, this.handlePreviewToggle)} > {i18n.t('preview')} @@ -294,13 +300,22 @@ export class PostForm extends Component<PostFormProps, PostFormState> { <a href={markdownHelpUrl} target="_blank" - class="d-inline-block float-right text-muted h6 font-weight-bold" + class="d-inline-block float-right text-muted font-weight-bold" title={i18n.t('formatting_help')} > <svg class="icon icon-inline"> <use xlinkHref="#icon-help-circle"></use> </svg> </a> + <span + onClick={linkEvent(this, this.handleEmojiPickerClick)} + class="pointer unselectable d-inline-block mr-3 float-right text-muted font-weight-bold" + data-tippy-content={i18n.t('emoji_picker')} + > + <svg class="icon icon-inline"> + <use xlinkHref="#icon-smile"></use> + </svg> + </span> </div> </div> {!this.props.post && ( @@ -369,6 +384,20 @@ export class PostForm extends Component<PostFormProps, PostFormState> { ); } + setupEmojiPicker() { + emojiPicker.on('emoji', twemojiHtmlStr => { + if (this.state.postForm.body == null) { + this.state.postForm.body = ''; + } + var el = document.createElement('div'); + el.innerHTML = twemojiHtmlStr; + let nativeUnicode = (el.childNodes[0] as HTMLElement).getAttribute('alt'); + let shortName = `:${emojiShortName[nativeUnicode]}:`; + this.state.postForm.body += shortName; + this.setState(this.state); + }); + } + handlePostSubmit(i: PostForm, event: any) { event.preventDefault(); if (i.props.post) { @@ -512,6 +541,10 @@ export class PostForm extends Component<PostFormProps, PostFormState> { }); } + handleEmojiPickerClick(_i: PostForm, event: any) { + emojiPicker.togglePicker(event.target); + } + parseMessage(msg: WebSocketJsonResponse) { let res = wsJsonToRes(msg); if (msg.error) { diff --git a/ui/src/components/post-listing.tsx b/ui/src/components/post-listing.tsx index 49970dfc..49749201 100644 --- a/ui/src/components/post-listing.tsx +++ b/ui/src/components/post-listing.tsx @@ -19,18 +19,19 @@ import { import { MomentTime } from './moment-time'; import { PostForm } from './post-form'; import { IFramelyCard } from './iframely-card'; +import { UserListing } from './user-listing'; import { + md, mdToHtml, canMod, isMod, isImage, isVideo, getUnixTime, - pictshareAvatarThumbnail, - showAvatars, pictshareImage, setupTippy, hostname, + previewLines, } from '../utils'; import { i18n } from '../i18next'; @@ -415,20 +416,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> { <ul class="list-inline mb-0 text-muted small"> <li className="list-inline-item"> <span>{i18n.t('by')} </span> - <Link - className="text-body font-weight-bold" - to={`/u/${post.creator_name}`} - > - {post.creator_avatar && showAvatars() && ( - <img - he |