Compare commits

..

20 Commits

Author SHA1 Message Date
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
spinline
93e853977a feat: simplify theme toggle and improve sidebar layout
All checks were successful
Build MIPS Binary / build (push) Successful in 5m20s
- frontend/src/components/layout/sidebar.rs: Replaced theme selector with simple dark/light toggle button.
- Cleaned up profile section layout and added safe-area padding.
2026-02-11 01:31:30 +03:00
spinline
e3bc956256 feat: migrate to shadcn toast (sonner)
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
- frontend/src/app.rs: Replaced custom ToastContainer with SonnerProvider
- frontend/src/store.rs: Updated show_toast to use leptos_shadcn_toast::toast API
- frontend/src/components/toast.rs: Deleted custom toast component
- frontend/src/components/torrent/add_torrent.rs: Updated toast usage
- frontend/src/components/torrent/table.rs: Updated toast usage
2026-02-11 01:26:46 +03:00
21 changed files with 588 additions and 1221 deletions

View File

@@ -1,154 +1,2 @@
use crate::AppState; // This file is intentionally empty as authentication is now handled by Server Functions.
use axum::{ // See shared/src/server_fns/auth.rs
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()
}

View File

@@ -1,125 +1,2 @@
use crate::AppState; // This file is intentionally empty as setup is now handled by Server Functions.
use axum::{ // See shared/src/server_fns/auth.rs
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()
}

View File

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

View File

@@ -1,16 +1,3 @@
use governor::clock::QuantaInstant; // This file can be removed or repurposed if rate limiting is needed for other endpoints.
use governor::middleware::NoOpMiddleware; // Login rate limiting is now handled within the server function or needs to be reimplemented
use tower_governor::governor::GovernorConfig; // as a middleware for the server function endpoint.
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()
}

View File

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

View File

@@ -7,7 +7,7 @@ edition = "2021"
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
leptos = { version = "0.8.15", features = ["csr"] } leptos = { version = "0.8.15", features = ["csr", "msgpack"] }
leptos_router = { version = "0.8.11" } leptos_router = { version = "0.8.11" }
console_error_panic_hook = "0.1" console_error_panic_hook = "0.1"
@@ -17,6 +17,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
gloo-net = "0.6" gloo-net = "0.6"
gloo-timers = { version = "0.3", features = ["futures"] } gloo-timers = { version = "0.3", features = ["futures"] }
gloo-console = "0.3"
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
uuid = { version = "1", features = ["v4", "js"] } uuid = { version = "1", features = ["v4", "js"] }

File diff suppressed because it is too large Load Diff

View File

@@ -14,10 +14,14 @@
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-cli": "^11.0.1", "postcss-cli": "^11.0.1",
"postcss-preset-env": "^11.1.3", "postcss-preset-env": "^10.1.3",
"tailwindcss": "^4.1.18" "tailwindcss": "^4.1.18"
}, },
"dependencies": { "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

@@ -1,5 +1,4 @@
use crate::components::layout::protected::Protected; use crate::components::layout::protected::Protected;
use crate::components::toast::ToastContainer;
use crate::components::torrent::table::TorrentTable; use crate::components::torrent::table::TorrentTable;
use crate::components::torrent::detail::TorrentDetail; use crate::components::torrent::detail::TorrentDetail;
use crate::components::auth::login::Login; use crate::components::auth::login::Login;
@@ -9,9 +8,19 @@ use leptos::task::spawn_local;
use leptos_router::components::{Router, Routes, Route}; use leptos_router::components::{Router, Routes, Route};
use leptos_router::hooks::use_navigate; use leptos_router::hooks::use_navigate;
use leptos_shadcn_skeleton::Skeleton; use leptos_shadcn_skeleton::Skeleton;
use leptos_shadcn_toast::SonnerProvider;
#[component] #[component]
pub fn App() -> impl IntoView { pub fn App() -> impl IntoView {
view! {
<SonnerProvider>
<InnerApp />
</SonnerProvider>
}
}
#[component]
fn InnerApp() -> impl IntoView {
crate::store::provide_torrent_store(); crate::store::provide_torrent_store();
let store = use_context::<crate::store::TorrentStore>(); let store = use_context::<crate::store::TorrentStore>();
@@ -22,6 +31,7 @@ pub fn App() -> impl IntoView {
Effect::new(move |_| { Effect::new(move |_| {
spawn_local(async move { spawn_local(async move {
log::info!("App initialization started..."); log::info!("App initialization started...");
gloo_console::log!("APP INIT: Checking setup status...");
// Check if setup is needed via Server Function // Check if setup is needed via Server Function
match shared::server_fns::auth::get_setup_status().await { match shared::server_fns::auth::get_setup_status().await {
@@ -54,6 +64,7 @@ pub fn App() -> impl IntoView {
} }
is_loading.1.set(false); is_loading.1.set(false);
crate::store::toast_success("VibeTorrent'e Hoşgeldiniz");
}); });
}); });
@@ -188,8 +199,6 @@ pub fn App() -> impl IntoView {
}/> }/>
</Routes> </Routes>
</Router> </Router>
<ToastContainer />
</div> </div>
} }
} }

