mirror of
https://github.com/LemmyNet/lemmy.git
synced 2024-12-16 04:56:30 +00:00
Merge branch 'admin_settings' into dev
This commit is contained in:
commit
641e4c5d96
29 changed files with 660 additions and 198 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -7,4 +7,4 @@ build/
|
||||||
.idea/
|
.idea/
|
||||||
ui/src/translations
|
ui/src/translations
|
||||||
docker/dev/volumes
|
docker/dev/volumes
|
||||||
docker/federation/volumes
|
docker/federation-test/volumes
|
||||||
|
|
2
docker/dev/docker-compose.yml
vendored
2
docker/dev/docker-compose.yml
vendored
|
@ -21,7 +21,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- RUST_LOG=debug
|
- RUST_LOG=debug
|
||||||
volumes:
|
volumes:
|
||||||
- ../lemmy.hjson:/config/config.hjson:ro
|
- ../lemmy.hjson:/config/config.hjson
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
- pictshare
|
- pictshare
|
||||||
|
|
2
docker/prod/docker-compose.yml
vendored
2
docker/prod/docker-compose.yml
vendored
|
@ -19,7 +19,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- RUST_LOG=error
|
- RUST_LOG=error
|
||||||
volumes:
|
volumes:
|
||||||
- ./lemmy.hjson:/config/config.hjson:ro
|
- ./lemmy.hjson:/config/config.hjson
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
- pictshare
|
- 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)
|
- [Request](#request-17)
|
||||||
- [Response](#response-17)
|
- [Response](#response-17)
|
||||||
- [HTTP](#http-18)
|
- [HTTP](#http-18)
|
||||||
* [Community](#community)
|
+ [Get Site Config](#get-site-config)
|
||||||
+ [Get Community](#get-community)
|
|
||||||
- [Request](#request-18)
|
- [Request](#request-18)
|
||||||
- [Response](#response-18)
|
- [Response](#response-18)
|
||||||
- [HTTP](#http-19)
|
- [HTTP](#http-19)
|
||||||
+ [Create Community](#create-community)
|
+ [Save Site Config](#save-site-config)
|
||||||
- [Request](#request-19)
|
- [Request](#request-19)
|
||||||
- [Response](#response-19)
|
- [Response](#response-19)
|
||||||
- [HTTP](#http-20)
|
- [HTTP](#http-20)
|
||||||
+ [List Communities](#list-communities)
|
* [Community](#community)
|
||||||
|
+ [Get Community](#get-community)
|
||||||
- [Request](#request-20)
|
- [Request](#request-20)
|
||||||
- [Response](#response-20)
|
- [Response](#response-20)
|
||||||
- [HTTP](#http-21)
|
- [HTTP](#http-21)
|
||||||
+ [Ban from Community](#ban-from-community)
|
+ [Create Community](#create-community)
|
||||||
- [Request](#request-21)
|
- [Request](#request-21)
|
||||||
- [Response](#response-21)
|
- [Response](#response-21)
|
||||||
- [HTTP](#http-22)
|
- [HTTP](#http-22)
|
||||||
+ [Add Mod to Community](#add-mod-to-community)
|
+ [List Communities](#list-communities)
|
||||||
- [Request](#request-22)
|
- [Request](#request-22)
|
||||||
- [Response](#response-22)
|
- [Response](#response-22)
|
||||||
- [HTTP](#http-23)
|
- [HTTP](#http-23)
|
||||||
+ [Edit Community](#edit-community)
|
+ [Ban from Community](#ban-from-community)
|
||||||
- [Request](#request-23)
|
- [Request](#request-23)
|
||||||
- [Response](#response-23)
|
- [Response](#response-23)
|
||||||
- [HTTP](#http-24)
|
- [HTTP](#http-24)
|
||||||
+ [Follow Community](#follow-community)
|
+ [Add Mod to Community](#add-mod-to-community)
|
||||||
- [Request](#request-24)
|
- [Request](#request-24)
|
||||||
- [Response](#response-24)
|
- [Response](#response-24)
|
||||||
- [HTTP](#http-25)
|
- [HTTP](#http-25)
|
||||||
+ [Get Followed Communities](#get-followed-communities)
|
+ [Edit Community](#edit-community)
|
||||||
- [Request](#request-25)
|
- [Request](#request-25)
|
||||||
- [Response](#response-25)
|
- [Response](#response-25)
|
||||||
- [HTTP](#http-26)
|
- [HTTP](#http-26)
|
||||||
+ [Transfer Community](#transfer-community)
|
+ [Follow Community](#follow-community)
|
||||||
- [Request](#request-26)
|
- [Request](#request-26)
|
||||||
- [Response](#response-26)
|
- [Response](#response-26)
|
||||||
- [HTTP](#http-27)
|
- [HTTP](#http-27)
|
||||||
* [Post](#post)
|
+ [Get Followed Communities](#get-followed-communities)
|
||||||
+ [Create Post](#create-post)
|
|
||||||
- [Request](#request-27)
|
- [Request](#request-27)
|
||||||
- [Response](#response-27)
|
- [Response](#response-27)
|
||||||
- [HTTP](#http-28)
|
- [HTTP](#http-28)
|
||||||
+ [Get Post](#get-post)
|
+ [Transfer Community](#transfer-community)
|
||||||
- [Request](#request-28)
|
- [Request](#request-28)
|
||||||
- [Response](#response-28)
|
- [Response](#response-28)
|
||||||
- [HTTP](#http-29)
|
- [HTTP](#http-29)
|
||||||
+ [Get Posts](#get-posts)
|
* [Post](#post)
|
||||||
|
+ [Create Post](#create-post)
|
||||||
- [Request](#request-29)
|
- [Request](#request-29)
|
||||||
- [Response](#response-29)
|
- [Response](#response-29)
|
||||||
- [HTTP](#http-30)
|
- [HTTP](#http-30)
|
||||||
+ [Create Post Like](#create-post-like)
|
+ [Get Post](#get-post)
|
||||||
- [Request](#request-30)
|
- [Request](#request-30)
|
||||||
- [Response](#response-30)
|
- [Response](#response-30)
|
||||||
- [HTTP](#http-31)
|
- [HTTP](#http-31)
|
||||||
+ [Edit Post](#edit-post)
|
+ [Get Posts](#get-posts)
|
||||||
- [Request](#request-31)
|
- [Request](#request-31)
|
||||||
- [Response](#response-31)
|
- [Response](#response-31)
|
||||||
- [HTTP](#http-32)
|
- [HTTP](#http-32)
|
||||||
+ [Save Post](#save-post)
|
+ [Create Post Like](#create-post-like)
|
||||||
- [Request](#request-32)
|
- [Request](#request-32)
|
||||||
- [Response](#response-32)
|
- [Response](#response-32)
|
||||||
- [HTTP](#http-33)
|
- [HTTP](#http-33)
|
||||||
* [Comment](#comment)
|
+ [Edit Post](#edit-post)
|
||||||
+ [Create Comment](#create-comment)
|
|
||||||
- [Request](#request-33)
|
- [Request](#request-33)
|
||||||
- [Response](#response-33)
|
- [Response](#response-33)
|
||||||
- [HTTP](#http-34)
|
- [HTTP](#http-34)
|
||||||
+ [Edit Comment](#edit-comment)
|
+ [Save Post](#save-post)
|
||||||
- [Request](#request-34)
|
- [Request](#request-34)
|
||||||
- [Response](#response-34)
|
- [Response](#response-34)
|
||||||
- [HTTP](#http-35)
|
- [HTTP](#http-35)
|
||||||
+ [Save Comment](#save-comment)
|
* [Comment](#comment)
|
||||||
|
+ [Create Comment](#create-comment)
|
||||||
- [Request](#request-35)
|
- [Request](#request-35)
|
||||||
- [Response](#response-35)
|
- [Response](#response-35)
|
||||||
- [HTTP](#http-36)
|
- [HTTP](#http-36)
|
||||||
+ [Create Comment Like](#create-comment-like)
|
+ [Edit Comment](#edit-comment)
|
||||||
- [Request](#request-36)
|
- [Request](#request-36)
|
||||||
- [Response](#response-36)
|
- [Response](#response-36)
|
||||||
- [HTTP](#http-37)
|
- [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)
|
* [RSS / Atom feeds](#rss--atom-feeds)
|
||||||
+ [All](#all)
|
+ [All](#all)
|
||||||
+ [Community](#community-1)
|
+ [Community](#community-1)
|
||||||
|
@ -779,6 +787,53 @@ Search types are `All, Comments, Posts, Communities, Users, Url`
|
||||||
|
|
||||||
`POST /site/transfer`
|
`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
|
### Community
|
||||||
#### Get Community
|
#### Get Community
|
||||||
##### Request
|
##### Request
|
||||||
|
|
|
@ -97,6 +97,22 @@ pub struct TransferSite {
|
||||||
auth: String,
|
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> {
|
impl Perform<ListCategoriesResponse> for Oper<ListCategories> {
|
||||||
fn perform(&self, conn: &PgConnection) -> Result<ListCategoriesResponse, Error> {
|
fn perform(&self, conn: &PgConnection) -> Result<ListCategoriesResponse, Error> {
|
||||||
let _data: &ListCategories = &self.data;
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -253,7 +253,7 @@ impl Perform<LoginResponse> for Oper<Register> {
|
||||||
// Register the new user
|
// Register the new user
|
||||||
let user_form = UserForm {
|
let user_form = UserForm {
|
||||||
name: data.username.to_owned(),
|
name: data.username.to_owned(),
|
||||||
fedi_name: Settings::get().hostname.to_owned(),
|
fedi_name: Settings::get().hostname,
|
||||||
email: data.email.to_owned(),
|
email: data.email.to_owned(),
|
||||||
matrix_user_id: None,
|
matrix_user_id: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
|
|
|
@ -112,7 +112,7 @@ pub fn send_email(
|
||||||
to_username: &str,
|
to_username: &str,
|
||||||
html: &str,
|
html: &str,
|
||||||
) -> Result<(), String> {
|
) -> 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()
|
let email = Email::builder()
|
||||||
.to((to_email, to_username))
|
.to((to_email, to_username))
|
||||||
|
@ -127,7 +127,7 @@ pub fn send_email(
|
||||||
} else {
|
} else {
|
||||||
SmtpClient::new(&email_config.smtp_server, ClientSecurity::None).unwrap()
|
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)
|
.smtp_utf8(true)
|
||||||
.authentication_mechanism(Mechanism::Plain)
|
.authentication_mechanism(Mechanism::Plain)
|
||||||
.connection_reuse(ConnectionReuseParameters::ReuseUnlimited);
|
.connection_reuse(ConnectionReuseParameters::ReuseUnlimited);
|
||||||
|
|
|
@ -39,6 +39,7 @@ async fn main() -> io::Result<()> {
|
||||||
|
|
||||||
// Create Http server with websocket support
|
// Create Http server with websocket support
|
||||||
HttpServer::new(move || {
|
HttpServer::new(move || {
|
||||||
|
let settings = Settings::get();
|
||||||
App::new()
|
App::new()
|
||||||
.wrap(middleware::Logger::default())
|
.wrap(middleware::Logger::default())
|
||||||
.data(pool.clone())
|
.data(pool.clone())
|
||||||
|
@ -58,7 +59,7 @@ async fn main() -> io::Result<()> {
|
||||||
))
|
))
|
||||||
.service(actix_files::Files::new(
|
.service(actix_files::Files::new(
|
||||||
"/docs",
|
"/docs",
|
||||||
settings.front_end_dir.to_owned() + "/documentation",
|
settings.front_end_dir + "/documentation",
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.bind((settings.bind, settings.port))?
|
.bind((settings.bind, settings.port))?
|
||||||
|
|
|
@ -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::post().to(route_post::<CreateSite, SiteResponse>))
|
||||||
.route("/api/v1/site", web::put().to(route_post::<EditSite, 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/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/admin/add", web::post().to(route_post::<AddAdmin, AddAdminResponse>))
|
||||||
.route("/api/v1/user/ban", web::post().to(route_post::<BanUser, BanUserResponse>))
|
.route("/api/v1/user/ban", web::post().to(route_post::<BanUser, BanUserResponse>))
|
||||||
// User account actions
|
// 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/community/{community_id}", web::get().to(index))
|
||||||
.route("/modlog", web::get().to(index))
|
.route("/modlog", web::get().to(index))
|
||||||
.route("/setup", web::get().to(index))
|
.route("/setup", web::get().to(index))
|
||||||
|
.route("/admin", web::get().to(index))
|
||||||
.route(
|
.route(
|
||||||
"/search/q/{q}/type/{type}/sort/{sort}/page/{page}",
|
"/search/q/{q}/type/{type}/sort/{sort}/page/{page}",
|
||||||
web::get().to(index),
|
web::get().to(index),
|
||||||
|
@ -44,6 +45,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||||
|
|
||||||
async fn index() -> Result<NamedFile, actix_web::error::Error> {
|
async fn index() -> Result<NamedFile, actix_web::error::Error> {
|
||||||
Ok(NamedFile::open(
|
Ok(NamedFile::open(
|
||||||
Settings::get().front_end_dir.to_owned() + "/index.html",
|
Settings::get().front_end_dir + "/index.html",
|
||||||
)?)
|
)?)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
use config::{Config, ConfigError, Environment, File};
|
use config::{Config, ConfigError, Environment, File};
|
||||||
|
use failure::Error;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
static CONFIG_FILE_DEFAULTS: &str = "config/defaults.hjson";
|
static CONFIG_FILE_DEFAULTS: &str = "config/defaults.hjson";
|
||||||
static CONFIG_FILE: &str = "config/config.hjson";
|
static CONFIG_FILE: &str = "config/config.hjson";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub setup: Option<Setup>,
|
pub setup: Option<Setup>,
|
||||||
pub database: Database,
|
pub database: Database,
|
||||||
|
@ -20,7 +23,7 @@ pub struct Settings {
|
||||||
pub federation_enabled: bool,
|
pub federation_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct Setup {
|
pub struct Setup {
|
||||||
pub admin_username: String,
|
pub admin_username: String,
|
||||||
pub admin_password: String,
|
pub admin_password: String,
|
||||||
|
@ -28,7 +31,7 @@ pub struct Setup {
|
||||||
pub site_name: String,
|
pub site_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct RateLimitConfig {
|
pub struct RateLimitConfig {
|
||||||
pub message: i32,
|
pub message: i32,
|
||||||
pub message_per_second: i32,
|
pub message_per_second: i32,
|
||||||
|
@ -38,7 +41,7 @@ pub struct RateLimitConfig {
|
||||||
pub register_per_second: i32,
|
pub register_per_second: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct EmailConfig {
|
pub struct EmailConfig {
|
||||||
pub smtp_server: String,
|
pub smtp_server: String,
|
||||||
pub smtp_login: Option<String>,
|
pub smtp_login: Option<String>,
|
||||||
|
@ -47,7 +50,7 @@ pub struct EmailConfig {
|
||||||
pub use_tls: bool,
|
pub use_tls: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct Database {
|
pub struct Database {
|
||||||
pub user: String,
|
pub user: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
@ -58,12 +61,10 @@ pub struct Database {
|
||||||
}
|
}
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref SETTINGS: Settings = {
|
static ref SETTINGS: RwLock<Settings> = RwLock::new(match Settings::init() {
|
||||||
match Settings::init() {
|
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => panic!("{}", e),
|
Err(e) => panic!("{}", e),
|
||||||
}
|
});
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
|
@ -89,8 +90,8 @@ impl Settings {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the config as a struct.
|
/// Returns the config as a struct.
|
||||||
pub fn get() -> &'static Self {
|
pub fn get() -> Self {
|
||||||
&SETTINGS
|
SETTINGS.read().unwrap().to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the postgres connection url. If LEMMY_DATABASE_URL is set, that is used,
|
/// 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 {
|
pub fn api_endpoint(&self) -> String {
|
||||||
format!("{}/api/v1", self.hostname)
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,4 +46,6 @@ pub enum UserOperation {
|
||||||
GetPrivateMessages,
|
GetPrivateMessages,
|
||||||
UserJoin,
|
UserJoin,
|
||||||
GetComments,
|
GetComments,
|
||||||
|
GetSiteConfig,
|
||||||
|
SaveSiteConfig,
|
||||||
}
|
}
|
||||||
|
|
|
@ -708,6 +708,16 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
|
||||||
res.online = chat.sessions.len();
|
res.online = chat.sessions.len();
|
||||||
to_json_string(&user_operation, &res)
|
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 => {
|
UserOperation::Search => {
|
||||||
do_user_operation::<Search, SearchResponse>(user_operation, data, &conn)
|
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,
|
getUnixTime,
|
||||||
canMod,
|
canMod,
|
||||||
isMod,
|
isMod,
|
||||||
pictshareAvatarThumbnail,
|
|
||||||
showAvatars,
|
|
||||||
setupTippy,
|
setupTippy,
|
||||||
colorList,
|
colorList,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
@ -33,6 +31,7 @@ import moment from 'moment';
|
||||||
import { MomentTime } from './moment-time';
|
import { MomentTime } from './moment-time';
|
||||||
import { CommentForm } from './comment-form';
|
import { CommentForm } from './comment-form';
|
||||||
import { CommentNodes } from './comment-nodes';
|
import { CommentNodes } from './comment-nodes';
|
||||||
|
import { UserListing } from './user-listing';
|
||||||
import { i18n } from '../i18next';
|
import { i18n } from '../i18next';
|
||||||
|
|
||||||
interface CommentNodeState {
|
interface CommentNodeState {
|
||||||
|
@ -148,20 +147,14 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
|
||||||
'ml-2'}`}
|
'ml-2'}`}
|
||||||
>
|
>
|
||||||
<div class="d-flex flex-wrap align-items-center mb-1 mt-1 text-muted small">
|
<div class="d-flex flex-wrap align-items-center mb-1 mt-1 text-muted small">
|
||||||
<Link
|
<span class="mr-2">
|
||||||
className="mr-2 text-body font-weight-bold"
|
<UserListing
|
||||||
to={`/u/${node.comment.creator_name}`}
|
user={{
|
||||||
>
|
name: node.comment.creator_name,
|
||||||
{node.comment.creator_avatar && showAvatars() && (
|
avatar: node.comment.creator_avatar,
|
||||||
<img
|
}}
|
||||||
height="32"
|
|
||||||
width="32"
|
|
||||||
src={pictshareAvatarThumbnail(node.comment.creator_avatar)}
|
|
||||||
class="rounded-circle mr-1"
|
|
||||||
/>
|
/>
|
||||||
)}
|
</span>
|
||||||
<span>{node.comment.creator_name}</span>
|
|
||||||
</Link>
|
|
||||||
{this.isMod && (
|
{this.isMod && (
|
||||||
<div className="badge badge-light d-none d-sm-inline mr-2">
|
<div className="badge badge-light d-none d-sm-inline mr-2">
|
||||||
{i18n.t('mod')}
|
{i18n.t('mod')}
|
||||||
|
@ -191,7 +184,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div
|
<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)}
|
onClick={linkEvent(this, this.handleCommentCollapse)}
|
||||||
>
|
>
|
||||||
{this.state.collapsed ? (
|
{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 { ListingTypeSelect } from './listing-type-select';
|
||||||
import { DataTypeSelect } from './data-type-select';
|
import { DataTypeSelect } from './data-type-select';
|
||||||
import { SiteForm } from './site-form';
|
import { SiteForm } from './site-form';
|
||||||
|
import { UserListing } from './user-listing';
|
||||||
import {
|
import {
|
||||||
wsJsonToRes,
|
wsJsonToRes,
|
||||||
repoUrl,
|
repoUrl,
|
||||||
mdToHtml,
|
mdToHtml,
|
||||||
fetchLimit,
|
fetchLimit,
|
||||||
pictshareAvatarThumbnail,
|
|
||||||
showAvatars,
|
|
||||||
toast,
|
toast,
|
||||||
getListingTypeFromProps,
|
getListingTypeFromProps,
|
||||||
getPageFromProps,
|
getPageFromProps,
|
||||||
|
@ -316,20 +315,12 @@ export class Main extends Component<any, MainState> {
|
||||||
<li class="list-inline-item">{i18n.t('admins')}:</li>
|
<li class="list-inline-item">{i18n.t('admins')}:</li>
|
||||||
{this.state.siteRes.admins.map(admin => (
|
{this.state.siteRes.admins.map(admin => (
|
||||||
<li class="list-inline-item">
|
<li class="list-inline-item">
|
||||||
<Link
|
<UserListing
|
||||||
class="text-body font-weight-bold"
|
user={{
|
||||||
to={`/u/${admin.name}`}
|
name: admin.name,
|
||||||
>
|
avatar: admin.avatar,
|
||||||
{admin.avatar && showAvatars() && (
|
}}
|
||||||
<img
|
|
||||||
height="32"
|
|
||||||
width="32"
|
|
||||||
src={pictshareAvatarThumbnail(admin.avatar)}
|
|
||||||
class="rounded-circle mr-1"
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<span>{admin.name}</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -619,6 +610,7 @@ export class Main extends Component<any, MainState> {
|
||||||
this.state.siteRes.site = data.site;
|
this.state.siteRes.site = data.site;
|
||||||
this.state.showEditSite = false;
|
this.state.showEditSite = false;
|
||||||
this.setState(this.state);
|
this.setState(this.state);
|
||||||
|
toast(i18n.t('site_saved'));
|
||||||
} else if (res.op == UserOperation.GetPosts) {
|
} else if (res.op == UserOperation.GetPosts) {
|
||||||
let data = res.data as GetPostsResponse;
|
let data = res.data as GetPostsResponse;
|
||||||
this.state.posts = data.posts;
|
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,
|
Comment,
|
||||||
CommentResponse,
|
CommentResponse,
|
||||||
PrivateMessage,
|
PrivateMessage,
|
||||||
|
UserView,
|
||||||
PrivateMessageResponse,
|
PrivateMessageResponse,
|
||||||
WebSocketJsonResponse,
|
WebSocketJsonResponse,
|
||||||
} from '../interfaces';
|
} from '../interfaces';
|
||||||
|
@ -40,6 +41,7 @@ interface NavbarState {
|
||||||
messages: Array<PrivateMessage>;
|
messages: Array<PrivateMessage>;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
siteName: string;
|
siteName: string;
|
||||||
|
admins: Array<UserView>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Navbar extends Component<any, NavbarState> {
|
export class Navbar extends Component<any, NavbarState> {
|
||||||
|
@ -53,6 +55,7 @@ export class Navbar extends Component<any, NavbarState> {
|
||||||
messages: [],
|
messages: [],
|
||||||
expanded: false,
|
expanded: false,
|
||||||
siteName: undefined,
|
siteName: undefined,
|
||||||
|
admins: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(props: any, context: any) {
|
constructor(props: any, context: any) {
|
||||||
|
@ -179,6 +182,19 @@ export class Navbar extends Component<any, NavbarState> {
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="navbar-nav ml-auto">
|
<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 ? (
|
{this.state.isLoggedIn ? (
|
||||||
<>
|
<>
|
||||||
<li className="nav-item mt-1">
|
<li className="nav-item mt-1">
|
||||||
|
@ -298,7 +314,10 @@ export class Navbar extends Component<any, NavbarState> {
|
||||||
|
|
||||||
if (data.site && !this.state.siteName) {
|
if (data.site && !this.state.siteName) {
|
||||||
this.state.siteName = data.site.name;
|
this.state.siteName = data.site.name;
|
||||||
|
this.state.admins = data.admins;
|
||||||
WebSocketService.Instance.site = data.site;
|
WebSocketService.Instance.site = data.site;
|
||||||
|
WebSocketService.Instance.admins = data.admins;
|
||||||
|
|
||||||
this.setState(this.state);
|
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() {
|
requestNotificationPermission() {
|
||||||
if (UserService.Instance.user) {
|
if (UserService.Instance.user) {
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
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 { MomentTime } from './moment-time';
|
||||||
import { PostForm } from './post-form';
|
import { PostForm } from './post-form';
|
||||||
import { IFramelyCard } from './iframely-card';
|
import { IFramelyCard } from './iframely-card';
|
||||||
|
import { UserListing } from './user-listing';
|
||||||
import {
|
import {
|
||||||
md,
|
md,
|
||||||
mdToHtml,
|
mdToHtml,
|
||||||
|
@ -27,8 +28,6 @@ import {
|
||||||
isImage,
|
isImage,
|
||||||
isVideo,
|
isVideo,
|
||||||
getUnixTime,
|
getUnixTime,
|
||||||
pictshareAvatarThumbnail,
|
|
||||||
showAvatars,
|
|
||||||
pictshareImage,
|
pictshareImage,
|
||||||
setupTippy,
|
setupTippy,
|
||||||
previewLines,
|
previewLines,
|
||||||
|
@ -417,20 +416,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
|
||||||
<ul class="list-inline mb-0 text-muted small">
|
<ul class="list-inline mb-0 text-muted small">
|
||||||
<li className="list-inline-item">
|
<li className="list-inline-item">
|
||||||
<span>{i18n.t('by')} </span>
|
<span>{i18n.t('by')} </span>
|
||||||
<Link
|
<UserListing
|
||||||
className="text-body font-weight-bold"
|
user={{
|
||||||
to={`/u/${post.creator_name}`}
|
name: post.creator_name,
|
||||||
>
|
avatar: post.creator_avatar,
|
||||||
{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>
|
|
||||||
{this.isMod && (
|
{this.isMod && (
|
||||||
<span className="mx-1 badge badge-light">
|
<span className="mx-1 badge badge-light">
|
||||||
{i18n.t('mod')}
|
{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,
|
capitalizeFirstLetter,
|
||||||
markdownHelpUrl,
|
markdownHelpUrl,
|
||||||
mdToHtml,
|
mdToHtml,
|
||||||
showAvatars,
|
|
||||||
pictshareAvatarThumbnail,
|
|
||||||
wsJsonToRes,
|
wsJsonToRes,
|
||||||
toast,
|
toast,
|
||||||
randomStr,
|
randomStr,
|
||||||
setupTribute,
|
setupTribute,
|
||||||
setupTippy,
|
setupTippy,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
import { UserListing } from './user-listing';
|
||||||
import Tribute from 'tributejs/src/Tribute.js';
|
import Tribute from 'tributejs/src/Tribute.js';
|
||||||
import autosize from 'autosize';
|
import autosize from 'autosize';
|
||||||
import { i18n } from '../i18next';
|
import { i18n } from '../i18next';
|
||||||
|
@ -132,22 +131,12 @@ export class PrivateMessageForm extends Component<
|
||||||
|
|
||||||
{this.state.recipient && (
|
{this.state.recipient && (
|
||||||
<div class="col-sm-10 form-control-plaintext">
|
<div class="col-sm-10 form-control-plaintext">
|
||||||
<Link
|
<UserListing
|
||||||
className="text-body font-weight-bold"
|
user={{
|
||||||
to={`/u/${this.state.recipient.name}`}
|
name: this.state.recipient.name,
|
||||||
>
|
avatar: this.state.recipient.avatar,
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 class="border-top border-light">
|
||||||
<div>
|
<div>
|
||||||
<ul class="list-inline mb-0 text-muted small">
|
<ul class="list-inline mb-0 text-muted small">
|
||||||
|
{/* TODO refactor this */}
|
||||||
<li className="list-inline-item">
|
<li className="list-inline-item">
|
||||||
{this.mine ? i18n.t('to') : i18n.t('from')}
|
{this.mine ? i18n.t('to') : i18n.t('from')}
|
||||||
</li>
|
</li>
|
||||||
|
|
21
ui/src/components/search.tsx
vendored
21
ui/src/components/search.tsx
vendored
|
@ -30,6 +30,7 @@ import {
|
||||||
commentsToFlatNodes,
|
commentsToFlatNodes,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import { PostListing } from './post-listing';
|
import { PostListing } from './post-listing';
|
||||||
|
import { UserListing } from './user-listing';
|
||||||
import { SortSelect } from './sort-select';
|
import { SortSelect } from './sort-select';
|
||||||
import { CommentNodes } from './comment-nodes';
|
import { CommentNodes } from './comment-nodes';
|
||||||
import { i18n } from '../i18next';
|
import { i18n } from '../i18next';
|
||||||
|
@ -266,22 +267,12 @@ export class Search extends Component<any, SearchState> {
|
||||||
{i.type_ == 'users' && (
|
{i.type_ == 'users' && (
|
||||||
<div>
|
<div>
|
||||||
<span>
|
<span>
|
||||||
<Link
|
<UserListing
|
||||||
className="text-info"
|
user={{
|
||||||
to={`/u/${(i.data as UserView).name}`}
|
name: (i.data as UserView).name,
|
||||||
>
|
avatar: (i.data as UserView).avatar,
|
||||||
{(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>
|
|
||||||
</span>
|
</span>
|
||||||
<span>{` - ${
|
<span>{` - ${
|
||||||
(i.data as UserView).comment_score
|
(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,
|
showAvatars,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import { CommunityForm } from './community-form';
|
import { CommunityForm } from './community-form';
|
||||||
|
import { UserListing } from './user-listing';
|
||||||
import { i18n } from '../i18next';
|
import { i18n } from '../i18next';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
|
@ -204,20 +205,12 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
|
||||||
<li class="list-inline-item">{i18n.t('mods')}: </li>
|
<li class="list-inline-item">{i18n.t('mods')}: </li>
|
||||||
{this.props.moderators.map(mod => (
|
{this.props.moderators.map(mod => (
|
||||||
<li class="list-inline-item">
|
<li class="list-inline-item">
|
||||||
<Link
|
<UserListing
|
||||||
class="text-body font-weight-bold"
|
user={{
|
||||||
to={`/u/${mod.user_name}`}
|
name: mod.user_name,
|
||||||
>
|
avatar: mod.avatar,
|
||||||
{mod.avatar && showAvatars() && (
|
}}
|
||||||
<img
|
|
||||||
height="32"
|
|
||||||
width="32"
|
|
||||||
src={pictshareAvatarThumbnail(mod.avatar)}
|
|
||||||
class="rounded-circle mr-1"
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<span>{mod.user_name}</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Prompt
|
<Prompt
|
||||||
when={
|
when={
|
||||||
!this.state.loading &&
|
!this.state.loading &&
|
||||||
|
!this.props.site &&
|
||||||
(this.state.siteForm.name || this.state.siteForm.description)
|
(this.state.siteForm.name || this.state.siteForm.description)
|
||||||
}
|
}
|
||||||
message={i18n.t('block_leaving')}
|
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 { User } from './components/user';
|
||||||
import { Modlog } from './components/modlog';
|
import { Modlog } from './components/modlog';
|
||||||
import { Setup } from './components/setup';
|
import { Setup } from './components/setup';
|
||||||
|
import { AdminSettings } from './components/admin-settings';
|
||||||
import { Inbox } from './components/inbox';
|
import { Inbox } from './components/inbox';
|
||||||
import { Search } from './components/search';
|
import { Search } from './components/search';
|
||||||
import { Sponsors } from './components/sponsors';
|
import { Sponsors } from './components/sponsors';
|
||||||
import { Symbols } from './components/symbols';
|
import { Symbols } from './components/symbols';
|
||||||
import { i18n } from './i18next';
|
import { i18n } from './i18next';
|
||||||
|
|
||||||
import { WebSocketService, UserService } from './services';
|
|
||||||
|
|
||||||
const container = document.getElementById('app');
|
const container = document.getElementById('app');
|
||||||
|
|
||||||
class Index extends Component<any, any> {
|
class Index extends Component<any, any> {
|
||||||
constructor(props: any, context: any) {
|
constructor(props: any, context: any) {
|
||||||
super(props, context);
|
super(props, context);
|
||||||
WebSocketService.Instance;
|
|
||||||
UserService.Instance;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<Provider i18next={i18n}>
|
<Provider i18next={i18n}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<div>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div class="mt-4 p-0 fl-1">
|
<div class="mt-4 p-0 fl-1">
|
||||||
<Switch>
|
<Switch>
|
||||||
|
@ -51,9 +49,15 @@ class Index extends Component<any, any> {
|
||||||
path={`/create_private_message`}
|
path={`/create_private_message`}
|
||||||
component={CreatePrivateMessage}
|
component={CreatePrivateMessage}
|
||||||
/>
|
/>
|
||||||
<Route path={`/communities/page/:page`} component={Communities} />
|
<Route
|
||||||
|
path={`/communities/page/:page`}
|
||||||
|
component={Communities}
|
||||||
|
/>
|
||||||
<Route path={`/communities`} 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={`/post/:id`} component={Post} />
|
||||||
<Route
|
<Route
|
||||||
path={`/c/:name/data_type/:data_type/sort/:sort/page/:page`}
|
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={`/modlog`} component={Modlog} />
|
||||||
<Route path={`/setup`} component={Setup} />
|
<Route path={`/setup`} component={Setup} />
|
||||||
|
<Route path={`/admin`} component={AdminSettings} />
|
||||||
<Route
|
<Route
|
||||||
path={`/search/q/:q/type/:type/sort/:sort/page/:page`}
|
path={`/search/q/:q/type/:type/sort/:sort/page/:page`}
|
||||||
component={Search}
|
component={Search}
|
||||||
|
@ -88,6 +93,7 @@ class Index extends Component<any, any> {
|
||||||
<Symbols />
|
<Symbols />
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
</div>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
|
|
22
ui/src/interfaces.ts
vendored
22
ui/src/interfaces.ts
vendored
|
@ -43,6 +43,8 @@ export enum UserOperation {
|
||||||
GetPrivateMessages,
|
GetPrivateMessages,
|
||||||
UserJoin,
|
UserJoin,
|
||||||
GetComments,
|
GetComments,
|
||||||
|
GetSiteConfig,
|
||||||
|
SaveSiteConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum CommentSortType {
|
export enum CommentSortType {
|
||||||
|
@ -102,7 +104,6 @@ export interface UserView {
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
matrix_user_id?: string;
|
matrix_user_id?: string;
|
||||||
fedi_name: string;
|
|
||||||
published: string;
|
published: string;
|
||||||
number_of_posts: number;
|
number_of_posts: number;
|
||||||
post_score: number;
|
post_score: number;
|
||||||
|
@ -699,6 +700,19 @@ export interface SiteForm {
|
||||||
auth?: string;
|
auth?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GetSiteConfig {
|
||||||
|
auth?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetSiteConfigResponse {
|
||||||
|
config_hjson: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteConfigForm {
|
||||||
|
config_hjson: string;
|
||||||
|
auth?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GetSiteResponse {
|
export interface GetSiteResponse {
|
||||||
site: Site;
|
site: Site;
|
||||||
admins: Array<UserView>;
|
admins: Array<UserView>;
|
||||||
|
@ -846,7 +860,8 @@ export type MessageType =
|
||||||
| PasswordChangeForm
|
| PasswordChangeForm
|
||||||
| PrivateMessageForm
|
| PrivateMessageForm
|
||||||
| EditPrivateMessageForm
|
| EditPrivateMessageForm
|
||||||
| GetPrivateMessagesForm;
|
| GetPrivateMessagesForm
|
||||||
|
| SiteConfigForm;
|
||||||
|
|
||||||
type ResponseType =
|
type ResponseType =
|
||||||
| SiteResponse
|
| SiteResponse
|
||||||
|
@ -868,7 +883,8 @@ type ResponseType =
|
||||||
| BanUserResponse
|
| BanUserResponse
|
||||||
| AddAdminResponse
|
| AddAdminResponse
|
||||||
| PrivateMessageResponse
|
| PrivateMessageResponse
|
||||||
| PrivateMessagesResponse;
|
| PrivateMessagesResponse
|
||||||
|
| GetSiteConfigResponse;
|
||||||
|
|
||||||
export interface WebSocketResponse {
|
export interface WebSocketResponse {
|
||||||
op: UserOperation;
|
op: UserOperation;
|
||||||
|
|
13
ui/src/services/WebSocketService.ts
vendored
13
ui/src/services/WebSocketService.ts
vendored
|
@ -40,6 +40,8 @@ import {
|
||||||
GetPrivateMessagesForm,
|
GetPrivateMessagesForm,
|
||||||
GetCommentsForm,
|
GetCommentsForm,
|
||||||
UserJoinForm,
|
UserJoinForm,
|
||||||
|
GetSiteConfig,
|
||||||
|
SiteConfigForm,
|
||||||
MessageType,
|
MessageType,
|
||||||
WebSocketJsonResponse,
|
WebSocketJsonResponse,
|
||||||
} from '../interfaces';
|
} from '../interfaces';
|
||||||
|
@ -268,6 +270,12 @@ export class WebSocketService {
|
||||||
this.ws.send(this.wsSendWrapper(UserOperation.GetSite, {}));
|
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) {
|
public search(form: SearchForm) {
|
||||||
this.setAuth(form, false);
|
this.setAuth(form, false);
|
||||||
this.ws.send(this.wsSendWrapper(UserOperation.Search, form));
|
this.ws.send(this.wsSendWrapper(UserOperation.Search, form));
|
||||||
|
@ -314,6 +322,11 @@ export class WebSocketService {
|
||||||
this.ws.send(this.wsSendWrapper(UserOperation.GetPrivateMessages, form));
|
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) {
|
private wsSendWrapper(op: UserOperation, data: MessageType) {
|
||||||
let send = { op: UserOperation[op], data: data };
|
let send = { op: UserOperation[op], data: data };
|
||||||
console.log(send);
|
console.log(send);
|
||||||
|
|
4
ui/translations/en.json
vendored
4
ui/translations/en.json
vendored
|
@ -53,6 +53,8 @@
|
||||||
"mods": "mods",
|
"mods": "mods",
|
||||||
"moderates": "Moderates",
|
"moderates": "Moderates",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
"admin_settings": "Admin Settings",
|
||||||
|
"site_config": "Site Configuration",
|
||||||
"remove_as_mod": "remove as mod",
|
"remove_as_mod": "remove as mod",
|
||||||
"appoint_as_mod": "appoint as mod",
|
"appoint_as_mod": "appoint as mod",
|
||||||
"modlog": "Modlog",
|
"modlog": "Modlog",
|
||||||
|
@ -78,6 +80,7 @@
|
||||||
"unban": "unban",
|
"unban": "unban",
|
||||||
"unban_from_site": "unban from site",
|
"unban_from_site": "unban from site",
|
||||||
"banned": "banned",
|
"banned": "banned",
|
||||||
|
"banned_users": "Banned Users",
|
||||||
"save": "save",
|
"save": "save",
|
||||||
"unsave": "unsave",
|
"unsave": "unsave",
|
||||||
"create": "create",
|
"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>.",
|
"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.",
|
"not_logged_in": "Not logged in.",
|
||||||
"logged_in": "Logged in.",
|
"logged_in": "Logged in.",
|
||||||
|
"site_saved": "Site Saved.",
|
||||||
"community_ban": "You have been banned from this community.",
|
"community_ban": "You have been banned from this community.",
|
||||||
"site_ban": "You have been banned from the site",
|
"site_ban": "You have been banned from the site",
|
||||||
"couldnt_create_comment": "Couldn't create comment.",
|
"couldnt_create_comment": "Couldn't create comment.",
|
||||||
|
|
Loading…
Reference in a new issue