Compare commits

..

20 Commits

Author SHA1 Message Date
spinline
907ae66a7f fix: resolve compilation error in ToastItem by cloning message string
All checks were successful
Build MIPS Binary / build (push) Successful in 5m21s
2026-02-11 20:09:58 +03:00
spinline
f35b119c0d fix: replace leptos-shadcn-toast with custom implementation to fix WASM panic
Some checks failed
Build MIPS Binary / build (push) Failing after 1m23s
2026-02-11 20:02:58 +03:00
spinline
920704ee72 chore: add gloo-console dependency for direct logging
All checks were successful
Build MIPS Binary / build (push) Successful in 5m30s
2026-02-11 19:51:50 +03:00
spinline
d8ad9e62d8 chore: add direct console logging to debug missing toasts
Some checks failed
Build MIPS Binary / build (push) Failing after 1m19s
2026-02-11 19:46:06 +03:00
spinline
ea99ac62bc fix: install tailwindcss-animate and add to config to enable toast animations
All checks were successful
Build MIPS Binary / build (push) Successful in 5m17s
2026-02-11 19:33:16 +03:00
spinline
af13b5af09 fix: resolve syntax error and duplicate code in main.rs router definition
All checks were successful
Build MIPS Binary / build (push) Successful in 5m18s
2026-02-11 19:14:15 +03:00
spinline
c8907e7999 revert: remove Toaster component and add test toast message
Some checks failed
Build MIPS Binary / build (push) Failing after 4m35s
2026-02-11 19:08:57 +03:00
spinline
714e2cb7d5 fix: add missing Toaster component to App to render notifications
Some checks failed
Build MIPS Binary / build (push) Failing after 1m21s
2026-02-11 19:05:12 +03:00
spinline
f35b716f93 chore: cleanup frontend unused imports and variables
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-11 19:03:36 +03:00
spinline
47db9fa0c0 chore: cleanup unused backend code after migration to server functions
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-11 19:02:36 +03:00
spinline
47dc4da6d1 fix: downgrade postcss-preset-env for Node 20.11.1 compatibility
All checks were successful
Build MIPS Binary / build (push) Successful in 5m23s
2026-02-11 18:55:37 +03:00
spinline
c501ed9207 fix: use input/output arguments for MsgPack encoding
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-11 18:50:47 +03:00
spinline
4861faee18 fix: use MsgPack type for encoding (remove quotes)
Some checks failed
Build MIPS Binary / build (push) Failing after 1m12s
2026-02-11 18:47:07 +03:00
spinline
6a4943d692 fix: re-add codec.rs for proper compilation
Some checks failed
Build MIPS Binary / build (push) Failing after 1m13s
2026-02-11 18:44:19 +03:00
spinline
b27caa77f2 fix: restore codec.rs for module export
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-11 18:43:10 +03:00
spinline
cba8c20d9b fix: switch to built-in MsgPack codec and sync leptos versions
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-11 18:42:50 +03:00
spinline
0cdd92dc95 fix: resolve messagepack codec trait bounds and literals
Some checks failed
Build MIPS Binary / build (push) Failing after 1m12s
2026-02-11 18:32:41 +03:00
spinline
b9798ce0e2 fix: resolve messagepack codec compilation errors
Some checks failed
Build MIPS Binary / build (push) Failing after 1m13s
2026-02-11 18:21:36 +03:00
spinline
6a882b75b6 feat: implement MessagePack codec for server functions
Some checks failed
Build MIPS Binary / build (push) Failing after 1m12s
2026-02-11 02:01:02 +03:00
spinline
40c9f66e5c fix: toast notifications context issue by wrapping app in SonnerProvider
All checks were successful
Build MIPS Binary / build (push) Successful in 5m14s
2026-02-11 01:42:58 +03:00
19 changed files with 782 additions and 1157 deletions

View File