View File

@@ -3,7 +3,7 @@ use leptos::task::spawn_local;
use leptos_shadcn_button::{Button, ButtonVariant, ButtonSize}; use leptos_shadcn_button::{Button, ButtonVariant, ButtonSize};
use leptos_shadcn_avatar::{Avatar, AvatarFallback}; use leptos_shadcn_avatar::{Avatar, AvatarFallback};
use leptos_shadcn_separator::Separator; use leptos_shadcn_separator::Separator;
use leptos::html;
use leptos_use::storage::use_local_storage; use leptos_use::storage::use_local_storage;
use ::codee::string::FromToStringCodec; use ::codee::string::FromToStringCodec;
@@ -93,11 +93,11 @@ pub fn Sidebar() -> impl IntoView {
} }
}); });
let theme_details_ref = NodeRef::<html::Details>::new();
let close_details = move |node_ref: NodeRef<html::Details>| {
if let Some(el) = node_ref.get_untracked() { let toggle_theme = move |_| {
el.set_open(false); let new_theme = if current_theme.get() == "dark" { "light" } else { "dark" };
} set_current_theme.set(new_theme.to_string());
}; };
// --- THEME LOGIC END --- // --- THEME LOGIC END ---
@@ -192,7 +192,7 @@ pub fn Sidebar() -> impl IntoView {
<Separator /> <Separator />
<div class="p-4 bg-card"> <div class="p-4 bg-card" style="padding-bottom: calc(1rem + env(safe-area-inset-bottom));">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Avatar class="h-8 w-8"> <Avatar class="h-8 w-8">
<AvatarFallback class="bg-primary text-primary-foreground text-xs font-medium"> <AvatarFallback class="bg-primary text-primary-foreground text-xs font-medium">
@@ -205,50 +205,25 @@ pub fn Sidebar() -> impl IntoView {
</div> </div>
// --- THEME BUTTON --- // --- THEME BUTTON ---
<details class="group relative" node_ref=theme_details_ref> <Button
<summary class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-8 w-8 cursor-pointer outline-none list-none [&::-webkit-details-marker]:hidden"> variant=ButtonVariant::Ghost
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> size=ButtonSize::Icon
<path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" /> class="h-8 w-8 text-muted-foreground hover:text-foreground"
on_click=Callback::new(toggle_theme)
>
// Sun icon for dark mode (to switch to light), Moon for light (to switch to dark)
// Actually show current state or action? Usually action.
// If dark, show Sun. If light, show Moon.
<Show when=move || current_theme.get() == "dark" fallback=|| view! {
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg> </svg>
</summary> }>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<div class="absolute bottom-full left-0 mb-2 z-[100] min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md hidden group-open:block animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2 max-h-64 overflow-y-auto"> <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
<ul class="w-full"> </svg>
{ </Show>
let themes = vec![ </Button>
"light", "dark", "dim", "nord", "cupcake", "dracula", "cyberpunk", "emerald", "sunset", "abyss"
];
themes.into_iter().map(|theme| {
let theme_name = theme.to_string();
let theme_name_for_class = theme_name.clone();
let theme_name_for_onclick = theme_name.clone();
let is_active = move || current_theme.get() == theme_name_for_class;
view! {
<li>
<button
class=move || {
let base = "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent hover:text-accent-foreground capitalize";
if is_active() { format!("{} bg-accent text-accent-foreground font-medium", base) } else { base.to_string() }
}
on:click=move |_| {
set_current_theme.set(theme_name_for_onclick.clone());
close_details(theme_details_ref);
}
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Show when=is_active.clone() fallback=|| ()>
<span>""</span>
</Show>
</span>
{theme_name}
</button>
</li>
}
}).collect::<Vec<_>>()
}
</ul>
</div>
</details>
<Button <Button
variant=ButtonVariant::Ghost variant=ButtonVariant::Ghost
size=ButtonSize::Icon size=ButtonSize::Icon

View File

@@ -1,5 +1,4 @@
pub mod context_menu; pub mod context_menu;
pub mod layout; pub mod layout;
pub mod toast;
pub mod torrent; pub mod torrent;
pub mod auth; pub mod auth;

View File

@@ -1,86 +0,0 @@
use leptos::prelude::*;
use shared::NotificationLevel;
use leptos_shadcn_alert::{Alert, AlertVariant};
// ============================================================================
// Toast Components - Using ShadCN Alert
// ============================================================================
fn level_to_variant(level: &NotificationLevel) -> AlertVariant {
match level {
NotificationLevel::Info => AlertVariant::Default,
NotificationLevel::Success => AlertVariant::Success,
NotificationLevel::Warning => AlertVariant::Warning,
NotificationLevel::Error => AlertVariant::Destructive,
}
}
fn level_icon(level: &NotificationLevel) -> impl IntoView {
match level {
NotificationLevel::Info => view! {
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 opacity-90">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
}.into_any(),
NotificationLevel::Success => view! {
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 opacity-90">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}.into_any(),
NotificationLevel::Warning => view! {
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 opacity-90">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
}.into_any(),
NotificationLevel::Error => view! {
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 opacity-90">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
}.into_any(),
}
}
/// Individual toast item component
#[component]
fn ToastItem(
level: NotificationLevel,
message: String,
) -> impl IntoView {
let variant = level_to_variant(&level);
let icon = level_icon(&level);
view! {
<Alert variant=variant class="pointer-events-auto shadow-lg">
<div class="flex items-center gap-3">
{icon}
<div class="text-sm font-medium">{message}</div>
</div>
</Alert>
}
}
/// Main toast container - renders all active notifications
#[component]
pub fn ToastContainer() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("TorrentStore not provided");
let notifications = store.notifications;
view! {
<div
class="fixed bottom-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px] gap-2"
>
<For
each=move || notifications.get()
key=|item| item.id
children=move |item| {
view! {
<ToastItem
level=item.notification.level
message=item.notification.message
/>
}
}
/>
</div>
}
}

View File

@@ -10,8 +10,7 @@ use crate::api;
pub fn AddTorrentDialog( pub fn AddTorrentDialog(
on_close: Callback<()>, on_close: Callback<()>,
) -> impl IntoView { ) -> impl IntoView {
let store = use_context::<TorrentStore>().expect("TorrentStore not provided"); let _store = use_context::<TorrentStore>().expect("TorrentStore not provided");
let notifications = store.notifications;
let uri = signal(String::new()); let uri = signal(String::new());
let is_loading = signal(false); let is_loading = signal(false);
@@ -34,11 +33,7 @@ pub fn AddTorrentDialog(
match api::torrent::add(&uri_val).await { match api::torrent::add(&uri_val).await {
Ok(_) => { Ok(_) => {
log::info!("Torrent added successfully"); log::info!("Torrent added successfully");
crate::store::show_toast_with_signal( crate::store::toast_success("Torrent başarıyla eklendi");
notifications,
shared::NotificationLevel::Success,
"Torrent başarıyla eklendi"
);
on_close.run(()); on_close.run(());
} }
Err(e) => { Err(e) => {

View File

@@ -1,6 +1,6 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use crate::store::{get_action_messages, show_toast_with_signal}; use crate::store::{get_action_messages, show_toast};
use crate::api; use crate::api;
use shared::NotificationLevel; use shared::NotificationLevel;
use crate::components::context_menu::TorrentContextMenu; use crate::components::context_menu::TorrentContextMenu;
@@ -116,7 +116,6 @@ pub fn TorrentTable() -> impl IntoView {
let (success_msg_str, error_msg_str): (&'static str, &'static str) = get_action_messages(&action); let (success_msg_str, error_msg_str): (&'static str, &'static str) = get_action_messages(&action);
let success_msg = success_msg_str.to_string(); let success_msg = success_msg_str.to_string();
let error_msg = error_msg_str.to_string(); let error_msg = error_msg_str.to_string();
let notifications = store.notifications;
spawn_local(async move { spawn_local(async move {
let result = match action.as_str() { let result = match action.as_str() {
"delete" => api::torrent::delete(&hash).await, "delete" => api::torrent::delete(&hash).await,
@@ -126,8 +125,8 @@ pub fn TorrentTable() -> impl IntoView {
_ => api::torrent::action(&hash, &action).await, _ => api::torrent::action(&hash, &action).await,
}; };
match result { match result {
Ok(_) => show_toast_with_signal(notifications, NotificationLevel::Success, success_msg), Ok(_) => show_toast(NotificationLevel::Success, success_msg),
Err(e) => show_toast_with_signal(notifications, NotificationLevel::Error, format!("{}: {:?}", error_msg, e)), Err(e) => show_toast(NotificationLevel::Error, format!("{}: {:?}", error_msg, e)),
} }
}); });
}); });

View File

@@ -2,45 +2,25 @@ use futures::StreamExt;
use gloo_net::eventsource::futures::EventSource; use gloo_net::eventsource::futures::EventSource;
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use shared::{AppEvent, GlobalStats, NotificationLevel, SystemNotification, Torrent}; use shared::{AppEvent, GlobalStats, NotificationLevel, Torrent};
use std::collections::HashMap; use std::collections::HashMap;
use struct_patch::traits::Patch; use struct_patch::traits::Patch;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
#[derive(Clone, Debug, PartialEq)]
pub struct NotificationItem {
pub id: u64,
pub notification: SystemNotification,
}
pub fn show_toast_with_signal(
notifications: RwSignal<Vec<NotificationItem>>,
level: NotificationLevel,
message: impl Into<String>,
) {
let id = js_sys::Date::now() as u64;
let notification = SystemNotification {
level,
message: message.into(),
};
let item = NotificationItem { id, notification };
notifications.update(|list| list.push(item));
leptos::prelude::set_timeout(
move || {
notifications.update(|list| list.retain(|i| i.id != id));
},
std::time::Duration::from_secs(5),
);
}
pub fn show_toast(level: NotificationLevel, message: impl Into<String>) { pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
if let Some(store) = use_context::<TorrentStore>() { let msg = message.into();
show_toast_with_signal(store.notifications, level, message); gloo_console::log!("TOAST CALL:", &msg, format!("{:?}", level));
log::info!("Displaying toast: [{:?}] {}", level, msg);
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(); },
} }
} }
pub fn toast_success(message: impl Into<String>) { show_toast(NotificationLevel::Success, message); } pub fn toast_success(message: impl Into<String>) { show_toast(NotificationLevel::Success, message); }
pub fn toast_error(message: impl Into<String>) { show_toast(NotificationLevel::Error, message); } pub fn toast_error(message: impl Into<String>) { show_toast(NotificationLevel::Error, message); }
@@ -67,7 +47,6 @@ pub struct TorrentStore {
pub filter: RwSignal<FilterStatus>, pub filter: RwSignal<FilterStatus>,
pub search_query: RwSignal<String>, pub search_query: RwSignal<String>,
pub global_stats: RwSignal<GlobalStats>, pub global_stats: RwSignal<GlobalStats>,
pub notifications: RwSignal<Vec<NotificationItem>>,
pub user: RwSignal<Option<String>>, pub user: RwSignal<Option<String>>,
pub selected_torrent: RwSignal<Option<String>>, pub selected_torrent: RwSignal<Option<String>>,
} }
@@ -77,16 +56,14 @@ pub fn provide_torrent_store() {
let filter = RwSignal::new(FilterStatus::All); let filter = RwSignal::new(FilterStatus::All);
let search_query = RwSignal::new(String::new()); let search_query = RwSignal::new(String::new());
let global_stats = RwSignal::new(GlobalStats::default()); let global_stats = RwSignal::new(GlobalStats::default());
let notifications = RwSignal::new(Vec::<NotificationItem>::new());
let user = RwSignal::new(Option::<String>::None); let user = RwSignal::new(Option::<String>::None);
let selected_torrent = RwSignal::new(Option::<String>::None); let selected_torrent = RwSignal::new(Option::<String>::None);
let show_browser_notification = crate::utils::notification::use_app_notification(); let show_browser_notification = crate::utils::notification::use_app_notification();
let store = TorrentStore { torrents, filter, search_query, global_stats, notifications, user, selected_torrent }; let store = TorrentStore { torrents, filter, search_query, global_stats, user, selected_torrent };
provide_context(store); provide_context(store);
let notifications_for_sse = notifications;
let global_stats_for_sse = global_stats; let global_stats_for_sse = global_stats;
let torrents_for_sse = torrents; let torrents_for_sse = torrents;
let show_browser_notification = show_browser_notification.clone(); let show_browser_notification = show_browser_notification.clone();
@@ -112,7 +89,7 @@ pub fn provide_torrent_store() {
got_first_message = true; got_first_message = true;
backoff_ms = 1000; backoff_ms = 1000;
if was_connected && disconnect_notified { if was_connected && disconnect_notified {
show_toast_with_signal(notifications_for_sse, NotificationLevel::Success, "Sunucu bağlantısı yeniden kuruldu"); show_toast(NotificationLevel::Success, "Sunucu bağlantısı yeniden kuruldu");
disconnect_notified = false; disconnect_notified = false;
} }
was_connected = true; was_connected = true;
@@ -149,7 +126,7 @@ pub fn provide_torrent_store() {
} }
AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); } AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); }
AppEvent::Notification(n) => { AppEvent::Notification(n) => {
show_toast_with_signal(notifications_for_sse, n.level.clone(), n.message.clone()); show_toast(n.level.clone(), n.message.clone());
if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error { if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
show_browser_notification("VibeTorrent", &n.message); show_browser_notification("VibeTorrent", &n.message);
} }
@@ -164,14 +141,14 @@ pub fn provide_torrent_store() {
} }
} }
if was_connected && !disconnect_notified { if was_connected && !disconnect_notified {
show_toast_with_signal(notifications_for_sse, NotificationLevel::Warning, "Sunucu bağlantısı kesildi, yeniden bağlanılıyor..."); show_toast(NotificationLevel::Warning, "Sunucu bağlantısı kesildi, yeniden bağlanılıyor...");
disconnect_notified = true; disconnect_notified = true;
} }
} }
} }
Err(_) => { Err(_) => {
if was_connected && !disconnect_notified { if was_connected && !disconnect_notified {
show_toast_with_signal(notifications_for_sse, NotificationLevel::Warning, "Sunucu bağlantısı kurulamıyor..."); show_toast(NotificationLevel::Warning, "Sunucu bağlantısı kurulamıyor...");
disconnect_notified = true; disconnect_notified = true;
} }
} }

