Merge branch 'admin_settings' into dev

This commit is contained in:
Dessalines 2020-04-14 15:45:49 -04:00
commit 641e4c5d96
29 changed files with 660 additions and 198 deletions

2
.gitignore vendored
View File

@ -7,4 +7,4 @@ build/
.idea/
ui/src/translations
docker/dev/volumes
docker/federation/volumes
docker/federation-test/volumes

View File

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

View File

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

View File

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

View File

@ -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 })
}
}

View File

@ -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,

View File

@ -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);

View File

@ -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))?

View File

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

View File

@ -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",
)?)
}

View File

@ -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()
}
}

View File

@ -46,4 +46,6 @@ pub enum UserOperation {
GetPrivateMessages,
UserJoin,
GetComments,
GetSiteConfig,
SaveSiteConfig,
}

View File

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

View File

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

View File

@ -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;

View File

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

View File

@ -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"
/>
)}
<span>{post.creator_name}</span>
</Link>
<UserListing
user={{
name: post.creator_name,
avatar: post.creator_avatar,
}}
/>
{this.isMod && (
<span className="mx-1 badge badge-light">
{i18n.t('mod')}

View File

@ -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"
/>
)}
<span>{this.state.recipient.name}</span>
</Link>
<UserListing
user={{
name: this.state.recipient.name,
avatar: this.state.recipient.avatar,
}}
/>
</div>
)}
</div>

View File

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

View File

@ -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"
/>
)}
<span>{`/u/${(i.data as UserView).name}`}</span>
</Link>
<UserListing
user={{
name: (i.data as UserView).name,
avatar: (i.data as UserView).avatar,
}}
/>
</span>
<span>{` - ${
(i.data as UserView).comment_score

View File

@ -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"
/>
)}
<span>{mod.user_name}</span>
</Link>
<UserListing
user={{
name: mod.user_name,
avatar: mod.avatar,
}}
/>
</li>
))}
</ul>

View File

@ -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')}

File diff suppressed because one or more lines are too long

36
ui/src/components/user-listing.tsx vendored Normal file
View 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>
);
}
}

116
ui/src/index.tsx vendored
View File

@ -15,79 +15,85 @@ 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>
<Navbar />
<div class="mt-4 p-0 fl-1">
<Switch>
<Route exact path={`/`} component={Main} />
<Route
path={`/home/data_type/:data_type/listing_type/:listing_type/sort/:sort/page/:page`}
component={Main}
/>
<Route path={`/login`} component={Login} />
<Route path={`/create_post`} component={CreatePost} />
<Route path={`/create_community`} component={CreateCommunity} />
<Route
path={`/create_private_message`}
component={CreatePrivateMessage}
/>
<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`} component={Post} />
<Route
path={`/c/:name/data_type/:data_type/sort/:sort/page/:page`}
component={Community}
/>
<Route path={`/community/:id`} component={Community} />
<Route path={`/c/:name`} component={Community} />
<Route
path={`/u/:username/view/:view/sort/:sort/page/:page`}
component={User}
/>
<Route path={`/user/:id`} component={User} />
<Route path={`/u/:username`} component={User} />
<Route path={`/inbox`} component={Inbox} />
<Route
path={`/modlog/community/:community_id`}
component={Modlog}
/>
<Route path={`/modlog`} component={Modlog} />
<Route path={`/setup`} component={Setup} />
<Route
path={`/search/q/:q/type/:type/sort/:sort/page/:page`}
component={Search}
/>
<Route path={`/search`} component={Search} />
<Route path={`/sponsors`} component={Sponsors} />
<Route
path={`/password_change/:token`}
component={PasswordChange}
/>
</Switch>
<Symbols />
<div>
<Navbar />
<div class="mt-4 p-0 fl-1">
<Switch>
<Route exact path={`/`} component={Main} />
<Route
path={`/home/data_type/:data_type/listing_type/:listing_type/sort/:sort/page/:page`}
component={Main}
/>
<Route path={`/login`} component={Login} />
<Route path={`/create_post`} component={CreatePost} />
<Route path={`/create_community`} component={CreateCommunity} />
<Route
path={`/create_private_message`}
component={CreatePrivateMessage}
/>
<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`} component={Post} />
<Route
path={`/c/:name/data_type/:data_type/sort/:sort/page/:page`}
component={Community}
/>
<Route path={`/community/:id`} component={Community} />
<Route path={`/c/:name`} component={Community} />
<Route
path={`/u/:username/view/:view/sort/:sort/page/:page`}
component={User}
/>
<Route path={`/user/:id`} component={User} />
<Route path={`/u/:username`} component={User} />
<Route path={`/inbox`} component={Inbox} />
<Route
path={`/modlog/community/:community_id`}
component={Modlog}
/>
<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}
/>
<Route path={`/search`} component={Search} />
<Route path={`/sponsors`} component={Sponsors} />
<Route
path={`/password_change/:token`}
component={PasswordChange}
/>
</Switch>
<Symbols />
</div>
<Footer />
</div>
<Footer />
</BrowserRouter>
</Provider>
);

22
ui/src/interfaces.ts vendored
View File

@ -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;

View File

@ -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);

View File

@ -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",
@ -211,6 +214,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.",