@@ -1,154 +1,2 @@
use crate::AppState;
use axum::{
extract::{State, Json},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use time::Duration;
#[derive(Deserialize, ToSchema)]
pub struct LoginRequest {
username: String,
password: String,
#[serde(default)]
remember_me: bool,
}
#[derive(Serialize, ToSchema)]
pub struct UserResponse {
username: String,
}
#[utoipa::path(
post,
path = "/api/auth/login",
request_body = LoginRequest,
responses(
(status = 200, description = "Login successful"),
(status = 401, description = "Invalid credentials"),
(status = 500, description = "Internal server error")
)
)]
pub async fn login_handler(
State(state): State<AppState>,
jar: CookieJar,
Json(payload): Json<LoginRequest>,
) -> impl IntoResponse {
tracing::info!("Login attempt for user: {}", payload.username);
let user = match state.db.get_user_by_username(&payload.username).await {
Ok(Some(u)) => u,
Ok(None) => {
tracing::warn!("Login failed: User not found for {}", payload.username);
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
}
Err(e) => {
tracing::error!("DB error during login for {}: {}", payload.username, e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
}
};
let (user_id, password_hash) = user;
match bcrypt::verify(&payload.password, &password_hash) {
Ok(true) => {
tracing::info!("Password verified for user: {}", payload.username);
// Create session
let token: String = (0..32).map(|_| {
use rand::{distributions::Alphanumeric, Rng};
rand::thread_rng().sample(Alphanumeric) as char
}).collect();
// Expiration: 30 days if remember_me is true, else 1 day
let expires_in = if payload.remember_me {
60 * 60 * 24 * 30
} else {
60 * 60 * 24
};
let expires_at = time::OffsetDateTime::now_utc().unix_timestamp() + expires_in;
if let Err(e) = state.db.create_session(user_id, &token, expires_at).await {
tracing::error!("Failed to create session for {}: {}", payload.username, e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create session").into_response();
}
let mut cookie = Cookie::build(("auth_token", token))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.build();
cookie.set_max_age(Duration::seconds(expires_in));
tracing::info!("Session created and cookie set for user: {}", payload.username);
(StatusCode::OK, jar.add(cookie), Json(UserResponse { username: payload.username })).into_response()
}
Ok(false) => {
tracing::warn!("Login failed: Invalid password for {}", payload.username);
(StatusCode::UNAUTHORIZED, "Invalid credentials").into_response()
}
Err(e) => {
tracing::error!("Bcrypt error for {}: {}", payload.username, e);
(StatusCode::INTERNAL_SERVER_ERROR, "Auth error").into_response()
}
}
}
#[utoipa::path(
post,
path = "/api/auth/logout",
responses(
(status = 200, description = "Logged out")
)
)]
pub async fn logout_handler(
State(state): State<AppState>,
jar: CookieJar,
) -> impl IntoResponse {
if let Some(token) = jar.get("auth_token") {
let _ = state.db.delete_session(token.value()).await;
}
let cookie = Cookie::build(("auth_token", ""))
.path("/")
.http_only(true)
.max_age(Duration::seconds(-1)) // Expire immediately
.build();
(StatusCode::OK, jar.add(cookie), "Logged out").into_response()
}
#[utoipa::path(
get,
path = "/api/auth/check",
responses(
(status = 200, description = "Authenticated", body = UserResponse),
(status = 401, description = "Not authenticated")
)
)]
pub async fn check_auth_handler(
State(state): State<AppState>,
jar: CookieJar,
) -> impl IntoResponse {
if let Some(token) = jar.get("auth_token") {
match state.db.get_session_user(token.value()).await {
Ok(Some(user_id)) => {
// Fetch username
// We need a helper in db.rs to get username by id, or we can use a direct query here if we don't want to change db.rs interface yet.
// But better to add `get_username_by_id` to db.rs
// For now let's query directly or via a new db method.
if let Ok(Some(username)) = state.db.get_username_by_id(user_id).await {
return (StatusCode::OK, Json(UserResponse { username })).into_response();
}
},
_ => {} // Invalid session
}
}
StatusCode::UNAUTHORIZED.into_response()
}
// This file is intentionally empty as authentication is now handled by Server Functions.
// See shared/src/server_fns/auth.rs

View File