View File

@@ -1,6 +1,6 @@
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use web_sys::{Notification, NotificationOptions}; use web_sys::{Notification, NotificationOptions};
use leptos::prelude::*;
/// Request browser notification permission from user /// Request browser notification permission from user
pub async fn request_notification_permission() -> bool { 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"] } utoipa = { version = "5.4.0", features = ["axum_extras"] }
struct-patch = "0.5" struct-patch = "0.5"
rmp-serde = "1.3" rmp-serde = "1.3"
bytes = "1"
http = "1"
# Leptos 0.8.7 # 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_router = { version = "0.8.7", features = ["nightly"] }
leptos_axum = { version = "0.8.7", optional = true } leptos_axum = { version = "0.8.7", optional = true }
axum = { version = "0.8", features = ["macros"], optional = true } axum = { version = "0.8", features = ["macros"], optional = true }
# SSR Dependencies (XML-RPC & SCGI) # SSR Dependencies (XML-RPC & SCGI)
tokio = { version = "1", features = ["full"], optional = true } tokio = { version = "1", features = ["full"], optional = true }
bytes = { version = "1", optional = true }
thiserror = { version = "2", optional = true } thiserror = { version = "2", optional = true }
quick-xml = { version = "0.31", features = ["serde", "serialize"], optional = true } quick-xml = { version = "0.31", features = ["serde", "serialize"], optional = true }
@@ -34,7 +35,6 @@ bcrypt = { version = "0.17", optional = true }
default = [] default = []
ssr = [ ssr = [
"dep:tokio", "dep:tokio",
"dep:bytes",
"dep:thiserror", "dep:thiserror",
"dep:quick-xml", "dep:quick-xml",
"dep:leptos_axum", "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")] #[cfg(feature = "ssr")]
pub mod db; pub mod db;
pub mod codec;
pub mod server_fns; pub mod server_fns;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]

