1
0
Fork 0
mirror of https://github.com/Nutomic/ibis.git synced 2024-12-05 02:01:08 +00:00

Merge branch 'leptos-0.7'

This commit is contained in:
Felix Ableitner 2024-11-25 12:04:46 +01:00
commit 34cbfe8cc9
33 changed files with 1223 additions and 812 deletions

1188
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -29,14 +29,9 @@ ssr = [
"config", "config",
"tower", "tower",
"tower-layer", "tower-layer",
"reqwest","diesel-derive-newtype"
] ]
hydrate = [ hydrate = ["leptos/hydrate", "katex/wasm-js", "gloo-net"]
"leptos/hydrate",
"leptos_meta/hydrate",
"leptos_router/hydrate",
"katex/wasm-js",
]
diesel-derive-newtype = ["dep:diesel-derive-newtype"]
# This profile significantly speeds up build time. If debug info is needed you can comment the line # This profile significantly speeds up build time. If debug info is needed you can comment the line
# out temporarily, but make sure to leave this in the main branch. # out temporarily, but make sure to leave this in the main branch.
@ -60,24 +55,28 @@ dbg_macro = "deny"
unwrap_used = "deny" unwrap_used = "deny"
[dependencies] [dependencies]
anyhow = "1.0.89" anyhow = "1.0.93"
leptos = "0.6.15" leptos = "0.7.0-rc2"
leptos_meta = "0.6.15" leptos_meta = "0.7.0-rc2"
leptos_router = "0.6.15" leptos_router = "0.7.0-rc2"
chrono = { version = "0.4.38", features = ["serde"] } chrono = { version = "0.4.38", features = ["serde"] }
env_logger = { version = "0.11.5", default-features = false } env_logger = { version = "0.11.5", default-features = false }
futures = "0.3.30" futures = "0.3.31"
hex = "0.4.3" hex = "0.4.3"
rand = "0.8.5" rand = "0.8.5"
serde_json = "1.0.128" getrandom = { version = "0.2", features = ["js"] }
serde_json = "1.0.132"
sha2 = "0.10.8" sha2 = "0.10.8"
uuid = { version = "1.10.0", features = ["serde"] } uuid = { version = "1.11.0", features = ["serde"] }
serde = { version = "1.0.210", features = ["derive"] } serde = { version = "1.0.215", features = ["derive"] }
url = { version = "2.5.2", features = ["serde"] } url = { version = "2.5.3", features = ["serde"] }
reqwest = { version = "0.12.8", features = ["json", "cookies"] } reqwest = { version = "0.12.9", features = [
"json",
"cookies",
], optional = true }
log = "0.4" log = "0.4"
tracing = "0.1.40" tracing = "0.1.40"
once_cell = "1.20.1" once_cell = "1.20.2"
console_error_panic_hook = "0.1.7" console_error_panic_hook = "0.1.7"
time = "0.3.36" time = "0.3.36"
markdown-it = "0.6.1" markdown-it = "0.6.1"
@ -88,14 +87,15 @@ markdown-it-heading-anchors = "0.3.0"
markdown-it-footnote = "0.2.0" markdown-it-footnote = "0.2.0"
markdown-it-sub = "1.0.0" markdown-it-sub = "1.0.0"
markdown-it-sup = "1.0.0" markdown-it-sup = "1.0.0"
leptos-use = "0.13.6" leptos-use = "0.14.0-rc3"
codee = "0.2.0" codee = "0.2.0"
wasm-bindgen = "=0.2.95"
# backend-only features # backend-only features
axum = { version = "0.7.7", optional = true } axum = { version = "0.7.7", optional = true }
axum-macros = { version = "0.4.2", optional = true } axum-macros = { version = "0.4.2", optional = true }
axum-extra = { version = "0.9.4", features = ["cookie"], optional = true } axum-extra = { version = "0.9.4", features = ["cookie"], optional = true }
tokio = { version = "1.40.0", features = ["full"], optional = true } tokio = { version = "1.41.1", features = ["full"], optional = true }
tower-http = { version = "0.6.1", features = ["cors", "fs"], optional = true } tower-http = { version = "0.6.1", features = ["cors", "fs"], optional = true }
activitypub_federation = { version = "0.6.0", features = [ activitypub_federation = { version = "0.6.0", features = [
"axum", "axum",
@ -111,15 +111,20 @@ diesel-derive-newtype = { version = "2.1.2", optional = true }
diesel_migrations = { version = "2.2.0", optional = true } diesel_migrations = { version = "2.2.0", optional = true }
doku = { version = "0.21.1", optional = true } doku = { version = "0.21.1", optional = true }
jsonwebtoken = { version = "9.3.0", optional = true } jsonwebtoken = { version = "9.3.0", optional = true }
leptos_axum = { version = "0.6.15", optional = true } leptos_axum = { version = "0.7.0-rc2", optional = true }
bcrypt = { version = "0.15.1", optional = true } bcrypt = { version = "0.15.1", optional = true }
diffy = { version = "0.4.0", optional = true } diffy = { version = "0.4.0", optional = true }
enum_delegate = { version = "0.2.0", optional = true } enum_delegate = { version = "0.2.0", optional = true }
async-trait = { version = "0.1.83", optional = true } async-trait = { version = "0.1.83", optional = true }
config = { version = "0.14.0", features = ["toml"], optional = true } config = { version = "0.14.1", features = ["toml"], optional = true }
tower = { version = "0.5.1", optional = true } tower = { version = "0.5.1", optional = true }
tower-layer = { version = "0.3.3", optional = true } tower-layer = { version = "0.3.3", optional = true }
console_log = "1.0.0" console_log = "1.0.0"
send_wrapper = "0.6.0"
gloo-net = { version = "0.6.0", optional = true }
web-sys = "0.3.72"
http = "1.1.0"
serde_urlencoded = "0.7.1"
[dev-dependencies] [dev-dependencies]
pretty_assertions = "1.4.1" pretty_assertions = "1.4.1"

View file

@ -1,6 +1,14 @@
use crate::{ use crate::{
backend::{database::IbisData, error::MyResult, federation::activities::follow::Follow}, backend::{database::IbisData, error::MyResult, federation::activities::follow::Follow},
common::{DbInstance, FollowInstance, GetInstance, InstanceView, LocalUserView, ResolveObject}, common::{
DbInstance,
FollowInstance,
FollowInstanceResponse,
GetInstance,
InstanceView,
LocalUserView,
ResolveObject,
},
}; };
use activitypub_federation::{config::Data, fetch::object_id::ObjectId}; use activitypub_federation::{config::Data, fetch::object_id::ObjectId};
use axum::{extract::Query, Extension, Form, Json}; use axum::{extract::Query, Extension, Form, Json};
@ -23,13 +31,13 @@ pub(in crate::backend::api) async fn follow_instance(
Extension(user): Extension<LocalUserView>, Extension(user): Extension<LocalUserView>,
data: Data<IbisData>, data: Data<IbisData>,
Form(query): Form<FollowInstance>, Form(query): Form<FollowInstance>,
) -> MyResult<()> { ) -> MyResult<Json<FollowInstanceResponse>> {
let target = DbInstance::read(query.id, &data)?; let target = DbInstance::read(query.id, &data)?;
let pending = !target.local; let pending = !target.local;
DbInstance::follow(&user.person, &target, pending, &data)?; DbInstance::follow(&user.person, &target, pending, &data)?;
let instance = DbInstance::read(query.id, &data)?; let instance = DbInstance::read(query.id, &data)?;
Follow::send(user.person, &instance, &data).await?; Follow::send(user.person, &instance, &data).await?;
Ok(()) Ok(Json(FollowInstanceResponse { success: true }))
} }
/// Fetch a remote instance actor. This automatically synchronizes the remote articles collection to /// Fetch a remote instance actor. This automatically synchronizes the remote articles collection to

View file

@ -5,7 +5,7 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use axum_macros::debug_handler; use axum_macros::debug_handler;
use leptos::LeptosOptions; use leptos::prelude::*;
use tower::ServiceExt; use tower::ServiceExt;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;

View file

@ -16,7 +16,7 @@ use crate::{
AUTH_COOKIE, AUTH_COOKIE,
MAIN_PAGE_NAME, MAIN_PAGE_NAME,
}, },
frontend::app::App, frontend::app::{shell, App},
}; };
use activitypub_federation::{ use activitypub_federation::{
config::{Data, FederationConfig, FederationMiddleware}, config::{Data, FederationConfig, FederationMiddleware},
@ -47,7 +47,7 @@ use federation::objects::{
articles_collection::local_articles_url, articles_collection::local_articles_url,
instance_collection::linked_instances_url, instance_collection::linked_instances_url,
}; };
use leptos::*; use leptos::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes}; use leptos_axum::{generate_route_list, LeptosRoutes};
use log::info; use log::info;
use std::net::SocketAddr; use std::net::SocketAddr;
@ -93,7 +93,7 @@ pub async fn start(config: IbisConfig, override_hostname: Option<SocketAddr>) ->
setup(&data.to_request_data()).await?; setup(&data.to_request_data()).await?;
} }
let leptos_options = get_configuration(Some("Cargo.toml")).await?.leptos_options; let leptos_options = get_configuration(Some("Cargo.toml"))?.leptos_options;
let mut addr = leptos_options.site_addr; let mut addr = leptos_options.site_addr;
if let Some(override_hostname) = override_hostname { if let Some(override_hostname) = override_hostname {
addr = override_hostname; addr = override_hostname;
@ -102,7 +102,6 @@ pub async fn start(config: IbisConfig, override_hostname: Option<SocketAddr>) ->
let config = data.clone(); let config = data.clone();
let app = Router::new() let app = Router::new()
//.leptos_routes(&leptos_options, routes, App)
.leptos_routes_with_handler(routes, get(leptos_routes_handler)) .leptos_routes_with_handler(routes, get(leptos_routes_handler))
.fallback(file_and_error_handler) .fallback(file_and_error_handler)
.with_state(leptos_options) .with_state(leptos_options)
@ -127,16 +126,15 @@ pub async fn start(config: IbisConfig, override_hostname: Option<SocketAddr>) ->
/// Make auth token available in hydrate mode /// Make auth token available in hydrate mode
async fn leptos_routes_handler( async fn leptos_routes_handler(
jar: CookieJar, jar: CookieJar,
State(option): State<LeptosOptions>, State(leptos_options): State<LeptosOptions>,
req: Request<Body>, req: Request<Body>,
) -> Response { ) -> Response {
let handler = leptos_axum::render_app_async_with_context( let handler = leptos_axum::render_app_async_with_context(
option.clone(),
move || { move || {
let cookie = jar.get(AUTH_COOKIE).map(|c| c.value().to_string()); let cookie = jar.get(AUTH_COOKIE).map(|c| c.value().to_string());
provide_context(Auth(cookie)); provide_context(Auth(cookie));
}, },
move || view! { <App /> }, move || shell(leptos_options.clone()),
); );
handler(req).await.into_response() handler(req).await.into_response()

View file

@ -36,7 +36,7 @@ pub struct GetArticleForm {
pub id: Option<ArticleId>, pub id: Option<ArticleId>,
} }
#[derive(Deserialize, Serialize, Clone, Default)] #[derive(Deserialize, Serialize, Clone, Default, Debug)]
pub struct ListArticlesForm { pub struct ListArticlesForm {
pub only_local: Option<bool>, pub only_local: Option<bool>,
pub instance_id: Option<InstanceId>, pub instance_id: Option<InstanceId>,
@ -128,13 +128,13 @@ impl Default for EditVersion {
} }
} }
#[derive(Deserialize, Serialize, Clone)] #[derive(Deserialize, Serialize, Clone, Debug)]
pub struct RegisterUserForm { pub struct RegisterUserForm {
pub username: String, pub username: String,
pub password: String, pub password: String,
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize, Debug)]
pub struct LoginUserForm { pub struct LoginUserForm {
pub username: String, pub username: String,
pub password: String, pub password: String,
@ -188,7 +188,7 @@ impl DbPerson {
} }
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize, Debug)]
pub struct CreateArticleForm { pub struct CreateArticleForm {
pub title: String, pub title: String,
pub text: String, pub text: String,
@ -217,19 +217,19 @@ pub struct ProtectArticleForm {
pub protected: bool, pub protected: bool,
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize, Debug)]
pub struct ForkArticleForm { pub struct ForkArticleForm {
pub article_id: ArticleId, pub article_id: ArticleId,
pub new_title: String, pub new_title: String,
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize, Debug)]
pub struct ApproveArticleForm { pub struct ApproveArticleForm {
pub article_id: ArticleId, pub article_id: ArticleId,
pub approve: bool, pub approve: bool,
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize, Debug)]
pub struct DeleteConflictForm { pub struct DeleteConflictForm {
pub conflict_id: ConflictId, pub conflict_id: ConflictId,
} }
@ -244,12 +244,17 @@ pub struct FollowInstance {
pub id: InstanceId, pub id: InstanceId,
} }
#[derive(Deserialize, Serialize, Clone)] #[derive(Deserialize, Serialize, Debug)]
pub struct FollowInstanceResponse {
pub success: bool,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct SearchArticleForm { pub struct SearchArticleForm {
pub query: String, pub query: String,
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize, Debug)]
pub struct ResolveObject { pub struct ResolveObject {
pub id: Url, pub id: Url,
} }

View file

@ -12,6 +12,7 @@ use crate::{
DeleteConflictForm, DeleteConflictForm,
EditArticleForm, EditArticleForm,
FollowInstance, FollowInstance,
FollowInstanceResponse,
ForkArticleForm, ForkArticleForm,
GetArticleForm, GetArticleForm,
GetInstance, GetInstance,
@ -30,37 +31,36 @@ use crate::{
frontend::error::MyResult, frontend::error::MyResult,
}; };
use anyhow::anyhow; use anyhow::anyhow;
use reqwest::{Client, RequestBuilder, StatusCode}; use http::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::LazyLock; use std::{fmt::Debug, sync::LazyLock};
use url::Url; use url::Url;
pub static CLIENT: LazyLock<ApiClient> = LazyLock::new(|| ApiClient::new(Client::new(), None)); pub static CLIENT: LazyLock<ApiClient> = LazyLock::new(|| {
#[cfg(feature = "ssr")]
{
ApiClient::new(reqwest::Client::new(), None)
}
#[cfg(not(feature = "ssr"))]
{
ApiClient::new()
}
});
#[derive(Clone)] #[derive(Clone)]
pub struct ApiClient { pub struct ApiClient {
client: Client, #[cfg(feature = "ssr")]
client: reqwest::Client,
pub hostname: String, pub hostname: String,
ssl: bool, ssl: bool,
} }
impl ApiClient { impl ApiClient {
pub fn new(client: Client, hostname_: Option<String>) -> Self { #[cfg(feature = "ssr")]
let mut hostname; pub fn new(client: reqwest::Client, hostname_: Option<String>) -> Self {
let ssl; use leptos::config::get_config_from_str;
#[cfg(not(feature = "ssr"))] let leptos_options = get_config_from_str(include_str!("../../Cargo.toml")).unwrap();
{ let mut hostname = leptos_options.site_addr.to_string();
use leptos_use::use_document;
hostname = use_document().location().unwrap().host().unwrap();
ssl = !cfg!(debug_assertions);
}
#[cfg(feature = "ssr")]
{
use leptos::leptos_config::get_config_from_str;
let leptos_options = get_config_from_str(include_str!("../../Cargo.toml")).unwrap();
hostname = leptos_options.site_addr.to_string();
ssl = false;
}
// required for tests // required for tests
if let Some(hostname_) = hostname_ { if let Some(hostname_) = hostname_ {
hostname = hostname_; hostname = hostname_;
@ -68,63 +68,44 @@ impl ApiClient {
Self { Self {
client, client,
hostname, hostname,
ssl, ssl: false,
} }
} }
#[cfg(not(feature = "ssr"))]
async fn get_query<T, R>(&self, endpoint: &str, query: Option<R>) -> MyResult<T> pub fn new() -> Self {
where use leptos_use::use_document;
T: for<'de> Deserialize<'de>, let hostname = use_document().location().unwrap().host().unwrap();
R: Serialize, let ssl = !cfg!(debug_assertions);
{ Self { hostname, ssl }
let mut req = self.client.get(self.request_endpoint(endpoint));
if let Some(query) = query {
req = req.query(&query);
}
handle_json_res::<T>(req).await
} }
pub async fn get_article(&self, data: GetArticleForm) -> MyResult<ArticleView> { pub async fn get_article(&self, data: GetArticleForm) -> MyResult<ArticleView> {
self.get_query("/api/v1/article", Some(data)).await self.get("/api/v1/article", Some(data)).await
} }
pub async fn list_articles(&self, data: ListArticlesForm) -> MyResult<Vec<DbArticle>> { pub async fn list_articles(&self, data: ListArticlesForm) -> MyResult<Vec<DbArticle>> {
self.get_query("/api/v1/article/list", Some(data)).await self.get("/api/v1/article/list", Some(data)).await
} }
pub async fn register(&self, register_form: RegisterUserForm) -> MyResult<LocalUserView> { pub async fn register(&self, register_form: RegisterUserForm) -> MyResult<LocalUserView> {
let req = self self.post("/api/v1/account/register", Some(&register_form))
.client .await
.post(self.request_endpoint("/api/v1/account/register"))
.form(&register_form);
handle_json_res::<LocalUserView>(req).await
} }
pub async fn login(&self, login_form: LoginUserForm) -> MyResult<LocalUserView> { pub async fn login(&self, login_form: LoginUserForm) -> MyResult<LocalUserView> {
let req = self self.post("/api/v1/account/login", Some(&login_form)).await
.client
.post(self.request_endpoint("/api/v1/account/login"))
.form(&login_form);
handle_json_res::<LocalUserView>(req).await
} }
pub async fn create_article(&self, data: &CreateArticleForm) -> MyResult<ArticleView> { pub async fn create_article(&self, data: &CreateArticleForm) -> MyResult<ArticleView> {
let req = self self.send(Method::POST, "/api/v1/article", Some(&data))
.client .await
.post(self.request_endpoint("/api/v1/article"))
.form(data);
handle_json_res(req).await
} }
pub async fn edit_article_with_conflict( pub async fn edit_article_with_conflict(
&self, &self,
edit_form: &EditArticleForm, edit_form: &EditArticleForm,
) -> MyResult<Option<ApiConflict>> { ) -> MyResult<Option<ApiConflict>> {
let req = self self.get("/api/v1/article", Some(&edit_form)).await
.client
.patch(self.request_endpoint("/api/v1/article"))
.form(edit_form);
handle_json_res(req).await
} }
pub async fn edit_article(&self, edit_form: &EditArticleForm) -> MyResult<ArticleView> { pub async fn edit_article(&self, edit_form: &EditArticleForm) -> MyResult<ArticleView> {
@ -140,17 +121,13 @@ impl ApiClient {
} }
pub async fn notifications_list(&self) -> MyResult<Vec<Notification>> { pub async fn notifications_list(&self) -> MyResult<Vec<Notification>> {
let req = self self.get("/api/v1/user/notifications/list", None::<()>)
.client .await
.get(self.request_endpoint("/api/v1/user/notifications/list"));
handle_json_res(req).await
} }
pub async fn notifications_count(&self) -> MyResult<usize> { pub async fn notifications_count(&self) -> MyResult<usize> {
let req = self self.get("/api/v1/user/notifications/count", None::<()>)
.client .await
.get(self.request_endpoint("/api/v1/user/notifications/count"));
handle_json_res(req).await
} }
pub async fn approve_article(&self, article_id: ArticleId, approve: bool) -> MyResult<()> { pub async fn approve_article(&self, article_id: ArticleId, approve: bool) -> MyResult<()> {
@ -158,36 +135,29 @@ impl ApiClient {
article_id, article_id,
approve, approve,
}; };
let req = self self.post("/api/v1/article/approve", Some(&form)).await
.client
.post(self.request_endpoint("/api/v1/article/approve"))
.form(&form);
handle_json_res(req).await
} }
pub async fn delete_conflict(&self, conflict_id: ConflictId) -> MyResult<()> { pub async fn delete_conflict(&self, conflict_id: ConflictId) -> MyResult<()> {
let form = DeleteConflictForm { conflict_id }; let form = DeleteConflictForm { conflict_id };
let req = self self.send(Method::DELETE, "/api/v1/conflict", Some(form))
.client .await
.delete(self.request_endpoint("/api/v1/conflict"))
.form(&form);
handle_json_res(req).await
} }
pub async fn search(&self, search_form: &SearchArticleForm) -> MyResult<Vec<DbArticle>> { pub async fn search(&self, search_form: &SearchArticleForm) -> MyResult<Vec<DbArticle>> {
self.get_query("/api/v1/search", Some(search_form)).await self.get("/api/v1/search", Some(search_form)).await
} }
pub async fn get_local_instance(&self) -> MyResult<InstanceView> { pub async fn get_local_instance(&self) -> MyResult<InstanceView> {
self.get_query("/api/v1/instance", None::<i32>).await self.get("/api/v1/instance", None::<i32>).await
} }
pub async fn get_instance(&self, get_form: &GetInstance) -> MyResult<InstanceView> { pub async fn get_instance(&self, get_form: &GetInstance) -> MyResult<InstanceView> {
self.get_query("/api/v1/instance", Some(get_form)).await self.get("/api/v1/instance", Some(&get_form)).await
} }
pub async fn list_instances(&self) -> MyResult<Vec<DbInstance>> { pub async fn list_instances(&self) -> MyResult<Vec<DbInstance>> {
self.get_query("/api/v1/instance/list", None::<i32>).await self.get("/api/v1/instance/list", None::<i32>).await
} }
pub async fn follow_instance_with_resolve( pub async fn follow_instance_with_resolve(
@ -199,7 +169,7 @@ impl ApiClient {
id: Url::parse(&format!("{}://{}", http_protocol_str(), follow_instance))?, id: Url::parse(&format!("{}://{}", http_protocol_str(), follow_instance))?,
}; };
let instance_resolved: DbInstance = self let instance_resolved: DbInstance = self
.get_query("/api/v1/instance/resolve", Some(resolve_form)) .get("/api/v1/instance/resolve", Some(resolve_form))
.await?; .await?;
// send follow // send follow
@ -210,63 +180,148 @@ impl ApiClient {
Ok(instance_resolved) Ok(instance_resolved)
} }
pub async fn follow_instance(&self, follow_form: FollowInstance) -> MyResult<()> { pub async fn follow_instance(
// cant use post helper because follow doesnt return json &self,
let res = self follow_form: FollowInstance,
.client ) -> MyResult<FollowInstanceResponse> {
.post(self.request_endpoint("/api/v1/instance/follow")) self.post("/api/v1/instance/follow", Some(follow_form))
.form(&follow_form) .await
.send()
.await?;
if res.status() == StatusCode::OK {
Ok(())
} else {
Err(anyhow!("API error: {}", res.text().await?).into())
}
} }
pub async fn site(&self) -> MyResult<SiteView> { pub async fn site(&self) -> MyResult<SiteView> {
let req = self.client.get(self.request_endpoint("/api/v1/site")); self.get("/api/v1/site", None::<()>).await
handle_json_res(req).await
} }
pub async fn logout(&self) -> MyResult<()> { pub async fn logout(&self) -> MyResult<()> {
self.client self.get("/api/v1/account/logout", None::<()>).await
.get(self.request_endpoint("/api/v1/account/logout"))
.send()
.await?;
Ok(())
} }
pub async fn fork_article(&self, form: &ForkArticleForm) -> MyResult<ArticleView> { pub async fn fork_article(&self, form: &ForkArticleForm) -> MyResult<ArticleView> {
let req = self Ok(self.post("/api/v1/article/fork", Some(form)).await.unwrap())
.client
.post(self.request_endpoint("/api/v1/article/fork"))
.form(form);
Ok(handle_json_res(req).await.unwrap())
} }
pub async fn protect_article(&self, params: &ProtectArticleForm) -> MyResult<DbArticle> { pub async fn protect_article(&self, params: &ProtectArticleForm) -> MyResult<DbArticle> {
let req = self self.post("/api/v1/article/protect", Some(params)).await
.client
.post(self.request_endpoint("/api/v1/article/protect"))
.form(params);
handle_json_res(req).await
} }
pub async fn resolve_article(&self, id: Url) -> MyResult<ArticleView> { pub async fn resolve_article(&self, id: Url) -> MyResult<ArticleView> {
let resolve_object = ResolveObject { id }; let resolve_object = ResolveObject { id };
self.get_query("/api/v1/article/resolve", Some(resolve_object)) self.get("/api/v1/article/resolve", Some(resolve_object))
.await .await
} }
pub async fn resolve_instance(&self, id: Url) -> MyResult<DbInstance> { pub async fn resolve_instance(&self, id: Url) -> MyResult<DbInstance> {
let resolve_object = ResolveObject { id }; let resolve_object = ResolveObject { id };
self.get_query("/api/v1/instance/resolve", Some(resolve_object)) self.get("/api/v1/instance/resolve", Some(resolve_object))
.await .await
} }
pub async fn get_user(&self, data: GetUserForm) -> MyResult<DbPerson> { pub async fn get_user(&self, data: GetUserForm) -> MyResult<DbPerson> {
self.get_query("/api/v1/user", Some(data)).await self.get("/api/v1/user", Some(data)).await
}
async fn get<T, R>(&self, endpoint: &str, query: Option<R>) -> MyResult<T>
where
T: for<'de> Deserialize<'de>,
R: Serialize + Debug,
{
self.send(Method::GET, endpoint, query).await
}
async fn post<T, R>(&self, endpoint: &str, query: Option<R>) -> MyResult<T>
where
T: for<'de> Deserialize<'de>,
R: Serialize + Debug,
{
self.send(Method::POST, endpoint, query).await
}
#[cfg(feature = "ssr")]
async fn send<P, T>(&self, method: Method, path: &str, params: Option<P>) -> MyResult<T>
where
P: Serialize + Debug,
T: for<'de> Deserialize<'de>,
{
use crate::common::{Auth, AUTH_COOKIE};
use leptos::prelude::use_context;
use reqwest::header::HeaderName;
let mut req = self
.client
.request(method, self.request_endpoint(path))
.query(&params);
let auth = use_context::<Auth>();
if let Some(Auth(Some(auth))) = auth {
req = req.header(HeaderName::from_static(AUTH_COOKIE), auth);
}
let res = req.send().await?;
let status = res.status();
let text = res.text().await?.to_string();
Self::response(status.into(), text)
}
#[cfg(not(feature = "ssr"))]
fn send<'a, P, T>(
&'a self,
method: Method,
path: &'a str,
params: Option<P>,
) -> impl std::future::Future<Output = MyResult<T>> + Send + 'a
where
P: Serialize + Debug + 'a,
T: for<'de> Deserialize<'de>,
{
use gloo_net::http::*;
use leptos::prelude::on_cleanup;
use send_wrapper::SendWrapper;
use web_sys::RequestCredentials;
SendWrapper::new(async move {
let abort_controller = SendWrapper::new(web_sys::AbortController::new().ok());
let abort_signal = abort_controller.as_ref().map(|a| a.signal());
// abort in-flight requests if, e.g., we've navigated away from this page
on_cleanup(move || {
if let Some(abort_controller) = abort_controller.take() {
abort_controller.abort()
}
});
let path_with_endpoint = self.request_endpoint(path);
let params_encoded = serde_urlencoded::to_string(&params).unwrap();
let path = if method == Method::GET {
// Cannot pass the struct directly but need to convert it manually
// https://github.com/rustwasm/gloo/issues/378
format!("{path_with_endpoint}?{params_encoded}")
} else {
path_with_endpoint
};
let builder = RequestBuilder::new(&path)
.method(method.clone())
.abort_signal(abort_signal.as_ref())
.credentials(RequestCredentials::Include);
let req = if method == Method::POST {
builder
.header("content-type", "application/x-www-form-urlencoded")
.body(params_encoded)
} else {
builder.build()
}
.unwrap();
let res = req.send().await.unwrap();
let status = res.status();
let text = res.text().await.unwrap();
Self::response(status, text)
})
}
fn response<T>(status: u16, text: String) -> MyResult<T>
where
T: for<'de> Deserialize<'de>,
{
if status == StatusCode::OK {
Ok(serde_json::from_str(&text).map_err(|e| anyhow!("Json error on {text}: {e}"))?)
} else {
Err(anyhow!("API error: {text}").into())
}
} }
fn request_endpoint(&self, path: &str) -> String { fn request_endpoint(&self, path: &str) -> String {
@ -274,33 +329,3 @@ impl ApiClient {
format!("{protocol}://{}{path}", &self.hostname) format!("{protocol}://{}{path}", &self.hostname)
} }
} }
async fn handle_json_res<T>(#[allow(unused_mut)] mut req: RequestBuilder) -> MyResult<T>
where
T: for<'de> Deserialize<'de>,
{
#[cfg(not(feature = "ssr"))]
{
req = req.fetch_credentials_include();
}
#[cfg(feature = "ssr")]
{
use crate::common::{Auth, AUTH_COOKIE};
use leptos::use_context;
use reqwest::header::HeaderName;
let auth = use_context::<Auth>();
if let Some(Auth(Some(auth))) = auth {
req = req.header(HeaderName::from_static(AUTH_COOKIE), auth);
}
}
let res = req.send().await?;
let status = res.status();
let text = res.text().await?;
if status == StatusCode::OK {
Ok(serde_json::from_str(&text).map_err(|e| anyhow!("Json error on {text}: {e}"))?)
} else {
Err(anyhow!("API error: {text}").into())
}
}

View file

@ -2,7 +2,7 @@ use crate::{
common::SiteView, common::SiteView,
frontend::{ frontend::{
api::CLIENT, api::CLIENT,
components::nav::Nav, components::{nav::Nav, protected_route::IbisProtectedRoute},
dark_mode::DarkMode, dark_mode::DarkMode,
pages::{ pages::{
article::{ article::{
@ -23,12 +23,15 @@ use crate::{
}, },
}, },
}; };
use leptos::*; use leptos::prelude::*;
use leptos_meta::{provide_meta_context, *}; use leptos_meta::{provide_meta_context, *};
use leptos_router::{Route, Router, Routes}; use leptos_router::{
components::{Route, Router, Routes},
path,
};
pub fn site() -> Resource<(), SiteView> { pub fn site() -> Resource<SiteView> {
use_context::<Resource<(), SiteView>>().unwrap() use_context::<Resource<SiteView>>().unwrap()
} }
pub fn is_logged_in() -> bool { pub fn is_logged_in() -> bool {
@ -43,11 +46,12 @@ pub fn is_admin() -> bool {
}) })
} }
// TODO: can probably get rid of this
pub trait DefaultResource<T> { pub trait DefaultResource<T> {
fn with_default<O>(&self, f: impl FnOnce(&T) -> O) -> O; fn with_default<O>(&self, f: impl FnOnce(&T) -> O) -> O;
} }
impl<T: Default> DefaultResource<T> for Resource<(), T> { impl<T: Default + Send + Sync> DefaultResource<T> for Resource<T> {
fn with_default<O>(&self, f: impl FnOnce(&T) -> O) -> O { fn with_default<O>(&self, f: impl FnOnce(&T) -> O) -> O {
self.with(|x| match x { self.with(|x| match x {
Some(x) => f(x), Some(x) => f(x),
@ -55,46 +59,66 @@ impl<T: Default> DefaultResource<T> for Resource<(), T> {
}) })
} }
} }
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<AutoReload options=options.clone() />
<HydrationScripts options />
<MetaTags />
</head>
<body>
<App />
</body>
</html>
}
}
#[component] #[component]
pub fn App() -> impl IntoView { pub fn App() -> impl IntoView {
// TODO: should create_resource() but then things break
let site_resource =
create_local_resource(move || (), |_| async move { CLIENT.site().await.unwrap() });
provide_context(site_resource);
provide_meta_context(); provide_meta_context();
// TODO: should Resource::new() but then things break
let site_resource = Resource::new(|| (), |_| async move { CLIENT.site().await.unwrap() });
provide_context(site_resource);
let darkmode = DarkMode::init(); let darkmode = DarkMode::init();
provide_context(darkmode.clone()); provide_context(darkmode.clone());
view! { view! {
<Html attr:data-theme=darkmode.theme class="h-full" /> <Html attr:data-theme=darkmode.theme {..} class="h-full" />
<Body class="h-full max-sm:flex max-sm:flex-col" /> <Body {..} class="h-full max-sm:flex max-sm:flex-col" />
<> <>
<Stylesheet id="ibis" href="/pkg/ibis.css" /> <Stylesheet id="ibis" href="/pkg/ibis.css" />
<Stylesheet id="katex" href="/katex.min.css" /> <Stylesheet id="katex" href="/katex.min.css" />
<Router> <Router>
<Nav /> <Nav />
<main class="p-4 md:ml-64"> <main class="p-4 md:ml-64">
<Routes> <Routes fallback=|| "Page not found.".into_view()>
<Route path="/" view=ReadArticle /> <Route path=path!("/") view=ReadArticle />
<Route path="/article/:title" view=ReadArticle /> <Route path=path!("/article/:title") view=ReadArticle />
<Route path="/article/:title/history" view=ArticleHistory /> <Route path=path!("/article/:title/history") view=ArticleHistory />
<Route path="/article/:title/edit/:conflict_id?" view=EditArticle /> <IbisProtectedRoute
<Route path="/article/:title/actions" view=ArticleActions /> path=path!("/article/:title/edit/:conflict_id?")
<Route path="/article/:title/diff/:hash" view=EditDiff /> view=EditArticle
// TODO: use protected route, otherwise user can view />
// /article/create without login <IbisProtectedRoute
// https://github.com/leptos-rs/leptos/blob/leptos_0.7/examples/router/src/lib.rs#L51 path=path!("/article/:title/actions")
<Route path="/article/create" view=CreateArticle /> view=ArticleActions
<Route path="/article/list" view=ListArticles /> />
<Route path="/instance/:hostname" view=InstanceDetails /> <Route path=path!("/article/:title/diff/:hash") view=EditDiff />
<Route path="/instance/list" view=ListInstances /> <IbisProtectedRoute path=path!("/create-article") view=CreateArticle />
<Route path="/user/:name" view=UserProfile /> <Route path=path!("/articles") view=ListArticles />
<Route path="/login" view=Login /> <Route path=path!("/instances") view=ListInstances />
<Route path="/register" view=Register /> <Route path=path!("/instance/:hostname") view=InstanceDetails />
<Route path="/search" view=Search /> <Route path=path!("/user/:name") view=UserProfile />
<Route path="/notifications" view=Notifications /> <Route path=path!("/login") view=Login />
<Route path=path!("/register") view=Register />
<Route path=path!("/search") view=Search />
<IbisProtectedRoute path=path!("/notifications") view=Notifications />
</Routes> </Routes>
</main> </main>
</Router> </Router>

View file

@ -8,8 +8,8 @@ use crate::{
components::instance_follow_button::InstanceFollowButton, components::instance_follow_button::InstanceFollowButton,
}, },
}; };
use leptos::*; use leptos::prelude::*;
use leptos_router::*; use leptos_router::components::A;
pub enum ActiveTab { pub enum ActiveTab {
Read, Read,
@ -19,10 +19,7 @@ pub enum ActiveTab {
} }
#[component] #[component]
pub fn ArticleNav( pub fn ArticleNav(article: Resource<ArticleView>, active_tab: ActiveTab) -> impl IntoView {
article: Resource<Option<String>, ArticleView>,
active_tab: ActiveTab,
) -> impl IntoView {
let tab_classes = tab_classes(&active_tab); let tab_classes = tab_classes(&active_tab);
view! { view! {
@ -32,7 +29,7 @@ pub fn ArticleNav(
.get() .get()
.map(|article_| { .map(|article_| {
let title = article_title(&article_.article); let title = article_title(&article_.article);
let instance = create_resource( let instance = Resource::new(
move || article_.article.instance_id, move || article_.article.instance_id,
move |instance_id| async move { move |instance_id| async move {
let form = GetInstance { let form = GetInstance {
@ -46,24 +43,33 @@ pub fn ArticleNav(
let protected = article_.article.protected; let protected = article_.article.protected;
view! { view! {
<div role="tablist" class="tabs tabs-lifted"> <div role="tablist" class="tabs tabs-lifted">
<A class=tab_classes.read href=article_link.clone()> <A href=article_link.clone() {..} class=tab_classes.read>
"Read" "Read"
</A> </A>
<A class=tab_classes.history href=format!("{article_link}/history")> <A
href=format!("{article_link}/history")
{..}
class=tab_classes.history
>
"History" "History"
</A> </A>
<Show when=move || { <Show when=move || {
is_logged_in() is_logged_in()
&& can_edit_article(&article_.article, is_admin()).is_ok() && can_edit_article(&article_.article, is_admin()).is_ok()
}> }>
<A class=tab_classes.edit href=format!("{article_link}/edit")> <A
href=format!("{article_link}/edit")
{..}
class=tab_classes.edit
>
"Edit" "Edit"
</A> </A>
</Show> </Show>
<Show when=is_logged_in> <Show when=is_logged_in>
<A <A
class=tab_classes.actions
href=format!("{article_link_}/actions") href=format!("{article_link_}/actions")
{..}
class=tab_classes.actions
> >
"Actions" "Actions"
</A> </A>

View file

@ -1,10 +1,21 @@
use crate::frontend::api::CLIENT; use crate::frontend::api::CLIENT;
use leptos::{component, *}; use codee::{Decoder, Encoder};
use leptos::prelude::*;
use std::fmt::Debug;
use url::Url; use url::Url;
#[component] #[component]
pub fn ConnectView<T: Clone + 'static, R: 'static>(res: Resource<T, R>) -> impl IntoView { pub fn ConnectView<T, R>(res: Resource<T, R>) -> impl IntoView
let connect_ibis_wiki = create_action(move |_: &()| async move { where
T: Clone + Send + Sync + 'static,
R: Encoder<T> + Decoder<T> + Send + Sync + 'static,
<R as Encoder<T>>::Error: Debug,
<R as Encoder<T>>::Encoded: IntoEncodedString,
<R as Decoder<T>>::Encoded: FromEncodedStr,
<R as Decoder<T>>::Error: Debug,
<<R as Decoder<T>>::Encoded as leptos::prelude::FromEncodedStr>::DecodingError: Debug,
{
let connect_ibis_wiki = Action::new(move |_: &()| async move {
CLIENT CLIENT
.resolve_instance(Url::parse("https://ibis.wiki").unwrap()) .resolve_instance(Url::parse("https://ibis.wiki").unwrap())
.await .await
@ -16,7 +27,9 @@ pub fn ConnectView<T: Clone + 'static, R: 'static>(res: Resource<T, R>) -> impl
<div class="flex justify-center h-screen"> <div class="flex justify-center h-screen">
<button <button
class="btn btn-primary place-self-center" class="btn btn-primary place-self-center"
on:click=move |_| connect_ibis_wiki.dispatch(()) on:click=move |_| {
connect_ibis_wiki.dispatch(());
}
> >
Connect with ibis.wiki Connect with ibis.wiki
</button> </button>

View file

@ -1,4 +1,4 @@
use leptos::{ev, *}; use leptos::{ev::KeyboardEvent, prelude::*};
#[component] #[component]
pub fn CredentialsForm( pub fn CredentialsForm(
@ -8,8 +8,8 @@ pub fn CredentialsForm(
error: Signal<Option<String>>, error: Signal<Option<String>>,
disabled: Signal<bool>, disabled: Signal<bool>,
) -> impl IntoView { ) -> impl IntoView {
let (password, set_password) = create_signal(String::new()); let (password, set_password) = signal(String::new());
let (username, set_username) = create_signal(String::new()); let (username, set_username) = signal(String::new());
let dispatch_action = move || action.dispatch((username.get(), password.get())); let dispatch_action = move || action.dispatch((username.get(), password.get()));
@ -34,7 +34,7 @@ pub fn CredentialsForm(
required required
placeholder="Username" placeholder="Username"
prop:disabled=move || disabled.get() prop:disabled=move || disabled.get()
on:keyup=move |ev: ev::KeyboardEvent| { on:keyup=move |ev: KeyboardEvent| {
let val = event_target_value(&ev); let val = event_target_value(&ev);
set_username.update(|v| *v = val); set_username.update(|v| *v = val);
} }
@ -51,7 +51,7 @@ pub fn CredentialsForm(
required required
placeholder="Password" placeholder="Password"
prop:disabled=move || disabled.get() prop:disabled=move || disabled.get()
on:keyup=move |ev: ev::KeyboardEvent| { on:keyup=move |ev: KeyboardEvent| {
match &*ev.key() { match &*ev.key() {
"Enter" => { "Enter" => {
dispatch_action(); dispatch_action();
@ -73,7 +73,9 @@ pub fn CredentialsForm(
<button <button
class="btn btn-primary my-2" class="btn btn-primary my-2"
prop:disabled=move || button_is_disabled.get() prop:disabled=move || button_is_disabled.get()
on:click=move |_| dispatch_action() on:click=move |_| {
dispatch_action();
}
> >
{action_label} {action_label}
</button> </button>

View file

@ -1,16 +1,14 @@
use crate::frontend::markdown::render_markdown; use crate::frontend::markdown::render_markdown;
use html::Textarea; use leptos::{html::Textarea, prelude::*};
use leptos::*;
#[component] #[component]
pub fn EditorView( pub fn EditorView(
// this param gives a false warning about being unused, ignore that textarea_ref: NodeRef<Textarea>,
#[allow(unused)] textarea_ref: NodeRef<Textarea>,
content: Signal<String>, content: Signal<String>,
set_content: WriteSignal<String>, set_content: WriteSignal<String>,
) -> impl IntoView { ) -> impl IntoView {
let (preview, set_preview) = create_signal(render_markdown(&content.get_untracked())); let (preview, set_preview) = signal(render_markdown(&content.get_untracked()));
let (show_preview, set_show_preview) = create_signal(false); let (show_preview, set_show_preview) = signal(false);
view! { view! {
<div> <div>

View file

@ -5,11 +5,11 @@ use crate::{
app::{site, DefaultResource}, app::{site, DefaultResource},
}, },
}; };
use leptos::{component, *}; use leptos::prelude::*;
#[component] #[component]
pub fn InstanceFollowButton(instance: DbInstance) -> impl IntoView { pub fn InstanceFollowButton(instance: DbInstance) -> impl IntoView {
let follow_action = create_action(move |instance_id: &InstanceId| { let follow_action = Action::new(move |instance_id: &InstanceId| {
let instance_id = *instance_id; let instance_id = *instance_id;
async move { async move {
let form = FollowInstance { id: instance_id }; let form = FollowInstance { id: instance_id };
@ -38,7 +38,9 @@ pub fn InstanceFollowButton(instance: DbInstance) -> impl IntoView {
view! { view! {
<button <button
class=class_ class=class_
on:click=move |_| follow_action.dispatch(instance.id) on:click=move |_| {
follow_action.dispatch(instance.id);
}
prop:disabled=move || is_following prop:disabled=move || is_following
title="Follow the instance so that new edits are synchronized to your instance." title="Follow the instance so that new edits are synchronized to your instance."
> >

View file

@ -4,3 +4,4 @@ pub mod credentials;
pub mod editor; pub mod editor;
pub mod instance_follow_button; pub mod instance_follow_button;
pub mod nav; pub mod nav;
pub mod protected_route;

View file

@ -3,21 +3,21 @@ use crate::frontend::{
app::{is_logged_in, site, DefaultResource}, app::{is_logged_in, site, DefaultResource},
dark_mode::DarkMode, dark_mode::DarkMode,
}; };
use leptos::{component, view, IntoView, *}; use leptos::{component, prelude::*, view, IntoView, *};
use leptos_router::*; use leptos_router::{components::A, hooks::use_navigate};
#[component] #[component]
pub fn Nav() -> impl IntoView { pub fn Nav() -> impl IntoView {
let logout_action = create_action(move |_| async move { let logout_action = Action::new(move |_| async move {
CLIENT.logout().await.unwrap(); CLIENT.logout().await.unwrap();
site().refetch(); site().refetch();
}); });
let notification_count = create_resource( let notification_count = Resource::new(
|| (), || (),
move |_| async move { CLIENT.notifications_count().await.unwrap_or_default() }, move |_| async move { CLIENT.notifications_count().await.unwrap_or_default() },
); );
let (search_query, set_search_query) = create_signal(String::new()); let (search_query, set_search_query) = signal(String::new());
let mut dark_mode = expect_context::<DarkMode>(); let mut dark_mode = expect_context::<DarkMode>();
view! { view! {
<nav class="max-sm:navbar p-2.5 h-full md:fixed md:w-64 max-sm: border-b md:border-e border-slate-400 border-solid"> <nav class="max-sm:navbar p-2.5 h-full md:fixed md:w-64 max-sm: border-b md:border-e border-slate-400 border-solid">
@ -45,32 +45,32 @@ pub fn Nav() -> impl IntoView {
<A href="/">"Main Page"</A> <A href="/">"Main Page"</A>
</li> </li>
<li> <li>
<A href="/instance/list">"Instances"</A> <A href="/instances">"Instances"</A>
</li> </li>
<li> <li>
<A href="/article/list">"Articles"</A> <A href="/articles">"Articles"</A>
</li> </li>
<Transition> <Suspense>
<Show when=is_logged_in> <Show when=is_logged_in>
<li> <li>
<A href="/article/create">"Create Article"</A> <A href="/create-article">"Create Article"</A>
</li> </li>
<li> <li>
<A href="/notifications"> <A href="/notifications">
"Notifications " "Notifications "
<span class="indicator-item indicator-end badge badge-neutral"> <span class="indicator-item indicator-end badge badge-neutral">
{move || notification_count.get()} <Suspense>{move || notification_count.get()}</Suspense>
</span> </span>
</A> </A>
</li> </li>
</Show> </Show>
</Transition> </Suspense>
<li> <li>
<form <form
class="form-control m-0 p-1" class="form-control m-0 p-1"
on:submit=move |ev| { on:submit=move |ev| {
ev.prevent_default(); ev.prevent_default();
let navigate = leptos_router::use_navigate(); let navigate = use_navigate();
let query = search_query.get(); let query = search_query.get();
if !query.is_empty() { if !query.is_empty() {
navigate( navigate(
@ -96,7 +96,7 @@ pub fn Nav() -> impl IntoView {
</li> </li>
</ul> </ul>
<div class="divider"></div> <div class="divider"></div>
<Transition> <Suspense>
<Show <Show
when=is_logged_in when=is_logged_in
fallback=move || { fallback=move || {
@ -127,7 +127,9 @@ pub fn Nav() -> impl IntoView {
</p> </p>
<button <button
class="btn btn-outline btn-xs w-min self-center" class="btn btn-outline btn-xs w-min self-center"
on:click=move |_| logout_action.dispatch(()) on:click=move |_| {
logout_action.dispatch(());
}
> >
Logout Logout
</button> </button>
@ -135,7 +137,7 @@ pub fn Nav() -> impl IntoView {
} }
</Show> </Show>
</Transition> </Suspense>
<div class="grow min-h-2"></div> <div class="grow min-h-2"></div>
<div class="m-1 grid gap-2"> <div class="m-1 grid gap-2">
<label class="flex cursor-pointer gap-2"> <label class="flex cursor-pointer gap-2">

View file

@ -0,0 +1,30 @@
use crate::frontend::app::is_logged_in;
use leptos::prelude::*;
use leptos_router::{
components::{ProtectedRoute, ProtectedRouteProps},
NestedRoute,
SsrMode,
};
#[component(transparent)]
pub fn IbisProtectedRoute<Segments, ViewFn, View>(
path: Segments,
view: ViewFn,
#[prop(optional)] ssr: SsrMode,
) -> NestedRoute<Segments, (), (), impl Fn() -> AnyView + Send + Clone>
where
ViewFn: Fn() -> View + Send + Clone + 'static,
View: IntoView + 'static,
{
let condition = move || Some(is_logged_in());
let redirect_path = || "/";
let props = ProtectedRouteProps {
path,
view,
condition,
redirect_path,
ssr,
fallback: Default::default(),
};
ProtectedRoute(props)
}

View file

@ -1,6 +1,6 @@
use chrono::{Duration, Local}; use chrono::{Duration, Local};
use codee::string::FromToStringCodec; use codee::string::FromToStringCodec;
use leptos::{Signal, SignalGet, SignalGetUntracked, SignalSet, WriteSignal}; use leptos::prelude::*;
use leptos_use::{use_cookie_with_options, use_preferred_dark, SameSite, UseCookieOptions}; use leptos_use::{use_cookie_with_options, use_preferred_dark, SameSite, UseCookieOptions};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View file

@ -1,6 +1,6 @@
use crate::common::{utils::extract_domain, DbArticle, DbPerson}; use crate::common::{utils::extract_domain, DbArticle, DbPerson};
use chrono::{DateTime, Local, Utc}; use chrono::{DateTime, Local, Utc};
use leptos::*; use leptos::prelude::*;
pub mod api; pub mod api;
pub mod app; pub mod app;
@ -16,7 +16,7 @@ pub fn hydrate() {
use crate::frontend::app::App; use crate::frontend::app::App;
console_log::init_with_level(log::Level::Debug).expect("error initializing logger"); console_log::init_with_level(log::Level::Debug).expect("error initializing logger");
console_error_panic_hook::set_once(); console_error_panic_hook::set_once();
leptos::mount_to_body(App); leptos::mount::hydrate_body(App);
} }
fn article_link(article: &DbArticle) -> String { fn article_link(article: &DbArticle) -> String {

View file

@ -9,16 +9,16 @@ use crate::{
DbArticle, DbArticle,
}, },
}; };
use leptos::*; use leptos::{ev::KeyboardEvent, prelude::*};
use leptos_router::Redirect; use leptos_router::components::Redirect;
#[component] #[component]
pub fn ArticleActions() -> impl IntoView { pub fn ArticleActions() -> impl IntoView {
let article = article_resource(); let article = article_resource();
let (new_title, set_new_title) = create_signal(String::new()); let (new_title, set_new_title) = signal(String::new());
let (fork_response, set_fork_response) = create_signal(Option::<DbArticle>::None); let (fork_response, set_fork_response) = signal(Option::<DbArticle>::None);
let (error, set_error) = create_signal(None::<String>); let (error, set_error) = signal(None::<String>);
let fork_action = create_action(move |(article_id, new_title): &(ArticleId, String)| { let fork_action = Action::new(move |(article_id, new_title): &(ArticleId, String)| {
let params = ForkArticleForm { let params = ForkArticleForm {
article_id: *article_id, article_id: *article_id,
new_title: new_title.to_string(), new_title: new_title.to_string(),
@ -34,7 +34,7 @@ pub fn ArticleActions() -> impl IntoView {
} }
} }
}); });
let protect_action = create_action(move |(id, protected): &(ArticleId, bool)| { let protect_action = Action::new(move |(id, protected): &(ArticleId, bool)| {
let params = ProtectArticleForm { let params = ProtectArticleForm {
article_id: *id, article_id: *id,
protected: !protected, protected: !protected,
@ -72,7 +72,7 @@ pub fn ArticleActions() -> impl IntoView {
class="btn btn-secondary" class="btn btn-secondary"
on:click=move |_| { on:click=move |_| {
protect_action protect_action
.dispatch((article.article.id, article.article.protected)) .dispatch((article.article.id, article.article.protected));
} }
> >
Toggle Article Protection Toggle Article Protection
@ -82,7 +82,7 @@ pub fn ArticleActions() -> impl IntoView {
<input <input
class="input" class="input"
placeholder="New Title" placeholder="New Title"
on:keyup=move |ev: ev::KeyboardEvent| { on:keyup=move |ev: KeyboardEvent| {
let val = event_target_value(&ev); let val = event_target_value(&ev);
set_new_title.update(|v| *v = val); set_new_title.update(|v| *v = val);
} }
@ -92,7 +92,7 @@ pub fn ArticleActions() -> impl IntoView {
class="btn" class="btn"
disabled=move || new_title.get().is_empty() disabled=move || new_title.get().is_empty()
on:click=move |_| { on:click=move |_| {
fork_action.dispatch((article.article.id, new_title.get())) fork_action.dispatch((article.article.id, new_title.get()));
} }
> >

View file

@ -2,27 +2,26 @@ use crate::{
common::CreateArticleForm, common::CreateArticleForm,
frontend::{api::CLIENT, components::editor::EditorView}, frontend::{api::CLIENT, components::editor::EditorView},
}; };
use html::Textarea; use leptos::{html::Textarea, prelude::*};
use leptos::*; use leptos_router::components::Redirect;
use leptos_router::Redirect;
use leptos_use::{use_textarea_autosize, UseTextareaAutosizeReturn}; use leptos_use::{use_textarea_autosize, UseTextareaAutosizeReturn};
#[component] #[component]
pub fn CreateArticle() -> impl IntoView { pub fn CreateArticle() -> impl IntoView {
let (title, set_title) = create_signal(String::new()); let (title, set_title) = signal(String::new());
let textarea_ref = create_node_ref::<Textarea>(); let textarea_ref = NodeRef::<Textarea>::new();
let UseTextareaAutosizeReturn { let UseTextareaAutosizeReturn {
content, content,
set_content, set_content,
trigger_resize: _, trigger_resize: _,
} = use_textarea_autosize(textarea_ref); } = use_textarea_autosize(textarea_ref);
let (summary, set_summary) = create_signal(String::new()); let (summary, set_summary) = signal(String::new());
let (create_response, set_create_response) = create_signal(None::<()>); let (create_response, set_create_response) = signal(None::<()>);
let (create_error, set_create_error) = create_signal(None::<String>); let (create_error, set_create_error) = signal(None::<String>);
let (wait_for_response, set_wait_for_response) = create_signal(false); let (wait_for_response, set_wait_for_response) = signal(false);
let button_is_disabled = let button_is_disabled =
Signal::derive(move || wait_for_response.get() || summary.get().is_empty()); Signal::derive(move || wait_for_response.get() || summary.get().is_empty());
let submit_action = create_action(move |(title, text, summary): &(String, String, String)| { let submit_action = Action::new(move |(title, text, summary): &(String, String, String)| {
let title = title.clone(); let title = title.clone();
let text = text.clone(); let text = text.clone();
let summary = summary.clone(); let summary = summary.clone();
@ -94,7 +93,7 @@ pub fn CreateArticle() -> impl IntoView {
prop:disabled=move || button_is_disabled.get() prop:disabled=move || button_is_disabled.get()
on:click=move |_| { on:click=move |_| {
submit_action submit_action
.dispatch((title.get(), content.get(), summary.get())) .dispatch((title.get(), content.get(), summary.get()));
} }
> >
Submit Submit

View file

@ -9,9 +9,8 @@ use crate::{
pages::article_resource, pages::article_resource,
}, },
}; };
use html::Textarea; use leptos::{html::Textarea, prelude::*};
use leptos::*; use leptos_router::hooks::use_params_map;
use leptos_router::use_params_map;
use leptos_use::{use_textarea_autosize, UseTextareaAutosizeReturn}; use leptos_use::{use_textarea_autosize, UseTextareaAutosizeReturn};
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
@ -26,12 +25,12 @@ const CONFLICT_MESSAGE: &str = "There was an edit conflict. Resolve it manually
#[component] #[component]
pub fn EditArticle() -> impl IntoView { pub fn EditArticle() -> impl IntoView {
let article = article_resource(); let article = article_resource();
let (edit_response, set_edit_response) = create_signal(EditResponse::None); let (edit_response, set_edit_response) = signal(EditResponse::None);
let (edit_error, set_edit_error) = create_signal(None::<String>); let (edit_error, set_edit_error) = signal(None::<String>);
let conflict_id = move || use_params_map().get_untracked().get("conflict_id").cloned(); let conflict_id = move || use_params_map().get_untracked().get("conflict_id").clone();
if let Some(conflict_id) = conflict_id() { if let Some(conflict_id) = conflict_id() {
create_action(move |conflict_id: &String| { Action::new(move |conflict_id: &String| {
let conflict_id = ConflictId(conflict_id.parse().unwrap()); let conflict_id = ConflictId(conflict_id.parse().unwrap());
async move { async move {
let conflict = CLIENT let conflict = CLIENT
@ -52,17 +51,17 @@ pub fn EditArticle() -> impl IntoView {
.dispatch(conflict_id); .dispatch(conflict_id);
} }
let textarea_ref = create_node_ref::<Textarea>(); let textarea_ref = NodeRef::<Textarea>::new();
let UseTextareaAutosizeReturn { let UseTextareaAutosizeReturn {
content, content,
set_content, set_content,
trigger_resize: _, trigger_resize: _,
} = use_textarea_autosize(textarea_ref); } = use_textarea_autosize(textarea_ref);
let (summary, set_summary) = create_signal(String::new()); let (summary, set_summary) = signal(String::new());
let (wait_for_response, set_wait_for_response) = create_signal(false); let (wait_for_response, set_wait_for_response) = signal(false);
let button_is_disabled = let button_is_disabled =
Signal::derive(move || wait_for_response.get() || summary.get().is_empty()); Signal::derive(move || wait_for_response.get() || summary.get().is_empty());
let submit_action = create_action( let submit_action = Action::new(
move |(new_text, summary, article, edit_response): &( move |(new_text, summary, article, edit_response): &(
String, String,
String, String,
@ -161,7 +160,7 @@ pub fn EditArticle() -> impl IntoView {
summary.get(), summary.get(),
article_.clone(), article_.clone(),
edit_response.get(), edit_response.get(),
)) ));
} }
> >

View file

@ -5,7 +5,7 @@ use crate::frontend::{
render_date_time, render_date_time,
user_link, user_link,
}; };
use leptos::*; use leptos::prelude::*;
#[component] #[component]
pub fn ArticleHistory() -> impl IntoView { pub fn ArticleHistory() -> impl IntoView {

View file

@ -2,15 +2,12 @@ use crate::{
common::ListArticlesForm, common::ListArticlesForm,
frontend::{api::CLIENT, article_link, article_title, components::connect::ConnectView}, frontend::{api::CLIENT, article_link, article_title, components::connect::ConnectView},
}; };
use html::Input; use leptos::prelude::*;
use leptos::*;
#[component] #[component]
pub fn ListArticles() -> impl IntoView { pub fn ListArticles() -> impl IntoView {
let (only_local, set_only_local) = create_signal(false); let (only_local, set_only_local) = signal(false);
let button_only_local = create_node_ref::<Input>(); let articles = Resource::new(
let button_all = create_node_ref::<Input>();
let articles = create_resource(
move || only_local.get(), move || only_local.get(),
|only_local| async move { |only_local| async move {
CLIENT CLIENT
@ -22,6 +19,28 @@ pub fn ListArticles() -> impl IntoView {
.unwrap() .unwrap()
}, },
); );
let only_local_class = Resource::new(
move || only_local.get(),
|only_local| async move {
if only_local {
"btn rounded-r-none btn-primary"
} else {
"btn rounded-r-none"
}
.to_string()
},
);
let all_class = Resource::new(
move || only_local.get(),
|only_local| async move {
if !only_local {
"btn rounded-l-none btn-primary"
} else {
"btn rounded-l-none"
}
.to_string()
},
);
view! { view! {
<h1 class="text-4xl font-bold font-serif my-4">Most recently edited Articles</h1> <h1 class="text-4xl font-bold font-serif my-4">Most recently edited Articles</h1>
@ -30,22 +49,16 @@ pub fn ListArticles() -> impl IntoView {
<input <input
type="button" type="button"
value="Only Local" value="Only Local"
class="btn rounded-r-none" class=move || only_local_class.get()
node_ref=button_only_local
on:click=move |_| { on:click=move |_| {
button_all.get().map(|c| c.class("btn-primary", false));
button_only_local.get().map(|c| c.class("btn-primary", true));
set_only_local.set(true); set_only_local.set(true);
} }
/> />
<input <input
type="button" type="button"
value="All" value="All"
class="btn btn-primary rounded-l-none" class=move || all_class.get()
node_ref=button_all
on:click=move |_| { on:click=move |_| {
button_all.get().map(|c| c.class("btn-primary", true));
button_only_local.get().map(|c| c.class("btn-primary", false));
set_only_local.set(false); set_only_local.set(false);
} }
/> />

View file

@ -3,7 +3,7 @@ use crate::frontend::{
markdown::render_markdown, markdown::render_markdown,
pages::article_resource, pages::article_resource,
}; };
use leptos::*; use leptos::prelude::*;
#[component] #[component]
pub fn ReadArticle() -> impl IntoView { pub fn ReadArticle() -> impl IntoView {

View file

@ -4,8 +4,8 @@ use crate::frontend::{
render_date_time, render_date_time,
user_link, user_link,
}; };
use leptos::*; use leptos::prelude::*;
use leptos_router::*; use leptos_router::hooks::use_params_map;
#[component] #[component]
pub fn EditDiff() -> impl IntoView { pub fn EditDiff() -> impl IntoView {
@ -21,7 +21,7 @@ pub fn EditDiff() -> impl IntoView {
article article
.get() .get()
.map(|article| { .map(|article| {
let hash = params.get_untracked().get("hash").cloned().unwrap(); let hash = params.get_untracked().get("hash").clone().unwrap();
let edit = article let edit = article
.edits .edits
.iter() .iter()

View file

@ -7,15 +7,15 @@ use crate::{
components::instance_follow_button::InstanceFollowButton, components::instance_follow_button::InstanceFollowButton,
}, },
}; };
use leptos::*; use leptos::prelude::*;
use leptos_router::use_params_map; use leptos_router::hooks::use_params_map;
use url::Url; use url::Url;
#[component] #[component]
pub fn InstanceDetails() -> impl IntoView { pub fn InstanceDetails() -> impl IntoView {
let params = use_params_map(); let params = use_params_map();
let hostname = move || params.get().get("hostname").cloned().unwrap(); let hostname = move || params.get().get("hostname").clone().unwrap();
let instance_profile = create_resource(hostname, move |hostname| async move { let instance_profile = Resource::new(hostname, move |hostname| async move {
let url = Url::parse(&format!("{}://{hostname}", http_protocol_str())).unwrap(); let url = Url::parse(&format!("{}://{hostname}", http_protocol_str())).unwrap();
CLIENT.resolve_instance(url).await.unwrap() CLIENT.resolve_instance(url).await.unwrap()
}); });
@ -28,7 +28,7 @@ pub fn InstanceDetails() -> impl IntoView {
instance_profile instance_profile
.get() .get()
.map(|instance: DbInstance| { .map(|instance: DbInstance| {
let articles = create_resource( let articles = Resource::new(
move || instance.id, move || instance.id,
|instance_id| async move { |instance_id| async move {
CLIENT CLIENT

View file

@ -1,9 +1,9 @@
use crate::frontend::{api::CLIENT, components::connect::ConnectView}; use crate::frontend::{api::CLIENT, components::connect::ConnectView};
use leptos::*; use leptos::prelude::*;
#[component] #[component]
pub fn ListInstances() -> impl IntoView { pub fn ListInstances() -> impl IntoView {
let instances = create_resource( let instances = Resource::new(
move || (), move || (),
|_| async move { CLIENT.list_instances().await.unwrap() }, |_| async move { CLIENT.list_instances().await.unwrap() },
); );
@ -21,14 +21,14 @@ pub fn ListInstances() -> impl IntoView {
.get() .get()
.map(|a| { .map(|a| {
a.into_iter() a.into_iter()
.map(|i| { .map(|ref i| {
view! { view! {
<li> <li>
<a <a
class="link text-lg" class="link text-lg"
href=format!("/instance/{}", i.domain) href=format!("/instance/{}", i.domain)
> >
{i.domain} {i.domain.to_string()}
</a> </a>
</li> </li>
} }

View file

@ -2,16 +2,16 @@ use crate::{
common::LoginUserForm, common::LoginUserForm,
frontend::{api::CLIENT, app::site, components::credentials::*}, frontend::{api::CLIENT, app::site, components::credentials::*},
}; };
use leptos::*; use leptos::prelude::*;
use leptos_router::Redirect; use leptos_router::components::Redirect;
#[component] #[component]
pub fn Login() -> impl IntoView { pub fn Login() -> impl IntoView {
let (login_response, set_login_response) = create_signal(None::<()>); let (login_response, set_login_response) = signal(None::<()>);
let (login_error, set_login_error) = create_signal(None::<String>); let (login_error, set_login_error) = signal(None::<String>);
let (wait_for_response, set_wait_for_response) = create_signal(false); let (wait_for_response, set_wait_for_response) = signal(false);
let login_action = create_action(move |(email, password): &(String, String)| { let login_action = Action::new(move |(email, password): &(String, String)| {
let username = email.to_string(); let username = email.to_string();
let password = password.to_string(); let password = password.to_string();
let credentials = LoginUserForm { username, password }; let credentials = LoginUserForm { username, password };

View file

@ -2,8 +2,8 @@ use crate::{
common::{ArticleView, GetArticleForm, MAIN_PAGE_NAME}, common::{ArticleView, GetArticleForm, MAIN_PAGE_NAME},
frontend::api::CLIENT, frontend::api::CLIENT,
}; };
use leptos::{create_resource, Resource, SignalGet}; use leptos::prelude::*;
use leptos_router::use_params_map; use leptos_router::hooks::use_params_map;
pub(crate) mod article; pub(crate) mod article;
pub(crate) mod diff; pub(crate) mod diff;
@ -14,10 +14,10 @@ pub(crate) mod register;
pub(crate) mod search; pub(crate) mod search;
pub(crate) mod user_profile; pub(crate) mod user_profile;
fn article_resource() -> Resource<Option<String>, ArticleView> { fn article_resource() -> Resource<ArticleView> {
let params = use_params_map(); let params = use_params_map();
let title = move || params.get().get("title").cloned(); let title = move || params.get().get("title").clone();
create_resource(title, move |title| async move { Resource::new(title, move |title| async move {
let mut title = title.unwrap_or(MAIN_PAGE_NAME.to_string()); let mut title = title.unwrap_or(MAIN_PAGE_NAME.to_string());
let mut domain = None; let mut domain = None;
if let Some((title_, domain_)) = title.clone().split_once('@') { if let Some((title_, domain_)) = title.clone().split_once('@') {

View file

@ -2,11 +2,11 @@ use crate::{
common::Notification, common::Notification,
frontend::{api::CLIENT, article_link, article_title}, frontend::{api::CLIENT, article_link, article_title},
}; };
use leptos::*; use leptos::prelude::*;
#[component] #[component]
pub fn Notifications() -> impl IntoView { pub fn Notifications() -> impl IntoView {
let notifications = create_resource( let notifications = Resource::new(
move || {}, move || {},
|_| async move { CLIENT.notifications_list().await.unwrap() }, |_| async move { CLIENT.notifications_list().await.unwrap() },
); );
@ -43,7 +43,7 @@ pub fn Notifications() -> impl IntoView {
} }
}; };
let notif_ = notif.clone(); let notif_ = notif.clone();
let click_approve = create_action(move |_: &()| { let click_approve = Action::new(move |_: &()| {
let notif_ = notif_.clone(); let notif_ = notif_.clone();
async move { async move {
if let ArticleApprovalRequired(a) = notif_ { if let ArticleApprovalRequired(a) = notif_ {
@ -53,7 +53,7 @@ pub fn Notifications() -> impl IntoView {
} }
}); });
let notif_ = notif.clone(); let notif_ = notif.clone();
let click_reject = create_action(move |_: &()| { let click_reject = Action::new(move |_: &()| {
let notif_ = notif_.clone(); let notif_ = notif_.clone();
async move { async move {
match notif_ { match notif_ {
@ -76,13 +76,17 @@ pub fn Notifications() -> impl IntoView {
<button <button
class="btn btn-sm btn-outline" class="btn btn-sm btn-outline"
style=my_style style=my_style
on:click=move |_| click_approve.dispatch(()) on:click=move |_| {
click_approve.dispatch(());
}
> >
Approve Approve
</button> </button>
<button <button
class="btn btn-sm btn-outline" class="btn btn-sm btn-outline"
on:click=move |_| click_reject.dispatch(()) on:click=move |_| {
click_reject.dispatch(());
}
> >
Reject Reject
</button> </button>

View file

@ -2,19 +2,20 @@ use crate::{
common::{LocalUserView, RegisterUserForm}, common::{LocalUserView, RegisterUserForm},
frontend::{api::CLIENT, app::site, components::credentials::*, error::MyResult}, frontend::{api::CLIENT, app::site, components::credentials::*, error::MyResult},
}; };
use leptos::{logging::log, *}; use leptos::prelude::*;
use log::info;
#[component] #[component]
pub fn Register() -> impl IntoView { pub fn Register() -> impl IntoView {
let (register_response, set_register_response) = create_signal(None::<()>); let (register_response, set_register_response) = signal(None::<()>);
let (register_error, set_register_error) = create_signal(None::<String>); let (register_error, set_register_error) = signal(None::<String>);
let (wait_for_response, set_wait_for_response) = create_signal(false); let (wait_for_response, set_wait_for_response) = signal(false);
let register_action = create_action(move |(email, password): &(String, String)| { let register_action = Action::new(move |(email, password): &(String, String)| {
let username = email.to_string(); let username = email.to_string();
let password = password.to_string(); let password = password.to_string();
let credentials = RegisterUserForm { username, password }; let credentials = RegisterUserForm { username, password };
log!("Try to register new account for {}", credentials.username); info!("Try to register new account for {}", credentials.username);
async move { async move {
set_wait_for_response.update(|w| *w = true); set_wait_for_response.update(|w| *w = true);
let result: MyResult<LocalUserView> = CLIENT.register(credentials).await; let result: MyResult<LocalUserView> = CLIENT.register(credentials).await;

View file

@ -2,8 +2,8 @@ use crate::{
common::{DbArticle, DbInstance, SearchArticleForm}, common::{DbArticle, DbInstance, SearchArticleForm},
frontend::{api::CLIENT, article_link, article_title}, frontend::{api::CLIENT, article_link, article_title},
}; };
use leptos::*; use leptos::prelude::*;
use leptos_router::use_query_map; use leptos_router::hooks::use_query_map;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
@ -22,9 +22,9 @@ impl SearchResults {
#[component] #[component]
pub fn Search() -> impl IntoView { pub fn Search() -> impl IntoView {
let params = use_query_map(); let params = use_query_map();
let query = move || params.get().get("query").cloned().unwrap(); let query = move || params.get().get("query").clone().unwrap();
let (error, set_error) = create_signal(None::<String>); let (error, set_error) = signal(None::<String>);
let search_results = create_resource(query, move |query| async move { let search_results = Resource::new(query, move |query| async move {
set_error.set(None); set_error.set(None);
let mut search_results = SearchResults::default(); let mut search_results = SearchResults::default();
let url = Url::parse(&query); let url = Url::parse(&query);
@ -89,7 +89,7 @@ pub fn Search() -> impl IntoView {
view! { view! {
<li> <li>
<a class="link text-lg" href=format!("/instance/{domain}")> <a class="link text-lg" href=format!("/instance/{domain}")>
{domain} {domain.to_string()}
</a> </a>
</li> </li>
}, },

View file

@ -2,15 +2,15 @@ use crate::{
common::{DbPerson, GetUserForm}, common::{DbPerson, GetUserForm},
frontend::{api::CLIENT, user_title}, frontend::{api::CLIENT, user_title},
}; };
use leptos::*; use leptos::prelude::*;
use leptos_router::use_params_map; use leptos_router::hooks::use_params_map;
#[component] #[component]
pub fn UserProfile() -> impl IntoView { pub fn UserProfile() -> impl IntoView {
let params = use_params_map(); let params = use_params_map();
let name = move || params.get().get("name").cloned().unwrap(); let name = move || params.get().get("name").clone().unwrap();
let (error, set_error) = create_signal(None::<String>); let (error, set_error) = signal(None::<String>);
let user_profile = create_resource(name, move |mut name| async move { let user_profile = Resource::new(name, move |mut name| async move {
set_error.set(None); set_error.set(None);
let mut domain = None; let mut domain = None;
if let Some((title_, domain_)) = name.clone().split_once('@') { if let Some((title_, domain_)) = name.clone().split_once('@') {