Adding an admin settings page.
- Fixes #620 - Adding a UserListing component. Fixes #627
This commit is contained in:
parent
ed264aba3c
commit
bb287cbd07
25 changed files with 633 additions and 179 deletions
2
docker/dev/docker-compose.yml
vendored
2
docker/dev/docker-compose.yml
vendored
|
@ -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
|
||||
|
|
2
docker/prod/docker-compose.yml
vendored
2
docker/prod/docker-compose.yml
vendored
|
@ -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
|
||||
|
|
99
docs/src/contributing_websocket_http_api.md
vendored
99
docs/src/contributing_websocket_http_api.md
vendored
|
@ -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
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
use config::{Config, ConfigError, Environment, File};
|
||||
use failure::Error;
|
||||
use serde::Deserialize;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::net::IpAddr;
|
||||
|
||||
static CONFIG_FILE_DEFAULTS: &str = "config/defaults.hjson";
|
||||
|
@ -112,4 +114,14 @@ 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)?;
|
||||
Self::init()?;
|
||||
Self::read_config_file()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,4 +46,6 @@ pub enum UserOperation {
|
|||
GetPrivateMessages,
|
||||
UserJoin,
|
||||
GetComments,
|
||||
GetSiteConfig,
|
||||
SaveSiteConfig,
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
241
ui/src/components/admin-settings.tsx
vendored
Normal file
241
ui/src/components/admin-settings.tsx
vendored
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
25
ui/src/components/comment-node.tsx
vendored
25
ui/src/components/comment-node.tsx
vendored
|
@ -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 class="mr-2">
|
||||
<UserListing
|
||||
user={{
|
||||
name: node.comment.creator_name,
|
||||
avatar: node.comment.creator_avatar,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>{node.comment.creator_name}</span>
|
||||
</Link>
|
||||
</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 ? (
|
||||
|
|
22
ui/src/components/main.tsx
vendored
22
ui/src/components/main.tsx
vendored
|
@ -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"
|
||||
<UserListing
|
||||
user={{
|
||||
name: admin.name,
|
||||
avatar: admin.avatar,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>{admin.name}</span>
|
||||
</Link>
|
||||
</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;
|
||||
|
|
26
ui/src/components/navbar.tsx
vendored
26
ui/src/components/navbar.tsx
vendored
|
@ -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,6 +372,13 @@ 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() {
|
||||
|
|
21
ui/src/components/post-listing.tsx
vendored
21
ui/src/components/post-listing.tsx
vendored
|
@ -19,6 +19,7 @@ import {
|
|||
import { MomentTime } from './moment-time';
|
||||
import { PostForm } from './post-form';
|
||||
import { IFramelyCard } from './iframely-card';
|
||||
import { UserListing } from './user-listing';
|
||||
import {
|
||||
md,
|
||||
mdToHtml,
|
||||
|
@ -27,8 +28,6 @@ import {
|
|||
isImage,
|
||||
isVideo,
|
||||
getUnixTime,
|
||||
pictshareAvatarThumbnail,
|
||||
showAvatars,
|
||||
pictshareImage,
|
||||
setupTippy,
|
||||
previewLines,
|
||||
|
@ -417,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
|
||||
height="32"
|
||||
width="32"
|
||||
src={pictshareAvatarThumbnail(post.creator_avatar)}
|
||||
class="rounded-circle mr-1"
|
||||
<UserListing
|
||||
user={{
|
||||
name: post.creator_name,
|
||||
avatar: post.creator_avatar,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>{post.creator_name}</span>
|
||||
</Link>
|
||||
{this.isMod && (
|
||||
<span className="mx-1 badge badge-light">
|
||||
{i18n.t('mod')}
|
||||
|
|
23
ui/src/components/private-message-form.tsx
vendored
23
ui/src/components/private-message-form.tsx
vendored
|
@ -21,14 +21,13 @@ import {
|
|||
capitalizeFirstLetter,
|
||||
markdownHelpUrl,
|
||||
mdToHtml,
|
||||
showAvatars,
|
||||
pictshareAvatarThumbnail,
|
||||
wsJsonToRes,
|
||||
toast,
|
||||
randomStr,
|
||||
setupTribute,
|
||||
setupTippy,
|
||||
} from '../utils';
|
||||
import { UserListing } from './user-listing';
|
||||
import Tribute from 'tributejs/src/Tribute.js';
|
||||
import autosize from 'autosize';
|
||||
import { i18n } from '../i18next';
|
||||
|
@ -132,22 +131,12 @@ export class PrivateMessageForm extends Component<
|
|||
|
||||
{this.state.recipient && (
|
||||
<div class="col-sm-10 form-control-plaintext">
|
||||
<Link
|
||||
className="text-body font-weight-bold"
|
||||
to={`/u/${this.state.recipient.name}`}
|
||||
>
|
||||
{this.state.recipient.avatar && showAvatars() && (
|
||||
<img
|
||||
height="32"
|
||||
width="32"
|
||||
src={pictshareAvatarThumbnail(
|
||||
this.state.recipient.avatar
|
||||
)}
|
||||
class="rounded-circle mr-1"
|
||||
<UserListing
|
||||
user={{
|
||||
name: this.state.recipient.name,
|
||||
avatar: this.state.recipient.avatar,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>{this.state.recipient.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
1
ui/src/components/private-message.tsx
vendored
1
ui/src/components/private-message.tsx
vendored
|
@ -58,6 +58,7 @@ export class PrivateMessage extends Component<
|
|||
<div class="border-top border-light">
|
||||
<div>
|
||||
<ul class="list-inline mb-0 text-muted small">
|
||||
{/* TODO refactor this */}
|
||||
<li className="list-inline-item">
|
||||
{this.mine ? i18n.t('to') : i18n.t('from')}
|
||||
</li>
|
||||
|
|
21
ui/src/components/search.tsx
vendored
21
ui/src/components/search.tsx
vendored
|
@ -30,6 +30,7 @@ import {
|
|||
commentsToFlatNodes,
|
||||
} from '../utils';
|
||||
import { PostListing } from './post-listing';
|
||||
import { UserListing } from './user-listing';
|
||||
import { SortSelect } from './sort-select';
|
||||
import { CommentNodes } from './comment-nodes';
|
||||
import { i18n } from '../i18next';
|
||||
|
@ -266,22 +267,12 @@ export class Search extends Component<any, SearchState> {
|
|||
{i.type_ == 'users' && (
|
||||
<div>
|
||||
<span>
|
||||
<Link
|
||||
className="text-info"
|
||||
to={`/u/${(i.data as UserView).name}`}
|
||||
>
|
||||
{(i.data as UserView).avatar && showAvatars() && (
|
||||
<img
|
||||
height="32"
|
||||
width="32"
|
||||
src={pictshareAvatarThumbnail(
|
||||
(i.data as UserView).avatar
|
||||
)}
|
||||
class="rounded-circle mr-1"
|
||||
<UserListing
|
||||
user={{
|
||||
name: (i.data as UserView).name,
|
||||
avatar: (i.data as UserView).avatar,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>{`/u/${(i.data as UserView).name}`}</span>
|
||||
</Link>
|
||||
</span>
|
||||
<span>{` - ${
|
||||
(i.data as UserView).comment_score
|
||||
|
|
19
ui/src/components/sidebar.tsx
vendored
19
ui/src/components/sidebar.tsx
vendored
|
@ -15,6 +15,7 @@ import {
|
|||
showAvatars,
|
||||
} from '../utils';
|
||||
import { CommunityForm } from './community-form';
|
||||
import { UserListing } from './user-listing';
|
||||
import { i18n } from '../i18next';
|
||||
|
||||
interface SidebarProps {
|
||||
|
@ -204,20 +205,12 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
|
|||
<li class="list-inline-item">{i18n.t('mods')}: </li>
|
||||
{this.props.moderators.map(mod => (
|
||||
<li class="list-inline-item">
|
||||
<Link
|
||||
class="text-body font-weight-bold"
|
||||
to={`/u/${mod.user_name}`}
|
||||
>
|
||||
{mod.avatar && showAvatars() && (
|
||||
<img
|
||||
height="32"
|
||||
width="32"
|
||||
src={pictshareAvatarThumbnail(mod.avatar)}
|
||||
class="rounded-circle mr-1"
|
||||
<UserListing
|
||||
user={{
|
||||
name: mod.user_name,
|
||||
avatar: mod.avatar,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>{mod.user_name}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
7
ui/src/components/site-form.tsx
vendored
7
ui/src/components/site-form.tsx
vendored
|
@ -58,12 +58,19 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
|
|||
});
|
||||
}
|
||||
|
||||
// Necessary to stop the loading
|
||||
componentWillReceiveProps() {
|
||||
this.state.loading = false;
|
||||
this.setState(this.state);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<Prompt
|
||||
when={
|
||||
!this.state.loading &&
|
||||
!this.props.site &&
|
||||
(this.state.siteForm.name || this.state.siteForm.description)
|
||||
}
|
||||
message={i18n.t('block_leaving')}
|
||||
|
|
3
ui/src/components/symbols.tsx
vendored
3
ui/src/components/symbols.tsx
vendored
File diff suppressed because one or more lines are too long
36
ui/src/components/user-listing.tsx
vendored
Normal file
36
ui/src/components/user-listing.tsx
vendored
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { Component } from 'inferno';
|
||||
import { Link } from 'inferno-router';
|
||||
import { UserView } from '../interfaces';
|
||||
import { pictshareAvatarThumbnail, showAvatars } from '../utils';
|
||||
|
||||
interface UserOther {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface UserListingProps {
|
||||
user: UserView | UserOther;
|
||||
}
|
||||
|
||||
export class UserListing extends Component<UserListingProps, any> {
|
||||
constructor(props: any, context: any) {
|
||||
super(props, context);
|
||||
}
|
||||
|
||||
render() {
|
||||
let user = this.props.user;
|
||||
return (
|
||||
<Link className="text-body font-weight-bold" to={`/u/${user.name}`}>
|
||||
{user.avatar && showAvatars() && (
|
||||
<img
|
||||
height="32"
|
||||
width="32"
|
||||
src={pictshareAvatarThumbnail(user.avatar)}
|
||||
class="rounded-circle mr-2"
|
||||
/>
|
||||
)}
|
||||
<span>{user.name}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
18
ui/src/index.tsx
vendored
18
ui/src/index.tsx
vendored
|
@ -15,27 +15,25 @@ import { Communities } from './components/communities';
|
|||
import { User } from './components/user';
|
||||
import { Modlog } from './components/modlog';
|
||||
import { Setup } from './components/setup';
|
||||
import { AdminSettings } from './components/admin-settings';
|
||||
import { Inbox } from './components/inbox';
|
||||
import { Search } from './components/search';
|
||||
import { Sponsors } from './components/sponsors';
|
||||
import { Symbols } from './components/symbols';
|
||||
import { i18n } from './i18next';
|
||||
|
||||
import { WebSocketService, UserService } from './services';
|
||||
|
||||
const container = document.getElementById('app');
|
||||
|
||||
class Index extends Component<any, any> {
|
||||
constructor(props: any, context: any) {
|
||||
super(props, context);
|
||||
WebSocketService.Instance;
|
||||
UserService.Instance;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Provider i18next={i18n}>
|
||||
<BrowserRouter>
|
||||
<div>
|
||||
<Navbar />
|
||||
<div class="mt-4 p-0 fl-1">
|
||||
<Switch>
|
||||
|
@ -51,9 +49,15 @@ class Index extends Component<any, any> {
|
|||
path={`/create_private_message`}
|
||||
component={CreatePrivateMessage}
|
||||
/>
|
||||
<Route path={`/communities/page/:page`} component={Communities} />
|
||||
<Route
|
||||
path={`/communities/page/:page`}
|
||||
component={Communities}
|
||||
/>
|
||||
<Route path={`/communities`} component={Communities} />
|
||||
<Route path={`/post/:id/comment/:comment_id`} component={Post} />
|
||||
<Route
|
||||
path={`/post/:id/comment/:comment_id`}
|
||||
component={Post}
|
||||
/>
|
||||
<Route path={`/post/:id`} component={Post} />
|
||||
<Route
|
||||
path={`/c/:name/data_type/:data_type/sort/:sort/page/:page`}
|
||||
|
@ -74,6 +78,7 @@ class Index extends Component<any, any> {
|
|||
/>
|
||||
<Route path={`/modlog`} component={Modlog} />
|
||||
<Route path={`/setup`} component={Setup} />
|
||||
<Route path={`/admin`} component={AdminSettings} />
|
||||
<Route
|
||||
path={`/search/q/:q/type/:type/sort/:sort/page/:page`}
|
||||
component={Search}
|
||||
|
@ -88,6 +93,7 @@ class Index extends Component<any, any> {
|
|||
<Symbols />
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
|
22
ui/src/interfaces.ts
vendored
22
ui/src/interfaces.ts
vendored
|
@ -43,6 +43,8 @@ export enum UserOperation {
|
|||
GetPrivateMessages,
|
||||
UserJoin,
|
||||
GetComments,
|
||||
GetSiteConfig,
|
||||
SaveSiteConfig,
|
||||
}
|
||||
|
||||
export enum CommentSortType {
|
||||
|
@ -102,7 +104,6 @@ export interface UserView {
|
|||
avatar?: string;
|
||||
email?: string;
|
||||
matrix_user_id?: string;
|
||||
fedi_name: string;
|
||||
published: string;
|
||||
number_of_posts: number;
|
||||
post_score: number;
|
||||
|
@ -699,6 +700,19 @@ export interface SiteForm {
|
|||
auth?: string;
|
||||
}
|
||||
|
||||
export interface GetSiteConfig {
|
||||
auth?: string;
|
||||
}
|
||||
|
||||
export interface GetSiteConfigResponse {
|
||||
config_hjson: string;
|
||||
}
|
||||
|
||||
export interface SiteConfigForm {
|
||||
config_hjson: string;
|
||||
auth?: string;
|
||||
}
|
||||
|
||||
export interface GetSiteResponse {
|
||||
site: Site;
|
||||
admins: Array<UserView>;
|
||||
|
@ -846,7 +860,8 @@ export type MessageType =
|
|||
| PasswordChangeForm
|
||||
| PrivateMessageForm
|
||||
| EditPrivateMessageForm
|
||||
| GetPrivateMessagesForm;
|
||||
| GetPrivateMessagesForm
|
||||
| SiteConfigForm;
|
||||
|
||||
type ResponseType =
|
||||
| SiteResponse
|
||||
|
@ -868,7 +883,8 @@ type ResponseType =
|
|||
| BanUserResponse
|
||||
| AddAdminResponse
|
||||
| PrivateMessageResponse
|
||||
| PrivateMessagesResponse;
|
||||
| PrivateMessagesResponse
|
||||
| GetSiteConfigResponse;
|
||||
|
||||
export interface WebSocketResponse {
|
||||
op: UserOperation;
|
||||
|
|
13
ui/src/services/WebSocketService.ts
vendored
13
ui/src/services/WebSocketService.ts
vendored
|
@ -40,6 +40,8 @@ import {
|
|||
GetPrivateMessagesForm,
|
||||
GetCommentsForm,
|
||||
UserJoinForm,
|
||||
GetSiteConfig,
|
||||
SiteConfigForm,
|
||||
MessageType,
|
||||
WebSocketJsonResponse,
|
||||
} from '../interfaces';
|
||||
|
@ -268,6 +270,12 @@ export class WebSocketService {
|
|||
this.ws.send(this.wsSendWrapper(UserOperation.GetSite, {}));
|
||||
}
|
||||
|
||||
public getSiteConfig() {
|
||||
let siteConfig: GetSiteConfig = {};
|
||||
this.setAuth(siteConfig);
|
||||
this.ws.send(this.wsSendWrapper(UserOperation.GetSiteConfig, siteConfig));
|
||||
}
|
||||
|
||||
public search(form: SearchForm) {
|
||||
this.setAuth(form, false);
|
||||
this.ws.send(this.wsSendWrapper(UserOperation.Search, form));
|
||||
|
@ -314,6 +322,11 @@ export class WebSocketService {
|
|||
this.ws.send(this.wsSendWrapper(UserOperation.GetPrivateMessages, form));
|
||||
}
|
||||
|
||||
public saveSiteConfig(form: SiteConfigForm) {
|
||||
this.setAuth(form);
|
||||
this.ws.send(this.wsSendWrapper(UserOperation.SaveSiteConfig, form));
|
||||
}
|
||||
|
||||
private wsSendWrapper(op: UserOperation, data: MessageType) {
|
||||
let send = { op: UserOperation[op], data: data };
|
||||
console.log(send);
|
||||
|
|
4
ui/translations/en.json
vendored
4
ui/translations/en.json
vendored
|
@ -53,6 +53,8 @@
|
|||
"mods": "mods",
|
||||
"moderates": "Moderates",
|
||||
"settings": "Settings",
|
||||
"admin_settings": "Admin Settings",
|
||||
"site_config": "Site Configuration",
|
||||
"remove_as_mod": "remove as mod",
|
||||
"appoint_as_mod": "appoint as mod",
|
||||
"modlog": "Modlog",
|
||||
|
@ -78,6 +80,7 @@
|
|||
"unban": "unban",
|
||||
"unban_from_site": "unban from site",
|
||||
"banned": "banned",
|
||||
"banned_users": "Banned Users",
|
||||
"save": "save",
|
||||
"unsave": "unsave",
|
||||
"create": "create",
|
||||
|
@ -210,6 +213,7 @@
|
|||
"Lemmy is a <1>link aggregator</1> / reddit alternative, intended to work in the <2>fediverse</2>.<3></3>It's self-hostable, has live-updating comment threads, and is tiny (<4>~80kB</4>). Federation into the ActivityPub network is on the roadmap. <5></5>This is a <6>very early beta version</6>, and a lot of features are currently broken or missing. <7></7>Suggest new features or report bugs <8>here.</8><9></9>Made with <10>Rust</10>, <11>Actix</11>, <12>Inferno</12>, <13>Typescript</13>.",
|
||||
"not_logged_in": "Not logged in.",
|
||||
"logged_in": "Logged in.",
|
||||
"site_saved": "Site Saved.",
|
||||
"community_ban": "You have been banned from this community.",
|
||||
"site_ban": "You have been banned from the site",
|
||||
"couldnt_create_comment": "Couldn't create comment.",
|
||||
|
|
Reference in a new issue