diff options
29 files changed, 660 insertions, 198 deletions
@@ -7,4 +7,4 @@ build/ .idea/ ui/src/translations docker/dev/volumes -docker/federation/volumes +docker/federation-test/volumes diff --git a/docker/dev/docker-compose.yml b/docker/dev/docker-compose.yml index a7d289b2..3c52d1e5 100644 --- a/docker/dev/docker-compose.yml +++ b/docker/dev/docker-compose.yml @@ -21,7 +21,7 @@ services: environment: - RUST_LOG=debug volumes: - - ../lemmy.hjson:/config/config.hjson:ro + - ../lemmy.hjson:/config/config.hjson depends_on: - postgres - pictshare diff --git a/docker/prod/docker-compose.yml b/docker/prod/docker-compose.yml index 727b7307..90551465 100644 --- a/docker/prod/docker-compose.yml +++ b/docker/prod/docker-compose.yml @@ -19,7 +19,7 @@ services: environment: - RUST_LOG=error volumes: - - ./lemmy.hjson:/config/config.hjson:ro + - ./lemmy.hjson:/config/config.hjson depends_on: - postgres - pictshare diff --git a/docs/src/contributing_websocket_http_api.md b/docs/src/contributing_websocket_http_api.md index a73a1c13..f228f94e 100644 --- a/docs/src/contributing_websocket_http_api.md +++ b/docs/src/contributing_websocket_http_api.md @@ -92,85 +92,93 @@ - [Request](#request-17) - [Response](#response-17) - [HTTP](#http-18) - * [Community](#community) - + [Get Community](#get-community) + + [Get Site Config](#get-site-config) - [Request](#request-18) - [Response](#response-18) - [HTTP](#http-19) - + [Create Community](#create-community) + + [Save Site Config](#save-site-config) - [Request](#request-19) - [Response](#response-19) - [HTTP](#http-20) - + [List Communities](#list-communities) + * [Community](#community) + + [Get Community](#get-community) - [Request](#request-20) - [Response](#response-20) - [HTTP](#http-21) - + [Ban from Community](#ban-from-community) + + [Create Community](#create-community) - [Request](#request-21) - [Response](#response-21) - [HTTP](#http-22) - + [Add Mod to Community](#add-mod-to-community) + + [List Communities](#list-communities) - [Request](#request-22) - [Response](#response-22) - [HTTP](#http-23) - + [Edit Community](#edit-community) + + [Ban from Community](#ban-from-community) - [Request](#request-23) - [Response](#response-23) - [HTTP](#http-24) - + [Follow Community](#follow-community) + + [Add Mod to Community](#add-mod-to-community) - [Request](#request-24) - [Response](#response-24) - [HTTP](#http-25) - + [Get Followed Communities](#get-followed-communities) + + [Edit Community](#edit-community) - [Request](#request-25) - [Response](#response-25) - [HTTP](#http-26) - + [Transfer Community](#transfer-community) + + [Follow Community](#follow-community) - [Request](#request-26) - [Response](#response-26) - [HTTP](#http-27) - * [Post](#post) - + [Create Post](#create-post) + + [Get Followed Communities](#get-followed-communities) - [Request](#request-27) - [Response](#response-27) - [HTTP](#http-28) - + [Get Post](#get-post) + + [Transfer Community](#transfer-community) - [Request](#request-28) - [Response](#response-28) - [HTTP](#http-29) - + [Get Posts](#get-posts) + * [Post](#post) + + [Create Post](#create-post) - [Request](#request-29) - [Response](#response-29) - [HTTP](#http-30) - + [Create Post Like](#create-post-like) + + [Get Post](#get-post) - [Request](#request-30) - [Response](#response-30) - [HTTP](#http-31) - + [Edit Post](#edit-post) + + [Get Posts](#get-posts) - [Request](#request-31) - [Response](#response-31) - [HTTP](#http-32) - + [Save Post](#save-post) + + [Create Post Like](#create-post-like) - [Request](#request-32) - [Response](#response-32) - [HTTP](#http-33) - * [Comment](#comment) - + [Create Comment](#create-comment) + + [Edit Post](#edit-post) - [Request](#request-33) - [Response](#response-33) - [HTTP](#http-34) - + [Edit Comment](#edit-comment) + + [Save Post](#save-post) - [Request](#request-34) - [Response](#response-34) - [HTTP](#http-35) - + [Save Comment](#save-comment) + * [Comment](#comment) + + [Create Comment](#create-comment) - [Request](#request-35) - [Response](#response-35) - [HTTP](#http-36) - + [Create Comment Like](#create-comment-like) + + [Edit Comment](#edit-comment) - [Request](#request-36) - [Response](#response-36) - [HTTP](#http-37) + + [Save Comment](#save-comment) + - [Request](#request-37) + - [Response](#response-37) + - [HTTP](#http-38) + + [Create Comment Like](#create-comment-like) + - [Request](#request-38) + - [Response](#response-38) + - [HTTP](#http-39) * [RSS / Atom feeds](#rss--atom-feeds) + [All](#all) + [Community](#community-1) @@ -779,6 +787,53 @@ Search types are `All, Comments, Posts, Communities, Users, Url` `POST /site/transfer` +#### Get Site Config +##### Request +```rust +{ + op: "GetSiteConfig", + data: { + auth: String + } +} +``` +##### Response +```rust +{ + op: "GetSiteConfig", + data: { + config_hjson: String, + } +} +``` +##### HTTP + +`GET /site/config` + +#### Save Site Config +##### Request +```rust +{ + op: "SaveSiteConfig", + data: { + config_hjson: String, + auth: String + } +} +``` +##### Response +```rust +{ + op: "SaveSiteConfig", + data: { + config_hjson: String, + } +} +``` +##### HTTP + +`PUT /site/config` + ### Community #### Get Community ##### Request diff --git a/server/src/api/site.rs b/server/src/api/site.rs index 6bd90149..3720a2c4 100644 --- a/server/src/api/site.rs +++ b/server/src/api/site.rs @@ -97,6 +97,22 @@ pub struct TransferSite { auth: String, } +#[derive(Serialize, Deserialize)] +pub struct GetSiteConfig { + auth: String, +} + +#[derive(Serialize, Deserialize)] +pub struct GetSiteConfigResponse { + config_hjson: String, +} + +#[derive(Serialize, Deserialize)] +pub struct SaveSiteConfig { + config_hjson: String, + auth: String, +} + impl Perform<ListCategoriesResponse> for Oper<ListCategories> { fn perform(&self, conn: &PgConnection) -> Result<ListCategoriesResponse, Error> { let _data: &ListCategories = &self.data; @@ -510,3 +526,57 @@ impl Perform<GetSiteResponse> for Oper<TransferSite> { }) } } + +impl Perform<GetSiteConfigResponse> for Oper<GetSiteConfig> { + fn perform(&self, conn: &PgConnection) -> Result<GetSiteConfigResponse, Error> { + let data: &GetSiteConfig = &self.data; + + let claims = match Claims::decode(&data.auth) { + Ok(claims) => claims.claims, + Err(_e) => return Err(APIError::err("not_logged_in").into()), + }; + + let user_id = claims.id; + + // Only let admins read this + let admins = UserView::admins(&conn)?; + let admin_ids: Vec<i32> = admins.into_iter().map(|m| m.id).collect(); + + if !admin_ids.contains(&user_id) { + return Err(APIError::err("not_an_admin").into()); + } + + let config_hjson = Settings::read_config_file()?; + + Ok(GetSiteConfigResponse { config_hjson }) + } +} + +impl Perform<GetSiteConfigResponse> for Oper<SaveSiteConfig> { + fn perform(&self, conn: &PgConnection) -> Result<GetSiteConfigResponse, Error> { + let data: &SaveSiteConfig = &self.data; + + let claims = match Claims::decode(&data.auth) { + Ok(claims) => claims.claims, + Err(_e) => return Err(APIError::err("not_logged_in").into()), + }; + + let user_id = claims.id; + + // Only let admins read this + let admins = UserView::admins(&conn)?; + let admin_ids: Vec<i32> = admins.into_iter().map(|m| m.id).collect(); + + if !admin_ids.contains(&user_id) { + return Err(APIError::err("not_an_admin").into()); + } + + // Make sure docker doesn't have :ro at the end of the volume, so its not a read-only filesystem + let config_hjson = match Settings::save_config_file(&data.config_hjson) { + Ok(config_hjson) => config_hjson, + Err(_e) => return Err(APIError::err("couldnt_update_site").into()), + }; + + Ok(GetSiteConfigResponse { config_hjson }) + } +} diff --git a/server/src/api/user.rs b/server/src/api/user.rs index 056a2a84..40e09969 100644 --- a/server/src/api/user.rs +++ b/server/src/api/user.rs @@ -253,7 +253,7 @@ impl Perform<LoginResponse> for Oper<Register> { // Register the new user let user_form = UserForm { name: data.username.to_owned(), - fedi_name: Settings::get().hostname.to_owned(), + fedi_name: Settings::get().hostname, email: data.email.to_owned(), matrix_user_id: None, avatar: None, diff --git a/server/src/lib.rs b/server/src/lib.rs index 8257dab9..9bbfe251 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -112,7 +112,7 @@ pub fn send_email( to_username: &str, html: &str, ) -> Result<(), String> { - let email_config = Settings::get().email.as_ref().ok_or("no_email_setup")?; + let email_config = Settings::get().email.ok_or("no_email_setup")?; let email = Email::builder() .to((to_email, to_username)) @@ -127,7 +127,7 @@ pub fn send_email( } else { SmtpClient::new(&email_config.smtp_server, ClientSecurity::None).unwrap() } - .hello_name(ClientId::Domain(Settings::get().hostname.to_owned())) + .hello_name(ClientId::Domain(Settings::get().hostname)) .smtp_utf8(true) .authentication_mechanism(Mechanism::Plain) .connection_reuse(ConnectionReuseParameters::ReuseUnlimited); diff --git a/server/src/main.rs b/server/src/main.rs index 601c2e0d..f3887527 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -39,6 +39,7 @@ async fn main() -> io::Result<()> { // Create Http server with websocket support HttpServer::new(move || { + let settings = Settings::get(); App::new() .wrap(middleware::Logger::default()) .data(pool.clone()) @@ -58,7 +59,7 @@ async fn main() -> io::Result<()> { )) .service(actix_files::Files::new( "/docs", - settings.front_end_dir.to_owned() + "/documentation", + settings.front_end_dir + "/documentation", )) }) .bind((settings.bind, settings.port))? diff --git a/server/src/routes/api.rs b/server/src/routes/api.rs index 29a360e4..36a55f96 100644 --- a/server/src/routes/api.rs +++ b/server/src/routes/api.rs @@ -52,6 +52,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { .route("/api/v1/site", web::post().to(route_post::<CreateSite, SiteResponse>)) .route("/api/v1/site", web::put().to(route_post::<EditSite, SiteResponse>)) .route("/api/v1/site/transfer", web::post().to(route_post::<TransferSite, GetSiteResponse>)) + .route("/api/v1/site/config", web::get().to(route_get::<GetSiteConfig, GetSiteConfigResponse>)) + .route("/api/v1/site/config", web::put().to(route_post::<SaveSiteConfig, GetSiteConfigResponse>)) .route("/api/v1/admin/add", web::post().to(route_post::<AddAdmin, AddAdminResponse>)) .route("/api/v1/user/ban", web::post().to(route_post::<BanUser, BanUserResponse>)) // User account actions diff --git a/server/src/routes/index.rs b/server/src/routes/index.rs index c1c363c9..2e192df4 100644 --- a/server/src/routes/index.rs +++ b/server/src/routes/index.rs @@ -33,6 +33,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { .route("/modlog/community/{community_id}", web::get().to(index)) .route("/modlog", web::get().to(index)) .route("/setup", web::get().to(index)) + .route("/admin", web::get().to(index)) .route( "/search/q/{q}/type/{type}/sort/{sort}/page/{page}", web::get().to(index), @@ -44,6 +45,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { async fn index() -> Result<NamedFile, actix_web::error::Error> { Ok(NamedFile::open( - Settings::get().front_end_dir.to_owned() + "/index.html", + Settings::get().front_end_dir + "/index.html", )?) } diff --git a/server/src/settings.rs b/server/src/settings.rs index 875323e9..6e5667cb 100644 --- a/server/src/settings.rs +++ b/server/src/settings.rs @@ -1,12 +1,15 @@ use config::{Config, ConfigError, Environment, File}; +use failure::Error; use serde::Deserialize; use std::env; +use std::fs; use std::net::IpAddr; +use std::sync::RwLock; static CONFIG_FILE_DEFAULTS: &str = "config/defaults.hjson"; static CONFIG_FILE: &str = "config/config.hjson"; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct Settings { pub setup: Option<Setup>, pub database: Database, @@ -20,7 +23,7 @@ pub struct Settings { pub federation_enabled: bool, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct Setup { pub admin_username: String, pub admin_password: String, @@ -28,7 +31,7 @@ pub struct Setup { pub site_name: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct RateLimitConfig { pub message: i32, pub message_per_second: i32, @@ -38,7 +41,7 @@ pub struct RateLimitConfig { pub register_per_second: i32, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct EmailConfig { pub smtp_server: String, pub smtp_login: Option<String>, @@ -47,7 +50,7 @@ pub struct EmailConfig { pub use_tls: bool, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct Database { pub user: String, pub password: String, @@ -58,12 +61,10 @@ pub struct Database { } lazy_static! { - static ref SETTINGS: Settings = { - match Settings::init() { - Ok(c) => c, - Err(e) => panic!("{}", e), - } - }; + static ref SETTINGS: RwLock<Settings> = RwLock::new(match Settings::init() { + Ok(c) => c, + Err(e) => panic!("{}", e), + }); } impl Settings { @@ -89,8 +90,8 @@ impl Settings { } /// Returns the config as a struct. - pub fn get() -> &'static Self { - &SETTINGS + pub fn get() -> Self { + SETTINGS.read().unwrap().to_owned() } /// Returns the postgres connection url. If LEMMY_DATABASE_URL is set, that is used, @@ -112,4 +113,22 @@ impl Settings { pub fn api_endpoint(&self) -> String { format!("{}/api/v1", self.hostname) } + + pub fn read_config_file() -> Result<String, Error> { + Ok(fs::read_to_string(CONFIG_FILE)?) + } + + pub fn save_config_file(data: &str) -> Result<String, Error> { + fs::write(CONFIG_FILE, data)?; + + // Reload the new settings + // From https://stackoverflow.com/questions/29654927/how-do-i-assign-a-string-to-a-mutable-static-variable/47181804#47181804 + let mut new_settings = SETTINGS.write().unwrap(); + *new_settings = match Settings::init() { + Ok(c) => c, + Err(e) => panic!("{}", e), + }; + + Self::read_config_file() + } } diff --git a/server/src/websocket/mod.rs b/server/src/websocket/mod.rs index a1feede2..c7136423 100644 --- a/server/src/websocket/mod.rs +++ b/server/src/websocket/mod.rs @@ -46,4 +46,6 @@ pub enum UserOperation { GetPrivateMessages, UserJoin, GetComments, + GetSiteConfig, + SaveSiteConfig, } diff --git a/server/src/websocket/server.rs b/server/src/websocket/server.rs index 831f12ee..0f2d2d26 100644 --- a/server/src/websocket/server.rs +++ b/server/src/websocket/server.rs @@ -708,6 +708,16 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str res.online = chat.sessions.len(); to_json_string(&user_operation, &res) } + UserOperation::GetSiteConfig => { + let get_site_config: GetSiteConfig = serde_json::from_str(data)?; + let res = Oper::new(get_site_config).perform(&conn)?; + to_json_string(&user_operation, &res) + } + UserOperation::SaveSiteConfig => { + let save_site_config: SaveSiteConfig = serde_json::from_str(data)?; + let res = Oper::new(save_site_config).perform(&conn)?; + to_json_string(&user_operation, &res) + } UserOperation::Search => { do_user_operation::<Search, SearchResponse>(user_operation, data, &conn) } 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-node.tsx b/ui/src/components/comment-node.tsx index 39f29b5f..ba4301e1 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 { @@ -148,20 +147,14 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> { '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 +184,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 ? ( 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 |