@@ -1,125 +1,2 @@
use crate::AppState;
use axum::{
extract::{State, Json},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use time::Duration;
#[derive(Deserialize, ToSchema)]
pub struct SetupRequest {
username: String,
password: String,
}
#[derive(Serialize, ToSchema)]
pub struct SetupStatusResponse {
completed: bool,
}
#[utoipa::path(
get,
path = "/api/setup/status",
responses(
(status = 200, description = "Setup status", body = SetupStatusResponse)
)
)]
pub async fn get_setup_status_handler(State(state): State<AppState>) -> impl IntoResponse {
let completed = match state.db.has_users().await {
Ok(has) => has,
Err(e) => {
tracing::error!("DB error checking users: {}", e);
false
}
};
Json(SetupStatusResponse { completed }).into_response()
}
#[utoipa::path(
post,
path = "/api/setup",
request_body = SetupRequest,
responses(
(status = 200, description = "Setup completed and logged in"),
(status = 400, description = "Invalid request"),
(status = 403, description = "Setup already completed"),
(status = 500, description = "Internal server error")
)
)]
pub async fn setup_handler(
State(state): State<AppState>,
jar: CookieJar,
Json(payload): Json<SetupRequest>,
) -> impl IntoResponse {
// 1. Check if setup is already completed (i.e., users exist)
match state.db.has_users().await {
Ok(true) => return (StatusCode::FORBIDDEN, "Setup already completed").into_response(),
Err(e) => {
tracing::error!("DB error checking users: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
}
Ok(false) => {} // Proceed
}
// 2. Validate input
if payload.username.len() < 3 || payload.password.len() < 6 {
return (StatusCode::BAD_REQUEST, "Username must be at least 3 chars, password at least 6").into_response();
}
// 3. Create User
// Lower cost for faster login on low-power devices (MIPS routers etc.)
let password_hash = match bcrypt::hash(&payload.password, 6) {
Ok(h) => h,
Err(e) => {
tracing::error!("Failed to hash password: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to process password").into_response();
}
};
if let Err(e) = state.db.create_user(&payload.username, &password_hash).await {
tracing::error!("Failed to create user: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create user").into_response();
}
// 4. Auto-Login (Create Session)
// Get the created user's ID
let user = match state.db.get_user_by_username(&payload.username).await {
Ok(Some(u)) => u,
Ok(None) => return (StatusCode::INTERNAL_SERVER_ERROR, "User created but not found").into_response(),
Err(e) => {
tracing::error!("DB error fetching new user: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
}
};
let (user_id, _) = user;
// Create session token
let token: String = (0..32).map(|_| {
use rand::{distributions::Alphanumeric, Rng};
rand::thread_rng().sample(Alphanumeric) as char
}).collect();
// Default expiration: 1 day (since it's not "remember me")
let expires_in = 60 * 60 * 24;
let expires_at = time::OffsetDateTime::now_utc().unix_timestamp() + expires_in;
if let Err(e) = state.db.create_session(user_id, &token, expires_at).await {
tracing::error!("Failed to create session for new user: {}", e);
// Even if session fails, setup is technically complete, but login failed.
// We return OK but user will have to login manually.
return (StatusCode::OK, "Setup completed, please login").into_response();
}
let mut cookie = Cookie::build(("auth_token", token))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.build();
cookie.set_max_age(Duration::seconds(expires_in));
(StatusCode::OK, jar.add(cookie), "Setup completed and logged in").into_response()
}
// This file is intentionally empty as setup is now handled by Server Functions.
// See shared/src/server_fns/auth.rs

View File

@@ -25,7 +25,6 @@ use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{broadcast, watch};
use tower::ServiceBuilder;
use tower_governor::GovernorLayer;
use tower_http::{
compression::{CompressionLayer, CompressionLevel},
cors::CorsLayer,
@@ -48,7 +47,7 @@ pub struct AppState {
}
async fn auth_middleware(
state: axum::extract::State<AppState>,
_state: axum::extract::State<AppState>,
jar: CookieJar,
request: Request<Body>,
next: Next,
@@ -113,13 +112,6 @@ struct Args {
#[cfg(feature = "swagger")]
#[derive(OpenApi)]
#[openapi(
paths(
handlers::auth::login_handler,
handlers::auth::logout_handler,
handlers::auth::check_auth_handler,
handlers::setup::setup_handler,
handlers::setup::get_setup_status_handler
),
components(
schemas(
shared::AddTorrentRequest,
@@ -132,10 +124,6 @@ struct Args {
shared::SetFilePriorityRequest,
shared::SetLabelRequest,
shared::GlobalLimitRequest,
handlers::auth::LoginRequest,
handlers::setup::SetupRequest,
handlers::setup::SetupStatusResponse,
handlers::auth::UserResponse
)
),
tags(
@@ -144,6 +132,7 @@ struct Args {
)]
struct ApiDoc;
#[tokio::main]
async fn main() {
// Load .env file
@@ -423,6 +412,7 @@ async fn main() {
#[cfg(feature = "swagger")]
let app = app.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()));
// Setup & Auth Routes (cookie-based, stay as REST)
// Setup & Auth Routes (cookie-based, stay as REST)
let scgi_path_for_ctx = args.socket.clone();
let db_for_ctx = db.clone();

View File

@@ -1,16 +1,3 @@
use governor::clock::QuantaInstant;
use governor::middleware::NoOpMiddleware;
use tower_governor::governor::GovernorConfig;
use tower_governor::governor::GovernorConfigBuilder;
use tower_governor::key_extractor::SmartIpKeyExtractor;
pub fn get_login_rate_limit_config() -> GovernorConfig<SmartIpKeyExtractor, NoOpMiddleware<QuantaInstant>> {
// 5 yanlış denemeden sonra bloklanır.
// Her yeni hak için 60 saniye (1 dakika) bekleme süresi.
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_second(60)
.burst_size(5)
.finish()
.unwrap()
}
// This file can be removed or repurposed if rate limiting is needed for other endpoints.
// Login rate limiting is now handled within the server function or needs to be reimplemented
// as a middleware for the server function endpoint.

View File

@@ -4,7 +4,7 @@ use shared::xmlrpc::{
use crate::AppState;
use axum::extract::State;
use axum::response::sse::{Event, Sse};
use futures::stream::{self, Stream};
use futures::stream::{self};
use shared::{AppEvent, GlobalStats, Torrent, TorrentStatus};
use std::convert::Infallible;
use tokio_stream::StreamExt;

View File

@@ -7,7 +7,7 @@ edition = "2021"
crate-type = ["cdylib", "rlib"]
[dependencies]
leptos = { version = "0.8.15", features = ["csr"] }
leptos = { version = "0.8.15", features = ["csr", "msgpack"] }
leptos_router = { version = "0.8.11" }
console_error_panic_hook = "0.1"
@@ -17,6 +17,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
gloo-net = "0.6"
gloo-timers = { version = "0.3", features = ["futures"] }
gloo-console = "0.3"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
uuid = { version = "1", features = ["v4", "js"] }
@@ -49,7 +50,7 @@ leptos-shadcn-scroll-area = "0.8"
leptos-shadcn-dialog = "0.8"
leptos-shadcn-label = "0.8"
leptos-shadcn-alert = "0.8"
leptos-shadcn-toast = "0.8"
leptos-shadcn-dropdown-menu = "0.8"
leptos-shadcn-tooltip = "0.8"
leptos-shadcn-skeleton = "0.8"

File diff suppressed because it is too large Load Diff

View File

@@ -14,10 +14,14 @@
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"postcss-cli": "^11.0.1",
"postcss-preset-env": "^11.1.3",
"postcss-preset-env": "^10.1.3",
"tailwindcss": "^4.1.18"
},
"dependencies": {
"@tailwindcss/cli": "^4.1.18"
"@tailwindcss/cli": "^4.1.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
}
}

View File

@@ -8,11 +8,20 @@ use leptos::task::spawn_local;
use leptos_router::components::{Router, Routes, Route};
use leptos_router::hooks::use_navigate;
use leptos_shadcn_skeleton::Skeleton;
use leptos_shadcn_toast::SonnerProvider;
use crate::components::toast::Toaster;
#[component]
pub fn App() -> impl IntoView {
view! {
<InnerApp />
<Toaster />
}
}
#[component]
fn InnerApp() -> impl IntoView {
crate::store::provide_torrent_store();
crate::components::toast::provide_toast_context();
let store = use_context::<crate::store::TorrentStore>();
let is_loading = signal(true);
@@ -22,6 +31,7 @@ pub fn App() -> impl IntoView {
Effect::new(move |_| {
spawn_local(async move {
log::info!("App initialization started...");
gloo_console::log!("APP INIT: Checking setup status...");
// Check if setup is needed via Server Function
match shared::server_fns::auth::get_setup_status().await {
@@ -54,6 +64,7 @@ pub fn App() -> impl IntoView {
}
is_loading.1.set(false);
crate::store::toast_success("VibeTorrent'e Hoşgeldiniz");
});
});
@@ -70,126 +81,124 @@ pub fn App() -> impl IntoView {
});
view! {
<SonnerProvider>
<div class="relative w-full h-screen" style="height: 100dvh;">
<Router>
<Routes fallback=|| view! { <div class="p-4">"404 Not Found"</div> }>
<Route path=leptos_router::path!("/login") view=move || {
let authenticated = is_authenticated.0.get();
let setup_needed = needs_setup.0.get();
Effect::new(move |_| {
if setup_needed {
let navigate = use_navigate();
<div class="relative w-full h-screen" style="height: 100dvh;">
<Router>
<Routes fallback=|| view! { <div class="p-4">"404 Not Found"</div> }>
<Route path=leptos_router::path!("/login") view=move || {
let authenticated = is_authenticated.0.get();
let setup_needed = needs_setup.0.get();
Effect::new(move |_| {
if setup_needed {
let navigate = use_navigate();
navigate("/setup", Default::default());
} else if authenticated {
log::info!("Already authenticated, redirecting to home");
let navigate = use_navigate();
navigate("/", Default::default());
}
});
view! { <Login /> }
} />
<Route path=leptos_router::path!("/setup") view=move || {
Effect::new(move |_| {
if is_authenticated.0.get() {
let navigate = use_navigate();
navigate("/", Default::default());
}
});
view! { <Setup /> }
} />
<Route path=leptos_router::path!("/") view=move || {
let navigate = use_navigate();
Effect::new(move |_| {
if !is_loading.0.get() {
if needs_setup.0.get() {
log::info!("Setup not completed, redirecting to setup");
navigate("/setup", Default::default());
} else if authenticated {
log::info!("Already authenticated, redirecting to home");
let navigate = use_navigate();
navigate("/", Default::default());
}
});
view! { <Login /> }
} />
<Route path=leptos_router::path!("/setup") view=move || {
Effect::new(move |_| {
if is_authenticated.0.get() {
let navigate = use_navigate();
navigate("/", Default::default());
}
});
view! { <Setup /> }
} />
<Route path=leptos_router::path!("/") view=move || {
let navigate = use_navigate();
Effect::new(move |_| {
if !is_loading.0.get() {
if needs_setup.0.get() {
log::info!("Setup not completed, redirecting to setup");
navigate("/setup", Default::default());
} else if !is_authenticated.0.get() {
log::info!("Not authenticated, redirecting to login");
navigate("/login", Default::default());
}
}
});
view! {
<Show when=move || !is_loading.0.get() fallback=|| view! {
<div class="flex h-screen bg-background">
// Sidebar skeleton
<div class="w-56 border-r border-border p-4 space-y-4">
<Skeleton class="h-8 w-3/4" />
<div class="space-y-2">
<Skeleton class="h-6 w-full" />
<Skeleton class="h-6 w-full" />
<Skeleton class="h-6 w-4/5" />
<Skeleton class="h-6 w-full" />
<Skeleton class="h-6 w-3/5" />
<Skeleton class="h-6 w-full" />
</div>
</div>
// Main content skeleton
<div class="flex-1 flex flex-col">
// Header skeleton
<div class="border-b border-border p-4 flex items-center gap-4">
<Skeleton class="h-8 w-48" />
<Skeleton class="h-8 w-64" />
<div class="ml-auto"><Skeleton class="h-8 w-24" /></div>
</div>
// Table skeleton rows
<div class="flex-1 p-4 space-y-3">
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-3/4" />
</div>
// Status bar skeleton
<div class="border-t border-border p-3">
<Skeleton class="h-5 w-96" />
</div>
</div>
</div>
}.into_any()>
<Show when=move || is_authenticated.0.get() fallback=|| ()>
<Protected>
<div class="flex flex-col h-full overflow-hidden">
<div class="flex-1 overflow-hidden">
<TorrentTable />
</div>
<TorrentDetail />
</div>
</Protected>
</Show>
</Show>
}.into_any()
}/>
<Route path=leptos_router::path!("/settings") view=move || {
Effect::new(move |_| {
if !is_authenticated.0.get() {
let navigate = use_navigate();
} else if !is_authenticated.0.get() {
log::info!("Not authenticated, redirecting to login");
navigate("/login", Default::default());
}
});
view! {
<Show when=move || !is_loading.0.get() fallback=|| ()>
<Show when=move || is_authenticated.0.get() fallback=|| ()>
<Protected>
<div class="p-4">"Settings Page (Coming Soon)"</div>
</Protected>
</Show>
</Show>
}
}/>
</Routes>
</Router>
</div>
</SonnerProvider>
});
view! {
<Show when=move || !is_loading.0.get() fallback=|| view! {
<div class="flex h-screen bg-background">
// Sidebar skeleton
<div class="w-56 border-r border-border p-4 space-y-4">
<Skeleton class="h-8 w-3/4" />
<div class="space-y-2">
<Skeleton class="h-6 w-full" />
<Skeleton class="h-6 w-full" />
<Skeleton class="h-6 w-4/5" />
<Skeleton class="h-6 w-full" />
<Skeleton class="h-6 w-3/5" />
<Skeleton class="h-6 w-full" />
</div>
</div>
// Main content skeleton
<div class="flex-1 flex flex-col">
// Header skeleton
<div class="border-b border-border p-4 flex items-center gap-4">
<Skeleton class="h-8 w-48" />
<Skeleton class="h-8 w-64" />
<div class="ml-auto"><Skeleton class="h-8 w-24" /></div>
</div>
// Table skeleton rows
<div class="flex-1 p-4 space-y-3">
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-3/4" />
</div>
// Status bar skeleton
<div class="border-t border-border p-3">
<Skeleton class="h-5 w-96" />
</div>
</div>
</div>
}.into_any()>
<Show when=move || is_authenticated.0.get() fallback=|| ()>
<Protected>
<div class="flex flex-col h-full overflow-hidden">
<div class="flex-1 overflow-hidden">
<TorrentTable />
</div>
<TorrentDetail />
</div>
</Protected>
</Show>
</Show>
}.into_any()
}/>
<Route path=leptos_router::path!("/settings") view=move || {
Effect::new(move |_| {
if !is_authenticated.0.get() {
let navigate = use_navigate();
navigate("/login", Default::default());
}
});
view! {
<Show when=move || !is_loading.0.get() fallback=|| ()>
<Show when=move || is_authenticated.0.get() fallback=|| ()>
<Protected>
<div class="p-4">"Settings Page (Coming Soon)"</div>
</Protected>
</Show>
</Show>
}
}/>
</Routes>
</Router>
</div>
}
}

View File

@@ -2,3 +2,4 @@ pub mod context_menu;
pub mod layout;
pub mod torrent;
pub mod auth;
pub mod toast;

View File

@@ -0,0 +1,111 @@
use leptos::prelude::*;
use std::collections::HashMap;
use uuid::Uuid;
use shared::NotificationLevel;
#[derive(Clone, Debug, PartialEq)]
pub struct Toast {
pub id: String,
pub message: String,
pub level: NotificationLevel,
pub visible: RwSignal<bool>,
}
#[derive(Clone, Copy)]
pub struct ToastContext {
pub toasts: RwSignal<HashMap<String, Toast>>,
}
impl ToastContext {
pub fn add(&self, message: impl Into<String>, level: NotificationLevel) {
let id = Uuid::new_v4().to_string();
let message = message.into();
let toast = Toast {
id: id.clone(),
message,
level,
visible: RwSignal::new(true),
};
self.toasts.update(|m| {
m.insert(id.clone(), toast);
});
// Auto remove after 5 seconds
let toasts = self.toasts;
let id_clone = id.clone();
leptos::task::spawn_local(async move {
gloo_timers::future::TimeoutFuture::new(5000).await;
toasts.update(|m| {
if let Some(t) = m.get(&id_clone) {
t.visible.set(false);
}
});
// Wait for animation
gloo_timers::future::TimeoutFuture::new(300).await;
toasts.update(|m| {
m.remove(&id_clone);
});
});
}
}
pub fn provide_toast_context() {
let toasts = RwSignal::new(HashMap::new());
provide_context(ToastContext { toasts });
}
#[component]
pub fn Toaster() -> impl IntoView {
let context = expect_context::<ToastContext>();
view! {
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 w-full max-w-sm pointer-events-none">
{move || {
context.toasts.get().into_values().map(|toast| {
view! { <ToastItem toast=toast /> }
}).collect::<Vec<_>>()
}}
</div>
}
}
#[component]
fn ToastItem(toast: Toast) -> impl IntoView {
let (visible, set_visible) = (toast.visible, toast.visible.write_only());
let base_classes = "pointer-events-auto relative w-full rounded-lg border p-4 shadow-lg transition-all duration-300 ease-in-out";
let color_classes = match toast.level {
NotificationLevel::Success => "bg-green-50 text-green-900 border-green-200 dark:bg-green-900 dark:text-green-100 dark:border-green-800",
NotificationLevel::Error => "bg-red-50 text-red-900 border-red-200 dark:bg-red-900 dark:text-red-100 dark:border-red-800",
NotificationLevel::Warning => "bg-yellow-50 text-yellow-900 border-yellow-200 dark:bg-yellow-900 dark:text-yellow-100 dark:border-yellow-800",
NotificationLevel::Info => "bg-blue-50 text-blue-900 border-blue-200 dark:bg-blue-900 dark:text-blue-100 dark:border-blue-800",
};
view! {
<div
class=move || format!("{} {} {}",
base_classes,
color_classes,
if visible.get() { "opacity-100 translate-x-0" } else { "opacity-0 translate-x-full" }
)
role="alert"
>
<div class="flex items-start gap-4">
<div class="flex-1">
<p class="text-sm font-medium">{toast.message.clone()}</p>
</div>
<button
class="inline-flex shrink-0 opacity-50 hover:opacity-100 focus:opacity-100 focus:outline-none"
on:click=move |_| set_visible.set(false)
>
<span class="sr-only">"Kapat"</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
<line x1="18" x2="6" y1="6" y2="18"></line>
<line x1="6" x2="18" y1="6" y2="18"></line>
</svg>
</button>
</div>
</div>
}
}

View File

@@ -10,7 +10,7 @@ use crate::api;
pub fn AddTorrentDialog(
on_close: Callback<()>,
) -> impl IntoView {
let store = use_context::<TorrentStore>().expect("TorrentStore not provided");
let _store = use_context::<TorrentStore>().expect("TorrentStore not provided");
let uri = signal(String::new());
let is_loading = signal(false);

View File

@@ -2,18 +2,23 @@ use futures::StreamExt;
use gloo_net::eventsource::futures::EventSource;
use leptos::prelude::*;
use leptos::task::spawn_local;
use shared::{AppEvent, GlobalStats, NotificationLevel, SystemNotification, Torrent};
use shared::{AppEvent, GlobalStats, NotificationLevel, Torrent};
use std::collections::HashMap;
use struct_patch::traits::Patch;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use crate::components::toast::ToastContext;
pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
let msg = message.into();
match level {
NotificationLevel::Info => { leptos_shadcn_toast::toast::info(&msg).show(); },
NotificationLevel::Success => { leptos_shadcn_toast::toast::success(&msg).show(); },
NotificationLevel::Warning => { leptos_shadcn_toast::toast::warning(&msg).show(); },
NotificationLevel::Error => { leptos_shadcn_toast::toast::error(&msg).show(); },
gloo_console::log!("TOAST CALL:", &msg, format!("{:?}", level));
log::info!("Displaying toast: [{:?}] {}", level, msg);
if let Some(context) = use_context::<ToastContext>() {
context.add(msg, level);
} else {
log::error!("ToastContext not found!");
gloo_console::error!("ToastContext not found!");
}
}

View File

@@ -1,6 +1,6 @@
use wasm_bindgen::prelude::*;
use web_sys::{Notification, NotificationOptions};
use leptos::prelude::*;
/// Request browser notification permission from user
pub async fn request_notification_permission() -> bool {

View File

@@ -26,4 +26,7 @@ module.exports = {
},
},
},
plugins: [
require("tailwindcss-animate"),
],
};

View File

@@ -8,16 +8,17 @@ serde = { version = "1.0", features = ["derive"] }
utoipa = { version = "5.4.0", features = ["axum_extras"] }
struct-patch = "0.5"
rmp-serde = "1.3"
bytes = "1"
http = "1"
# Leptos 0.8.7
leptos = { version = "0.8.7", features = ["nightly"] }
leptos = { version = "0.8.15", features = ["nightly", "msgpack"] }
leptos_router = { version = "0.8.7", features = ["nightly"] }
leptos_axum = { version = "0.8.7", optional = true }
axum = { version = "0.8", features = ["macros"], optional = true }
# SSR Dependencies (XML-RPC & SCGI)
tokio = { version = "1", features = ["full"], optional = true }
bytes = { version = "1", optional = true }
thiserror = { version = "2", optional = true }
quick-xml = { version = "0.31", features = ["serde", "serialize"], optional = true }
@@ -34,7 +35,6 @@ bcrypt = { version = "0.17", optional = true }
default = []
ssr = [
"dep:tokio",
"dep:bytes",
"dep:thiserror",
"dep:quick-xml",
"dep:leptos_axum",

1
shared/src/codec.rs Normal file
View File

@@ -0,0 +1 @@
pub use leptos::server_fn::codec::MsgPack;

View File

@@ -11,6 +11,8 @@ pub mod xmlrpc;
#[cfg(feature = "ssr")]
pub mod db;
pub mod codec;
pub mod server_fns;
#[derive(Clone, Debug)]

View File

@@ -1,5 +1,6 @@
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use crate::codec::MsgPack;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserResponse {
@@ -19,7 +20,7 @@ pub struct SetupStatus {
pub completed: bool,
}
#[server(GetSetupStatus, "/api/server_fns/GetSetupStatus")]
#[server(GetSetupStatus, "/api/server_fns/GetSetupStatus", input = MsgPack, output = MsgPack)]
pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
use crate::DbContext;
@@ -32,7 +33,7 @@ pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
})
}
#[server(Setup, "/api/server_fns/Setup")]
#[server(Setup, "/api/server_fns/Setup", input = MsgPack, output = MsgPack)]
pub async fn setup(username: String, password: String) -> Result<(), ServerFnError> {
use crate::DbContext;
@@ -54,7 +55,7 @@ pub async fn setup(username: String, password: String) -> Result<(), ServerFnErr
Ok(())
}
#[server(Login, "/api/server_fns/Login")]
#[server(Login, "/api/server_fns/Login", input = MsgPack, output = MsgPack)]
pub async fn login(username: String, password: String) -> Result<UserResponse, ServerFnError> {
use crate::DbContext;
use leptos_axum::ResponseOptions;
@@ -110,7 +111,7 @@ pub async fn login(username: String, password: String) -> Result<UserResponse, S
}
}
#[server(Logout, "/api/server_fns/Logout")]
#[server(Logout, "/api/server_fns/Logout", input = MsgPack, output = MsgPack)]
pub async fn logout() -> Result<(), ServerFnError> {
use leptos_axum::ResponseOptions;
use cookie::{Cookie, SameSite};
@@ -131,7 +132,7 @@ pub async fn logout() -> Result<(), ServerFnError> {
Ok(())
}
#[server(GetUser, "/api/server_fns/GetUser")]
#[server(GetUser, "/api/server_fns/GetUser", input = MsgPack, output = MsgPack)]
pub async fn get_user() -> Result<Option<UserResponse>, ServerFnError> {
use axum::http::HeaderMap;
use leptos_axum::extract;