View File

@@ -1,5 +1,6 @@
use leptos::prelude::*; use leptos::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::codec::MsgPack;
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserResponse { pub struct UserResponse {
@@ -19,7 +20,7 @@ pub struct SetupStatus {
pub completed: bool, 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> { pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
use crate::DbContext; 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> { pub async fn setup(username: String, password: String) -> Result<(), ServerFnError> {
use crate::DbContext; use crate::DbContext;
@@ -54,7 +55,7 @@ pub async fn setup(username: String, password: String) -> Result<(), ServerFnErr
Ok(()) 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> { pub async fn login(username: String, password: String) -> Result<UserResponse, ServerFnError> {
use crate::DbContext; use crate::DbContext;
use leptos_axum::ResponseOptions; 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> { pub async fn logout() -> Result<(), ServerFnError> {
use leptos_axum::ResponseOptions; use leptos_axum::ResponseOptions;
use cookie::{Cookie, SameSite}; use cookie::{Cookie, SameSite};
@@ -131,7 +132,7 @@ pub async fn logout() -> Result<(), ServerFnError> {
Ok(()) 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> { pub async fn get_user() -> Result<Option<UserResponse>, ServerFnError> {
use axum::http::HeaderMap; use axum::http::HeaderMap;
use leptos_axum::extract; use leptos_axum::extract;