Compare commits

..

1 Commits

Author SHA1 Message Date
spinline
275bb6e37a feat(torrent): add date sorting and display
All checks were successful
Build MIPS Binary / build (push) Successful in 4m13s
- Sort torrents by added date (newest first) by default
- Add Date column to desktop table (after ETA)
- Add Date to mobile card view
- Add Date option to mobile sort dropdown
- Display dates in DD/MM/YYYY HH:mm format
2026-02-08 03:35:49 +03:00
100 changed files with 7631 additions and 13732 deletions

View File

@@ -26,22 +26,23 @@ jobs:
run: | run: |
cd frontend cd frontend
npm install npm install
npx @tailwindcss/cli -i input.css -o public/tailwind.css --minify --content './src/**/*.rs' # Run Tailwind manually first
# Trunk'ın optimizasyonunu kapalı (0) tutuyoruz çünkü Cargo.toml'daki opt-level='z' zaten o işi yapıyor. npx @tailwindcss/cli -i input.css -o public/tailwind.css
trunk build --release trunk build --release
- name: Build Backend (MIPS) - name: Build Backend (MIPS)
env: env:
# -s ve -w ile binary içindeki gereksiz tüm yükleri siliyoruz. # Ensure we are building a fully static binary
RUSTFLAGS: "-C target-feature=+crt-static -C link-self-contained=no -C link-arg=-msoft-float -C link-arg=-s -C link-arg=-w" # -C link-self-contained=no: Let Zig (the linker) handle CRT objects (crt1.o, etc.)
RUSTFLAGS: "-C target-feature=+crt-static -C link-self-contained=no -C link-arg=-msoft-float"
CFLAGS_mips_unknown_linux_musl: "-msoft-float" CFLAGS_mips_unknown_linux_musl: "-msoft-float"
run: | run: |
# Sadece gerekli özellikleri derliyoruz (Boyut tasarrufu için swagger kapalı) cd backend
cargo zigbuild -p backend --target mips-unknown-linux-musl --release -Z build-std=std,panic_abort --no-default-features --features push-notifications cargo zigbuild --target mips-unknown-linux-musl --release -Z build-std=std,panic_abort
file target/mips-unknown-linux-musl/release/backend
- name: Create Release Assets - name: Rename Binary
run: | run: mv target/mips-unknown-linux-musl/release/backend target/mips-unknown-linux-musl/release/vibetorrent-mips
mv target/mips-unknown-linux-musl/release/backend target/mips-unknown-linux-musl/release/vibetorrent-mips
- name: Generate Release Tag - name: Generate Release Tag
id: tag id: tag
@@ -55,10 +56,8 @@ jobs:
REPO="admin/vibetorrent" REPO="admin/vibetorrent"
API_URL="${{ gitea.server_url }}/api/v1" API_URL="${{ gitea.server_url }}/api/v1"
RELEASE_RESPONSE=$(curl -s -X POST "${API_URL}/repos/${REPO}/releases" \ # Create release
-H "Authorization: token ${RELEASE_TOKEN}" \ RELEASE_RESPONSE=$(curl -s -X POST "${API_URL}/repos/${REPO}/releases" -H "Authorization: token ${RELEASE_TOKEN}" -H "Content-Type: application/json" -d "{
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"${TAG}\", \"tag_name\": \"${TAG}\",
\"name\": \"Release ${TAG}\", \"name\": \"Release ${TAG}\",
\"body\": \"Automated build from commit ${{ gitea.sha }}\", \"body\": \"Automated build from commit ${{ gitea.sha }}\",
@@ -67,9 +66,15 @@ jobs:
}") }")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id') RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then exit 1; fi echo "Release ID: $RELEASE_ID"
curl -s -X POST "${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=vibetorrent-mips" \ if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
-H "Authorization: token ${RELEASE_TOKEN}" \ echo "Failed to create release:"
-H "Content-Type: application/octet-stream" \ echo "$RELEASE_RESPONSE"
--data-binary @target/mips-unknown-linux-musl/release/vibetorrent-mips exit 1
fi
# Upload binary as release asset
curl -s -X POST "${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=vibetorrent-mips" -H "Authorization: token ${RELEASE_TOKEN}" -H "Content-Type: application/octet-stream" --data-binary @target/mips-unknown-linux-musl/release/vibetorrent-mips
echo "Release ${TAG} created with binary attached."

2
.gitignore vendored
View File

@@ -6,5 +6,3 @@ result.xml
frontend/dist frontend/dist
backend.log backend.log
.runner .runner
.env
backend/.env

1391
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,20 +2,13 @@
members = ["backend", "frontend", "shared"] members = ["backend", "frontend", "shared"]
resolver = "2" resolver = "2"
# Optimize for size (aggressive)
[profile.release] [profile.release]
# En küçük binary boyutu
opt-level = "z" opt-level = "z"
# En derin kod temizliği (dead code elimination) lto = true
lto = "fat"
# En iyi optimizasyon için tek birim derleme
codegen-units = 1 codegen-units = 1
# Hata izleme kodlarını atarak yer kazan
panic = "abort" panic = "abort"
# Sembolleri ve hata ayıklama bilgilerini kesin sil
strip = true strip = true
# Artık (incremental) build'i kapat ki optimizasyon tam olsun
incremental = false
[patch.crates-io] [patch.crates-io]
coarsetime = { path = "patches/coarsetime" } coarsetime = { path = "third_party/coarsetime" }

View File

@@ -3,12 +3,3 @@ RTORRENT_SOCKET=/tmp/rtorrent.sock
# Backend Listen Port # Backend Listen Port
PORT=3000 PORT=3000
# Database URL
DATABASE_URL=sqlite:vibetorrent.db
# VAPID Keys for Push Notifications
# Generate new keys for production using: npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY=YOUR_PUBLIC_VAPID_KEY
VAPID_PRIVATE_KEY=YOUR_PRIVATE_VAPID_KEY
VAPID_EMAIL=mailto:your-email@example.com

View File

@@ -4,46 +4,38 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[features] [features]
default = ["swagger"] # push-notifications kaldırıldı default = ["push-notifications"]
push-notifications = ["web-push", "openssl"] push-notifications = ["web-push", "openssl"]
swagger = ["utoipa-swagger-ui"]
[dependencies] [dependencies]
axum = { version = "0.8", features = ["macros", "ws"] } axum = { version = "0.8", features = ["macros", "ws"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tower = { version = "0.5", features = ["util", "timeout"] } tower = { version = "0.4", features = ["util", "timeout"] }
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "compression-full"] } tower-http = { version = "0.5", features = ["fs", "trace", "cors", "compression-full"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
rmp-serde = "1.3"
struct-patch = "0.5"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tokio-stream = "0.1" tokio-stream = "0.1"
bytes = "1" bytes = "1"
futures = "0.3" futures = "0.3"
quick-xml = { version = "0.31", features = ["serde", "serialize"] } quick-xml = { version = "0.31", features = ["serde", "serialize"] }
# We might need `tokio-util` for codecs if we implement SCGI manually
tokio-util = { version = "0.7", features = ["codec", "io"] } tokio-util = { version = "0.7", features = ["codec", "io"] }
clap = { version = "4.4", features = ["derive", "env"] } clap = { version = "4.4", features = ["derive", "env"] }
rust-embed = "8.2" rust-embed = "8.2"
mime_guess = "2.0" mime_guess = "2.0"
shared = { path = "../shared", features = ["ssr"] } shared = { path = "../shared" }
thiserror = "2.0.18" thiserror = "2.0.18"
dotenvy = "0.15.7" dotenvy = "0.15.7"
utoipa = { version = "5.4.0", features = ["axum_extras"] } utoipa = { version = "5.4.0", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0", features = ["axum"], optional = true } utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
web-push = { version = "0.10", default-features = false, features = ["hyper-client"], optional = true } web-push = { version = "0.10", default-features = false, features = ["hyper-client"], optional = true }
base64 = "0.22" base64 = "0.22"
openssl = { version = "0.10", features = ["vendored"], optional = true } openssl = { version = "0.10", features = ["vendored"], optional = true }
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
bcrypt = "0.17.0" bcrypt = "0.17.0"
axum-extra = { version = "0.10", features = ["cookie"] } axum-extra = { version = "0.10", features = ["cookie"] }
rand = "0.8" rand = "0.8"
anyhow = "1.0.101" anyhow = "1.0.101"
time = { version = "0.3.47", features = ["serde", "formatting", "parsing"] } time = { version = "0.3.47", features = ["serde", "formatting", "parsing"] }
tower_governor = "0.8.0"
governor = "0.10.4"
# Leptos
leptos = { version = "0.8.15", features = ["nightly"] }
leptos_axum = { version = "0.8.7" }
jsonwebtoken = "9"

View File

@@ -1,7 +1,6 @@
use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite, Row, sqlite::SqliteConnectOptions}; use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite, Row};
use std::time::Duration; use std::time::Duration;
use anyhow::Result; use anyhow::Result;
use std::str::FromStr;
#[derive(Clone)] #[derive(Clone)]
pub struct Db { pub struct Db {
@@ -10,25 +9,42 @@ pub struct Db {
impl Db { impl Db {
pub async fn new(db_url: &str) -> Result<Self> { pub async fn new(db_url: &str) -> Result<Self> {
let options = SqliteConnectOptions::from_str(db_url)?
.create_if_missing(true)
.busy_timeout(Duration::from_secs(10)) // Bekleme süresini 10 saniyeye çıkardık
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.synchronous(sqlx::sqlite::SqliteSynchronous::Normal);
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
.max_connections(5) .max_connections(5)
.acquire_timeout(Duration::from_secs(10)) .acquire_timeout(Duration::from_secs(3))
.connect_with(options) .connect(db_url)
.await?; .await?;
let db = Self { pool }; let db = Self { pool };
db.run_migrations().await?; db.init().await?;
Ok(db) Ok(db)
} }
async fn run_migrations(&self) -> Result<()> { async fn init(&self) -> Result<()> {
sqlx::migrate!("./migrations").run(&self.pool).await?; // Create users table
sqlx::query(
"CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)",
)
.execute(&self.pool)
.await?;
// Create sessions table
sqlx::query(
"CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at DATETIME NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
)",
)
.execute(&self.pool)
.await?;
Ok(()) Ok(())
} }
@@ -43,24 +59,23 @@ impl Db {
Ok(()) Ok(())
} }
pub async fn get_user_by_username(&self, username: &str) -> Result<Option<(i64, String)>> { pub async fn get_user_by_username(&self, username: &str) -> Result<Option<(i64, String)>> {
let row = sqlx::query("SELECT id, password_hash FROM users WHERE username = ?") let row = sqlx::query("SELECT id, password_hash FROM users WHERE username = ?")
.bind(username) .bind(username)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await?; .await?;
Ok(row.map(|r| (r.get(0), r.get(1)))) Ok(row.map(|r| (r.get(0), r.get(1))))
} }
pub async fn get_username_by_id(&self, id: i64) -> Result<Option<String>> { pub async fn get_username_by_id(&self, id: i64) -> Result<Option<String>> {
let row = sqlx::query("SELECT username FROM users WHERE id = ?") let row = sqlx::query("SELECT username FROM users WHERE id = ?")
.bind(id) .bind(id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await?; .await?;
Ok(row.map(|r| r.get(0)))
}
Ok(row.map(|r| r.get(0)))
}
pub async fn has_users(&self) -> Result<bool> { pub async fn has_users(&self) -> Result<bool> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&self.pool) .fetch_one(&self.pool)
@@ -113,36 +128,4 @@ impl Db {
.await?; .await?;
Ok(()) Ok(())
} }
// --- Push Subscription Operations ---
pub async fn save_push_subscription(&self, endpoint: &str, p256dh: &str, auth: &str) -> Result<()> {
sqlx::query(
"INSERT INTO push_subscriptions (endpoint, p256dh, auth) VALUES (?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth"
)
.bind(endpoint)
.bind(p256dh)
.bind(auth)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn remove_push_subscription(&self, endpoint: &str) -> Result<()> {
sqlx::query("DELETE FROM push_subscriptions WHERE endpoint = ?")
.bind(endpoint)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_all_push_subscriptions(&self) -> Result<Vec<(String, String, String)>> {
let rows = sqlx::query_as::<_, (String, String, String)>(
"SELECT endpoint, p256dh, auth FROM push_subscriptions"
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
} }

View File

@@ -1,4 +1,3 @@
use std::collections::HashMap;
use shared::{AppEvent, NotificationLevel, SystemNotification, Torrent, TorrentUpdate}; use shared::{AppEvent, NotificationLevel, SystemNotification, Torrent, TorrentUpdate};
#[derive(Debug)] #[derive(Debug)]
@@ -9,54 +8,99 @@ pub enum DiffResult {
} }
pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult { pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult {
// 1. Structural Check (Length or Order changed)
if old.len() != new.len() { if old.len() != new.len() {
return DiffResult::FullUpdate; return DiffResult::FullUpdate;
} }
let old_map: HashMap<&str, &Torrent> = old.iter().map(|t| (t.hash.as_str(), t)).collect(); for (i, t) in new.iter().enumerate() {
if old[i].hash != t.hash {
for new_t in new {
if !old_map.contains_key(new_t.hash.as_str()) {
return DiffResult::FullUpdate; return DiffResult::FullUpdate;
} }
} }
// 2. Field Updates
let mut events = Vec::new(); let mut events = Vec::new();
for new_t in new { for (i, new_t) in new.iter().enumerate() {
let old_t = old_map.get(new_t.hash.as_str()).unwrap(); let old_t = &old[i];
// Initialize with all None
let mut update = TorrentUpdate {
hash: new_t.hash.clone(),
name: None,
size: None,
down_rate: None,
up_rate: None,
percent_complete: None,
completed: None,
eta: None,
status: None,
error_message: None,
label: None,
};
// Manuel diff creating TorrentUpdate (which is the Patch struct)
let mut patch = TorrentUpdate::default();
let mut has_changes = false; let mut has_changes = false;
if old_t.name != new_t.name { patch.name = Some(new_t.name.clone()); has_changes = true; } // Compare fields
if old_t.size != new_t.size { patch.size = Some(new_t.size); has_changes = true; } if old_t.name != new_t.name {
if old_t.down_rate != new_t.down_rate { patch.down_rate = Some(new_t.down_rate); has_changes = true; } update.name = Some(new_t.name.clone());
if old_t.up_rate != new_t.up_rate { patch.up_rate = Some(new_t.up_rate); has_changes = true; } has_changes = true;
if old_t.completed != new_t.completed { patch.completed = Some(new_t.completed); has_changes = true; } }
if old_t.eta != new_t.eta { patch.eta = Some(new_t.eta); has_changes = true; } if old_t.size != new_t.size {
if (old_t.percent_complete - new_t.percent_complete).abs() > 0.01 { update.size = Some(new_t.size);
patch.percent_complete = Some(new_t.percent_complete); has_changes = true;
has_changes = true; }
if old_t.down_rate != new_t.down_rate {
update.down_rate = Some(new_t.down_rate);
has_changes = true;
}
if old_t.up_rate != new_t.up_rate {
update.up_rate = Some(new_t.up_rate);
has_changes = true;
}
if (old_t.percent_complete - new_t.percent_complete).abs() > 0.01 {
update.percent_complete = Some(new_t.percent_complete);
has_changes = true;
// Check for torrent completion: reached 100%
if old_t.percent_complete < 100.0 && new_t.percent_complete >= 100.0 { if old_t.percent_complete < 100.0 && new_t.percent_complete >= 100.0 {
tracing::info!("Torrent completed: {} ({})", new_t.name, new_t.hash);
events.push(AppEvent::Notification(SystemNotification { events.push(AppEvent::Notification(SystemNotification {
level: NotificationLevel::Success, level: NotificationLevel::Success,
message: format!("Torrent tamamlandı: {}", new_t.name), message: format!("Torrent tamamlandı: {}", new_t.name),
})); }));
} }
} }
if old_t.status != new_t.status { patch.status = Some(new_t.status.clone()); has_changes = true; } if old_t.completed != new_t.completed {
if old_t.error_message != new_t.error_message { patch.error_message = Some(new_t.error_message.clone()); has_changes = true; } update.completed = Some(new_t.completed);
if old_t.label != new_t.label { patch.label = Some(new_t.label.clone()); has_changes = true; } has_changes = true;
}
if old_t.eta != new_t.eta {
update.eta = Some(new_t.eta);
has_changes = true;
}
if old_t.status != new_t.status {
update.status = Some(new_t.status.clone());
has_changes = true;
// Log status changes for debugging
tracing::debug!(
"Torrent status changed: {} ({}) {:?} -> {:?}",
new_t.name, new_t.hash, old_t.status, new_t.status
);
}
if old_t.error_message != new_t.error_message {
update.error_message = Some(new_t.error_message.clone());
has_changes = true;
}
if old_t.label != new_t.label {
update.label = new_t.label.clone();
has_changes = true;
}
if has_changes { if has_changes {
// Set the hash (not an Option in Patch usually, but check shared/src/lib.rs) events.push(AppEvent::Update(update));
// Wait, TorrentUpdate is a Patch, does it have 'hash' field?
// Yes, because Torrent has 'hash' field.
patch.hash = Some(new_t.hash.clone());
events.push(AppEvent::Update(patch));
} }
} }
@@ -66,4 +110,4 @@ pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult {
tracing::debug!("Generated {} partial updates", events.len()); tracing::debug!("Generated {} partial updates", events.len());
DiffResult::Partial(events) DiffResult::Partial(events)
} }
} }

View File

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

View File

@@ -1,9 +1,22 @@
use crate::{
xmlrpc::{self, RpcParam},
AppState,
};
#[cfg(feature = "push-notifications")]
use crate::push;
use axum::{ use axum::{
extract::{Json, Path, State},
http::{header, StatusCode, Uri}, http::{header, StatusCode, Uri},
response::IntoResponse, response::IntoResponse,
BoxError, BoxError,
}; };
use rust_embed::RustEmbed; use rust_embed::RustEmbed;
use serde::Deserialize;
use shared::{
GlobalLimitRequest, SetFilePriorityRequest, SetLabelRequest, TorrentActionRequest, TorrentFile,
TorrentPeer, TorrentTracker,
};
use utoipa::ToSchema;
pub mod auth; pub mod auth;
pub mod setup; pub mod setup;
@@ -12,6 +25,13 @@ pub mod setup;
#[folder = "../frontend/dist"] #[folder = "../frontend/dist"]
pub struct Asset; pub struct Asset;
#[derive(Deserialize, ToSchema)]
pub struct AddTorrentRequest {
/// Magnet link or Torrent file URL
#[schema(example = "magnet:?xt=urn:btih:...")]
uri: String,
}
pub async fn static_handler(uri: Uri) -> impl IntoResponse { pub async fn static_handler(uri: Uri) -> impl IntoResponse {
let mut path = uri.path().trim_start_matches('/').to_string(); let mut path = uri.path().trim_start_matches('/').to_string();
@@ -28,6 +48,7 @@ pub async fn static_handler(uri: Uri) -> impl IntoResponse {
if path.contains('.') { if path.contains('.') {
return StatusCode::NOT_FOUND.into_response(); return StatusCode::NOT_FOUND.into_response();
} }
// Fallback to index.html for SPA routing
match Asset::get("index.html") { match Asset::get("index.html") {
Some(content) => { Some(content) => {
let mime = mime_guess::from_path("index.html").first_or_octet_stream(); let mime = mime_guess::from_path("index.html").first_or_octet_stream();
@@ -39,6 +60,614 @@ pub async fn static_handler(uri: Uri) -> impl IntoResponse {
} }
} }
// --- TORRENT ACTIONS ---
/// Add a new torrent via magnet link or URL
#[utoipa::path(
post,
path = "/api/torrents/add",
request_body = AddTorrentRequest,
responses(
(status = 200, description = "Torrent added successfully"),
(status = 500, description = "Internal server error or rTorrent fault")
)
)]
pub async fn add_torrent_handler(
State(state): State<AppState>,
Json(payload): Json<AddTorrentRequest>,
) -> StatusCode {
tracing::info!(
"Received add_torrent request. URI length: {}",
payload.uri.len()
);
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
let params = vec![RpcParam::from(""), RpcParam::from(payload.uri.as_str())];
match client.call("load.start", &params).await {
Ok(response) => {
tracing::debug!("rTorrent response to load.start: {}", response);
if response.contains("faultCode") {
tracing::error!("rTorrent returned fault: {}", response);
return StatusCode::INTERNAL_SERVER_ERROR;
}
// Note: Frontend shows its own toast, no SSE notification needed
StatusCode::OK
}
Err(e) => {
tracing::error!("Failed to add torrent: {}", e);
// Note: Frontend shows its own toast, no SSE notification needed
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
/// Perform an action on a torrent (start, stop, delete)
#[utoipa::path(
post,
path = "/api/torrents/action",
request_body = TorrentActionRequest,
responses(
(status = 200, description = "Action executed successfully"),
(status = 400, description = "Invalid action or request"),
(status = 403, description = "Forbidden: Security risk detected"),
(status = 500, description = "Internal server error")
)
)]
pub async fn handle_torrent_action(
State(state): State<AppState>,
Json(payload): Json<TorrentActionRequest>,
) -> impl IntoResponse {
tracing::info!(
"Received action: {} for hash: {}",
payload.action,
payload.hash
);
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
// Special handling for delete_with_data
if payload.action == "delete_with_data" {
return match delete_torrent_with_data(&client, &payload.hash).await {
Ok(msg) => {
// Note: Frontend shows its own toast
(StatusCode::OK, msg).into_response()
}
Err((status, msg)) => (status, msg).into_response(),
};
}
let method = match payload.action.as_str() {
"start" => "d.start",
"stop" => "d.stop",
"delete" => "d.erase",
_ => return (StatusCode::BAD_REQUEST, "Invalid action").into_response(),
};
let params = vec![RpcParam::from(payload.hash.as_str())];
match client.call(method, &params).await {
Ok(_) => {
// Note: Frontend shows its own toast, no SSE notification needed
(StatusCode::OK, "Action executed").into_response()
}
Err(e) => {
tracing::error!("RPC error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to execute action",
)
.into_response()
}
}
}
/// Helper function to handle secure deletion of torrent data
async fn delete_torrent_with_data(
client: &xmlrpc::RtorrentClient,
hash: &str,
) -> Result<&'static str, (StatusCode, String)> {
let params_hash = vec![RpcParam::from(hash)];
// 1. Get Base Path
let path_xml = client
.call("d.base_path", &params_hash)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to call rTorrent: {}", e),
)
})?;
let path = xmlrpc::parse_string_response(&path_xml).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to parse path: {}", e),
)
})?;
// 1.5 Get Default Download Directory (Sandbox Root)
let root_xml = client.call("directory.default", &[]).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to get valid download root: {}", e),
)
})?;
let root_path_str = xmlrpc::parse_string_response(&root_xml).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to parse root path: {}", e),
)
})?;
// Resolve Paths (Canonicalize) to prevent .. traversal and symlink attacks
let root_path = tokio::fs::canonicalize(std::path::Path::new(&root_path_str))
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Invalid download root configuration (on server): {}", e),
)
})?;
// Check if target path exists before trying to resolve it
let target_path_raw = std::path::Path::new(&path);
if !tokio::fs::try_exists(target_path_raw)
.await
.unwrap_or(false)
{
tracing::warn!(
"Data path not found: {:?}. Removing torrent only.",
target_path_raw
);
// If file doesn't exist, we just remove the torrent entry
client.call("d.erase", &params_hash).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to erase torrent: {}", e),
)
})?;
return Ok("Torrent removed (Data not found)");
}
let target_path = tokio::fs::canonicalize(target_path_raw)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Invalid data path: {}", e),
)
})?;
tracing::info!(
"Delete request: Target='{:?}', Root='{:?}'",
target_path,
root_path
);
// SECURITY CHECK: Ensure path is inside root_path
if !target_path.starts_with(&root_path) {
tracing::error!(
"Security Risk: Attempted to delete path outside download directory: {:?}",
target_path
);
return Err((
StatusCode::FORBIDDEN,
"Security Error: Cannot delete files outside default download directory".to_string(),
));
}
// SECURITY CHECK: Ensure we are not deleting the root itself
if target_path == root_path {
return Err((
StatusCode::BAD_REQUEST,
"Security Error: Cannot delete the download root directory itself".to_string(),
));
}
// 2. Erase Torrent first
client.call("d.erase", &params_hash).await.map_err(|e| {
tracing::warn!("Failed to erase torrent entry: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to erase torrent: {}", e),
)
})?;
// 3. Delete Files via Native FS
let delete_result = if target_path.is_dir() {
tokio::fs::remove_dir_all(&target_path).await
} else {
tokio::fs::remove_file(&target_path).await
};
match delete_result {
Ok(_) => Ok("Torrent and data deleted"),
Err(e) => {
tracing::error!("Failed to delete data at {:?}: {}", target_path, e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to delete data: {}", e),
))
}
}
}
// --- NEW HANDLERS ---
/// Get rTorrent version
#[utoipa::path(
get,
path = "/api/system/version",
responses(
(status = 200, description = "rTorrent version", body = String),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_version_handler(State(state): State<AppState>) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
match client.call("system.client_version", &[]).await {
Ok(xml) => {
let version = xmlrpc::parse_string_response(&xml).unwrap_or(xml);
(StatusCode::OK, version).into_response()
}
Err(e) => {
tracing::error!("Failed to get version: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to get version").into_response()
}
}
}
/// Get files for a torrent
#[utoipa::path(
get,
path = "/api/torrents/{hash}/files",
responses(
(status = 200, description = "Files list", body = Vec<TorrentFile>),
(status = 500, description = "Internal server error")
),
params(
("hash" = String, Path, description = "Torrent Hash")
)
)]
pub async fn get_files_handler(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("f.path="),
RpcParam::from("f.size_bytes="),
RpcParam::from("f.completed_chunks="),
RpcParam::from("f.priority="),
];
match client.call("f.multicall", &params).await {
Ok(xml) => match xmlrpc::parse_multicall_response(&xml) {
Ok(rows) => {
let files: Vec<TorrentFile> = rows
.into_iter()
.enumerate()
.map(|(idx, row)| TorrentFile {
index: idx as u32,
path: row.get(0).cloned().unwrap_or_default(),
size: row.get(1).and_then(|s| s.parse().ok()).unwrap_or(0),
completed_chunks: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0),
priority: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
})
.collect();
(StatusCode::OK, Json(files)).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Parse error: {}", e),
)
.into_response(),
},
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("RPC error: {}", e),
)
.into_response(),
}
}
/// Get peers for a torrent
#[utoipa::path(
get,
path = "/api/torrents/{hash}/peers",
responses(
(status = 200, description = "Peers list", body = Vec<TorrentPeer>),
(status = 500, description = "Internal server error")
),
params(
("hash" = String, Path, description = "Torrent Hash")
)
)]
pub async fn get_peers_handler(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("p.address="),
RpcParam::from("p.client_version="),
RpcParam::from("p.down_rate="),
RpcParam::from("p.up_rate="),
RpcParam::from("p.completed_percent="),
];
match client.call("p.multicall", &params).await {
Ok(xml) => match xmlrpc::parse_multicall_response(&xml) {
Ok(rows) => {
let peers: Vec<TorrentPeer> = rows
.into_iter()
.map(|row| TorrentPeer {
ip: row.get(0).cloned().unwrap_or_default(),
client: row.get(1).cloned().unwrap_or_default(),
down_rate: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0),
up_rate: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
progress: row.get(4).and_then(|s| s.parse().ok()).unwrap_or(0.0),
})
.collect();
(StatusCode::OK, Json(peers)).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Parse error: {}", e),
)
.into_response(),
},
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("RPC error: {}", e),
)
.into_response(),
}
}
/// Get trackers for a torrent
#[utoipa::path(
get,
path = "/api/torrents/{hash}/trackers",
responses(
(status = 200, description = "Trackers list", body = Vec<TorrentTracker>),
(status = 500, description = "Internal server error")
),
params(
("hash" = String, Path, description = "Torrent Hash")
)
)]
pub async fn get_trackers_handler(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("t.url="),
RpcParam::from("t.activity_date_last="),
RpcParam::from("t.message="),
];
match client.call("t.multicall", &params).await {
Ok(xml) => {
match xmlrpc::parse_multicall_response(&xml) {
Ok(rows) => {
let trackers: Vec<TorrentTracker> = rows
.into_iter()
.map(|row| {
TorrentTracker {
url: row.get(0).cloned().unwrap_or_default(),
status: "Unknown".to_string(), // Derive from type/activity?
message: row.get(2).cloned().unwrap_or_default(),
}
})
.collect();
(StatusCode::OK, Json(trackers)).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Parse error: {}", e),
)
.into_response(),
}
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("RPC error: {}", e),
)
.into_response(),
}
}
/// Set file priority
#[utoipa::path(
post,
path = "/api/torrents/files/priority",
request_body = SetFilePriorityRequest,
responses(
(status = 200, description = "Priority updated"),
(status = 500, description = "Internal server error")
)
)]
pub async fn set_file_priority_handler(
State(state): State<AppState>,
Json(payload): Json<SetFilePriorityRequest>,
) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
// f.set_priority takes "hash", index, priority
// Priority: 0 (off), 1 (normal), 2 (high)
// f.set_priority is tricky. Let's send as string first as before, or int if we knew.
// Usually priorities are small integers.
// But since we are updating everything to RpcParam, let's use Int if possible or String.
// The previous implementation used string. Let's stick to string for now or try Int.
// Actually, f.set_priority likely takes an integer.
let target = format!("{}:f{}", payload.hash, payload.file_index);
let params = vec![
RpcParam::from(target.as_str()),
RpcParam::from(payload.priority as i64),
];
match client.call("f.set_priority", &params).await {
Ok(_) => {
let _ = client
.call(
"d.update_priorities",
&[RpcParam::from(payload.hash.as_str())],
)
.await;
(StatusCode::OK, "Priority updated").into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("RPC error: {}", e),
)
.into_response(),
}
}
/// Set torrent label
#[utoipa::path(
post,
path = "/api/torrents/label",
request_body = SetLabelRequest,
responses(
(status = 200, description = "Label updated"),
(status = 500, description = "Internal server error")
)
)]
pub async fn set_label_handler(
State(state): State<AppState>,
Json(payload): Json<SetLabelRequest>,
) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
let params = vec![
RpcParam::from(payload.hash.as_str()),
RpcParam::from(payload.label),
];
match client.call("d.custom1.set", &params).await {
Ok(_) => (StatusCode::OK, "Label updated").into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("RPC error: {}", e),
)
.into_response(),
}
}
/// Get global speed limits
#[utoipa::path(
get,
path = "/api/settings/global-limits",
responses(
(status = 200, description = "Current limits", body = GlobalLimitRequest),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_global_limit_handler(State(state): State<AppState>) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
// throttle.global_down.max_rate, throttle.global_up.max_rate
let down_fut = client.call("throttle.global_down.max_rate", &[]);
let up_fut = client.call("throttle.global_up.max_rate", &[]);
let down = match down_fut.await {
Ok(xml) => xmlrpc::parse_i64_response(&xml).unwrap_or(0),
Err(_) => -1,
};
let up = match up_fut.await {
Ok(xml) => xmlrpc::parse_i64_response(&xml).unwrap_or(0),
Err(_) => -1,
};
let resp = GlobalLimitRequest {
max_download_rate: Some(down),
max_upload_rate: Some(up),
};
(StatusCode::OK, Json(resp)).into_response()
}
/// Set global speed limits
#[utoipa::path(
post,
path = "/api/settings/global-limits",
request_body = GlobalLimitRequest,
responses(
(status = 200, description = "Limits updated"),
(status = 500, description = "Internal server error")
)
)]
pub async fn set_global_limit_handler(
State(state): State<AppState>,
Json(payload): Json<GlobalLimitRequest>,
) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
// Use throttle.global_*.max_rate.set_kb which is more reliable than .set (which is buggy)
// The .set_kb method expects KB/s, so we convert bytes to KB
if let Some(down) = payload.max_download_rate {
// Convert bytes/s to KB/s (divide by 1024)
let down_kb = down / 1024;
tracing::info!(
"Setting download limit: {} bytes/s = {} KB/s",
down,
down_kb
);
// Use set_kb with empty string as first param (throttle name), then value
if let Err(e) = client
.call(
"throttle.global_down.max_rate.set_kb",
&[RpcParam::from(""), RpcParam::Int(down_kb)],
)
.await
{
tracing::error!("Failed to set download limit: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to set down limit: {}", e),
)
.into_response();
}
}
if let Some(up) = payload.max_upload_rate {
// Convert bytes/s to KB/s
let up_kb = up / 1024;
tracing::info!("Setting upload limit: {} bytes/s = {} KB/s", up, up_kb);
if let Err(e) = client
.call(
"throttle.global_up.max_rate.set_kb",
&[RpcParam::from(""), RpcParam::Int(up_kb)],
)
.await
{
tracing::error!("Failed to set upload limit: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to set up limit: {}", e),
)
.into_response();
}
}
(StatusCode::OK, "Limits updated").into_response()
}
pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) { pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) {
if err.is::<tower::timeout::error::Elapsed>() { if err.is::<tower::timeout::error::Elapsed>() {
(StatusCode::REQUEST_TIMEOUT, "Request timed out") (StatusCode::REQUEST_TIMEOUT, "Request timed out")
@@ -49,3 +678,42 @@ pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) {
) )
} }
} }
// --- PUSH NOTIFICATION HANDLERS ---
#[cfg(feature = "push-notifications")]
/// Get VAPID public key for push subscription
#[utoipa::path(
get,
path = "/api/push/public-key",
responses(
(status = 200, description = "VAPID public key", body = String)
)
)]
pub async fn get_push_public_key_handler() -> impl IntoResponse {
let public_key = push::get_vapid_public_key();
(StatusCode::OK, Json(serde_json::json!({ "publicKey": public_key }))).into_response()
}
#[cfg(feature = "push-notifications")]
/// Subscribe to push notifications
#[utoipa::path(
post,
path = "/api/push/subscribe",
request_body = push::PushSubscription,
responses(
(status = 200, description = "Subscription saved"),
(status = 400, description = "Invalid subscription data")
)
)]
pub async fn subscribe_push_handler(
State(state): State<AppState>,
Json(subscription): Json<push::PushSubscription>,
) -> impl IntoResponse {
tracing::info!("Received push subscription: {:?}", subscription);
state.push_store.add_subscription(subscription).await;
(StatusCode::OK, "Subscription saved").into_response()
}

View File

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

View File

@@ -1,11 +1,11 @@
mod db;
mod diff; mod diff;
mod handlers; mod handlers;
#[cfg(feature = "push-notifications")] #[cfg(feature = "push-notifications")]
mod push; mod push;
mod rate_limit; mod scgi;
mod sse; mod sse;
mod xmlrpc;
use shared::xmlrpc;
use axum::error_handling::HandleErrorLayer; use axum::error_handling::HandleErrorLayer;
use axum::{ use axum::{
@@ -30,9 +30,7 @@ use tower_http::{
cors::CorsLayer, cors::CorsLayer,
trace::TraceLayer, trace::TraceLayer,
}; };
#[cfg(feature = "swagger")]
use utoipa::OpenApi; use utoipa::OpenApi;
#[cfg(feature = "swagger")]
use utoipa_swagger_ui::SwaggerUi; use utoipa_swagger_ui::SwaggerUi;
#[derive(Clone)] #[derive(Clone)]
@@ -40,49 +38,34 @@ pub struct AppState {
pub tx: Arc<watch::Sender<Vec<Torrent>>>, pub tx: Arc<watch::Sender<Vec<Torrent>>>,
pub event_bus: broadcast::Sender<AppEvent>, pub event_bus: broadcast::Sender<AppEvent>,
pub scgi_socket_path: String, pub scgi_socket_path: String,
pub db: shared::db::Db, pub db: db::Db,
#[cfg(feature = "push-notifications")] #[cfg(feature = "push-notifications")]
pub push_store: push::PushSubscriptionStore, pub push_store: push::PushSubscriptionStore,
pub notify_poll: Arc<tokio::sync::Notify>,
} }
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,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
// Skip auth for public server functions // Skip auth for public paths
let path = request.uri().path(); let path = request.uri().path();
if path.starts_with("/api/server_fns/Login") if path.starts_with("/api/auth/login")
|| path.starts_with("/api/server_fns/login") || path.starts_with("/api/auth/check") // Used by frontend to decide where to go
|| path.starts_with("/api/server_fns/GetSetupStatus") || path.starts_with("/api/setup")
|| path.starts_with("/api/server_fns/get_setup_status")
|| path.starts_with("/api/server_fns/Setup")
|| path.starts_with("/api/server_fns/setup")
|| path.starts_with("/swagger-ui") || path.starts_with("/swagger-ui")
|| path.starts_with("/api-docs") || path.starts_with("/api-docs")
|| !path.starts_with("/api/") || !path.starts_with("/api/") // Allow static files (frontend)
{ {
return Ok(next.run(request).await); return Ok(next.run(request).await);
} }
// Check token // Check token
if let Some(token) = jar.get("auth_token") { if let Some(token) = jar.get("auth_token") {
use jsonwebtoken::{decode, Validation, DecodingKey}; match state.db.get_session_user(token.value()).await {
use shared::server_fns::auth::Claims; Ok(Some(_)) => return Ok(next.run(request).await),
_ => {} // Invalid
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
let validation = Validation::default();
match decode::<Claims>(
token.value(),
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
) {
Ok(_) => return Ok(next.run(request).await),
Err(_) => {} // Invalid token
} }
} }
@@ -113,12 +96,31 @@ struct Args {
reset_password: Option<String>, reset_password: Option<String>,
} }
#[cfg(feature = "swagger")] #[cfg(feature = "push-notifications")]
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
paths(
handlers::add_torrent_handler,
handlers::handle_torrent_action,
handlers::get_version_handler,
handlers::get_files_handler,
handlers::get_peers_handler,
handlers::get_trackers_handler,
handlers::set_file_priority_handler,
handlers::set_label_handler,
handlers::get_global_limit_handler,
handlers::set_global_limit_handler,
handlers::get_push_public_key_handler,
handlers::subscribe_push_handler,
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, handlers::AddTorrentRequest,
shared::TorrentActionRequest, shared::TorrentActionRequest,
shared::Torrent, shared::Torrent,
shared::TorrentStatus, shared::TorrentStatus,
@@ -128,6 +130,12 @@ struct Args {
shared::SetFilePriorityRequest, shared::SetFilePriorityRequest,
shared::SetLabelRequest, shared::SetLabelRequest,
shared::GlobalLimitRequest, shared::GlobalLimitRequest,
push::PushSubscription,
push::PushKeys,
handlers::auth::LoginRequest,
handlers::setup::SetupRequest,
handlers::setup::SetupStatusResponse,
handlers::auth::UserResponse
) )
), ),
tags( tags(
@@ -136,6 +144,49 @@ struct Args {
)] )]
struct ApiDoc; struct ApiDoc;
#[cfg(not(feature = "push-notifications"))]
#[derive(OpenApi)]
#[openapi(
paths(
handlers::add_torrent_handler,
handlers::handle_torrent_action,
handlers::get_version_handler,
handlers::get_files_handler,
handlers::get_peers_handler,
handlers::get_trackers_handler,
handlers::set_file_priority_handler,
handlers::set_label_handler,
handlers::get_global_limit_handler,
handlers::set_global_limit_handler,
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(
handlers::AddTorrentRequest,
shared::TorrentActionRequest,
shared::Torrent,
shared::TorrentStatus,
shared::TorrentFile,
shared::TorrentPeer,
shared::TorrentTracker,
shared::SetFilePriorityRequest,
shared::SetLabelRequest,
shared::GlobalLimitRequest,
handlers::auth::LoginRequest,
handlers::setup::SetupRequest,
handlers::setup::SetupStatusResponse,
handlers::auth::UserResponse
)
),
tags(
(name = "vibetorrent", description = "VibeTorrent API")
)
)]
struct ApiDoc;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
@@ -155,9 +206,19 @@ async fn main() {
// Initialize Database // Initialize Database
tracing::info!("Connecting to database: {}", args.db_url); tracing::info!("Connecting to database: {}", args.db_url);
// Redundant manual creation removed, shared::db handles it // Ensure the db file exists if it's sqlite
if args.db_url.starts_with("sqlite:") {
let path = args.db_url.trim_start_matches("sqlite:");
if !std::path::Path::new(path).exists() {
tracing::info!("Database file not found, creating: {}", path);
match std::fs::File::create(path) {
Ok(_) => tracing::info!("Created empty database file"),
Err(e) => tracing::error!("Failed to create database file: {}", e),
}
}
}
let db: shared::db::Db = match shared::db::Db::new(&args.db_url).await { let db: db::Db = match db::Db::new(&args.db_url).await {
Ok(db) => db, Ok(db) => db,
Err(e) => { Err(e) => {
tracing::error!("Failed to connect to database: {}", e); tracing::error!("Failed to connect to database: {}", e);
@@ -192,7 +253,9 @@ async fn main() {
} }
}; };
// Update in DB // Update in DB (using a direct query since db.rs doesn't have update_password yet)
// We should add `update_password` to db.rs for cleaner code, but for now direct query is fine or we can extend Db.
// Let's extend Db.rs first to be clean.
if let Err(e) = db.update_password(user_id, &password_hash).await { if let Err(e) = db.update_password(user_id, &password_hash).await {
tracing::error!("Failed to update password in DB: {}", e); tracing::error!("Failed to update password in DB: {}", e);
std::process::exit(1); std::process::exit(1);
@@ -225,18 +288,6 @@ async fn main() {
tracing::info!("Socket: {}", args.socket); tracing::info!("Socket: {}", args.socket);
tracing::info!("Port: {}", args.port); tracing::info!("Port: {}", args.port);
// Force linking of server functions from shared crate for registration on Mac
{
use shared::server_fns::auth::*;
let _ = get_setup_status;
let _ = setup;
let _ = login;
let _ = logout;
let _ = get_user;
tracing::info!("Server functions linked successfully.");
}
// ... rest of the main function ... // ... rest of the main function ...
// Startup Health Check // Startup Health Check
let socket_path = std::path::Path::new(&args.socket); let socket_path = std::path::Path::new(&args.socket);
@@ -269,28 +320,13 @@ async fn main() {
// Channel for Events (Diffs) // Channel for Events (Diffs)
let (event_bus, _) = broadcast::channel::<AppEvent>(1024); let (event_bus, _) = broadcast::channel::<AppEvent>(1024);
#[cfg(feature = "push-notifications")]
let push_store = match push::PushSubscriptionStore::with_db(&db).await {
Ok(store) => store,
Err(e) => {
tracing::error!("Failed to initialize push store: {}", e);
push::PushSubscriptionStore::new()
}
};
#[cfg(not(feature = "push-notifications"))]
let _push_store = ();
let notify_poll = Arc::new(tokio::sync::Notify::new());
let app_state = AppState { let app_state = AppState {
tx: tx.clone(), tx: tx.clone(),
event_bus: event_bus.clone(), event_bus: event_bus.clone(),
scgi_socket_path: args.socket.clone(), scgi_socket_path: args.socket.clone(),
db: db.clone(), db: db.clone(),
#[cfg(feature = "push-notifications")] #[cfg(feature = "push-notifications")]
push_store, push_store: push::PushSubscriptionStore::new(),
notify_poll: notify_poll.clone(),
}; };
// Spawn background task to poll rTorrent // Spawn background task to poll rTorrent
@@ -299,7 +335,6 @@ async fn main() {
let socket_path = args.socket.clone(); // Clone for background task let socket_path = args.socket.clone(); // Clone for background task
#[cfg(feature = "push-notifications")] #[cfg(feature = "push-notifications")]
let push_store_clone = app_state.push_store.clone(); let push_store_clone = app_state.push_store.clone();
let notify_poll_clone = notify_poll.clone();
tokio::spawn(async move { tokio::spawn(async move {
let client = xmlrpc::RtorrentClient::new(&socket_path); let client = xmlrpc::RtorrentClient::new(&socket_path);
@@ -308,14 +343,6 @@ async fn main() {
let mut backoff_duration = Duration::from_secs(1); let mut backoff_duration = Duration::from_secs(1);
loop { loop {
// Determine polling interval based on active clients
let active_clients = event_bus_tx.receiver_count();
let loop_interval = if active_clients > 0 {
Duration::from_secs(1)
} else {
Duration::from_secs(30)
};
// 1. Fetch Torrents // 1. Fetch Torrents
let torrents_result = sse::fetch_torrents(&client).await; let torrents_result = sse::fetch_torrents(&client).await;
@@ -351,7 +378,10 @@ async fn main() {
match diff::diff_torrents(&previous_torrents, &new_torrents) { match diff::diff_torrents(&previous_torrents, &new_torrents) {
diff::DiffResult::FullUpdate => { diff::DiffResult::FullUpdate => {
let _ = event_bus_tx.send(AppEvent::FullList(new_torrents.clone(), now)); let _ = event_bus_tx.send(AppEvent::FullList {
torrents: new_torrents.clone(),
timestamp: now,
});
} }
diff::DiffResult::Partial(updates) => { diff::DiffResult::Partial(updates) => {
for update in updates { for update in updates {
@@ -383,14 +413,6 @@ async fn main() {
} }
previous_torrents = new_torrents; previous_torrents = new_torrents;
// Success case: wait for the determined interval OR a wakeup notification
tokio::select! {
_ = tokio::time::sleep(loop_interval) => {},
_ = notify_poll_clone.notified() => {
tracing::debug!("Background loop awakened by new client connection");
}
}
} }
Err(e) => { Err(e) => {
tracing::error!("Error fetching torrents in background: {}", e); tracing::error!("Error fetching torrents in background: {}", e);
@@ -411,49 +433,66 @@ async fn main() {
"Backoff: Sleeping for {:?} due to rTorrent error.", "Backoff: Sleeping for {:?} due to rTorrent error.",
backoff_duration backoff_duration
); );
tokio::time::sleep(backoff_duration).await;
} }
} }
// Handle Stats // Handle Stats
if let Ok(stats) = stats_result { match stats_result {
let _ = event_bus_tx.send(AppEvent::Stats(stats)); Ok(stats) => {
let _ = event_bus_tx.send(AppEvent::Stats(stats));
}
Err(e) => {
tracing::warn!("Error fetching global stats: {}", e);
}
} }
tokio::time::sleep(backoff_duration).await;
} }
}); });
let app = Router::new(); let app = Router::new()
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
#[cfg(feature = "swagger")] // Setup & Auth Routes
let app = app.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi())); .route("/api/setup/status", get(handlers::setup::get_setup_status_handler))
.route("/api/setup", post(handlers::setup::setup_handler))
// Setup & Auth Routes (cookie-based, stay as REST) .route("/api/auth/login", post(handlers::auth::login_handler))
// Setup & Auth Routes (cookie-based, stay as REST) .route("/api/auth/logout", post(handlers::auth::logout_handler))
let scgi_path_for_ctx = args.socket.clone(); .route("/api/auth/check", get(handlers::auth::check_auth_handler))
let db_for_ctx = db.clone(); // App Routes
let app = app
.route("/api/events", get(sse::sse_handler)) .route("/api/events", get(sse::sse_handler))
.route("/api/server_fns/{*fn_name}", post({ .route("/api/torrents/add", post(handlers::add_torrent_handler))
let scgi_path = scgi_path_for_ctx.clone(); .route(
let db = db_for_ctx.clone(); "/api/torrents/action",
move |req: Request<Body>| { post(handlers::handle_torrent_action),
let scgi_path = scgi_path.clone(); )
let db = db.clone(); .route("/api/system/version", get(handlers::get_version_handler))
leptos_axum::handle_server_fns_with_context( .route(
move || { "/api/torrents/{hash}/files",
leptos::context::provide_context(shared::ServerContext { get(handlers::get_files_handler),
scgi_socket_path: scgi_path.clone(), )
}); .route(
leptos::context::provide_context(shared::DbContext { "/api/torrents/{hash}/peers",
db: db.clone(), get(handlers::get_peers_handler),
}); )
}, .route(
req, "/api/torrents/{hash}/trackers",
) get(handlers::get_trackers_handler),
} )
})) .route(
.fallback(handlers::static_handler); "/api/torrents/files/priority",
post(handlers::set_file_priority_handler),
)
.route("/api/torrents/label", post(handlers::set_label_handler))
.route(
"/api/settings/global-limits",
get(handlers::get_global_limit_handler).post(handlers::set_global_limit_handler),
)
.fallback(handlers::static_handler); // Serve static files for everything else
#[cfg(feature = "push-notifications")]
let app = app
.route("/api/push/public-key", get(handlers::get_push_public_key_handler))
.route("/api/push/subscribe", post(handlers::subscribe_push_handler));
let app = app let app = app
.layer(middleware::from_fn_with_state(app_state.clone(), auth_middleware)) .layer(middleware::from_fn_with_state(app_state.clone(), auth_middleware))
@@ -485,12 +524,7 @@ async fn main() {
} }
}; };
tracing::info!("Backend listening on {}", addr); tracing::info!("Backend listening on {}", addr);
if let Err(e) = axum::serve( if let Err(e) = axum::serve(listener, app).await {
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
{
tracing::error!("Server error: {}", e); tracing::error!("Server error: {}", e);
std::process::exit(1); std::process::exit(1);
} }

View File

@@ -5,9 +5,11 @@ use utoipa::ToSchema;
use web_push::{ use web_push::{
HyperWebPushClient, SubscriptionInfo, VapidSignatureBuilder, WebPushClient, WebPushMessageBuilder, HyperWebPushClient, SubscriptionInfo, VapidSignatureBuilder, WebPushClient, WebPushMessageBuilder,
}; };
use futures::StreamExt;
use shared::db::Db; // VAPID keys - PRODUCTION'DA ENVIRONMENT VARIABLE'DAN ALINMALI!
const VAPID_PUBLIC_KEY: &str = "BEdPj6XQR7MGzM28Nev9wokF5upHoydNDahouJbQ9ZdBJpEFAN1iNfANSEvY0ItasNY5zcvvqN_tjUt64Rfd0gU";
const VAPID_PRIVATE_KEY: &str = "aUcCYJ7kUd9UClCaWwad0IVgbYJ6svwl19MjSX7GH10";
const VAPID_EMAIL: &str = "mailto:admin@vibetorrent.app";
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PushSubscription { pub struct PushSubscription {
@@ -21,107 +23,39 @@ pub struct PushKeys {
pub auth: String, pub auth: String,
} }
#[derive(Clone)] /// In-memory store for push subscriptions
pub struct VapidConfig { /// TODO: Replace with database in production
pub private_key: String, #[derive(Default, Clone)]
pub public_key: String,
pub email: String,
}
#[derive(Clone)]
pub struct PushSubscriptionStore { pub struct PushSubscriptionStore {
db: Option<Db>,
subscriptions: Arc<RwLock<Vec<PushSubscription>>>, subscriptions: Arc<RwLock<Vec<PushSubscription>>>,
vapid_config: VapidConfig,
} }
impl PushSubscriptionStore { impl PushSubscriptionStore {
pub fn new() -> Self { pub fn new() -> Self {
let private_key = std::env::var("VAPID_PRIVATE_KEY").expect("VAPID_PRIVATE_KEY must be set in .env");
let public_key = std::env::var("VAPID_PUBLIC_KEY").expect("VAPID_PUBLIC_KEY must be set in .env");
let email = std::env::var("VAPID_EMAIL").expect("VAPID_EMAIL must be set in .env");
Self { Self {
db: None,
subscriptions: Arc::new(RwLock::new(Vec::new())), subscriptions: Arc::new(RwLock::new(Vec::new())),
vapid_config: VapidConfig {
private_key,
public_key,
email,
},
} }
} }
pub async fn with_db(db: &Db) -> Result<Self, Box<dyn std::error::Error>> {
let mut subscriptions_vec: Vec<PushSubscription> = Vec::new();
// Load existing subscriptions from DB
let subs = db.get_all_push_subscriptions().await?;
for (endpoint, p256dh, auth) in subs {
subscriptions_vec.push(PushSubscription {
endpoint,
keys: PushKeys { p256dh, auth },
});
}
tracing::info!("Loaded {} push subscriptions from database", subscriptions_vec.len());
let private_key = std::env::var("VAPID_PRIVATE_KEY").expect("VAPID_PRIVATE_KEY must be set in .env");
let public_key = std::env::var("VAPID_PUBLIC_KEY").expect("VAPID_PUBLIC_KEY must be set in .env");
let email = std::env::var("VAPID_EMAIL").expect("VAPID_EMAIL must be set in .env");
Ok(Self {
db: Some(db.clone()),
subscriptions: Arc::new(RwLock::new(subscriptions_vec)),
vapid_config: VapidConfig {
private_key,
public_key,
email,
},
})
}
pub async fn add_subscription(&self, subscription: PushSubscription) { pub async fn add_subscription(&self, subscription: PushSubscription) {
// Add to memory
let mut subs = self.subscriptions.write().await; let mut subs = self.subscriptions.write().await;
// Remove duplicate endpoint if exists // Remove duplicate endpoint if exists
subs.retain(|s| s.endpoint != subscription.endpoint); subs.retain(|s| s.endpoint != subscription.endpoint);
subs.push(subscription.clone());
subs.push(subscription);
tracing::info!("Added push subscription. Total: {}", subs.len()); tracing::info!("Added push subscription. Total: {}", subs.len());
// Save to DB if available
if let Some(db) = &self.db {
if let Err(e) = db.save_push_subscription(
&subscription.endpoint,
&subscription.keys.p256dh,
&subscription.keys.auth,
).await {
tracing::error!("Failed to save push subscription to DB: {}", e);
}
}
} }
pub async fn remove_subscription(&self, endpoint: &str) { pub async fn remove_subscription(&self, endpoint: &str) {
// Remove from memory
let mut subs = self.subscriptions.write().await; let mut subs = self.subscriptions.write().await;
subs.retain(|s| s.endpoint != endpoint); subs.retain(|s| s.endpoint != endpoint);
tracing::info!("Removed push subscription. Total: {}", subs.len()); tracing::info!("Removed push subscription. Total: {}", subs.len());
// Remove from DB if available
if let Some(db) = &self.db {
if let Err(e) = db.remove_push_subscription(endpoint).await {
tracing::error!("Failed to remove push subscription from DB: {}", e);
}
}
} }
pub async fn get_all_subscriptions(&self) -> Vec<PushSubscription> { pub async fn get_all_subscriptions(&self) -> Vec<PushSubscription> {
self.subscriptions.read().await.clone() self.subscriptions.read().await.clone()
} }
pub fn get_public_key(&self) -> &str {
&self.vapid_config.public_key
}
} }
/// Send push notification to all subscribed clients /// Send push notification to all subscribed clients
@@ -131,7 +65,7 @@ pub async fn send_push_notification(
body: &str, body: &str,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let subscriptions = store.get_all_subscriptions().await; let subscriptions = store.get_all_subscriptions().await;
if subscriptions.is_empty() { if subscriptions.is_empty() {
tracing::debug!("No push subscriptions to send to"); tracing::debug!("No push subscriptions to send to");
return Ok(()); return Ok(());
@@ -147,68 +81,47 @@ pub async fn send_push_notification(
"tag": "vibetorrent" "tag": "vibetorrent"
}); });
let client = Arc::new(HyperWebPushClient::new()); let client = HyperWebPushClient::new();
let vapid_config = store.vapid_config.clone();
let payload_str = payload.to_string();
// Send notifications concurrently for subscription in subscriptions {
futures::stream::iter(subscriptions) let subscription_info = SubscriptionInfo {
.for_each_concurrent(10, |subscription| { endpoint: subscription.endpoint.clone(),
let client = client.clone(); keys: web_push::SubscriptionKeys {
let vapid_config = vapid_config.clone(); p256dh: subscription.keys.p256dh.clone(),
let payload_str = payload_str.clone(); auth: subscription.keys.auth.clone(),
},
};
async move { let mut sig_builder = VapidSignatureBuilder::from_base64(
let subscription_info = SubscriptionInfo { VAPID_PRIVATE_KEY,
endpoint: subscription.endpoint.clone(), web_push::URL_SAFE_NO_PAD,
keys: web_push::SubscriptionKeys { &subscription_info,
p256dh: subscription.keys.p256dh.clone(), )?;
auth: subscription.keys.auth.clone(),
}, sig_builder.add_claim("sub", VAPID_EMAIL);
}; sig_builder.add_claim("aud", subscription.endpoint.clone());
let signature = sig_builder.build()?;
let sig_res = VapidSignatureBuilder::from_base64( let mut builder = WebPushMessageBuilder::new(&subscription_info);
&vapid_config.private_key, builder.set_vapid_signature(signature);
web_push::URL_SAFE_NO_PAD,
&subscription_info, let payload_str = payload.to_string();
); builder.set_payload(web_push::ContentEncoding::Aes128Gcm, payload_str.as_bytes());
match sig_res { match client.send(builder.build()?).await {
Ok(mut sig_builder) => { Ok(_) => {
sig_builder.add_claim("sub", vapid_config.email.as_str()); tracing::debug!("Push notification sent to: {}", subscription.endpoint);
sig_builder.add_claim("aud", subscription.endpoint.as_str());
match sig_builder.build() {
Ok(signature) => {
let mut builder = WebPushMessageBuilder::new(&subscription_info);
builder.set_vapid_signature(signature);
builder.set_payload(web_push::ContentEncoding::Aes128Gcm, payload_str.as_bytes());
match builder.build() {
Ok(msg) => {
match client.send(msg).await {
Ok(_) => {
tracing::debug!("Push notification sent to: {}", subscription.endpoint);
}
Err(e) => {
tracing::error!("Failed to send push notification to {}: {}", subscription.endpoint, e);
}
}
}
Err(e) => tracing::error!("Failed to build push message: {}", e),
}
}
Err(e) => tracing::error!("Failed to build VAPID signature: {}", e),
}
}
Err(e) => tracing::error!("Failed to create VAPID signature builder: {}", e),
}
} }
}) Err(e) => {
.await; tracing::error!("Failed to send push notification: {}", e);
// TODO: Remove invalid subscriptions
Ok(()) }
}
} }
Ok(())
}
pub fn get_vapid_public_key() -> &'static str {
VAPID_PUBLIC_KEY
}

View File

@@ -1,3 +0,0 @@
// 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

@@ -1,5 +1,3 @@
#![cfg(feature = "ssr")]
use bytes::Bytes; use bytes::Bytes;
use std::collections::HashMap; use std::collections::HashMap;
use thiserror::Error; use thiserror::Error;
@@ -13,8 +11,6 @@ pub enum ScgiError {
#[allow(dead_code)] #[allow(dead_code)]
#[error("Protocol Error: {0}")] #[error("Protocol Error: {0}")]
Protocol(String), Protocol(String),
#[error("Timeout: SCGI request took too long")]
Timeout,
} }
pub struct ScgiRequest { pub struct ScgiRequest {
@@ -82,48 +78,20 @@ impl ScgiRequest {
} }
pub async fn send_request(socket_path: &str, request: ScgiRequest) -> Result<Bytes, ScgiError> { pub async fn send_request(socket_path: &str, request: ScgiRequest) -> Result<Bytes, ScgiError> {
let perform_request = async { let mut stream = UnixStream::connect(socket_path).await?;
let mut stream = UnixStream::connect(socket_path).await?; let data = request.encode();
let data = request.encode(); stream.write_all(&data).await?;
stream.write_all(&data).await?;
let mut response = Vec::new(); let mut response = Vec::new();
stream.read_to_end(&mut response).await?; stream.read_to_end(&mut response).await?;
Ok::<Vec<u8>, std::io::Error>(response)
};
let response = tokio::time::timeout(std::time::Duration::from_secs(10), perform_request) let double_newline = b"\r\n\r\n";
.await if let Some(pos) = response
.map_err(|_| ScgiError::Timeout)??; .windows(double_newline.len())
.position(|window| window == double_newline)
let mut response_vec = response; {
Ok(Bytes::from(response.split_off(pos + double_newline.len())))
// Improved header stripping: find the first occurrence of "<?xml" OR double newline
let patterns = [
&b"\r\n\r\n"[..],
&b"\n\n"[..],
&b"<?xml"[..] // If headers are missing or weird, find start of XML
];
let mut found_pos = None;
for (i, pattern) in patterns.iter().enumerate() {
if let Some(pos) = response_vec
.windows(pattern.len())
.position(|window| window == *pattern)
{
// For XML pattern, we keep it. For newlines, we skip them.
if i == 2 {
found_pos = Some(pos);
} else {
found_pos = Some(pos + pattern.len());
}
break;
}
}
if let Some(pos) = found_pos {
Ok(Bytes::from(response_vec.split_off(pos)))
} else { } else {
Ok(Bytes::from(response_vec)) Ok(Bytes::from(response))
} }
} }

View File

@@ -1,15 +1,13 @@
use shared::xmlrpc::{ use crate::xmlrpc::{
parse_i64_response, parse_multicall_response, RpcParam, RtorrentClient, XmlRpcError, parse_i64_response, parse_multicall_response, RpcParam, RtorrentClient, XmlRpcError,
}; };
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}; use futures::stream::{self, Stream};
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;
use axum::response::IntoResponse;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
// Field definitions to keep query and parser in sync // Field definitions to keep query and parser in sync
mod fields { mod fields {
@@ -196,10 +194,7 @@ pub async fn fetch_global_stats(client: &RtorrentClient) -> Result<GlobalStats,
pub async fn sse_handler( pub async fn sse_handler(
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
// Notify background worker to wake up and poll immediately
state.notify_poll.notify_one();
// Get initial value synchronously (from the watch channel's current state) // Get initial value synchronously (from the watch channel's current state)
let initial_rx = state.tx.subscribe(); let initial_rx = state.tx.subscribe();
let initial_torrents = initial_rx.borrow().clone(); let initial_torrents = initial_rx.borrow().clone();
@@ -210,10 +205,13 @@ pub async fn sse_handler(
.unwrap() .unwrap()
.as_secs(); .as_secs();
let event_data = AppEvent::FullList(initial_torrents, timestamp); let event_data = AppEvent::FullList {
torrents: initial_torrents,
timestamp,
};
match rmp_serde::to_vec(&event_data) { match serde_json::to_string(&event_data) {
Ok(bytes) => Event::default().data(BASE64.encode(bytes)), Ok(json) => Event::default().data(json),
Err(_) => Event::default().comment("init_error"), Err(_) => Event::default().comment("init_error"),
} }
}; };
@@ -225,10 +223,10 @@ pub async fn sse_handler(
let rx = state.event_bus.subscribe(); let rx = state.event_bus.subscribe();
let update_stream = stream::unfold(rx, |mut rx| async move { let update_stream = stream::unfold(rx, |mut rx| async move {
match rx.recv().await { match rx.recv().await {
Ok(event) => match rmp_serde::to_vec(&event) { Ok(event) => match serde_json::to_string(&event) {
Ok(bytes) => Some((Ok::<Event, Infallible>(Event::default().data(BASE64.encode(bytes))), rx)), Ok(json) => Some((Ok::<Event, Infallible>(Event::default().data(json)), rx)),
Err(e) => { Err(e) => {
tracing::warn!("Failed to serialize SSE event (MessagePack): {}", e); tracing::warn!("Failed to serialize SSE event: {}", e);
Some(( Some((
Ok::<Event, Infallible>(Event::default().comment("error")), Ok::<Event, Infallible>(Event::default().comment("error")),
rx, rx,
@@ -243,11 +241,6 @@ pub async fn sse_handler(
} }
}); });
let sse = Sse::new(initial_stream.chain(update_stream)) Sse::new(initial_stream.chain(update_stream))
.keep_alive(axum::response::sse::KeepAlive::default()); .keep_alive(axum::response::sse::KeepAlive::default())
}
(
[("content-type", "text/event-stream")],
sse
)
}

View File

@@ -1,5 +1,3 @@
#![cfg(feature = "ssr")]
use crate::scgi::{send_request, ScgiError, ScgiRequest}; use crate::scgi::{send_request, ScgiError, ScgiRequest};
use quick_xml::de::from_str; use quick_xml::de::from_str;
use quick_xml::se::to_string; use quick_xml::se::to_string;

View File

View File

@@ -20,8 +20,6 @@ RUN apt-get update && apt-get install -y \
jq \ jq \
# Needed for some crate compilations # Needed for some crate compilations
protobuf-compiler \ protobuf-compiler \
# Install binaryen to have wasm-opt available system-wide
binaryen \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# 2. Install Node.js v20 (Manual install to support multi-arch cleanly) # 2. Install Node.js v20 (Manual install to support multi-arch cleanly)
@@ -72,7 +70,7 @@ RUN . "$HOME/.cargo/env" && \
ARCH=$(dpkg --print-architecture) && \ ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then TRUNK_ARCH="x86_64-unknown-linux-gnu"; \ if [ "$ARCH" = "amd64" ]; then TRUNK_ARCH="x86_64-unknown-linux-gnu"; \
elif [ "$ARCH" = "arm64" ]; then TRUNK_ARCH="aarch64-unknown-linux-gnu"; fi && \ elif [ "$ARCH" = "arm64" ]; then TRUNK_ARCH="aarch64-unknown-linux-gnu"; fi && \
wget -qO- "https://github.com/trunk-rs/trunk/releases/download/v0.21.14/trunk-$TRUNK_ARCH.tar.gz" | tar -xzf - -C /root/.cargo/bin/ && \ wget -qO- "https://github.com/trunk-rs/trunk/releases/download/v0.21.5/trunk-$TRUNK_ARCH.tar.gz" | tar -xzf - -C /root/.cargo/bin/ && \
chmod +x /root/.cargo/bin/trunk && \ chmod +x /root/.cargo/bin/trunk && \
# Install wasm-bindgen-cli (Compiling from source to avoid glibc issues, doing it ONCE here) # Install wasm-bindgen-cli (Compiling from source to avoid glibc issues, doing it ONCE here)
cargo install wasm-bindgen-cli --version 0.2.108 cargo install wasm-bindgen-cli --version 0.2.108

1
frontend/.gitignore vendored
View File

@@ -1 +0,0 @@
node_modules/

View File

@@ -7,39 +7,48 @@ edition = "2021"
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
leptos = { version = "0.8.15", features = ["csr", "msgpack", "nightly"] } leptos = { version = "0.6", features = ["csr"] }
leptos_router = { version = "0.8.11" } leptos_router = { version = "0.6", features = ["csr"] }
console_error_panic_hook = "0.1" console_error_panic_hook = "0.1"
console_log = "1" console_log = "1"
log = "0.4" log = "0.4"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
gloo-net = "0.6" gloo-net = "0.5"
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"] }
futures = "0.3" futures = "0.3"
chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] } chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] }
web-sys = { version = "0.3", features = ["HtmlDivElement", "HtmlUListElement", "HtmlLiElement", "HtmlAnchorElement", "MouseEvent", "Event", "Window", "Document", "Element", "DomTokenList", "CssStyleDeclaration", "Storage", "TouchEvent", "TouchList", "Touch", "Navigator", "Notification", "NotificationOptions", "NotificationPermission", "ServiceWorkerContainer", "ServiceWorkerRegistration", "PushManager", "PushSubscription", "PushSubscriptionOptions", "PushSubscriptionOptionsInit", "HtmlDetailsElement", "HtmlInputElement", "HtmlFormElement", "HtmlDialogElement", "ProgressEvent"] } web-sys = { version = "0.3", features = [
shared = { path = "../shared", features = ["hydrate"] } "HtmlDivElement",
"HtmlUListElement",
"HtmlLiElement",
"HtmlAnchorElement",
"MouseEvent",
"Event",
"Window",
"Document",
"Element",
"DomTokenList",
"CssStyleDeclaration",
"Storage",
"TouchEvent",
"TouchList",
"Touch",
"Navigator",
"Notification",
"NotificationOptions",
"NotificationPermission",
"ServiceWorkerContainer",
"ServiceWorkerRegistration",
"PushManager",
"PushSubscription",
"PushSubscriptionOptions",
"PushSubscriptionOptionsInit"
] }
shared = { path = "../shared" }
tailwind_fuse = "0.3.2" tailwind_fuse = "0.3.2"
js-sys = "0.3.85" js-sys = "0.3.85"
base64 = "0.22.1"
serde-wasm-bindgen = "0.6.5"
leptos-use = { version = "0.16", features = ["storage"] }
codee = "0.3"
thiserror = "2.0"
rmp-serde = "1.3"
struct-patch = "0.5"
# Rust/UI Components
leptos_ui = "0.3"
tw_merge = "0.1"
strum = { version = "0.26", features = ["derive"] }
icons = { version = "0.18.0", features = ["leptos"] }
[package.metadata.leptos]
tailwind-input-file = "input.css"

View File

@@ -1,102 +1,98 @@
<!doctype html> <!doctype html>
<html> <html>
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<title>VibeTorrent</title>
<head> <!-- PWA & Mobile Capable -->
<meta charset="utf-8" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="viewport" <meta name="apple-mobile-web-app-capable" content="yes" />
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>VibeTorrent</title> <meta name="apple-mobile-web-app-title" content="VibeTorrent" />
<meta name="theme-color" content="#111827" />
<link rel="manifest" href="manifest.json" />
<link rel="icon" type="image/png" href="icon-192.png" />
<link rel="apple-touch-icon" href="icon-192.png" />
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png" />
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
<!-- PWA & Mobile Capable --> <!-- Trunk Assets -->
<meta name="mobile-web-app-capable" content="yes" /> <link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="0" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <link data-trunk rel="css" href="public/tailwind.css" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <link data-trunk rel="copy-file" href="manifest.json" />
<meta name="apple-mobile-web-app-title" content="VibeTorrent" /> <link data-trunk rel="copy-file" href="icon-192.png" />
<meta name="theme-color" content="#111827" /> <link data-trunk rel="copy-file" href="icon-512.png" />
<link rel="manifest" href="manifest.json" /> <link data-trunk rel="copy-file" href="sw.js" />
<link rel="icon" type="image/png" href="icon-192.png" /> <script>
<link rel="apple-touch-icon" href="icon-192.png" /> (function () {
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png" /> var localTheme = localStorage.getItem("vibetorrent_theme");
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" /> var t = localTheme || "dark";
if (t === "Amoled") t = "black";
if (t === "Light") t = "light";
if (t === "Dark" || t === "Midnight") t = "dark";
<!-- Trunk Assets --> var theme = t.toLowerCase();
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="0" /> document.documentElement.setAttribute("data-theme", theme);
<link data-trunk rel="css" href="public/tailwind.css" /> if (!localTheme) {
<link data-trunk rel="copy-file" href="manifest.json" /> localStorage.setItem("vibetorrent_theme", "dark");
<link data-trunk rel="copy-file" href="icon-192.png" /> }
<link data-trunk rel="copy-file" href="icon-512.png" />
<link data-trunk rel="copy-file" href="sw.js" />
<script>
(function () {
var localTheme = localStorage.getItem("vibetorrent_theme");
var t = localTheme || "dark";
if (t === "Amoled") t = "black";
if (t === "Light") t = "light";
if (t === "Dark" || t === "Midnight") t = "dark";
var theme = t.toLowerCase(); var meta = document.querySelector('meta[name="theme-color"]');
document.documentElement.setAttribute("data-theme", theme); if (meta) {
// Shadcn dark mode CSS değişkenleri .dark class ile çalışıyor var colorMap = {
var darkThemes = ["dark", "black", "night", "coffee", "luxury", "business", "dracula", "halloween", "forest", "synthwave", "dim", "nord", "sunset", "cyberpunk", "abyss"]; light: "#ffffff",
if (darkThemes.indexOf(theme) !== -1) { cupcake: "#faf7f5",
document.documentElement.classList.add("dark"); bumblebee: "#ffffff",
} else { emerald: "#ffffff",
document.documentElement.classList.remove("dark"); corporate: "#ffffff",
} synthwave: "#2d1b69",
if (!localTheme) { retro: "#ece3ca",
localStorage.setItem("vibetorrent_theme", "dark"); cyberpunk: "#ffee00",
} valentine: "#f0d6e8",
halloween: "#212121",
garden: "#e9e7e7",
forest: "#171212",
aqua: "#345da7",
lofi: "#ffffff",
pastel: "#ffffff",
fantasy: "#ffffff",
wireframe: "#ffffff",
black: "#000000",
luxury: "#09090b",
dracula: "#282a36",
cmyk: "#ffffff",
autumn: "#8C0327",
business: "#202020",
acid: "#fafafa",
lemonade: "#F1F8E8",
night: "#0f1729",
coffee: "#20161f",
winter: "#ffffff",
dark: "#1d232a",
};
var color = colorMap[theme] || "#1d232a";
meta.setAttribute("content", color);
}
})();
</script>
</head>
var meta = document.querySelector('meta[name="theme-color"]'); <body>
if (meta) { <div
var colorMap = { id="app-loading"
light: "#ffffff", style="
cupcake: "#faf7f5",
bumblebee: "#ffffff",
emerald: "#ffffff",
corporate: "#ffffff",
synthwave: "#2d1b69",
retro: "#ece3ca",
cyberpunk: "#ffee00",
valentine: "#f0d6e8",
halloween: "#212121",
garden: "#e9e7e7",
forest: "#171212",
aqua: "#345da7",
lofi: "#ffffff",
pastel: "#ffffff",
fantasy: "#ffffff",
wireframe: "#ffffff",
black: "#000000",
luxury: "#09090b",
dracula: "#282a36",
cmyk: "#ffffff",
autumn: "#8C0327",
business: "#202020",
acid: "#fafafa",
lemonade: "#F1F8E8",
night: "#0f1729",
coffee: "#20161f",
winter: "#ffffff",
dark: "#1d232a",
};
var color = colorMap[theme] || "#1d232a";
meta.setAttribute("content", color);
}
})();
</script>
</head>
<body style="cursor: pointer;">
<div id="app-loading" style="
display: flex; display: flex;
flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 100vh; height: 100vh;
font-family: sans-serif; "
"> >
<div id="app-loading-spinner" style=" <div
style="
width: 40px; width: 40px;
height: 40px; height: 40px;
border: 3px solid currentColor; border: 3px solid currentColor;
@@ -104,122 +100,49 @@
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
opacity: 0.5; opacity: 0.5;
"></div> "
<div id="app-loading-error" style="display: none; text-align: center; margin-top: 20px; padding: 0 20px"> ></div>
<p style="color: #ef4444; font-weight: bold; margin-bottom: 8px">
Uygulama yüklenemedi
</p>
<p style="font-size: 14px; opacity: 0.7">
Bağlantınız yavaş olabilir veya bir sistem hatası oluşmuş olabilir.
</p>
<button onclick="location.reload()" style="
margin-top: 16px;
padding: 8px 16px;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
">
Sayfayı Yenile
</button>
</div> </div>
</div> <style>
<style> @keyframes spin {
@keyframes spin { to {
to { transform: rotate(360deg);
transform: rotate(360deg); }
} }
}
body.app-loaded #app-loading { body.app-loaded #app-loading {
display: none !important; display: none !important;
} }
</style>
/* iOS Safari Click Fixes */
body { <!-- Service Worker Registration & PWA Setup -->
cursor: pointer; <script>
-webkit-tap-highlight-color: transparent; if ("serviceWorker" in navigator) {
} window.addEventListener("load", () => {
navigator.serviceWorker
summary { .register("/sw.js")
list-style: none; .then((registration) => {
} console.log("✅ Service Worker registered:", registration);
summary::-webkit-details-marker { // Request notification permission after a delay (better UX)
display: none; setTimeout(() => {
} if ("Notification" in window && Notification.permission === "default") {
</style> // Only request if user hasn't decided yet
const shouldRequest = localStorage.getItem("vibetorrent_notification_prompt_shown");
<script> if (!shouldRequest) {
// App loading timeout handler Notification.requestPermission().then((permission) => {
(function () { console.log("Notification permission:", permission);
var timeout = setTimeout(function () { localStorage.setItem("vibetorrent_notification_prompt_shown", "true");
if (!document.body.classList.contains("app-loaded")) { });
var spinner = document.getElementById("app-loading-spinner"); }
var error = document.getElementById("app-loading-error");
if (spinner) spinner.style.display = "none";
if (error) error.style.display = "block";
}
}, 15000); // 15 seconds timeout
// Clean up timeout if app loads
var observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (
mutation.attributeName === "class" &&
document.body.classList.contains("app-loaded")
) {
clearTimeout(timeout);
observer.disconnect();
}
});
});
observer.observe(document.body, { attributes: true });
})();
</script>
<!-- Service Worker Registration & PWA Setup -->
<script>
// Global Dropdown Closer for iOS/Mobile
document.addEventListener('click', function (event) {
const details = document.querySelectorAll('details[open]');
details.forEach(detail => {
// Eğer tıklanan yer bu details'in içinde değilse kapat
if (!detail.contains(event.target)) {
detail.removeAttribute('open');
}
});
}, true); // Use capture phase for better mobile support
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("✅ Service Worker registered:", registration);
// Request notification permission after a delay (better UX)
setTimeout(() => {
if ("Notification" in window && Notification.permission === "default") {
// Only request if user hasn't decided yet
const shouldRequest = localStorage.getItem("vibetorrent_notification_prompt_shown");
if (!shouldRequest) {
Notification.requestPermission().then((permission) => {
console.log("Notification permission:", permission);
localStorage.setItem("vibetorrent_notification_prompt_shown", "true");
});
} }
} }, 3000); // Wait 3 seconds before asking
}, 3000); // Wait 3 seconds before asking })
}) .catch((error) => {
.catch((error) => { console.warn("⚠️ Service Worker registration failed:", error);
console.warn("⚠️ Service Worker registration failed:", error); });
}); });
}); }
} </script>
</script> </body>
</body> </html>
</html>

View File

@@ -1,161 +1,16 @@
@import "tailwindcss"; @import "tailwindcss";
@config "./tailwind.config.js"; @config "./tailwind.config.js";
@source "../src/**/*.rs";
@source "/Users/bilal/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/leptos-shadcn-*/src/**/*.rs";
@theme { @plugin "daisyui" {
/* Shadcn Colors */ themes:
--color-border: hsl(var(--border)); light, dark, dim, nord, cupcake, dracula, cyberpunk, emerald, sunset,
--color-input: hsl(var(--input)); abyss;
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
} }
@layer base { @layer base {
:root { html,
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body { body {
@apply bg-background text-foreground; @apply min-h-dvh w-full overflow-hidden bg-base-100 text-base-content overscroll-y-none;
}
/* Ensure Shadcn Utilities are always available */
.bg-popover {
background-color: hsl(var(--popover));
}
.text-popover-foreground {
color: hsl(var(--popover-foreground));
}
.border-border {
border-color: hsl(var(--border));
}
.shadow-md {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.z-50 {
z-index: 50;
}
.z-100 {
z-index: 100;
} }
} }
@@ -174,4 +29,4 @@
:focus { :focus {
outline: none !important; outline: none !important;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,21 @@
{ {
"author": "",
"dependencies": {
"@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",
"tw-animate-css": "^1.4.0"
},
"description": "",
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"postcss-cli": "^11.0.1",
"postcss-preset-env": "^10.1.3",
"tailwindcss": "^4.1.18"
},
"keywords": [],
"license": "ISC",
"main": "tailwind.config.js",
"name": "frontend", "name": "frontend",
"version": "1.0.0",
"description": "",
"main": "tailwind.config.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"type": "module", "keywords": [],
"version": "1.0.0" "author": "",
"license": "ISC",
"devDependencies": {
"autoprefixer": "^10.4.23",
"daisyui": "^5.5.1-beta.2",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18"
},
"dependencies": {
"@tailwindcss/cli": "^4.1.18"
}
} }

View File

@@ -1,15 +0,0 @@
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
"postcss-preset-env": {
features: {
"nesting-rules": true,
},
browsers: [
"last 2 versions",
"iOS >= 15",
"Safari >= 15",
],
},
},
};

View File

@@ -1,253 +0,0 @@
/**
* Scroll Lock Utility
* Handles locking and unlocking scroll for both window and all scrollable containers
* Similar to react-remove-scroll but in vanilla JavaScript
*/
(() => {
// Prevent multiple initializations
if (window.ScrollLock) {
return;
}
class ScrollLock {
constructor() {
this.locked = false;
this.scrollableElements = [];
this.scrollPositions = new Map();
this.originalStyles = new Map();
this.fixedElements = [];
}
/**
* Find all scrollable elements in the DOM (optimized)
* Uses more targeted selectors instead of querying all elements
*/
findScrollableElements() {
const scrollables = [];
// More targeted query - only look for elements with overflow properties
const candidates = document.querySelectorAll(
'[style*="overflow"], [class*="overflow"], [class*="scroll"], main, aside, section, div',
);
// Batch all style reads first to minimize reflows
const elementsToCheck = [];
for (const el of candidates) {
// Skip the element itself or if it's inside these containers
const dataName = el.getAttribute("data-name");
const isExcludedElement =
dataName === "ScrollArea" ||
dataName === "CommandList" ||
dataName === "SelectContent" ||
dataName === "MultiSelectContent" ||
dataName === "DropdownMenuContent" ||
dataName === "ContextMenuContent";
if (
el !== document.body &&
el !== document.documentElement &&
!isExcludedElement &&
!el.closest('[data-name="ScrollArea"]') &&
!el.closest('[data-name="CommandList"]') &&
!el.closest('[data-name="SelectContent"]') &&
!el.closest('[data-name="MultiSelectContent"]') &&
!el.closest('[data-name="DropdownMenuContent"]') &&
!el.closest('[data-name="ContextMenuContent"]')
) {
elementsToCheck.push(el);
}
}
// Now batch read all computed styles and dimensions
elementsToCheck.forEach((el) => {
const style = window.getComputedStyle(el);
const hasOverflow =
style.overflow === "auto" ||
style.overflow === "scroll" ||
style.overflowY === "auto" ||
style.overflowY === "scroll";
// Only check scrollHeight if overflow is set
if (hasOverflow && el.scrollHeight > el.clientHeight) {
scrollables.push(el);
}
});
return scrollables;
}
/**
* Lock scrolling on all scrollable elements (optimized)
* Batches all DOM reads before DOM writes to prevent forced reflows
*/
lock() {
if (this.locked) return;
this.locked = true;
// Find all scrollable elements
this.scrollableElements = this.findScrollableElements();
// ===== BATCH 1: READ PHASE - Read all layout properties first =====
const windowScrollY = window.scrollY;
const scrollbarWidth = window.innerWidth - document.body.clientWidth;
// Store window scroll position
this.scrollPositions.set("window", windowScrollY);
// Store original body styles
this.originalStyles.set("body", {
position: document.body.style.position,
top: document.body.style.top,
width: document.body.style.width,
overflow: document.body.style.overflow,
paddingRight: document.body.style.paddingRight,
});
// Read all fixed-position elements and their padding (only if we have scrollbar)
if (scrollbarWidth > 0) {
// Use more targeted query for fixed elements
const fixedCandidates = document.querySelectorAll(
'[style*="fixed"], [class*="fixed"], header, nav, aside, [role="dialog"], [role="alertdialog"]',
);
this.fixedElements = Array.from(fixedCandidates).filter((el) => {
const style = window.getComputedStyle(el);
return (
style.position === "fixed" &&
!el.closest('[data-name="DropdownMenuContent"]') &&
!el.closest('[data-name="MultiSelectContent"]') &&
!el.closest('[data-name="ContextMenuContent"]')
);
});
// Batch read all padding values
this.fixedElements.forEach((el) => {
const computedStyle = window.getComputedStyle(el);
const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0;
this.originalStyles.set(el, {
paddingRight: el.style.paddingRight,
computedPadding: currentPadding,
});
});
}
// Read scrollable elements info
const scrollableInfo = this.scrollableElements.map((el) => {
const scrollTop = el.scrollTop;
const elementScrollbarWidth = el.offsetWidth - el.clientWidth;
const computedStyle = window.getComputedStyle(el);
const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0;
this.scrollPositions.set(el, scrollTop);
this.originalStyles.set(el, {
overflow: el.style.overflow,
overflowY: el.style.overflowY,
paddingRight: el.style.paddingRight,
});
return { el, elementScrollbarWidth, currentPadding };
});
// ===== BATCH 2: WRITE PHASE - Apply all styles at once =====
// Apply body lock
document.body.style.position = "fixed";
document.body.style.top = `-${windowScrollY}px`;
document.body.style.width = "100%";
document.body.style.overflow = "hidden";
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`;
// Apply padding compensation to fixed elements
this.fixedElements.forEach((el) => {
const stored = this.originalStyles.get(el);
if (stored) {
el.style.paddingRight = `${stored.computedPadding + scrollbarWidth}px`;
}
});
}
// Lock all scrollable containers
scrollableInfo.forEach(({ el, elementScrollbarWidth, currentPadding }) => {
el.style.overflow = "hidden";
if (elementScrollbarWidth > 0) {
el.style.paddingRight = `${currentPadding + elementScrollbarWidth}px`;
}
});
}
/**
* Unlock scrolling on all elements (optimized)
* @param {number} delay - Delay in milliseconds before unlocking (for animations)
*/
unlock(delay = 0) {
if (!this.locked) return;
const performUnlock = () => {
// Restore body scroll
const bodyStyles = this.originalStyles.get("body");
if (bodyStyles) {
document.body.style.position = bodyStyles.position;
document.body.style.top = bodyStyles.top;
document.body.style.width = bodyStyles.width;
document.body.style.overflow = bodyStyles.overflow;
document.body.style.paddingRight = bodyStyles.paddingRight;
}
// Restore window scroll position
const windowScrollY = this.scrollPositions.get("window") || 0;
window.scrollTo(0, windowScrollY);
// Restore all scrollable containers
this.scrollableElements.forEach((el) => {
const originalStyles = this.originalStyles.get(el);
if (originalStyles) {
el.style.overflow = originalStyles.overflow;
el.style.overflowY = originalStyles.overflowY;
el.style.paddingRight = originalStyles.paddingRight;
}
// Restore scroll position
const scrollPosition = this.scrollPositions.get(el) || 0;
el.scrollTop = scrollPosition;
});
// Restore fixed-position elements padding
this.fixedElements.forEach((el) => {
const styles = this.originalStyles.get(el);
if (styles && styles.paddingRight !== undefined) {
el.style.paddingRight = styles.paddingRight;
}
});
// Clear storage
this.scrollableElements = [];
this.fixedElements = [];
this.scrollPositions.clear();
this.originalStyles.clear();
this.locked = false;
};
if (delay > 0) {
setTimeout(performUnlock, delay);
} else {
performUnlock();
}
}
/**
* Check if scrolling is currently locked
*/
isLocked() {
return this.locked;
}
}
// Export as singleton
window.ScrollLock = new ScrollLock();
})();

File diff suppressed because it is too large Load Diff

View File

@@ -1,210 +0,0 @@
use gloo_net::http::Request;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiError {
#[error("Network error")]
Network,
#[error("Server error: {status}")]
Server { status: u16 },
#[error("Login failed")]
LoginFailed,
#[error("Unauthorized")]
Unauthorized,
#[error("Too many requests")]
RateLimited,
#[error("Server function error: {0}")]
ServerFn(String),
}
fn base_url() -> String {
"/api".to_string()
}
pub mod auth {
use super::*;
#[derive(serde::Serialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
pub remember_me: bool,
}
pub async fn login(
username: &str,
password: &str,
remember_me: bool,
) -> Result<(), ApiError> {
let req = LoginRequest {
username: username.to_string(),
password: password.to_string(),
remember_me,
};
let resp = Request::post(&format!("{}/auth/login", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
if resp.ok() {
Ok(())
} else if resp.status() == 429 {
Err(ApiError::RateLimited)
} else {
Err(ApiError::LoginFailed)
}
}
pub async fn logout() -> Result<(), ApiError> {
Request::post(&format!("{}/auth/logout", base_url()))
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
}
pub async fn check_auth() -> Result<bool, ApiError> {
let resp = Request::get(&format!("{}/auth/check", base_url()))
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(resp.ok())
}
#[derive(serde::Deserialize)]
pub struct UserResponse {
pub username: String,
}
pub async fn get_user() -> Result<UserResponse, ApiError> {
let resp = Request::get(&format!("{}/auth/check", base_url()))
.send()
.await
.map_err(|_| ApiError::Network)?;
let user = resp.json().await.map_err(|_| ApiError::Network)?;
Ok(user)
}
}
pub mod setup {
use super::*;
#[derive(serde::Serialize)]
pub struct SetupRequest {
pub username: String,
pub password: String,
}
#[derive(serde::Deserialize)]
pub struct SetupStatusResponse {
pub completed: bool,
}
pub async fn get_status() -> Result<SetupStatusResponse, ApiError> {
let resp = Request::get(&format!("{}/setup/status", base_url()))
.send()
.await
.map_err(|_| ApiError::Network)?;
let status = resp.json().await.map_err(|_| ApiError::Network)?;
Ok(status)
}
pub async fn setup(username: &str, password: &str) -> Result<(), ApiError> {
let req = SetupRequest {
username: username.to_string(),
password: password.to_string(),
};
Request::post(&format!("{}/setup", base_url()))
.json(&req)
.map_err(|_| ApiError::Network)?
.send()
.await
.map_err(|_| ApiError::Network)?;
Ok(())
}
}
pub mod settings {
use super::*;
use shared::GlobalLimitRequest;
pub async fn set_global_limits(req: &GlobalLimitRequest) -> Result<(), ApiError> {
shared::server_fns::settings::set_global_limits(
req.max_download_rate,
req.max_upload_rate,
)
.await
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
}
pub mod push {
use super::*;
pub async fn get_public_key() -> Result<String, ApiError> {
shared::server_fns::push::get_public_key()
.await
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
pub async fn subscribe(endpoint: &str, p256dh: &str, auth: &str) -> Result<(), ApiError> {
shared::server_fns::push::subscribe_push(
endpoint.to_string(),
p256dh.to_string(),
auth.to_string(),
)
.await
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
}
pub mod torrent {
use super::*;
pub async fn add(uri: &str) -> Result<(), ApiError> {
shared::server_fns::torrent::add_torrent(uri.to_string())
.await
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
pub async fn action(hash: &str, action: &str) -> Result<(), ApiError> {
shared::server_fns::torrent::torrent_action(hash.to_string(), action.to_string())
.await
.map(|_| ())
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
pub async fn delete(hash: &str) -> Result<(), ApiError> {
action(hash, "delete").await
}
pub async fn delete_with_data(hash: &str) -> Result<(), ApiError> {
action(hash, "delete_with_data").await
}
pub async fn start(hash: &str) -> Result<(), ApiError> {
action(hash, "start").await
}
pub async fn stop(hash: &str) -> Result<(), ApiError> {
action(hash, "stop").await
}
pub async fn set_label(hash: &str, label: &str) -> Result<(), ApiError> {
shared::server_fns::torrent::set_label(hash.to_string(), label.to_string())
.await
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
pub async fn set_priority(hash: &str, file_index: u32, priority: u8) -> Result<(), ApiError> {
shared::server_fns::torrent::set_file_priority(
hash.to_string(),
file_index,
priority,
)
.await
.map_err(|e| ApiError::ServerFn(e.to_string()))
}
}

View File

@@ -1,91 +1,102 @@
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::auth::login::Login; use crate::components::auth::login::Login;
use crate::components::auth::setup::Setup; use crate::components::auth::setup::Setup;
use leptos::prelude::*; use leptos::*;
use leptos::task::spawn_local; use leptos_router::*;
use leptos_router::components::{Router, Routes, Route}; use serde::Deserialize;
use leptos_router::hooks::use_navigate;
use crate::components::ui::toast::Toaster;
use crate::components::hooks::use_theme_mode::ThemeMode;
#[component] #[derive(Deserialize)]
pub fn App() -> impl IntoView { struct SetupStatus {
crate::components::ui::toast::provide_toaster(); completed: bool,
let theme_mode = ThemeMode::init(); }
// Sync theme with document #[derive(Deserialize)]
Effect::new(move |_| { struct UserResponse {
let is_dark = theme_mode.get(); username: String,
if let Some(doc) = document().document_element() {
if is_dark {
let _ = doc.class_list().add_1("dark");
let _ = doc.set_attribute("data-theme", "dark");
} else {
let _ = doc.class_list().remove_1("dark");
let _ = doc.set_attribute("data-theme", "light");
}
}
});
view! {
<Toaster />
<InnerApp />
}
} }
#[component] #[component]
fn InnerApp() -> impl IntoView { pub fn App() -> impl IntoView {
crate::store::provide_torrent_store(); crate::store::provide_torrent_store();
let store = use_context::<crate::store::TorrentStore>();
let is_loading = signal(true); // Auth State
let is_authenticated = signal(false); let (is_loading, set_is_loading) = create_signal(true);
let needs_setup = signal(false); let (is_authenticated, set_is_authenticated) = create_signal(false);
Effect::new(move |_| { // Check Auth & Setup Status on load
create_effect(move |_| {
spawn_local(async move { spawn_local(async move {
log::info!("App initialization started..."); logging::log!("App initialization started...");
gloo_console::log!("APP INIT: Checking setup status...");
// Check if setup is needed via Server Function // 1. Check Setup Status
match shared::server_fns::auth::get_setup_status().await { let setup_res = gloo_net::http::Request::get("/api/setup/status").send().await;
Ok(status) => {
if !status.completed { match setup_res {
log::info!("Setup not completed"); Ok(resp) => {
needs_setup.1.set(true); if resp.ok() {
is_loading.1.set(false); match resp.json::<SetupStatus>().await {
return; Ok(status) => {
if !status.completed {
logging::log!("Setup not completed, redirecting to /setup");
let navigate = use_navigate();
navigate("/setup", Default::default());
set_is_loading.set(false);
return;
}
}
Err(e) => logging::error!("Failed to parse setup status: {}", e),
}
} }
} }
Err(e) => log::error!("Failed to get setup status: {:?}", e), Err(e) => logging::error!("Network error checking setup status: {}", e),
} }
// Check authentication via GetUser Server Function // 2. Check Auth Status
match shared::server_fns::auth::get_user().await { let auth_res = gloo_net::http::Request::get("/api/auth/check").send().await;
Ok(Some(user_info)) => {
log::info!("Authenticated as {}", user_info.username);
if let Some(s) = store {
s.user.set(Some(user_info.username));
}
is_authenticated.1.set(true);
}
Ok(None) => {
log::info!("Not authenticated");
}
Err(e) => {
log::error!("Auth check failed: {:?}", e);
}
}
is_loading.1.set(false); match auth_res {
crate::store::toast_success("VibeTorrent'e Hoşgeldiniz"); Ok(resp) => {
if resp.status() == 200 {
logging::log!("Authenticated!");
// Parse user info
if let Ok(user_info) = resp.json::<UserResponse>().await {
if let Some(store) = use_context::<crate::store::TorrentStore>() {
store.user.set(Some(user_info.username));
}
}
set_is_authenticated.set(true);
// If user is already authenticated but on login/setup page, redirect to home
let pathname = window().location().pathname().unwrap_or_default();
if pathname == "/login" || pathname == "/setup" {
logging::log!("Already authenticated, redirecting to home");
let navigate = use_navigate();
navigate("/", Default::default());
}
} else {
logging::log!("Not authenticated, redirecting to /login");
let navigate = use_navigate();
let pathname = window().location().pathname().unwrap_or_default();
if pathname != "/login" && pathname != "/setup" {
navigate("/login", Default::default());
}
}
}
Err(e) => logging::error!("Network error checking auth status: {}", e),
}
set_is_loading.set(false);
}); });
}); });
// Initialize push notifications (Only if authenticated)
Effect::new(move |_| { create_effect(move |_| {
if is_authenticated.0.get() { if is_authenticated.get() {
spawn_local(async { spawn_local(async {
// ... (Push notification logic kept same, shortened for brevity in this replace)
// Wait a bit for service worker to be ready
gloo_timers::future::TimeoutFuture::new(2000).await; gloo_timers::future::TimeoutFuture::new(2000).await;
if crate::utils::platform::supports_push_notifications() && !crate::utils::platform::is_safari() { if crate::utils::platform::supports_push_notifications() && !crate::utils::platform::is_safari() {
@@ -98,109 +109,30 @@ fn InnerApp() -> impl IntoView {
view! { view! {
<div class="relative w-full h-screen" style="height: 100dvh;"> <div class="relative w-full h-screen" style="height: 100dvh;">
<Router> <Router>
<Routes fallback=|| view! { <div class="p-4">"404 Not Found"</div> }> <Routes>
<Route path=leptos_router::path!("/login") view=move || { <Route path="/login" view=move || view! { <Login /> } />
let authenticated = is_authenticated.0.get(); <Route path="/setup" view=move || view! { <Setup /> } />
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 || { <Route 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! { view! {
<Show when=move || !is_loading.0.get() fallback=|| view! { <Show when=move || !is_loading.get() fallback=|| view! {
<div class="flex h-screen bg-background"> <div class="flex items-center justify-center h-screen bg-base-100">
// Sidebar skeleton <span class="loading loading-spinner loading-lg"></span>
<div class="w-56 border-r border-border p-4 space-y-4">
<div class="h-8 w-3/4 animate-pulse rounded-md bg-muted" />
<div class="space-y-2">
<div class="h-6 w-full animate-pulse rounded-md bg-muted" />
<div class="h-6 w-full animate-pulse rounded-md bg-muted" />
<div class="h-6 w-4/5 animate-pulse rounded-md bg-muted" />
<div class="h-6 w-full animate-pulse rounded-md bg-muted" />
<div class="h-6 w-3/5 animate-pulse rounded-md bg-muted" />
<div class="h-6 w-full animate-pulse rounded-md bg-muted" />
</div>
</div>
// Main content skeleton
<div class="flex-1 flex flex-col">
<div class="border-b border-border p-4 flex items-center gap-4">
<div class="h-8 w-48 animate-pulse rounded-md bg-muted" />
<div class="h-8 w-64 animate-pulse rounded-md bg-muted" />
<div class="ml-auto"><div class="h-8 w-24 animate-pulse rounded-md bg-muted" /></div>
</div>
<div class="flex-1 p-4 space-y-3">
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
<div class="h-10 w-full animate-pulse rounded-md bg-muted" />
<div class="h-10 w-3/4 animate-pulse rounded-md bg-muted" />
</div>
<div class="border-t border-border p-3">
<div class="h-5 w-96 animate-pulse rounded-md bg-muted" />
</div>
</div>
</div> </div>
}.into_any()> }>
<Show when=move || is_authenticated.0.get() fallback=|| ()> <Show when=move || is_authenticated.get() fallback=|| ()>
<Protected> <Protected>
<div class="flex flex-col h-full overflow-hidden"> <TorrentTable />
<div class="flex-1 overflow-hidden">
<TorrentTable />
</div>
</div>
</Protected> </Protected>
</Show> </Show>
</Show> </Show>
}.into_any() }
}/> }/>
<Route path=leptos_router::path!("/settings") view=move || { <Route path="/settings" view=move || {
Effect::new(move |_| {
if !is_authenticated.0.get() {
let navigate = use_navigate();
navigate("/login", Default::default());
}
});
view! { view! {
<Show when=move || !is_loading.0.get() fallback=|| ()> <Show when=move || !is_loading.get() fallback=|| ()>
<Show when=move || is_authenticated.0.get() fallback=|| ()> <Show when=move || is_authenticated.get() fallback=|| ()>
<Protected> <Protected>
<div class="p-4">"Settings Page (Coming Soon)"</div> <div class="p-4">"Settings Page (Coming Soon)"</div>
</Protected> </Protected>
@@ -210,6 +142,8 @@ fn InnerApp() -> impl IntoView {
}/> }/>
</Routes> </Routes>
</Router> </Router>
<ToastContainer />
</div> </div>
} }
} }

View File

@@ -1,95 +1,131 @@
use leptos::prelude::*; use leptos::*;
use leptos::task::spawn_local; use serde::Serialize;
use crate::components::ui::card::{Card, CardHeader, CardContent};
use crate::components::ui::input::{Input, InputType};
use crate::components::ui::button::Button; #[derive(Serialize)]
struct LoginRequest {
username: String,
password: String,
remember_me: bool,
}
#[component] #[component]
pub fn Login() -> impl IntoView { pub fn Login() -> impl IntoView {
let username = RwSignal::new(String::new()); let (username, set_username) = create_signal(String::new());
let password = RwSignal::new(String::new()); let (password, set_password) = create_signal(String::new());
let error = signal(Option::<String>::None); let (remember_me, set_remember_me) = create_signal(false);
let loading = signal(false); let (error, set_error) = create_signal(Option::<String>::None);
let (loading, set_loading) = create_signal(false);
let handle_login = move |ev: web_sys::SubmitEvent| { let handle_login = move |ev: web_sys::SubmitEvent| {
ev.prevent_default(); ev.prevent_default();
loading.1.set(true); set_loading.set(true);
error.1.set(None); set_error.set(None);
let user = username.get(); logging::log!("Attempting login for user: {}", username.get());
let pass = password.get();
spawn_local(async move { spawn_local(async move {
match shared::server_fns::auth::login(user, pass).await { let req = LoginRequest {
Ok(_) => { username: username.get(),
let window = web_sys::window().expect("window should exist"); password: password.get(),
let _ = window.location().set_href("/"); remember_me: remember_me.get(),
};
let client = gloo_net::http::Request::post("/api/auth/login")
.json(&req)
.expect("Failed to create request");
match client.send().await {
Ok(resp) => {
logging::log!("Login response status: {}", resp.status());
if resp.ok() {
logging::log!("Login successful, redirecting...");
// Force a full reload to re-run auth checks in App.rs
let _ = window().location().set_href("/");
} else {
let text = resp.text().await.unwrap_or_default();
logging::error!("Login failed: {}", text);
set_error.set(Some("Kullanıcı adı veya şifre hatalı".to_string()));
}
} }
Err(_) => { Err(e) => {
error.1.set(Some("Geçersiz kullanıcı adı veya şifre".to_string())); logging::error!("Network error: {}", e);
loading.1.set(false); set_error.set(Some("Bağlantı hatası".to_string()));
} }
} }
set_loading.set(false);
}); });
}; };
view! { view! {
<div class="flex items-center justify-center min-h-screen bg-muted/40 px-4"> <div class="flex items-center justify-center min-h-screen bg-base-200">
<Card class="w-full max-w-sm shadow-lg"> <div class="card w-full max-w-sm shadow-xl bg-base-100">
<CardHeader class="pb-2 items-center"> <div class="card-body">
<div class="w-12 h-12 bg-primary rounded-xl flex items-center justify-center text-primary-foreground shadow-sm mb-4"> <h2 class="card-title justify-center mb-4">"VibeTorrent Giriş"</h2>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.25 0 003 2.48z" /> <form on:submit=handle_login>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18a3.75 3.75 0 00.495-7.467 5.99 5.99 0 00-1.925 3.546 5.974 5.974 0 01-2.133-1A3.75 3.75 0 0012 18z" /> <div class="form-control w-full">
</svg> <label class="label">
</div> <span class="label-text">"Kullanıcı Adı"</span>
<h3 class="font-semibold tracking-tight text-2xl">"VibeTorrent"</h3> </label>
<p class="text-sm text-muted-foreground">"Hesabınıza giriş yapın"</p> <input
</CardHeader> type="text"
<CardContent class="pt-4">
<form on:submit=handle_login class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium leading-none">"Kullanıcı Adı"</label>
<Input
r#type=InputType::Text
placeholder="Kullanıcı adınız" placeholder="Kullanıcı adınız"
bind_value=username class="input input-bordered w-full"
disabled=loading.0.get() prop:value=username
/> on:input=move |ev| set_username.set(event_target_value(&ev))
</div> disabled=move || loading.get()
<div class="space-y-2">
<label class="text-sm font-medium leading-none">"Şifre"</label>
<Input
r#type=InputType::Password
placeholder="******"
bind_value=password
disabled=loading.0.get()
/> />
</div> </div>
<Show when=move || error.0.get().is_some()> <div class="form-control w-full mt-4">
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <label class="label">
{move || error.0.get().unwrap_or_default()} <span class="label-text">"Şifre"</span>
</label>
<input
type="password"
placeholder="******"
class="input input-bordered w-full"
prop:value=password
on:input=move |ev| set_password.set(event_target_value(&ev))
disabled=move || loading.get()
/>
</div>
<div class="form-control mt-4">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
prop:checked=remember_me
on:change=move |ev| set_remember_me.set(event_target_checked(&ev))
disabled=move || loading.get()
/>
<span class="label-text">"Beni Hatırla"</span>
</label>
</div>
<Show when=move || error.get().is_some()>
<div class="alert alert-error mt-4 text-sm py-2">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>{move || error.get()}</span>
</div> </div>
</Show> </Show>
<div class="pt-2"> <div class="card-actions justify-end mt-6">
<Button <button
class="w-full" class="btn btn-primary w-full"
attr:r#type="submit" type="submit"
attr:disabled=move || loading.0.get() disabled=move || loading.get()
> >
<Show when=move || loading.0.get() fallback=|| view! { "Giriş Yap" }.into_any()> <Show when=move || loading.get() fallback=|| "Giriş Yap">
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span> <span class="loading loading-spinner"></span>
"Giriş Yapılıyor..." "Giriş Yapılıyor..."
</Show> </Show>
</Button> </button>
</div> </div>
</form> </form>
</CardContent> </div>
</Card> </div>
</div> </div>
} }
} }

View File

@@ -1,119 +1,144 @@
use leptos::prelude::*; use leptos::*;
use leptos::task::spawn_local; use serde::Serialize;
use crate::components::ui::card::{Card, CardHeader, CardContent};
use crate::components::ui::input::{Input, InputType};
use crate::components::ui::button::Button; #[derive(Serialize)]
struct SetupRequest {
username: String,
password: String,
}
#[component] #[component]
pub fn Setup() -> impl IntoView { pub fn Setup() -> impl IntoView {
let username = RwSignal::new(String::new()); let (username, set_username) = create_signal(String::new());
let password = RwSignal::new(String::new()); let (password, set_password) = create_signal(String::new());
let confirm_password = RwSignal::new(String::new()); let (confirm_password, set_confirm_password) = create_signal(String::new());
let error = signal(Option::<String>::None); let (error, set_error) = create_signal(Option::<String>::None);
let loading = signal(false); let (loading, set_loading) = create_signal(false);
let handle_setup = move |ev: web_sys::SubmitEvent| { let handle_setup = move |ev: web_sys::SubmitEvent| {
ev.prevent_default(); ev.prevent_default();
set_loading.set(true);
set_error.set(None);
let pass = password.get(); let pass = password.get();
let confirm = confirm_password.get(); let confirm = confirm_password.get();
if pass != confirm { if pass != confirm {
error.1.set(Some("Şifreler eşleşmiyor".to_string())); set_error.set(Some("Şifreler eşleşmiyor".to_string()));
set_loading.set(false);
return; return;
} }
if pass.len() < 6 { if pass.len() < 6 {
error.1.set(Some("Şifre en az 6 karakter olmalıdır".to_string())); set_error.set(Some("Şifre en az 6 karakter olmalıdır".to_string()));
set_loading.set(false);
return; return;
} }
loading.1.set(true);
error.1.set(None);
let user = username.get();
spawn_local(async move { spawn_local(async move {
match shared::server_fns::auth::setup(user, pass).await { let req = SetupRequest {
Ok(_) => { username: username.get(),
log::info!("Setup completed successfully, redirecting..."); password: pass,
let window = web_sys::window().expect("window should exist"); };
let _ = window.location().set_href("/");
let client = gloo_net::http::Request::post("/api/setup")
.json(&req)
.expect("Failed to create request");
match client.send().await {
Ok(resp) => {
if resp.ok() {
// Redirect to home after setup (auto-login handled by backend)
// Full reload to ensure auth state is refreshed
let _ = window().location().set_href("/");
} else {
let text = resp.text().await.unwrap_or_default();
set_error.set(Some(format!("Hata: {}", text)));
}
} }
Err(e) => { Err(_) => {
log::error!("Setup failed: {:?}", e); set_error.set(Some("Bağlantı hatası".to_string()));
error.1.set(Some("Kurulum sırasında bir hata oluştu".to_string()));
loading.1.set(false);
} }
} }
set_loading.set(false);
}); });
}; };
view! { view! {
<div class="flex items-center justify-center min-h-screen bg-muted/40 px-4"> <div class="flex items-center justify-center min-h-screen bg-base-200">
<Card class="w-full max-w-md shadow-lg overflow-hidden"> <div class="card w-full max-w-md shadow-xl bg-base-100">
<CardHeader class="pb-2 items-center text-center"> <div class="card-body">
<div class="w-12 h-12 bg-primary rounded-xl flex items-center justify-center text-primary-foreground shadow-sm mb-4"> <h2 class="card-title justify-center mb-2">"VibeTorrent Kurulumu"</h2>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> <p class="text-center text-sm opacity-70 mb-4">"Yönetici hesabınızı oluşturun"</p>
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-3.497a2.548 2.548 0 113.586 3.586l-6.837 5.63m-5.108 3.497l2.496-3.03c.317-.384.74-.626 1.208-.766M15.75 9.25a2.548 2.548 0 11-5.096 0 2.548 2.548 0 015.096 0z" />
</svg>
</div>
<h3 class="font-semibold tracking-tight text-2xl">"VibeTorrent Kurulumu"</h3>
<p class="text-sm text-muted-foreground">"Yönetici hesabınızı oluşturun"</p>
</CardHeader>
<CardContent class="pt-4"> <form on:submit=handle_setup>
<form on:submit=handle_setup class="space-y-4"> <div class="form-control w-full">
<div class="space-y-2"> <label class="label">
<label class="text-sm font-medium leading-none">"Yönetici Kullanıcı Adı"</label> <span class="label-text">"Kullanıcı Adı"</span>
<Input </label>
r#type=InputType::Text <input
type="text"
placeholder="admin" placeholder="admin"
bind_value=username class="input input-bordered w-full"
disabled=loading.0.get() prop:value=username
/> on:input=move |ev| set_username.set(event_target_value(&ev))
</div> disabled=move || loading.get()
<div class="space-y-2"> required
<label class="text-sm font-medium leading-none">"Şifre"</label>
<Input
r#type=InputType::Password
placeholder="******"
bind_value=password
disabled=loading.0.get()
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium leading-none">"Şifre Onay"</label>
<Input
r#type=InputType::Password
placeholder="******"
bind_value=confirm_password
disabled=loading.0.get()
/> />
</div> </div>
<Show when=move || error.0.get().is_some() fallback=|| ()> <div class="form-control w-full mt-4">
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <label class="label">
<span>{move || error.0.get().unwrap_or_default()}</span> <span class="label-text">"Şifre"</span>
</label>
<input
type="password"
placeholder="******"
class="input input-bordered w-full"
prop:value=password
on:input=move |ev| set_password.set(event_target_value(&ev))
disabled=move || loading.get()
required
/>
</div>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">"Şifre Tekrar"</span>
</label>
<input
type="password"
placeholder="******"
class="input input-bordered w-full"
prop:value=confirm_password
on:input=move |ev| set_confirm_password.set(event_target_value(&ev))
disabled=move || loading.get()
required
/>
</div>
<Show when=move || error.get().is_some()>
<div class="alert alert-error mt-4 text-sm py-2">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>{move || error.get()}</span>
</div> </div>
</Show> </Show>
<div class="pt-2"> <div class="card-actions justify-end mt-6">
<Button <button
class="w-full" class="btn btn-primary w-full"
attr:r#type="submit" type="submit"
attr:disabled=move || loading.0.get() disabled=move || loading.get()
> >
<Show when=move || loading.0.get() fallback=|| view! { "Kurulumu Tamamla" }.into_any()> <Show when=move || loading.get() fallback=|| "Kurulumu Tamamla">
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span> <span class="loading loading-spinner"></span>
"Kuruluyor..." "İşleniyor..."
</Show> </Show>
</Button> </button>
</div> </div>
</form> </form>
</CardContent> </div>
</Card> </div>
</div> </div>
} }
} }

View File

@@ -1,78 +1,98 @@
use leptos::prelude::*; use leptos::*;
use crate::components::ui::context_menu::*;
#[component] #[component]
pub fn TorrentContextMenu( pub fn ContextMenu(
children: Children, position: (i32, i32),
visible: bool,
torrent_hash: String, torrent_hash: String,
on_action: Callback<(String, String)>, on_close: Callback<()>,
on_action: Callback<(String, String)>, // (Action, Hash)
) -> impl IntoView { ) -> impl IntoView {
let hash = StoredValue::new(torrent_hash); let handle_action = move |action: &str| {
let hash = torrent_hash.clone();
let menu_action = move |action: &'static str| { let action_str = action.to_string();
on_action.run((action.to_string(), hash.get_value()));
logging::log!("ContextMenu: Action '{}' for hash '{}'", action_str, hash);
on_action.call((action_str, hash)); // Delegate FIRST
on_close.call(()); // Close menu AFTER
}; };
view! { if !visible {
<ContextMenu> return view! {}.into_view();
<ContextMenuTrigger>
{children()}
</ContextMenuTrigger>
<ContextMenuContent class="w-56">
<ContextMenuAction
class="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-sm"
on:click=move |_| menu_action("start")
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
</svg>
"Start"
</ContextMenuAction>
<ContextMenuAction
class="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-sm"
on:click=move |_| menu_action("stop")
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
</svg>
"Stop"
</ContextMenuAction>
<ContextMenuAction
class="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-sm"
on:click=move |_| menu_action("recheck")
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
"Recheck"
</ContextMenuAction>
<div class="-mx-1 my-1 h-px bg-border" />
<ContextMenuAction
class="px-2 py-1.5 text-destructive hover:bg-destructive/10 hover:text-destructive rounded-sm"
on:click=move |_| menu_action("delete")
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.164h-2.34c-1.18 0-2.09.984-2.09 2.164v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
"Remove"
</ContextMenuAction>
<ContextMenuHoldAction
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
on_hold_complete=move |_| menu_action("delete_with_data")
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25 2.25M12 13.875l2.25-2.25M12 13.875l-2.25-2.25M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
"Remove with Data"
<span class="ml-auto text-[10px] opacity-50">"Hold"</span>
</ContextMenuHoldAction>
</ContextMenuContent>
</ContextMenu>
} }
}
view! {
// Backdrop to catch clicks outside
<div
class="fixed inset-0 z-[99] cursor-default"
role="button"
tabindex="-1"
on:click=move |_| on_close.call(())
on:contextmenu=move |e| e.prevent_default()
></div>
<div
class="fixed z-[100] min-w-[200px] animate-in fade-in zoom-in-95 duration-100"
style=format!("left: {}px; top: {}px", position.0, position.1)
on:contextmenu=move |e| e.prevent_default()
>
<ul class="menu bg-base-200 text-base-content rounded-box shadow-xl border border-white/5 p-2 gap-1">
<li>
<button
class="gap-3 active:bg-primary active:text-primary-content"
on:click={
let handle_action = handle_action.clone();
move |_| handle_action("start")
}
>
<svg class="w-4 h-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
"Resume"
</button>
</li>
<li>
<button
class="gap-3 active:bg-primary active:text-primary-content"
on:click={
let handle_action = handle_action.clone();
move |_| handle_action("stop")
}
>
<svg class="w-4 h-4 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
"Pause"
</button>
</li>
<div class="divider my-0 h-px p-0 opacity-10"></div>
<li>
<button
class="gap-3 text-error hover:bg-error/10 active:bg-error active:text-error-content"
on:click={
let handle_action = handle_action.clone();
move |_| handle_action("delete")
}
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
"Delete"
</button>
</li>
<li>
<button
class="gap-3 text-error hover:bg-error/10 active:bg-error active:text-error-content text-xs"
on:click={
let handle_action = handle_action.clone();
move |_| handle_action("delete_with_data")
}
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
<span>"Delete with Data"</span>
</button>
</li>
</ul>
</div>
}.into_view()
}

View File

@@ -1,3 +0,0 @@
pub mod use_random;
pub mod use_theme_mode;
pub mod use_can_scroll_vertical;

View File

@@ -1,25 +0,0 @@
use leptos::prelude::*;
use wasm_bindgen::JsCast;
/// Hook to determine if an element can scroll vertically.
///
/// Returns (on_scroll_callback, can_scroll_up_signal, can_scroll_down_signal)
pub fn use_can_scroll_vertical() -> (Callback<web_sys::Event>, ReadSignal<bool>, ReadSignal<bool>) {
let can_scroll_up = RwSignal::new(false);
let can_scroll_down = RwSignal::new(false);
let on_scroll = Callback::new(move |ev: web_sys::Event| {
if let Some(target) = ev.target() {
if let Some(el) = target.dyn_ref::<web_sys::HtmlElement>() {
let scroll_top = el.scroll_top();
let scroll_height = el.scroll_height();
let client_height = el.client_height();
can_scroll_up.set(scroll_top > 0);
can_scroll_down.set(scroll_top + client_height < scroll_height - 1);
}
}
});
(on_scroll, can_scroll_up.read_only(), can_scroll_down.read_only())
}

View File

@@ -1,31 +0,0 @@
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicUsize, Ordering};
const PREFIX: &str = "rust_ui"; // Must NOT contain "/" or "-"
pub fn use_random_id() -> String {
format!("_{PREFIX}_{}", generate_hash())
}
pub fn use_random_id_for(element: &str) -> String {
format!("{}_{PREFIX}_{}", element, generate_hash())
}
pub fn use_random_transition_name() -> String {
let random_id = use_random_id();
format!("view-transition-name: {random_id}")
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
static COUNTER: AtomicUsize = AtomicUsize::new(1);
fn generate_hash() -> u64 {
let mut hasher = DefaultHasher::new();
let counter = COUNTER.fetch_add(1, Ordering::SeqCst);
counter.hash(&mut hasher);
hasher.finish()
}

View File

@@ -1,108 +0,0 @@
use leptos::prelude::*;
use web_sys::Storage;
#[derive(Debug, Clone, Copy)]
pub struct ThemeMode {
state: RwSignal<bool>,
}
const LOCALSTORAGE_KEY: &str = "darkmode";
/// Hook to access the dark mode context
///
/// Returns the ThemeMode instance from context for easy access
pub fn use_theme_mode() -> ThemeMode {
expect_context::<ThemeMode>()
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
impl ThemeMode {
#[must_use]
/// Initializes a new ThemeMode instance.
pub fn init() -> Self {
let theme_mode = Self { state: RwSignal::new(false) };
provide_context(theme_mode);
// Use Effect to handle browser-only initialization
Effect::new(move |_| {
let initial = Self::get_storage_state().unwrap_or(Self::prefers_dark_mode());
theme_mode.state.set(initial);
});
theme_mode
}
pub fn toggle(&self) {
self.state.update(|state| {
*state = !*state;
Self::set_storage_state(*state);
});
}
pub fn set_dark(&self) {
self.set(true);
}
pub fn set_light(&self) {
self.set(false);
}
/// - `dark`: Set to `true` for dark mode, and `false` for light mode.
pub fn set(&self, dark: bool) {
self.state.set(dark);
Self::set_storage_state(dark);
}
#[must_use]
pub fn get(&self) -> bool {
self.state.get()
}
#[must_use]
pub fn is_dark(&self) -> bool {
self.state.get()
}
#[must_use]
pub fn is_light(&self) -> bool {
!self.state.get()
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
/// Retrieves the local storage object, if available.
fn get_storage() -> Option<Storage> {
window().local_storage().ok().flatten()
}
/// Retrieves the dark mode state from local storage, if available.
fn get_storage_state() -> Option<bool> {
Self::get_storage()
.and_then(|storage| storage.get(LOCALSTORAGE_KEY).ok())
.flatten()
.and_then(|entry| entry.parse::<bool>().ok())
}
/// Checks whether the user's system prefers dark mode based on media queries.
fn prefers_dark_mode() -> bool {
window()
.match_media("(prefers-color-scheme: dark)")
.ok()
.flatten()
.map(|media| media.matches())
.unwrap_or_default()
}
/// Stores the dark mode state in local storage.
fn set_storage_state(state: bool) {
if let Some(storage) = Self::get_storage() {
storage.set(LOCALSTORAGE_KEY, state.to_string().as_str()).ok();
}
}
}

View File

@@ -1,52 +1,30 @@
use leptos::prelude::*; use leptos::*;
use crate::components::layout::sidebar::Sidebar; use crate::components::layout::sidebar::Sidebar;
use crate::components::layout::toolbar::Toolbar;
use crate::components::layout::statusbar::StatusBar; use crate::components::layout::statusbar::StatusBar;
use crate::components::layout::toolbar::Toolbar;
#[component] #[component]
pub fn Protected(children: Children) -> impl IntoView { pub fn Protected(children: Children) -> impl IntoView {
// Mobil menü durumu için bir sinyal oluşturuyoruz (RwSignal for easier passing)
let is_mobile_menu_open = RwSignal::new(false);
// Sinyali context olarak sağlıyoruz ki Toolbar ve Sidebar buna erişebilsin
provide_context(is_mobile_menu_open);
view! { view! {
<div class="flex h-screen w-full overflow-hidden bg-background"> <div class="drawer lg:drawer-open h-full w-full">
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
// --- SIDEBAR (Desktop: Sabit, Mobil: Overlay) ---
<aside class=move || {
let base = "fixed inset-y-0 left-0 z-50 w-64 transform transition-transform duration-300 ease-in-out border-r border-border bg-card lg:relative lg:translate-x-0";
if is_mobile_menu_open.get() {
format!("{} translate-x-0", base)
} else {
format!("{} -translate-x-full", base)
}
}>
<Sidebar />
</aside>
// Mobil arka plan karartma (Overlay) <div class="drawer-content flex flex-col h-full overflow-hidden bg-base-100 text-base-content text-sm select-none">
<Show when=move || is_mobile_menu_open.get()>
<div
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm lg:hidden"
on:click=move |_| is_mobile_menu_open.set(false)
></div>
</Show>
// --- MAIN CONTENT AREA ---
<div class="flex flex-1 flex-col overflow-hidden">
// --- TOOLBAR (TOP) ---
<Toolbar /> <Toolbar />
// --- MAIN CONTENT --- <main class="flex-1 flex flex-col min-w-0 bg-base-100 overflow-hidden pb-8">
<main class="flex-1 overflow-hidden relative bg-background">
{children()} {children()}
</main> </main>
// --- STATUS BAR (BOTTOM) ---
<StatusBar /> <StatusBar />
</div> </div>
<div class="drawer-side z-40 transition-none duration-0">
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay transition-none duration-0"></label>
<div class="menu p-0 min-h-full bg-base-200 text-base-content border-r border-base-300 transition-none duration-0">
<Sidebar />
</div>
</div>
</div> </div>
} }
} }

View File

@@ -1,188 +1,275 @@
use leptos::prelude::*; use leptos::wasm_bindgen::JsCast;
use leptos::task::spawn_local; use leptos::*;
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
#[component] #[component]
pub fn Sidebar() -> impl IntoView { pub fn Sidebar() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided"); let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let is_mobile_menu_open = use_context::<RwSignal<bool>>().expect("mobile menu state not provided");
let total_count = move || store.torrents.with(|map| map.len()); let total_count = move || store.torrents.get().len();
let downloading_count = move || { let downloading_count = move || {
store.torrents.with(|map| { store
map.values() .torrents
.filter(|t| t.status == shared::TorrentStatus::Downloading) .get()
.count() .iter()
}) .filter(|t| t.status == shared::TorrentStatus::Downloading)
.count()
}; };
let seeding_count = move || { let seeding_count = move || {
store.torrents.with(|map| { store
map.values() .torrents
.filter(|t| t.status == shared::TorrentStatus::Seeding) .get()
.count() .iter()
}) .filter(|t| t.status == shared::TorrentStatus::Seeding)
.count()
}; };
let completed_count = move || { let completed_count = move || {
store.torrents.with(|map| { store
map.values() .torrents
.filter(|t| { .get()
t.status == shared::TorrentStatus::Seeding .iter()
|| (t.status == shared::TorrentStatus::Paused && t.percent_complete >= 100.0) .filter(|t| {
}) t.status == shared::TorrentStatus::Seeding
.count() || (t.status == shared::TorrentStatus::Paused && t.percent_complete >= 100.0)
}) })
.count()
}; };
let paused_count = move || { let paused_count = move || {
store.torrents.with(|map| { store
map.values() .torrents
.filter(|t| t.status == shared::TorrentStatus::Paused) .get()
.count() .iter()
}) .filter(|t| t.status == shared::TorrentStatus::Paused)
.count()
}; };
let inactive_count = move || { let inactive_count = move || {
store.torrents.with(|map| { store
map.values() .torrents
.filter(|t| { .get()
t.status == shared::TorrentStatus::Paused .iter()
|| t.status == shared::TorrentStatus::Error .filter(|t| {
}) t.status == shared::TorrentStatus::Paused
.count() || t.status == shared::TorrentStatus::Error
}) })
.count()
};
let close_drawer = move || {
if let Some(element) = document().get_element_by_id("my-drawer") {
if let Ok(input) = element.dyn_into::<web_sys::HtmlInputElement>() {
input.set_checked(false);
}
}
}; };
let set_filter = move |f: crate::store::FilterStatus| { let set_filter = move |f: crate::store::FilterStatus| {
store.filter.set(f); store.filter.set(f);
is_mobile_menu_open.set(false); close_drawer();
}; };
let is_active = move |f: crate::store::FilterStatus| store.filter.get() == f; let filter_class = move |f: crate::store::FilterStatus| {
if store.filter.get() == f {
let username = move || { "active"
store.user.get().unwrap_or_else(|| "User".to_string()) } else {
""
}
}; };
let first_letter = move || { let handle_logout = move |_| {
username().chars().next().unwrap_or('?').to_uppercase().to_string() spawn_local(async move {
let client = gloo_net::http::Request::post("/api/auth/logout");
if let Ok(resp) = client.send().await {
if resp.ok() {
// Force full reload to clear state
let _ = window().location().set_href("/login");
}
}
});
}; };
view! { let username = move || {
<div class="w-full h-full flex flex-col bg-card" style="padding-top: env(safe-area-inset-top);">
<div class="p-4 flex-1 overflow-y-auto">
<div class="mb-4 px-2 text-lg font-semibold tracking-tight text-foreground">
"VibeTorrent"
</div>
<div class="space-y-1">
<h4 class="mb-1 rounded-md px-2 py-1 text-sm font-semibold text-muted-foreground">"Filters"</h4>
<SidebarButton
active=Signal::derive(move || is_active(crate::store::FilterStatus::All))
on_click=move |_| set_filter(crate::store::FilterStatus::All)
icon="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
label="All"
count=Signal::derive(total_count)
/>
<SidebarButton
active=Signal::derive(move || is_active(crate::store::FilterStatus::Downloading))
on_click=move |_| set_filter(crate::store::FilterStatus::Downloading)
icon="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
label="Downloading"
count=Signal::derive(downloading_count)
/>
<SidebarButton
active=Signal::derive(move || is_active(crate::store::FilterStatus::Seeding))
on_click=move |_| set_filter(crate::store::FilterStatus::Seeding)
icon="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
label="Seeding"
count=Signal::derive(seeding_count)
/>
<SidebarButton
active=Signal::derive(move || is_active(crate::store::FilterStatus::Completed))
on_click=move |_| set_filter(crate::store::FilterStatus::Completed)
icon="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
label="Completed"
count=Signal::derive(completed_count)
/>
<SidebarButton
active=Signal::derive(move || is_active(crate::store::FilterStatus::Paused))
on_click=move |_| set_filter(crate::store::FilterStatus::Paused)
icon="M15.75 5.25v13.5m-7.5-13.5v13.5"
label="Paused"
count=Signal::derive(paused_count)
/>
<SidebarButton
active=Signal::derive(move || is_active(crate::store::FilterStatus::Inactive))
on_click=move |_| set_filter(crate::store::FilterStatus::Inactive)
icon="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
label="Inactive"
count=Signal::derive(inactive_count)
/>
</div>
</div>
// Separator store.user.get().unwrap_or_else(|| "User".to_string())
<div class="border-t border-border" />
};
let first_letter = move || {
username().chars().next().unwrap_or('?').to_uppercase().to_string()
};
view! {
<div class="w-64 min-h-[100dvh] flex flex-col bg-base-200 border-r border-base-300 pb-8" style="padding-top: env(safe-area-inset-top);">
<div class="p-2 flex-1 overflow-y-auto">
<ul class="menu w-full rounded-box gap-1">
<li class="menu-title text-primary uppercase font-bold px-4">"Filters"</li>
<li>
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::All))} on:click=move |_| set_filter(crate::store::FilterStatus::All)>
<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">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
"All"
<span class="badge badge-sm badge-ghost ml-auto">{total_count}</span>
</button>
</li>
<li>
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Downloading))} on:click=move |_| set_filter(crate::store::FilterStatus::Downloading)>
<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">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
"Downloading"
<span class="badge badge-sm badge-ghost ml-auto">{downloading_count}</span>
</button>
</li>
<li>
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Seeding))} on:click=move |_| set_filter(crate::store::FilterStatus::Seeding)>
<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">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
"Seeding"
<span class="badge badge-sm badge-ghost ml-auto">{seeding_count}</span>
</button>
</li>
<li>
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Completed))} on:click=move |_| set_filter(crate::store::FilterStatus::Completed)>
<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">
<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>
"Completed"
<span class="badge badge-sm badge-ghost ml-auto">{completed_count}</span>
</button>
</li>
<li>
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Paused))} on:click=move |_| set_filter(crate::store::FilterStatus::Paused)>
<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">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
</svg>
"Paused"
<span class="badge badge-sm badge-ghost ml-auto">{paused_count}</span>
</button>
</li>
<li>
<button class={move || format!("cursor-pointer {}", filter_class(crate::store::FilterStatus::Inactive))} on:click=move |_| set_filter(crate::store::FilterStatus::Inactive)>
<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">
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
"Inactive"
<span class="badge badge-sm badge-ghost ml-auto">{inactive_count}</span>
</button>
</li>
</ul>
</div>
<div class="p-4 border-t border-base-300 bg-base-200/50">
<div class="flex items-center gap-3">
<div class="avatar">
<div class="w-8 rounded-full bg-neutral text-neutral-content ring ring-primary ring-offset-base-100 ring-offset-1">
<span class="text-sm font-bold flex items-center justify-center h-full">{first_letter}</span>
</div>
</div>
<div class="flex-1 overflow-hidden">
<div class="font-bold text-sm truncate">{username}</div>
<div class="text-[10px] text-base-content/60 truncate">"Online"</div>
</div>
<button
class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/10"
title="Logout"
on:click=handle_logout
>
<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="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
</button>
</div>
</div>
<div class="p-4 bg-card" style="padding-bottom: calc(1rem + env(safe-area-inset-bottom));">
<div class="flex items-center gap-3">
// Avatar
<div class="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0">
{first_letter}
</div>
<div class="flex-1 overflow-hidden">
<div class="font-medium text-sm truncate text-foreground">{username}</div>
<div class="text-[10px] text-muted-foreground truncate">"Online"</div>
</div> </div>
// Theme toggle button }}
<div class="inline-flex items-center justify-center size-8 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground hover:text-foreground transition-colors">
<crate::components::ui::theme_toggle::ThemeToggle />
</div>
// Logout button
<Button
variant=ButtonVariant::Ghost
size=ButtonSize::Icon
class="text-destructive hover:bg-destructive/10"
attr:disabled=move || false
on:click=move |_| {
spawn_local(async move {
if shared::server_fns::auth::logout().await.is_ok() {
let window = web_sys::window().expect("window should exist");
let _ = window.location().set_href("/login");
}
});
}
>
<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="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
</Button>
</div>
</div>
</div>
}
}
#[component]
fn SidebarButton(
active: Signal<bool>,
on_click: impl Fn(web_sys::MouseEvent) + 'static,
#[prop(into)] icon: String,
#[prop(into)] label: &'static str,
count: Signal<usize>,
) -> impl IntoView {
let variant = move || if active.get() { ButtonVariant::Secondary } else { ButtonVariant::Ghost };
view! {
<Button
variant=Signal::derive(variant)
class="justify-start gap-2 w-full h-8 px-3"
on:click=on_click
>
<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=icon.clone() />
</svg>
{label}
<span class="ml-auto text-xs font-mono opacity-70">{count}</span>
</Button>
}
}

View File

@@ -1,7 +1,5 @@
use leptos::prelude::*; use leptos::*;
use leptos::html;
use shared::GlobalLimitRequest; use shared::GlobalLimitRequest;
use crate::api;
fn format_bytes(bytes: i64) -> String { fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"]; const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
@@ -28,10 +26,39 @@ pub fn StatusBar() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided"); let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let stats = store.global_stats; let stats = store.global_stats;
let initial_theme = if let Some(win) = web_sys::window() {
if let Some(doc) = win.document() {
doc.document_element()
.and_then(|el| el.get_attribute("data-theme"))
.unwrap_or_else(|| "dark".to_string())
} else {
"dark".to_string()
}
} else {
"dark".to_string()
};
let (current_theme, set_current_theme) = create_signal(initial_theme);
create_effect(move |_| {
if let Some(win) = web_sys::window() {
if let Some(storage) = win.local_storage().ok().flatten() {
if let Ok(Some(stored_theme)) = storage.get_item("vibetorrent_theme") {
let theme = stored_theme.to_lowercase();
set_current_theme.set(theme.clone());
if let Some(doc) = win.document() {
let _ = doc
.document_element()
.unwrap()
.set_attribute("data-theme", &theme);
}
}
}
}
});
// Preset limits in bytes/s // Preset limits in bytes/s
let limits: Vec<(i64, &str)> = vec!( let limits: Vec<(i64, &str)> = vec![
(0, "Unlimited"), (0, "Unlimited"),
(100 * 1024, "100 KB/s"), (100 * 1024, "100 KB/s"),
(500 * 1024, "500 KB/s"), (500 * 1024, "500 KB/s"),
@@ -40,48 +67,87 @@ pub fn StatusBar() -> impl IntoView {
(5 * 1024 * 1024, "5 MB/s"), (5 * 1024 * 1024, "5 MB/s"),
(10 * 1024 * 1024, "10 MB/s"), (10 * 1024 * 1024, "10 MB/s"),
(20 * 1024 * 1024, "20 MB/s"), (20 * 1024 * 1024, "20 MB/s"),
); ];
let set_limit = move |limit_type: &str, val: i64| { let set_limit = move |limit_type: &str, val: i64| {
let limit_type = limit_type.to_string(); let limit_type = limit_type.to_string();
log::info!("Setting {} limit to {}", limit_type, val); logging::log!("Setting {} limit to {}", limit_type, val);
let req = if limit_type == "down" { spawn_local(async move {
GlobalLimitRequest { let req_body = if limit_type == "down" {
max_download_rate: Some(val), GlobalLimitRequest {
max_upload_rate: None, max_download_rate: Some(val),
} max_upload_rate: None,
} else { }
GlobalLimitRequest {
max_download_rate: None,
max_upload_rate: Some(val),
}
};
leptos::task::spawn_local(async move {
if let Err(e) = api::settings::set_global_limits(&req).await {
log::error!("Failed to set limit: {:?}", e);
} else { } else {
log::info!("Limit set successfully"); GlobalLimitRequest {
max_download_rate: None,
max_upload_rate: Some(val),
}
};
let client =
gloo_net::http::Request::post("/api/settings/global-limits").json(&req_body);
match client {
Ok(req) => match req.send().await {
Ok(resp) => {
if !resp.ok() {
logging::error!(
"Failed to set limit: {} {}",
resp.status(),
resp.status_text()
);
} else {
logging::log!("Limit set successfully");
}
}
Err(e) => logging::error!("Network error setting limit: {}", e),
},
Err(e) => logging::error!("Failed to create request: {}", e),
} }
}); });
}; };
let down_details_ref = NodeRef::<html::Details>::new(); // Signal-based dropdown state: 0=none, 1=download, 2=upload, 3=theme
let up_details_ref = NodeRef::<html::Details>::new(); let (active_dropdown, set_active_dropdown) = create_signal(0u8);
let close_details = move |node_ref: NodeRef<html::Details>| { // Toggle a specific dropdown
if let Some(el) = node_ref.get_untracked() { let toggle = move |id: u8| {
el.set_open(false); let current = active_dropdown.get_untracked();
if current == id {
set_active_dropdown.set(0);
} else {
set_active_dropdown.set(id);
} }
}; };
// Close all dropdowns
let close_all = move || {
set_active_dropdown.set(0);
};
view! { view! {
<div class="fixed bottom-0 left-0 right-0 h-8 min-h-8 bg-muted border-t border-border flex items-center px-4 text-xs gap-4 text-muted-foreground z-[99] cursor-pointer"> // Transparent overlay to close dropdowns when clicking outside
<Show when=move || active_dropdown.get() != 0>
<div
class="fixed inset-0 z-[98] cursor-default"
on:pointerdown=move |_| close_all()
></div>
</Show>
<div class="fixed bottom-0 left-0 right-0 h-8 min-h-8 bg-base-200 border-t border-base-300 flex items-center px-4 text-xs gap-4 text-base-content/70 z-[99]">
// --- DOWNLOAD SPEED DROPDOWN --- // --- DOWNLOAD SPEED DROPDOWN ---
<details class="group relative" node_ref=down_details_ref> <div class="relative">
<summary class="flex items-center gap-2 cursor-pointer hover:text-foreground transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none"> <div
class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none"
title="Global Download Speed - Click to Set Limit"
on:pointerdown=move |e| {
e.stop_propagation();
toggle(1);
}
>
<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"> <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="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" /> <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
</svg> </svg>
@@ -91,46 +157,50 @@ pub fn StatusBar() -> impl IntoView {
{move || format!("(Limit: {})", format_speed(stats.get().down_limit.unwrap_or(0)))} {move || format!("(Limit: {})", format_speed(stats.get().down_limit.unwrap_or(0)))}
</span> </span>
</Show> </Show>
</summary>
<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">
<ul class="w-full">
{
limits.clone().into_iter().map(|(val, label)| {
let is_active = move || {
let current = stats.get().down_limit.unwrap_or(0);
(current - val).abs() < 1024
};
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";
if is_active() { format!("{} bg-accent text-accent-foreground font-medium", base) } else { base.to_string() }
}
on:click=move |_| {
set_limit("down", val);
close_details(down_details_ref);
}
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Show when=is_active fallback=|| ()>
<span>""</span>
</Show>
</span>
{label}
</button>
</li>
}
}).collect::<Vec<_>>()
}
</ul>
</div> </div>
</details>
<ul
class="absolute bottom-full left-0 z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300"
style=move || if active_dropdown.get() == 1 { "display: block" } else { "display: none" }
>
{
limits.clone().into_iter().map(|(val, label)| {
let is_active = move || {
let current = stats.get().down_limit.unwrap_or(0);
(current - val).abs() < 1024
};
view! {
<li>
<button
class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" }
on:pointerdown=move |e| {
e.stop_propagation();
set_limit("down", val);
close_all();
}
>
{label}
<Show when=is_active fallback=|| ()>
<span>""</span>
</Show>
</button>
</li>
}
}).collect::<Vec<_>>()
}
</ul>
</div>
// --- UPLOAD SPEED DROPDOWN --- // --- UPLOAD SPEED DROPDOWN ---
<details class="group relative" node_ref=up_details_ref> <div class="relative">
<summary class="flex items-center gap-2 cursor-pointer hover:text-foreground transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none"> <div
class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none"
title="Global Upload Speed - Click to Set Limit"
on:pointerdown=move |e| {
e.stop_propagation();
toggle(2);
}
>
<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"> <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="M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18" /> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18" />
</svg> </svg>
@@ -140,34 +210,83 @@ pub fn StatusBar() -> impl IntoView {
{move || format!("(Limit: {})", format_speed(stats.get().up_limit.unwrap_or(0)))} {move || format!("(Limit: {})", format_speed(stats.get().up_limit.unwrap_or(0)))}
</span> </span>
</Show> </Show>
</summary> </div>
<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"> <ul
<ul class="w-full"> class="absolute bottom-full left-0 z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300"
style=move || if active_dropdown.get() == 2 { "display: block" } else { "display: none" }
>
{
limits.clone().into_iter().map(|(val, label)| {
let is_active = move || {
let current = stats.get().up_limit.unwrap_or(0);
(current - val).abs() < 1024
};
view! {
<li>
<button
class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" }
on:pointerdown=move |e| {
e.stop_propagation();
set_limit("up", val);
close_all();
}
>
{label}
<Show when=is_active fallback=|| ()>
<span>""</span>
</Show>
</button>
</li>
}
}).collect::<Vec<_>>()
}
</ul>
</div>
<div class="ml-auto flex items-center gap-4">
<div class="relative">
<div
class="btn btn-ghost btn-xs btn-square"
title="Change Theme"
on:pointerdown=move |e| {
e.stop_propagation();
toggle(3);
}
>
<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="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" />
</svg>
</div>
<ul
class="absolute bottom-full right-0 z-[100] menu p-2 shadow bg-base-200 rounded-box w-52 mb-2 border border-base-300 max-h-96 overflow-y-auto"
style=move || if active_dropdown.get() == 3 { "display: block" } else { "display: none" }
>
{ {
limits.clone().into_iter().map(|(val, label)| { let themes = vec![
let is_active = move || { "light", "dark", "dim", "nord", "cupcake", "dracula", "cyberpunk", "emerald", "sunset", "abyss"
let current = stats.get().up_limit.unwrap_or(0); ];
(current - val).abs() < 1024 themes.into_iter().map(|theme| {
};
view! { view! {
<li> <li>
<button <button
class=move || { class=move || if current_theme.get() == theme { "bg-primary/10 text-primary font-bold text-xs capitalize" } else { "text-xs capitalize" }
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"; on:pointerdown=move |e| {
if is_active() { format!("{} bg-accent text-accent-foreground font-medium", base) } else { base.to_string() } e.stop_propagation();
} set_current_theme.set(theme.to_string());
on:click=move |_| { if let Some(win) = web_sys::window() {
set_limit("up", val); if let Some(doc) = win.document() {
close_details(up_details_ref); let _ = doc.document_element().unwrap().set_attribute("data-theme", theme);
}
if let Some(storage) = win.local_storage().ok().flatten() {
let _ = storage.set_item("vibetorrent_theme", theme);
}
}
close_all();
} }
> >
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> {theme}
<Show when=is_active fallback=|| ()>
<span>""</span>
</Show>
</span>
{label}
</button> </button>
</li> </li>
} }
@@ -175,27 +294,61 @@ pub fn StatusBar() -> impl IntoView {
} }
</ul> </ul>
</div> </div>
</details>
<div class="ml-auto flex items-center gap-4">
<button <button
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-7 w-7" class="btn btn-ghost btn-xs btn-square"
title="Settings & Notification Permissions" title="Settings & Notification Permissions"
on:click=move |_| { on:click=move |_| {
// Request push notification permission // Request push notification permission when settings button is clicked
leptos::task::spawn_local(async { spawn_local(async {
// ... existing logic ... log::info!("Settings button clicked - requesting push notification permission");
// Check current permission state before requesting
let window = web_sys::window().expect("window should exist");
let _current_perm = js_sys::Reflect::get(&window, &"Notification".into())
.ok()
.and_then(|n| js_sys::Reflect::get(&n, &"permission".into()).ok())
.and_then(|p| p.as_string())
.unwrap_or_default();
crate::store::subscribe_to_push_notifications().await; crate::store::subscribe_to_push_notifications().await;
// ... existing logic ...
// Check permission after request
let new_perm = js_sys::Reflect::get(&window, &"Notification".into())
.ok()
.and_then(|n| js_sys::Reflect::get(&n, &"permission".into()).ok())
.and_then(|p| p.as_string())
.unwrap_or_default();
if let Some(store) = use_context::<crate::store::TorrentStore>() {
if new_perm == "granted" {
crate::store::show_toast_with_signal(
store.notifications,
shared::NotificationLevel::Success,
"Bildirimler etkinleştirildi! Torrent tamamlandığında bildirim alacaksınız.".to_string(),
);
} else if new_perm == "denied" {
crate::store::show_toast_with_signal(
store.notifications,
shared::NotificationLevel::Error,
"Bildirim izni reddedildi. Tarayıcı ayarlarından izin verebilirsiniz.".to_string(),
);
} else {
crate::store::show_toast_with_signal(
store.notifications,
shared::NotificationLevel::Warning,
"Bildirim izni verilemedi. Açılan izin penceresinde 'İzin Ver' seçeneğini seçin.".to_string(),
);
}
}
}); });
} }
> >
<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"> <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="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.212 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 012.6-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.212 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
</button> </button>
</div> </div>
</div> </div>
} }
} }

View File

@@ -1,64 +1,64 @@
use leptos::prelude::*; use leptos::*;
use crate::components::torrent::add_torrent::AddTorrentDialog;
#[component] #[component]
pub fn Toolbar() -> impl IntoView { pub fn Toolbar() -> impl IntoView {
let show_add_modal = signal(false); let (show_add_modal, set_show_add_modal) = create_signal(false);
let store = use_context::<crate::store::TorrentStore>().expect("store not provided"); let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let is_mobile_menu_open = use_context::<RwSignal<bool>>().expect("mobile menu state not provided");
let search_value = RwSignal::new(String::new());
// Sync search_value to store
Effect::new(move |_| {
let val = search_value.get();
store.search_query.set(val);
});
view! { view! {
<div class="flex min-h-14 h-auto items-center border-b border-border bg-background px-4" style="padding-top: env(safe-area-inset-top);"> <div class="navbar min-h-14 h-auto bg-base-100 p-0" style="padding-top: env(safe-area-inset-top);">
// Sol kısım: Menü butonu + Add Torrent <div class="navbar-start gap-4 px-4">
<div class="flex items-center gap-3"> <label for="my-drawer" class="btn btn-square btn-ghost lg:hidden drawer-button">
// Mobile Menu Trigger <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-5 h-5 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
<button </label>
class="inline-flex items-center justify-center size-9 rounded-md hover:bg-accent hover:text-accent-foreground lg:hidden"
on:click=move |_| is_mobile_menu_open.update(|v| *v = !*v)
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
</button>
<button
class="inline-flex items-center justify-center gap-2 h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all active:scale-[0.98]"
on:click=move |_| show_add_modal.1.set(true)
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 md:w-5 md:h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span class="hidden sm:inline">"Add Torrent"</span>
<span class="sm:hidden">"Add"</span>
</button>
</div>
// Sağ kısım: Search kutusu <div class="flex gap-2">
<div class="flex flex-1 items-center justify-end gap-2"> <button
<div class="hidden md:flex items-center gap-2 w-full max-w-xs"> class="btn btn-sm btn-primary gap-2 font-normal"
<div class="relative flex-1"> title="Add Magnet Link"
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none"> on:click=move |_| set_show_add_modal.set(true)
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /> >
<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="M12 4.5v15m7.5-7.5h-15" />
</svg> </svg>
<input "Add Torrent"
type="search" </button>
placeholder="Search..."
class="file:text-foreground placeholder:text-muted-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 md:text-sm pl-8"
bind:value=search_value
/>
</div>
</div> </div>
</div> </div>
<Show when=move || show_add_modal.0.get()> <div class="navbar-end gap-2 px-4">
<AddTorrentDialog on_close=Callback::new(move |()| show_add_modal.1.set(false)) /> <div class="join">
<input
type="text"
placeholder="Search..."
class="input input-sm input-bordered join-item w-full max-w-xs focus:outline-none"
prop:value=move || store.search_query.get()
on:input=move |ev| store.search_query.set(event_target_value(&ev))
on:keydown=move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" {
store.search_query.set(String::new());
}
}
/>
<Show when=move || !store.search_query.get().is_empty()>
<button
class="btn btn-sm btn-ghost join-item border-base-content/20 border-l-0 px-2"
title="Clear Search"
on:click=move |_| store.search_query.set(String::new())
>
<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 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Show>
</div>
</div>
<Show when=move || show_add_modal.get()>
<crate::components::torrent::add_torrent::AddTorrentModal on_close=move |_| set_show_add_modal.set(false) />
</Show> </Show>
</div> </div>
} }
} }

View File

@@ -1,7 +1,6 @@
pub mod hooks;
pub mod context_menu; pub mod context_menu;
pub mod layout; pub mod layout;
pub mod modal;
pub mod toast;
pub mod torrent; pub mod torrent;
pub mod auth; pub mod auth;
// pub mod toast; (Removed)
pub mod ui;

View File

@@ -0,0 +1,56 @@
use leptos::*;
#[component]
pub fn Modal(
#[prop(into)] title: String,
children: Children,
#[prop(into)] on_confirm: Callback<()>,
#[prop(into)] on_cancel: Callback<()>,
#[prop(into)] visible: Signal<bool>,
#[prop(into, default = "Confirm".to_string())] confirm_text: String,
#[prop(into, default = "Cancel".to_string())] cancel_text: String,
#[prop(into, default = false)] is_danger: bool,
) -> impl IntoView {
let title = store_value(title);
// Eagerly render children to a Fragment, which is Clone
let child_view = store_value(children());
let on_confirm = store_value(on_confirm);
let on_cancel = store_value(on_cancel);
let confirm_text = store_value(confirm_text);
let cancel_text = store_value(cancel_text);
view! {
<Show when=move || visible.get() fallback=|| ()>
<div class="fixed inset-0 bg-background/80 backdrop-blur-sm flex items-end md:items-center justify-center z-[200] animate-in fade-in duration-200 sm:p-4">
<div class="bg-card p-6 rounded-t-2xl md:rounded-lg w-full max-w-sm shadow-xl border border-border ring-0 transform transition-all animate-in slide-in-from-bottom-10 md:slide-in-from-bottom-0 md:zoom-in-95">
<h3 class="text-lg font-semibold text-card-foreground mb-4">{title.get_value()}</h3>
<div class="text-muted-foreground mb-6 text-sm">
{child_view.with_value(|c| c.clone())}
</div>
<div class="flex justify-end gap-3">
<button
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 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2"
on:click=move |_| on_cancel.with_value(|cb| cb.call(()))
>
{cancel_text.get_value()}
</button>
<button
class=move || crate::utils::cn(format!("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 h-10 px-4 py-2 {}",
if is_danger { "bg-destructive text-destructive-foreground hover:bg-destructive/90" }
else { "bg-primary text-primary-foreground hover:bg-primary/90" }
))
on:click=move |_| {
logging::log!("Modal: Confirm clicked");
on_confirm.with_value(|cb| cb.call(()))
}
>
{confirm_text.get_value()}
</button>
</div>
</div>
</div>
</Show>
}
}

View File

@@ -1,111 +1,83 @@
use leptos::prelude::*; use leptos::*;
use std::collections::HashMap;
use uuid::Uuid;
use shared::NotificationLevel; use shared::NotificationLevel;
#[derive(Clone, Debug, PartialEq)] // ============================================================================
pub struct Toast { // Toast Components - DaisyUI Alert Style
pub id: String, // ============================================================================
pub message: String,
pub level: NotificationLevel,
pub visible: RwSignal<bool>,
}
#[derive(Clone, Copy)] /// Returns the DaisyUI alert class for the notification level
pub struct ToastContext { fn get_alert_class(level: &NotificationLevel) -> &'static str {
pub toasts: RwSignal<HashMap<String, Toast>>, match level {
} NotificationLevel::Info => "alert alert-info",
NotificationLevel::Success => "alert alert-success",
impl ToastContext { NotificationLevel::Warning => "alert alert-warning",
pub fn add(&self, message: impl Into<String>, level: NotificationLevel) { NotificationLevel::Error => "alert alert-error",
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() { /// Individual toast item component
let toasts = RwSignal::new(HashMap::new());
provide_context(ToastContext { toasts });
}
#[component] #[component]
pub fn Toaster() -> impl IntoView { fn ToastItem(
let context = expect_context::<ToastContext>(); level: NotificationLevel,
message: String,
) -> impl IntoView {
let alert_class = get_alert_class(&level);
view! { // DaisyUI SVG icons
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 w-full max-w-sm pointer-events-none"> let icon_svg = match level {
{move || { NotificationLevel::Info => view! {
context.toasts.get().into_values().map(|toast| { <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
view! { <ToastItem toast=toast /> } <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
}).collect::<Vec<_>>() </svg>
}} }.into_view(),
</div> NotificationLevel::Success => view! {
} <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
} <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
#[component] }.into_view(),
fn ToastItem(toast: Toast) -> impl IntoView { NotificationLevel::Warning => view! {
let (visible, set_visible) = (toast.visible, toast.visible.write_only()); <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
let base_classes = "pointer-events-auto relative w-full rounded-lg border p-4 shadow-lg transition-all duration-300 ease-in-out"; </svg>
let color_classes = match toast.level { }.into_view(),
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 => view! {
NotificationLevel::Error => "bg-red-50 text-red-900 border-red-200 dark:bg-red-900 dark:text-red-100 dark:border-red-800", <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
NotificationLevel::Warning => "bg-yellow-50 text-yellow-900 border-yellow-200 dark:bg-yellow-900 dark:text-yellow-100 dark:border-yellow-800", <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
NotificationLevel::Info => "bg-blue-50 text-blue-900 border-blue-200 dark:bg-blue-900 dark:text-blue-100 dark:border-blue-800", </svg>
}.into_view(),
}; };
view! { view! {
<div <div class=alert_class>
class=move || format!("{} {} {}", {icon_svg}
base_classes, <span>{message}</span>
color_classes, </div>
if visible.get() { "opacity-100 translate-x-0" } else { "opacity-0 translate-x-full" } }
) }
role="alert"
> /// Main toast container - renders all active notifications
<div class="flex items-start gap-4"> #[component]
<div class="flex-1"> pub fn ToastContainer() -> impl IntoView {
<p class="text-sm font-medium">{toast.message.clone()}</p> let store = use_context::<crate::store::TorrentStore>().expect("TorrentStore not provided");
</div> let notifications = store.notifications;
<button
class="inline-flex shrink-0 opacity-50 hover:opacity-100 focus:opacity-100 focus:outline-none" view! {
on:click=move |_| set_visible.set(false) <div
> class="toast toast-end toast-bottom"
<span class="sr-only">"Kapat"</span> style="position: fixed; bottom: 20px; right: 20px; z-index: 99999;"
<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> <For
<line x1="6" x2="18" y1="6" y2="18"></line> each=move || notifications.get()
</svg> key=|item| item.id
</button> children=move |item| {
</div> view! {
<ToastItem
level=item.notification.level
message=item.notification.message
/>
}
}
/>
</div> </div>
} }
} }

View File

@@ -1,122 +1,128 @@
use leptos::prelude::*; use leptos::*;
use leptos::task::spawn_local; use leptos::html::Dialog;
use crate::components::ui::input::{Input, InputType}; use crate::store::{show_toast_with_signal, TorrentStore};
use crate::store::TorrentStore; use shared::NotificationLevel;
use crate::api;
use crate::components::ui::button::{Button, ButtonVariant};
#[component] #[component]
pub fn AddTorrentDialog( pub fn AddTorrentModal(
#[prop(into)]
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 dialog_ref = create_node_ref::<Dialog>();
let (uri, set_uri) = create_signal(String::new());
let (is_loading, set_loading) = create_signal(false);
let (error_msg, set_error_msg) = create_signal(Option::<String>::None);
let uri = RwSignal::new(String::new()); // Effect to open the dialog when the component mounts/renders
let is_loading = signal(false); create_effect(move |_| {
let error_msg = signal(Option::<String>::None); if let Some(dialog) = dialog_ref.get() {
let _ = dialog.show_modal();
}
});
let handle_submit = move |ev: web_sys::SubmitEvent| { let handle_submit = move |_| {
ev.prevent_default();
let uri_val = uri.get(); let uri_val = uri.get();
if uri_val.is_empty() { if uri_val.is_empty() {
error_msg.1.set(Some("Please enter a Magnet URI or URL".to_string())); show_toast_with_signal(notifications, NotificationLevel::Warning, "Lütfen bir Magnet URI veya URL girin");
set_error_msg.set(Some("Please enter a Magnet URI or URL".to_string()));
return; return;
} }
is_loading.1.set(true); set_loading.set(true);
error_msg.1.set(None); set_error_msg.set(None);
let on_close = on_close.clone();
spawn_local(async move { spawn_local(async move {
match api::torrent::add(&uri_val).await { let req_body = serde_json::json!({
Ok(_) => { "uri": uri_val
log::info!("Torrent added successfully"); });
crate::store::toast_success("Torrent başarıyla eklendi");
on_close.run(()); match gloo_net::http::Request::post("/api/torrents/add")
.json(&req_body)
{
Ok(req) => {
match req.send().await {
Ok(resp) => {
if resp.ok() {
logging::log!("Torrent added successfully");
show_toast_with_signal(notifications, NotificationLevel::Success, "Torrent eklendi");
set_loading.set(false);
if let Some(dialog) = dialog_ref.get() {
dialog.close();
}
on_close.call(());
} else {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
logging::error!("Failed to add torrent: {} - {}", status, text);
show_toast_with_signal(notifications, NotificationLevel::Error, "Torrent eklenemedi");
set_error_msg.set(Some(format!("Error {}: {}", status, text)));
set_loading.set(false);
}
}
Err(e) => {
logging::error!("Network error: {}", e);
show_toast_with_signal(notifications, NotificationLevel::Error, "Bağlantı hatası");
set_error_msg.set(Some(format!("Network Error: {}", e)));
set_loading.set(false);
}
}
} }
Err(e) => { Err(e) => {
log::error!("Failed to add torrent: {:?}", e); logging::error!("Serialization error: {}", e);
error_msg.1.set(Some(format!("Hata: {:?}", e))); show_toast_with_signal(notifications, NotificationLevel::Error, "İstek hatası");
is_loading.1.set(false); set_error_msg.set(Some(format!("Request Error: {}", e)));
set_loading.set(false);
} }
} }
}); });
}; };
let handle_backdrop = { let handle_close = move |_| {
let on_close = on_close.clone(); if let Some(dialog) = dialog_ref.get() {
move |e: web_sys::MouseEvent| { dialog.close();
e.stop_propagation();
on_close.run(());
} }
on_close.call(());
}; };
view! { view! {
// Backdrop overlay <dialog node_ref=dialog_ref class="modal modal-bottom sm:modal-middle">
<div <div class="modal-box">
class="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm" <h3 class="font-bold text-lg">"Add Torrent"</h3>
on:click=handle_backdrop <p class="py-4">"Enter a Magnet URI or direct URL to a .torrent file."</p>
/>
// Dialog panel
<div class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-card p-6 shadow-lg rounded-lg sm:max-w-[425px]">
// Header
<div class="flex flex-col space-y-1.5 text-center sm:text-left">
<h2 class="text-lg font-semibold leading-none tracking-tight">"Add Torrent"</h2>
<p class="text-sm text-muted-foreground">"Enter a Magnet link or a .torrent file URL."</p>
</div>
<form on:submit=handle_submit class="space-y-4">
<Input
r#type=InputType::Text
placeholder="magnet:?xt=urn:btih:..."
bind_value=uri
disabled=is_loading.0.get()
/>
{move || error_msg.0.get().map(|msg| view! { <div class="form-control w-full">
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <input
{msg} type="text"
</div> placeholder="magnet:?xt=urn:btih:..."
})} class="input input-bordered w-full"
prop:value=uri
<div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2"> on:input=move |ev| set_uri.set(event_target_value(&ev))
<Button disabled=is_loading
variant=ButtonVariant::Ghost />
attr:r#type="button"
on:click=move |_| on_close.run(())
>
"Cancel"
</Button>
<Button
attr:r#type="submit"
attr:disabled=move || is_loading.0.get()
>
{move || if is_loading.0.get() {
leptos::either::Either::Left(view! {
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
"Adding..."
})
} else {
leptos::either::Either::Right(view! { "Add" })
}}
</Button>
</div> </div>
</form>
// Close button (X) <div class="modal-action">
<Button <button class="btn" on:click=handle_close disabled=is_loading>"Cancel"</button>
variant=ButtonVariant::Ghost <button class="btn btn-primary" on:click=handle_submit disabled=is_loading>
class="absolute right-2 top-2 size-8 p-0 opacity-70 hover:opacity-100" {move || if is_loading.get() {
on:click=move |_| on_close.run(()) view! { <span class="loading loading-spinner"></span> "Adding..." }.into_view()
> } else {
<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"> view! { "Add" }.into_view()
<path d="M18 6 6 18"></path> }}
<path d="m6 6 12 12"></path> </button>
</svg> </div>
<span class="sr-only">"Close"</span>
</Button> {move || error_msg.get().map(|msg| view! {
</div> <div class="text-error text-sm mt-2">{msg}</div>
})}
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" on:click=handle_close>"close"</button>
</form>
</dialog>
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,94 +0,0 @@
use leptos::prelude::*;
use crate::components::ui::button::{ButtonSize, ButtonVariant};
use crate::components::ui::dialog::{
Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
DialogTrigger,
};
#[component]
pub fn AlertDialog(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
view! { <Dialog class=class>{children()}</Dialog> }
}
#[component]
pub fn AlertDialogTrigger(
children: Children,
#[prop(optional, into)] class: String,
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
#[prop(default = ButtonSize::Default)] size: ButtonSize,
) -> impl IntoView {
view! {
<DialogTrigger class=class variant=variant size=size>
{children()}
</DialogTrigger>
}
}
#[component]
pub fn AlertDialogContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
view! {
<DialogContent class=class close_on_backdrop_click=false data_name_prefix="AlertDialog">
{children()}
</DialogContent>
}
}
#[component]
pub fn AlertDialogBody(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
view! {
<DialogBody class=class attr:data-name="AlertDialogBody">
{children()}
</DialogBody>
}
}
#[component]
pub fn AlertDialogHeader(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
view! {
<DialogHeader class=class attr:data-name="AlertDialogHeader">
{children()}
</DialogHeader>
}
}
#[component]
pub fn AlertDialogTitle(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
view! {
<DialogTitle class=class attr:data-name="AlertDialogTitle">
{children()}
</DialogTitle>
}
}
#[component]
pub fn AlertDialogDescription(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
view! {
<DialogDescription class=class attr:data-name="AlertDialogDescription">
{children()}
</DialogDescription>
}
}
#[component]
pub fn AlertDialogFooter(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
view! {
<DialogFooter class=class attr:data-name="AlertDialogFooter">
{children()}
</DialogFooter>
}
}
#[component]
pub fn AlertDialogClose(
children: Children,
#[prop(optional, into)] class: String,
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
#[prop(default = ButtonSize::Default)] size: ButtonSize,
) -> impl IntoView {
view! {
<DialogClose class=class variant=variant size=size>
{children()}
</DialogClose>
}
}

View File

@@ -1,39 +0,0 @@
use leptos::prelude::*;
use leptos_ui::variants;
// TODO 💪 Loading state (demo_use_timeout_fn.rs and demo_button.rs)
variants! {
Button {
base: "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive w-fit hover:cursor-pointer active:scale-[0.98] active:opacity-100 touch-manipulation [-webkit-tap-highlight-color:transparent] select-none [-webkit-touch-callout:none]", // Using hover:cursor-pointer as workaround for href_support.
variants: {
variant: {
Default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
Destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
Outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/5",
Secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
Ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
Accent: "bg-accent text-accent-foreground hover:bg-accent/80",
Link: "text-primary underline-offset-4 hover:underline",
//
Warning: "bg-warning text-warning-foreground hover:bg-warning/90",
Success: "bg-success text-success-foreground hover:bg-success/90",
Bordered: "bg-transparent border border-zinc-200 text-muted-foreground",
},
size: {
Default: "h-9 px-4 py-2 has-[>svg]:px-3",
Sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
Lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
Icon: "size-9",
//
Mobile: "px-6 py-3 rounded-[24px]",
Badge: "px-2.5 py-0.5 text-xs"
}
},
component: {
element: button,
support_href: true,
support_aria_current: true
}
}
}

View File

@@ -1,15 +0,0 @@
use leptos::prelude::*;
use leptos_ui::clx;
mod components {
use super::*;
clx! {Card, div, "bg-card text-card-foreground flex flex-col gap-4 rounded-xl border py-6 shadow-sm"}
clx! {CardHeader, div, "@container/card-header flex flex-col items-start gap-1.5 px-6 [.border-b]:pb-6"}
clx! {CardTitle, h2, "leading-none font-semibold"}
clx! {CardContent, div, "px-6"}
clx! {CardDescription, p, "text-muted-foreground text-sm"}
clx! {CardFooter, footer, "flex items-center px-6 [.border-t]:pt-6", "gap-2"}
}
pub use components::*;

View File

@@ -1,43 +0,0 @@
use icons::Check;
use leptos::prelude::*;
use tw_merge::tw_merge;
#[component]
pub fn Checkbox(
#[prop(into, optional)] class: String,
#[prop(into, optional)] checked: Signal<bool>,
#[prop(into, optional)] disabled: Signal<bool>,
#[prop(into, optional)] on_checked_change: Option<Callback<bool>>,
#[prop(into, optional, default = "Checkbox".to_string())] aria_label: String,
) -> impl IntoView {
let checked_state = move || if checked.get() { "checked" } else { "unchecked" };
let checkbox_class = tw_merge!(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
class
);
view! {
<button
data-name="Checkbox"
class=checkbox_class
data-state=checked_state
type="button"
role="checkbox"
aria-checked=move || checked.get().to_string()
aria-label=aria_label
disabled=move || disabled.get()
on:click=move |_| {
if !disabled.get() {
if let Some(callback) = on_checked_change {
callback.run(!checked.get());
}
}
}
>
<span data-name="CheckboxIndicator" class="flex justify-center items-center text-current transition-none">
{move || { checked.get().then(|| view! { <Check class="size-3.5".to_string() /> }) }}
</span>
</button>
}
}

View File

@@ -1,436 +0,0 @@
use icons::ChevronRight;
use leptos::context::Provider;
use leptos::prelude::*;
use leptos_ui::clx;
use tw_merge::*;
use wasm_bindgen::JsCast;
use crate::components::hooks::use_random::use_random_id_for;
/// Programmatically close any open context menu.
pub fn close_context_menu() {
let Some(document) = window().document() else {
return;
};
let Some(menu) = document.query_selector("[data-target='target__context'][data-state='open']").ok().flatten()
else {
return;
};
let _ = menu.set_attribute("data-state", "closed");
if let Some(el) = menu.dyn_ref::<web_sys::HtmlElement>() {
let _ = el.style().set_property("pointer-events", "none");
}
}
mod components {
use super::*;
clx! {ContextMenuLabel, span, "px-2 py-1.5 text-sm font-medium data-inset:pl-8", "mb-1"}
clx! {ContextMenuGroup, ul, "group"}
clx! {ContextMenuItem, li, "inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4"}
clx! {ContextMenuSubContent, ul, "context__menu_sub_content", "rounded-md border bg-card shadow-lg p-1 absolute z-[100] min-w-[160px] opacity-0 invisible translate-x-[-8px] transition-all duration-200 ease-out pointer-events-none"}
clx! {ContextMenuLink, a, "w-full inline-flex gap-2 items-center"}
}
pub use components::*;
#[component]
pub fn ContextMenuAction(
children: Children,
#[prop(optional, into)] class: String,
#[prop(optional, into)] aria_selected: Option<Signal<bool>>,
#[prop(optional, into)] href: Option<String>,
) -> impl IntoView {
let _ctx = expect_context::<ContextMenuContext>();
let class = tw_merge!(
"inline-flex gap-2 items-center w-full text-sm text-left transition-colors duration-200 focus:outline-none focus-visible:outline-none text-popover-foreground [&_svg:not([class*='size-'])]:size-4",
class
);
let aria_selected_attr = move || aria_selected.map(|s| s.get()).unwrap_or(false).to_string();
if let Some(href) = href {
view! {
<a
data-name="ContextMenuAction"
class=class
href=href
aria-selected=aria_selected_attr
data-context-close="true"
>
{children()}
</a>
}
.into_any()
} else {
view! {
<button
type="button"
data-name="ContextMenuAction"
class=class
data-context-close="true"
aria-selected=aria_selected_attr
>
{children()}
</button>
}
.into_any()
}
}
#[component]
pub fn ContextMenuHoldAction(
children: Children,
#[prop(into)] on_hold_complete: Callback<()>,
#[prop(optional, into)] class: String,
#[prop(default = 1000)] hold_duration: u64,
) -> impl IntoView {
let is_holding = RwSignal::new(false);
let progress = RwSignal::new(0.0);
let on_mousedown = move |_| {
is_holding.set(true);
progress.set(0.0);
};
let on_mouseup = move |_| {
is_holding.set(false);
progress.set(0.0);
};
Effect::new(move |_| {
if is_holding.get() {
let start_time = js_sys::Date::now();
let duration = hold_duration as f64;
leptos::task::spawn_local(async move {
while is_holding.get_untracked() {
let now = js_sys::Date::now();
let elapsed = now - start_time;
let p = (elapsed / duration).min(1.0);
progress.set(p * 100.0);
if p >= 1.0 {
on_hold_complete.run(());
is_holding.set(false);
close_context_menu();
break;
}
gloo_timers::future::TimeoutFuture::new(16).await; // ~60fps
}
});
}
});
let class = tw_merge!(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors overflow-hidden",
class
);
view! {
<div
class=class
on:mousedown=on_mousedown
on:mouseup=on_mouseup
on:mouseleave=on_mouseup
on:touchstart=move |_| on_mousedown(web_sys::MouseEvent::new("mousedown").unwrap())
on:touchend=move |_| on_mouseup(web_sys::MouseEvent::new("mouseup").unwrap())
>
// Progress background
<div
class="absolute inset-y-0 left-0 bg-destructive/20 transition-all duration-75 ease-linear pointer-events-none"
style=move || format!("width: {}%;", progress.get())
/>
<span class="relative z-10 flex items-center gap-2 w-full">
{children()}
</span>
</div>
}.into_any()
}
#[derive(Clone)]
struct ContextMenuContext {
target_id: String,
}
#[component]
pub fn ContextMenu(children: Children) -> impl IntoView {
let context_target_id = use_random_id_for("context");
let ctx = ContextMenuContext { target_id: context_target_id.clone() };
view! {
<Provider value=ctx>
<style>
"
/* Submenu Styles */
.context__menu_sub_content {
position: absolute;
inset-inline-start: calc(100% + 8px);
inset-block-start: -4px;
z-index: 100;
min-inline-size: 160px;
opacity: 0;
visibility: hidden;
transform: translateX(-8px);
transition: all 0.2s ease-out;
pointer-events: none;
}
.context__menu_sub_trigger:hover .context__menu_sub_content {
opacity: 1;
visibility: visible;
transform: translateX(0);
pointer-events: auto;
}
"
</style>
<div data-name="ContextMenu" class="contents">
{children()}
</div>
</Provider>
}
}
/// Wrapper that triggers the context menu on right-click.
/// The `on_open` callback is triggered when the context menu opens (right-click).
#[component]
pub fn ContextMenuTrigger(
children: Children,
#[prop(optional, into)] class: String,
#[prop(optional)] on_open: Option<Callback<()>>,
) -> impl IntoView {
let ctx = expect_context::<ContextMenuContext>();
let trigger_class = tw_merge!("contents", class);
view! {
<div
class=trigger_class
data-name="ContextMenuTrigger"
data-context-trigger=ctx.target_id
on:contextmenu=move |e: web_sys::MouseEvent| {
if let Some(cb) = on_open {
cb.run(());
}
}
>
{children()}
</div>
}
}
/// Content of the context menu that appears on right-click.
/// The `on_close` callback is triggered when the menu closes (click outside, ESC key, or action click).
#[component]
pub fn ContextMenuContent(
children: Children,
#[prop(optional, into)] class: String,
#[prop(optional)] on_close: Option<Callback<()>>,
) -> impl IntoView {
let ctx = expect_context::<ContextMenuContext>();
let base_classes = "fixed z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100";
let class = tw_merge!(base_classes, class);
let target_id_for_script = ctx.target_id.clone();
view! {
<script src="/lock_scroll.js"></script>
<div
data-name="ContextMenuContent"
class=class
// Listen for custom 'contextmenuclose' event dispatched by JS when menu closes
on:contextmenuclose=move |_: web_sys::CustomEvent| {
if let Some(cb) = on_close {
cb.run(());
}
}
id=ctx.target_id
data-target="target__context"
data-state="closed"
style="pointer-events: none;"
>
{children()}
</div>
<script>
{format!(
r#"
(function() {{
const setupContextMenu = () => {{
const menu = document.querySelector('#{}');
const trigger = document.querySelector('[data-context-trigger="{}"]');
if (!menu || !trigger) {{
setTimeout(setupContextMenu, 50);
return;
}}
if (menu.hasAttribute('data-initialized')) {{
return;
}}
menu.setAttribute('data-initialized', 'true');
let isOpen = false;
const updatePosition = (x, y) => {{
const menuRect = menu.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
// Calculate position, ensuring menu stays within viewport
let left = x;
let top = y;
// Adjust if menu would go off right edge
if (x + menuRect.width > viewportWidth) {{
left = x - menuRect.width;
}}
// Adjust if menu would go off bottom edge
if (y + menuRect.height > viewportHeight) {{
top = y - menuRect.height;
}}
menu.style.left = `${{left}}px`;
menu.style.top = `${{top}}px`;
menu.style.transformOrigin = 'top left';
}};
const openMenu = (x, y) => {{
isOpen = true;
// Close any other open context menus
const allMenus = document.querySelectorAll('[data-target="target__context"]');
allMenus.forEach(m => {{
if (m !== menu && m.getAttribute('data-state') === 'open') {{
m.setAttribute('data-state', 'closed');
m.style.pointerEvents = 'none';
}}
}});
menu.setAttribute('data-state', 'open');
menu.style.visibility = 'hidden';
menu.style.pointerEvents = 'auto';
// Force reflow
menu.offsetHeight;
updatePosition(x, y);
menu.style.visibility = 'visible';
// Lock scroll
if (window.ScrollLock) {{
window.ScrollLock.lock();
}}
setTimeout(() => {{
document.addEventListener('click', handleClickOutside);
document.addEventListener('contextmenu', handleContextOutside);
}}, 0);
}};
const closeMenu = () => {{
isOpen = false;
menu.setAttribute('data-state', 'closed');
menu.style.pointerEvents = 'none';
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('contextmenu', handleContextOutside);
// Dispatch custom event for Leptos to listen to
menu.dispatchEvent(new CustomEvent('contextmenuclose', {{ bubbles: false }}));
if (window.ScrollLock) {{
window.ScrollLock.unlock(200);
}}
}};
const handleClickOutside = (e) => {{
if (!menu.contains(e.target)) {{
closeMenu();
}}
}};
const handleContextOutside = (e) => {{
if (!trigger.contains(e.target)) {{
closeMenu();
}}
}};
// Right-click on trigger
trigger.addEventListener('contextmenu', (e) => {{
e.preventDefault();
e.stopPropagation();
if (isOpen) {{
closeMenu();
}}
openMenu(e.clientX, e.clientY);
}});
// Close when action is clicked
const actions = menu.querySelectorAll('[data-context-close]');
actions.forEach(action => {{
action.addEventListener('click', () => {{
closeMenu();
}});
}});
// Handle ESC key
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape' && isOpen) {{
e.preventDefault();
closeMenu();
}}
}});
}};
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', setupContextMenu);
}} else {{
setupContextMenu();
}}
}})();
"#,
target_id_for_script,
target_id_for_script,
)}
</script>
}.into_any()
}
#[component]
pub fn ContextMenuSub(children: Children) -> impl IntoView {
clx! {ContextMenuSubRoot, li, "context__menu_sub_trigger", " relative inline-flex relative gap-2 items-center py-1.5 px-2 w-full text-sm no-underline rounded-sm transition-colors duration-200 cursor-pointer text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground"}
view! { <ContextMenuSubRoot>{children()}</ContextMenuSubRoot> }
}
#[component]
pub fn ContextMenuSubTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("flex items-center justify-between w-full", class);
view! {
<span data-name="ContextMenuSubTrigger" class=class>
<span class="flex gap-2 items-center">{children()}</span>
<ChevronRight class="opacity-70 size-4" />
</span>
}
}
#[component]
pub fn ContextMenuSubItem(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!(
"inline-flex gap-2 items-center w-full rounded-sm px-3 py-2 text-sm transition-all duration-150 ease text-popover-foreground hover:bg-accent hover:text-accent-foreground cursor-pointer hover:translate-x-[2px]",
class
);
view! {
<li data-name="ContextMenuSubItem" class=class data-context-close="true">
{children()}
</li>
}
}

View File

@@ -1,6 +0,0 @@
// * Reuse @table.rs
pub use crate::components::ui::table::{
Table as DataTable, TableBody as DataTableBody, TableCaption as DataTableCaption, TableCell as DataTableCell,
TableFooter as DataTableFooter, TableHead as DataTableHead, TableHeader as DataTableHeader,
TableRow as DataTableRow, TableWrapper as DataTableWrapper,
};

View File

@@ -1,251 +0,0 @@
use icons::X;
use leptos::context::Provider;
use leptos::prelude::*;
use leptos_ui::clx;
use tw_merge::*;
use crate::components::hooks::use_random::use_random_id_for;
use crate::components::ui::button::{Button, ButtonSize, ButtonVariant};
mod components {
use super::*;
clx! {DialogBody, div, "flex flex-col gap-4"}
clx! {DialogHeader, div, "flex flex-col gap-2 text-center sm:text-left"}
clx! {DialogTitle, h3, "text-lg leading-none font-semibold"}
clx! {DialogDescription, p, "text-muted-foreground text-sm"}
clx! {DialogFooter, footer, "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"}
}
pub use components::*;
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
#[derive(Clone)]
struct DialogContext {
target_id: String,
}
#[component]
pub fn Dialog(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let dialog_target_id = use_random_id_for("dialog");
let ctx = DialogContext { target_id: dialog_target_id.clone() };
let merged_class = tw_merge!("w-fit", class);
view! {
<Provider value=ctx>
<div class=merged_class data-name="__Dialog">
{children()}
</div>
</Provider>
}
}
#[component]
pub fn DialogTrigger(
children: Children,
#[prop(optional, into)] class: String,
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
#[prop(default = ButtonSize::Default)] size: ButtonSize,
) -> impl IntoView {
let ctx = expect_context::<DialogContext>();
let trigger_id = format!("trigger_{}", ctx.target_id);
view! {
<Button
class=class
attr:id=trigger_id
attr:tabindex="0"
attr:data-dialog-trigger=ctx.target_id
variant=variant
size=size
>
{children()}
</Button>
}
}
#[component]
pub fn DialogContent(
children: Children,
#[prop(optional, into)] class: String,
#[prop(into, optional)] hide_close_button: Option<bool>,
#[prop(default = true)] close_on_backdrop_click: bool,
#[prop(default = "Dialog")] data_name_prefix: &'static str,
) -> impl IntoView {
let ctx = expect_context::<DialogContext>();
let merged_class = tw_merge!(
// "flex flex-col gap-4", // TODO 🐛 Bug when I try to have this.. Using DialogBody instead.
"relative bg-background border rounded-2xl shadow-lg p-6 w-full max-w-[calc(100%-2rem)] max-h-[85vh] fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-100 transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100",
class
);
let backdrop_data_name = format!("{}Backdrop", data_name_prefix);
let content_data_name = format!("{}Content", data_name_prefix);
let target_id_clone = ctx.target_id.clone();
let backdrop_id = format!("{}_backdrop", ctx.target_id);
let target_id_for_script = ctx.target_id.clone();
let backdrop_id_for_script = backdrop_id.clone();
let backdrop_behavior = if close_on_backdrop_click { "auto" } else { "manual" };
view! {
<script src="/hooks/lock_scroll.js"></script>
<div
data-name=backdrop_data_name
id=backdrop_id
class="fixed inset-0 transition-opacity duration-200 pointer-events-none z-60 bg-black/50 data-[state=closed]:opacity-0 data-[state=open]:opacity-100"
data-state="closed"
/>
<div
data-name=content_data_name
class=merged_class
id=ctx.target_id
data-target="target__dialog"
data-state="closed"
data-backdrop=backdrop_behavior
style="pointer-events: none;"
>
<button
type="button"
class=format!(
"absolute top-4 right-4 p-1 rounded-sm focus:ring-2 focus:ring-offset-2 focus:outline-none [&_svg:not([class*='size-'])]:size-4 focus:ring-ring{}",
if hide_close_button.unwrap_or(false) { " hidden" } else { "" },
)
data-dialog-close=target_id_clone.clone()
aria-label="Close dialog"
>
<span class="hidden">"Close Dialog"</span>
<X />
</button>
{children()}
</div>
<script>
{format!(
r#"
(function() {{
const setupDialog = () => {{
const dialog = document.querySelector('#{}');
const backdrop = document.querySelector('#{}');
const trigger = document.querySelector('[data-dialog-trigger="{}"]');
if (!dialog || !backdrop || !trigger) {{
setTimeout(setupDialog, 50);
return;
}}
if (dialog.hasAttribute('data-initialized')) {{
return;
}}
dialog.setAttribute('data-initialized', 'true');
const openDialog = () => {{
// Lock scrolling
window.ScrollLock.lock();
dialog.setAttribute('data-state', 'open');
backdrop.setAttribute('data-state', 'open');
dialog.style.pointerEvents = 'auto';
backdrop.style.pointerEvents = 'auto';
}};
const closeDialog = () => {{
dialog.setAttribute('data-state', 'closed');
backdrop.setAttribute('data-state', 'closed');
dialog.style.pointerEvents = 'none';
backdrop.style.pointerEvents = 'none';
// Unlock scrolling after animation
window.ScrollLock.unlock(200);
}};
// Open dialog when trigger is clicked
trigger.addEventListener('click', openDialog);
// Close buttons
const closeButtons = dialog.querySelectorAll('[data-dialog-close]');
closeButtons.forEach(btn => {{
btn.addEventListener('click', closeDialog);
}});
// Close on backdrop click (if data-backdrop="auto")
backdrop.addEventListener('click', () => {{
if (dialog.getAttribute('data-backdrop') === 'auto') {{
closeDialog();
}}
}});
// Handle ESC key to close
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape' && dialog.getAttribute('data-state') === 'open') {{
e.preventDefault();
closeDialog();
}}
}});
}};
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', setupDialog);
}} else {{
setupDialog();
}}
}})();
"#,
target_id_for_script,
backdrop_id_for_script,
target_id_for_script,
)}
</script>
}
}
#[component]
pub fn DialogClose(
children: Children,
#[prop(optional, into)] class: String,
#[prop(default = ButtonVariant::Outline)] variant: ButtonVariant,
#[prop(default = ButtonSize::Default)] size: ButtonSize,
) -> impl IntoView {
let ctx = expect_context::<DialogContext>();
view! {
<Button
class=class
attr:data-dialog-close=ctx.target_id
attr:aria-label="Close dialog"
variant=variant
size=size
>
{children()}
</Button>
}
}
#[component]
pub fn DialogAction(
children: Children,
#[prop(optional, into)] class: String,
#[prop(default = ButtonVariant::Default)] variant: ButtonVariant,
#[prop(default = ButtonSize::Default)] size: ButtonSize,
) -> impl IntoView {
let ctx = expect_context::<DialogContext>();
view! {
<Button
class=class
attr:data-dialog-close=ctx.target_id
attr:aria-label="Close dialog"
variant=variant
size=size
>
{children()}
</Button>
}
}

View File

@@ -1,538 +0,0 @@
use icons::{Check, ChevronRight};
use leptos::context::Provider;
use leptos::prelude::*;
use leptos_ui::clx;
use tw_merge::*;
use crate::components::hooks::use_random::use_random_id_for;
pub use crate::components::ui::separator::Separator as DropdownMenuSeparator;
mod components {
use super::*;
clx! {DropdownMenuLabel, span, "px-2 py-1.5 text-sm font-medium data-inset:pl-8", "mb-1"}
clx! {DropdownMenuGroup, ul, "group"}
clx! {DropdownMenuItem, li, "inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4"}
clx! {DropdownMenuSubContent, ul, "dropdown__menu_sub_content", "rounded-md border bg-card shadow-lg p-1 absolute z-[100] min-w-[160px] opacity-0 invisible translate-x-[-8px] transition-all duration-200 ease-out pointer-events-none"}
clx! {DropdownMenuLink, a, "w-full inline-flex gap-2 items-center"}
}
pub use components::*;
/* ========================================================== */
/* RADIO GROUP */
/* ========================================================== */
#[derive(Clone)]
struct DropdownMenuRadioContext<T: Clone + PartialEq + Send + Sync + 'static> {
value_signal: RwSignal<T>,
}
/// A group of radio items where only one can be selected at a time.
#[component]
pub fn DropdownMenuRadioGroup<T>(
children: Children,
/// The signal holding the current selected value
value: RwSignal<T>,
) -> impl IntoView
where
T: Clone + PartialEq + Send + Sync + 'static,
{
let ctx = DropdownMenuRadioContext { value_signal: value };
view! {
<Provider value=ctx>
<ul data-name="DropdownMenuRadioGroup" role="group" class="group">
{children()}
</ul>
</Provider>
}
}
/// A radio item that shows a checkmark when selected.
#[component]
pub fn DropdownMenuRadioItem<T>(
children: Children,
/// The value this item represents
value: T,
#[prop(optional, into)] class: String,
) -> impl IntoView
where
T: Clone + PartialEq + Send + Sync + 'static,
{
let ctx = expect_context::<DropdownMenuRadioContext<T>>();
let value_for_check = value.clone();
let value_for_click = value.clone();
let is_selected = move || ctx.value_signal.get() == value_for_check;
let merged_class = tw_merge!(
"group inline-flex gap-2 items-center w-full rounded-sm pl-2 pr-2 py-1.5 text-sm cursor-pointer no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
class
);
view! {
<li
data-name="DropdownMenuRadioItem"
class=merged_class
role="menuitemradio"
aria-checked=move || is_selected().to_string()
data-dropdown-close="true"
on:click=move |_| {
ctx.value_signal.set(value_for_click.clone());
}
>
{children()}
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-checked:opacity-100" />
</li>
}
}
/// An action item in a dropdown menu (no checkmark, just triggers an action).
#[component]
pub fn DropdownMenuAction(
children: Children,
#[prop(optional, into)] class: String,
#[prop(optional, into)] href: Option<String>,
) -> impl IntoView {
let _ctx = expect_context::<DropdownMenuContext>();
let class = tw_merge!(
"inline-flex gap-2 items-center w-full text-sm text-left transition-colors duration-200 focus:outline-none focus-visible:outline-none text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground",
class
);
if let Some(href) = href {
// Render as <a> tag when href is provided
view! {
<a data-name="DropdownMenuAction" class=class href=href data-dropdown-close="true">
{children()}
</a>
<script>
{r#"
(function() {
const link = document.currentScript.previousElementSibling;
if (!link) return;
link.addEventListener('click', function() {
// Close dropdown on route change after navigation
let currentPath = window.location.pathname;
const checkRouteChange = () => {
if (window.location.pathname !== currentPath) {
currentPath = window.location.pathname;
// Find and close the dropdown
const dropdown = link.closest('[data-target="target__dropdown"]');
if (dropdown) {
dropdown.setAttribute('data-state', 'closed');
dropdown.style.pointerEvents = 'none';
// Unlock scroll
if (window.ScrollLock) {
window.ScrollLock.unlock(200);
}
}
clearInterval(routeCheckInterval);
}
};
const routeCheckInterval = setInterval(checkRouteChange, 50);
// Clear interval after 2 seconds to prevent memory leaks
setTimeout(() => clearInterval(routeCheckInterval), 2000);
});
})();
"#}
</script>
}
.into_any()
} else {
// Render as <button> tag when no href
view! {
<button type="button" data-name="DropdownMenuAction" class=class data-dropdown-close="true">
{children()}
</button>
}
.into_any()
}
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum DropdownMenuAlign {
#[default]
Start,
StartOuter,
End,
EndOuter,
Center,
}
#[derive(Clone)]
struct DropdownMenuContext {
target_id: String,
align: DropdownMenuAlign,
}
#[component]
pub fn DropdownMenu(
children: Children,
#[prop(default = DropdownMenuAlign::default())] align: DropdownMenuAlign,
) -> impl IntoView {
let dropdown_target_id = use_random_id_for("dropdown");
let ctx = DropdownMenuContext { target_id: dropdown_target_id.clone(), align };
view! {
<Provider value=ctx>
<style>
"
/* Submenu Styles */
.dropdown__menu_sub_content {
position: absolute;
inset-inline-start: calc(100% + 8px);
inset-block-start: -4px;
z-index: 100;
min-inline-size: 160px;
opacity: 0;
visibility: hidden;
transform: translateX(-8px);
transition: all 0.2s ease-out;
pointer-events: none;
}
.dropdown__menu_sub_trigger:hover .dropdown__menu_sub_content {
opacity: 1;
visibility: visible;
transform: translateX(0);
pointer-events: auto;
}
"
</style>
<div data-name="DropdownMenu">{children()}</div>
</Provider>
}
}
#[component]
pub fn DropdownMenuTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let ctx = expect_context::<DropdownMenuContext>();
let button_class = tw_merge!(
"px-4 py-2 h-9 inline-flex justify-center items-center text-sm font-medium whitespace-nowrap rounded-md transition-colors w-fit focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
class
);
view! {
<button
type="button"
class=button_class
data-name="DropdownMenuTrigger"
data-dropdown-trigger=ctx.target_id
tabindex="0"
>
{children()}
</button>
}
}
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum DropdownMenuPosition {
#[default]
Auto,
Top,
Bottom,
}
#[component]
pub fn DropdownMenuContent(
children: Children,
#[prop(optional, into)] class: String,
#[prop(default = DropdownMenuPosition::default())] position: DropdownMenuPosition,
) -> impl IntoView {
let ctx = expect_context::<DropdownMenuContext>();
let base_classes = "z-50 p-1 rounded-md border bg-card shadow-md h-fit fixed transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100";
let width_class = match ctx.align {
DropdownMenuAlign::Center => "min-w-full",
_ => "w-[180px]",
};
let class = tw_merge!(width_class, base_classes, class);
let target_id_for_script = ctx.target_id.clone();
let align_for_script = match ctx.align {
DropdownMenuAlign::Start => "start",
DropdownMenuAlign::StartOuter => "start-outer",
DropdownMenuAlign::End => "end",
DropdownMenuAlign::EndOuter => "end-outer",
DropdownMenuAlign::Center => "center",
};
let position_for_script = match position {
DropdownMenuPosition::Auto => "auto",
DropdownMenuPosition::Top => "top",
DropdownMenuPosition::Bottom => "bottom",
};
view! {
<script src="/hooks/lock_scroll.js"></script>
<div
data-name="DropdownMenuContent"
class=class
id=ctx.target_id
data-target="target__dropdown"
data-state="closed"
data-align=align_for_script
data-position=position_for_script
style="pointer-events: none;"
>
{children()}
</div>
<script>
{format!(
r#"
(function() {{
const setupDropdown = () => {{
const dropdown = document.querySelector('#{}');
const trigger = document.querySelector('[data-dropdown-trigger="{}"]');
if (!dropdown || !trigger) {{
setTimeout(setupDropdown, 50);
return;
}}
if (dropdown.hasAttribute('data-initialized')) {{
return;
}}
dropdown.setAttribute('data-initialized', 'true');
let isOpen = false;
const updatePosition = () => {{
const triggerRect = trigger.getBoundingClientRect();
const dropdownRect = dropdown.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const spaceBelow = viewportHeight - triggerRect.bottom;
const spaceAbove = triggerRect.top;
const align = dropdown.getAttribute('data-align') || 'start';
const position = dropdown.getAttribute('data-position') || 'auto';
// Determine if we should position above
let shouldPositionAbove = false;
if (position === 'top') {{
shouldPositionAbove = true;
}} else if (position === 'bottom') {{
shouldPositionAbove = false;
}} else {{
// Auto: position above if there's space above AND not enough space below
shouldPositionAbove = spaceAbove >= dropdownRect.height && spaceBelow < dropdownRect.height;
}}
switch (align) {{
case 'start':
if (shouldPositionAbove) {{
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
dropdown.style.transformOrigin = 'left bottom';
}} else {{
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
dropdown.style.transformOrigin = 'left top';
}}
dropdown.style.left = `${{triggerRect.left}}px`;
break;
case 'end':
if (shouldPositionAbove) {{
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
dropdown.style.transformOrigin = 'right bottom';
}} else {{
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
dropdown.style.transformOrigin = 'right top';
}}
dropdown.style.left = `${{triggerRect.right - dropdownRect.width}}px`;
break;
case 'start-outer':
if (shouldPositionAbove) {{
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
dropdown.style.transformOrigin = 'right bottom';
}} else {{
dropdown.style.top = `${{triggerRect.top}}px`;
dropdown.style.transformOrigin = 'right top';
}}
dropdown.style.left = `${{triggerRect.left - dropdownRect.width - 16}}px`;
break;
case 'end-outer':
if (shouldPositionAbove) {{
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
dropdown.style.transformOrigin = 'left bottom';
}} else {{
dropdown.style.top = `${{triggerRect.top}}px`;
dropdown.style.transformOrigin = 'left top';
}}
dropdown.style.left = `${{triggerRect.right + 8}}px`;
break;
case 'center':
if (shouldPositionAbove) {{
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
dropdown.style.transformOrigin = 'center bottom';
}} else {{
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
dropdown.style.transformOrigin = 'center top';
}}
dropdown.style.left = `${{triggerRect.left}}px`;
dropdown.style.minWidth = `${{triggerRect.width}}px`;
break;
}}
}};
const openDropdown = () => {{
isOpen = true;
// Set state to open first to remove scale transform for accurate measurements
dropdown.setAttribute('data-state', 'open');
// Make dropdown invisible but rendered to measure true height
dropdown.style.visibility = 'hidden';
dropdown.style.pointerEvents = 'auto';
// Force reflow to ensure height is calculated
dropdown.offsetHeight;
// Calculate position with accurate height
updatePosition();
// Now make it visible
dropdown.style.visibility = 'visible';
// Lock all scrollable elements
window.ScrollLock.lock();
// Close on click outside
setTimeout(() => {{
document.addEventListener('click', handleClickOutside);
}}, 0);
}};
const closeDropdown = () => {{
isOpen = false;
dropdown.setAttribute('data-state', 'closed');
dropdown.style.pointerEvents = 'none';
document.removeEventListener('click', handleClickOutside);
// Unlock scroll after animation (200ms delay)
window.ScrollLock.unlock(200);
}};
const handleClickOutside = (e) => {{
if (!dropdown.contains(e.target) && !trigger.contains(e.target)) {{
closeDropdown();
}}
}};
// Toggle dropdown when trigger is clicked
trigger.addEventListener('click', (e) => {{
e.stopPropagation();
// Check if any other dropdown is open
const allDropdowns = document.querySelectorAll('[data-target=\"target__dropdown\"]');
let otherDropdownOpen = false;
allDropdowns.forEach(dd => {{
if (dd !== dropdown && dd.getAttribute('data-state') === 'open') {{
otherDropdownOpen = true;
dd.setAttribute('data-state', 'closed');
dd.style.pointerEvents = 'none';
// Unlock scroll
if (window.ScrollLock) {{
window.ScrollLock.unlock(200);
}}
}}
}});
// If another dropdown was open, just close it and don't open this one
if (otherDropdownOpen) {{
return;
}}
// Normal toggle behavior
if (isOpen) {{
closeDropdown();
}} else {{
openDropdown();
}}
}});
// Close when action is clicked
const actions = dropdown.querySelectorAll('[data-dropdown-close]');
actions.forEach(action => {{
action.addEventListener('click', () => {{
closeDropdown();
}});
}});
// Handle ESC key to close
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape' && isOpen) {{
e.preventDefault();
closeDropdown();
}}
}});
}};
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', setupDropdown);
}} else {{
setupDropdown();
}}
}})();
"#,
target_id_for_script,
target_id_for_script,
)}
</script>
}
}
#[component]
pub fn DropdownMenuSub(children: Children) -> impl IntoView {
// TODO. Find a better way for dropdown__menu_sub_trigger.
clx! {DropdownMenuSubRoot, li, "dropdown__menu_sub_trigger", " relative inline-flex relative gap-2 items-center py-1.5 px-2 w-full text-sm no-underline rounded-sm transition-colors duration-200 cursor-pointer text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground"}
view! { <DropdownMenuSubRoot>{children()}</DropdownMenuSubRoot> }
}
#[component]
pub fn DropdownMenuSubTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("flex items-center justify-between w-full", class);
view! {
<span attr:data-name="DropdownMenuSubTrigger" class=class>
<span class="flex gap-2 items-center">{children()}</span>
<ChevronRight class="opacity-70 size-4" />
</span>
}
}
#[component]
pub fn DropdownMenuSubItem(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!(
"inline-flex gap-2 items-center w-full rounded-sm px-3 py-2 text-sm transition-all duration-150 ease text-popover-foreground hover:bg-accent hover:text-accent-foreground cursor-pointer hover:translate-x-[2px]",
class
);
view! {
<li data-name="DropdownMenuSubItem" class=class data-dropdown-close="true">
{children()}
</li>
}
}

View File

@@ -1,35 +0,0 @@
use leptos::prelude::*;
use leptos_ui::{clx, variants};
mod components {
use super::*;
clx! {Empty, div, "flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8 text-center"}
clx! {EmptyHeader, div, "flex flex-col items-center gap-2"}
clx! {EmptyTitle, h3, "text-lg font-semibold leading-none"}
clx! {EmptyDescription, p, "text-muted-foreground text-sm"}
clx! {EmptyContent, div, "flex items-center justify-center gap-2"}
}
pub use components::*;
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
variants! {
EmptyMedia {
base: "flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
Default: "bg-transparent",
Icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
size: {
Default: "",
}
},
component: {
element: div
}
}
}

View File

@@ -1,99 +0,0 @@
use leptos::html;
use leptos::prelude::*;
use strum::AsRefStr;
use tw_merge::tw_merge;
#[derive(Default, Clone, Copy, PartialEq, Eq, AsRefStr)]
#[strum(serialize_all = "lowercase")]
#[allow(dead_code)]
pub enum InputType {
#[default]
Text,
Email,
Password,
Number,
Tel,
Url,
Search,
Date,
Time,
#[strum(serialize = "datetime-local")]
DatetimeLocal,
Month,
Week,
Color,
File,
Hidden,
}
#[component]
pub fn Input(
#[prop(into, optional)] class: String,
#[prop(default = InputType::default())] r#type: InputType,
#[prop(into, optional)] placeholder: Option<String>,
#[prop(into, optional)] name: Option<String>,
#[prop(into, optional)] id: Option<String>,
#[prop(into, optional)] title: Option<String>,
#[prop(optional)] disabled: bool,
#[prop(optional)] readonly: bool,
#[prop(optional)] required: bool,
#[prop(optional)] autofocus: bool,
#[prop(into, optional)] min: Option<String>,
#[prop(into, optional)] max: Option<String>,
#[prop(into, optional)] step: Option<String>,
#[prop(into, optional)] bind_value: Option<RwSignal<String>>,
#[prop(optional)] node_ref: NodeRef<html::Input>,
) -> impl IntoView {
let merged_class = tw_merge!(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50",
"focus-visible:ring-2",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"read-only:bg-muted",
class
);
let type_str = r#type.as_ref();
match bind_value {
Some(signal) => view! {
<input
data-name="Input"
type=type_str
class=merged_class
placeholder=placeholder
name=name
id=id
title=title
disabled=disabled
readonly=readonly
required=required
autofocus=autofocus
min=min
max=max
step=step
bind:value=signal
node_ref=node_ref
/>
}.into_any(),
None => view! {
<input
data-name="Input"
type=type_str
class=merged_class
placeholder=placeholder
name=name
id=id
title=title
disabled=disabled
readonly=readonly
required=required
autofocus=autofocus
min=min
max=max
step=step
node_ref=node_ref
/>
}.into_any(),
}
}

View File

@@ -1,17 +0,0 @@
pub mod alert_dialog;
pub mod button;
pub mod card;
pub mod checkbox;
pub mod context_menu;
pub mod data_table;
pub mod dialog;
pub mod dropdown_menu;
pub mod empty;
pub mod input;
pub mod multi_select;
pub mod select;
pub mod separator;
pub mod svg_icon;
pub mod table;
pub mod theme_toggle;
pub mod toast;

View File

@@ -1,296 +0,0 @@
use std::collections::HashSet;
use icons::{Check, ChevronDown, ChevronUp};
use leptos::context::Provider;
use leptos::prelude::*;
use tw_merge::*;
use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
use crate::components::hooks::use_random::use_random_id_for;
// * Reuse @select.rs
pub use crate::components::ui::select::{
SelectGroup as MultiSelectGroup, SelectItem as MultiSelectItem, SelectLabel as MultiSelectLabel,
};
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum MultiSelectAlign {
Start,
#[default]
Center,
End,
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
#[component]
pub fn MultiSelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView {
let multi_select_ctx = expect_context::<MultiSelectContext>();
view! {
<span data-name="MultiSelectValue" class="text-sm text-muted-foreground truncate">
{move || {
let values = multi_select_ctx.values_signal.get();
if values.is_empty() {
placeholder.clone()
} else {
let count = values.len();
if count == 1 { "1 selected".to_string() } else { format!("{} selected", count) }
}
}}
</span>
}
}
#[component]
pub fn MultiSelectOption(
children: Children,
#[prop(optional, into)] class: String,
#[prop(optional, into)] value: Option<String>,
) -> impl IntoView {
let multi_select_ctx = expect_context::<MultiSelectContext>();
let value_clone = value.clone();
let is_selected = Signal::derive(move || {
if let Some(ref val) = value_clone {
multi_select_ctx.values_signal.with(|values| values.contains(val))
} else {
false
}
});
let class = tw_merge!(
"group inline-flex gap-2 items-center w-full text-sm text-left transition-colors duration-200 focus:outline-none focus-visible:outline-none text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50",
class
);
view! {
<button
type="button"
data-name="MultiSelectOption"
class=class
role="option"
aria-selected=move || is_selected.get().to_string()
on:click=move |ev: web_sys::MouseEvent| {
ev.prevent_default();
ev.stop_propagation();
if let Some(val) = value.clone() {
multi_select_ctx
.values_signal
.update(|values| {
if values.contains(&val) {
values.remove(&val);
} else {
values.insert(val);
}
});
}
}
>
{children()}
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-selected:opacity-100" />
</button>
}
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
#[derive(Clone)]
struct MultiSelectContext {
target_id: String,
values_signal: RwSignal<HashSet<String>>,
align: MultiSelectAlign,
}
#[component]
pub fn MultiSelect(
children: Children,
#[prop(optional, into)] values: Option<RwSignal<HashSet<String>>>,
#[prop(default = MultiSelectAlign::default())] align: MultiSelectAlign,
) -> impl IntoView {
let multi_select_target_id = use_random_id_for("multi_select");
let values_signal = values.unwrap_or_else(|| RwSignal::new(HashSet::<String>::new()));
let multi_select_ctx = MultiSelectContext { target_id: multi_select_target_id.clone(), values_signal, align };
view! {
<Provider value=multi_select_ctx>
<div data-name="MultiSelect" class="relative w-fit">
{children()}
</div>
</Provider>
}
}
#[component]
pub fn MultiSelectTrigger(
children: Children,
#[prop(optional, into)] class: String,
#[prop(optional, into)] id: String,
) -> impl IntoView {
let multi_select_ctx = expect_context::<MultiSelectContext>();
let peer_class = if !id.is_empty() { format!("peer/{}", id) } else { String::new() };
let button_class = tw_merge!(
"w-full p-2 h-9 inline-flex items-center justify-between text-sm font-medium whitespace-nowrap rounded-md transition-colors focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not(:last-child)]:mr-2 [&_svg:not(:first-child)]:ml-2 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
&peer_class,
class
);
let button_id = if !id.is_empty() { id } else { format!("trigger_{}", multi_select_ctx.target_id) };
view! {
<button
type="button"
data-name="MultiSelectTrigger"
class=button_class
id=button_id
tabindex="0"
data-multi-select-trigger=multi_select_ctx.target_id
>
{children()}
<ChevronDown class="text-muted-foreground" />
</button>
}
}
#[component]
pub fn MultiSelectContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let multi_select_ctx = expect_context::<MultiSelectContext>();
let align_str = match multi_select_ctx.align {
MultiSelectAlign::Start => "start",
MultiSelectAlign::Center => "center",
MultiSelectAlign::End => "end",
};
let class = tw_merge!(
"w-[150px] overflow-auto z-50 p-1 rounded-md border bg-card shadow-md h-fit max-h-[300px] absolute top-[calc(100%+4px)] transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[align=start]:left-0 data-[align=center]:left-1/2 data-[align=center]:-translate-x-1/2 data-[align=end]:right-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
class
);
let target_id_for_script = multi_select_ctx.target_id.clone();
let target_id_for_script_2 = multi_select_ctx.target_id.clone();
// Scroll indicator signals
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
view! {
<script src="/lock_scroll.js"></script>
<div
data-name="MultiSelectContent"
class=class
id=multi_select_ctx.target_id
data-target="target__multi_select"
data-state="closed"
data-align=align_str
style="pointer-events: none;"
on:scroll=move |ev| on_scroll.run(ev)
>
<div
data-scroll-up="true"
class=move || {
let is_up: bool = can_scroll_up_signal.get();
if is_up {
"sticky -top-1 z-10 flex items-center justify-center py-1 bg-card"
} else {
"hidden"
}
}
>
<ChevronUp class="size-4 text-muted-foreground" />
</div>
{children()}
<div
data-scroll-down="true"
class=move || {
let is_down: bool = can_scroll_down_signal.get();
if is_down {
"sticky -bottom-1 z-10 flex items-center justify-center py-1 bg-card"
} else {
"hidden"
}
}
>
<ChevronDown class="size-4 text-muted-foreground" />
</div>
</div>
<script>
{format!(
r#"
(function() {{
const setupMultiSelect = () => {{
const multiSelect = document.querySelector('#{}');
const trigger = document.querySelector('[data-multi-select-trigger="{}"]');
if (!multiSelect || !trigger) {{
setTimeout(setupMultiSelect, 50);
return;
}}
if (multiSelect.hasAttribute('data-initialized')) {{
return;
}}
multiSelect.setAttribute('data-initialized', 'true');
let isOpen = false;
const openMultiSelect = () => {{
isOpen = true;
if (window.ScrollLock) window.ScrollLock.lock();
multiSelect.setAttribute('data-state', 'open');
multiSelect.style.pointerEvents = 'auto';
const triggerRect = trigger.getBoundingClientRect();
multiSelect.style.minWidth = `${{triggerRect.width}}px`;
multiSelect.dispatchEvent(new Event('scroll'));
setTimeout(() => {{
document.addEventListener('click', handleClickOutside);
}}, 0);
}};
const closeMultiSelect = () => {{
isOpen = false;
multiSelect.setAttribute('data-state', 'closed');
multiSelect.style.pointerEvents = 'none';
document.removeEventListener('click', handleClickOutside);
if (window.ScrollLock) window.ScrollLock.unlock(200);
}};
const handleClickOutside = (e) => {{
if (!multiSelect.contains(e.target) && !trigger.contains(e.target)) {{
closeMultiSelect();
}}
}};
trigger.addEventListener('click', (e) => {{
e.stopPropagation();
if (isOpen) closeMultiSelect(); else openMultiSelect();
}});
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape' && isOpen) {{
e.preventDefault();
closeMultiSelect();
}}
}});
}};
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', setupMultiSelect);
}} else {{
setupMultiSelect();
}}
}})();
"#,
target_id_for_script,
target_id_for_script_2,
)}
</script>
}.into_any()
}

View File

@@ -1,313 +0,0 @@
use icons::{Check, ChevronDown, ChevronUp};
use leptos::context::Provider;
use leptos::prelude::*;
use leptos_ui::clx;
use strum::{AsRefStr, Display};
use tw_merge::*;
use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
use crate::components::hooks::use_random::use_random_id_for;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, AsRefStr)]
pub enum SelectPosition {
#[default]
Below,
Above,
}
mod components {
use super::*;
clx! {SelectLabel, span, "px-2 py-1.5 text-sm font-medium data-inset:pl-8", "mb-1"}
clx! {SelectItem, li, "inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4"}
}
pub use components::*;
#[component]
pub fn SelectGroup(
children: Children,
#[prop(optional, into)] class: String,
#[prop(default = "Select options".into(), into)] aria_label: String,
) -> impl IntoView {
let merged_class = tw_merge!("group", class);
view! {
<ul data-name="SelectGroup" role="listbox" aria-label=aria_label class=merged_class>
{children()}
</ul>
}
}
#[component]
pub fn SelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView {
let select_ctx = expect_context::<SelectContext>();
view! {
<span data-name="SelectValue" class="text-sm text-muted-foreground truncate">
{move || { select_ctx.value_signal.get().unwrap_or_else(|| placeholder.clone()) }}
</span>
}
}
#[component]
pub fn SelectOption(
children: Children,
#[prop(optional, into)] class: String,
#[prop(default = false.into(), into)] aria_selected: Signal<bool>,
#[prop(optional, into)] value: Option<String>,
) -> impl IntoView {
let ctx = expect_context::<SelectContext>();
let merged_class = tw_merge!(
"group inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm cursor-pointer no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
class
);
let value_for_check = value.clone();
let is_selected = move || aria_selected.get() || ctx.value_signal.get() == value_for_check;
view! {
<li
data-name="SelectOption"
class=merged_class
role="option"
tabindex="0"
aria-selected=move || is_selected().to_string()
data-select-option="true"
on:click=move |_| {
let val = value.clone();
ctx.value_signal.set(val.clone());
if let Some(on_change) = ctx.on_change {
on_change.run(val);
}
}
>
{children()}
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-selected:opacity-100" />
</li>
}
}
#[derive(Clone)]
struct SelectContext {
target_id: String,
value_signal: RwSignal<Option<String>>,
on_change: Option<Callback<Option<String>>>,
}
#[component]
pub fn Select(
children: Children,
#[prop(optional, into)] class: String,
#[prop(optional, into)] default_value: Option<String>,
#[prop(optional)] on_change: Option<Callback<Option<String>>>,
) -> impl IntoView {
let select_target_id = use_random_id_for("select");
let value_signal = RwSignal::new(default_value);
let ctx = SelectContext { target_id: select_target_id.clone(), value_signal, on_change };
let merged_class = tw_merge!("relative w-fit", class);
view! {
<Provider value=ctx>
<div data-name="Select" class=merged_class>
{children()}
</div>
</Provider>
}
}
#[component]
pub fn SelectTrigger(
children: Children,
#[prop(optional, into)] class: String,
#[prop(optional, into)] id: String,
) -> impl IntoView {
let ctx = expect_context::<SelectContext>();
let peer_class = if !id.is_empty() { format!("peer/{}", id) } else { String::new() };
let button_class = tw_merge!(
"w-full p-2 h-9 inline-flex items-center justify-between text-sm font-medium whitespace-nowrap rounded-md transition-colors focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not(:last-child)]:mr-2 [&_svg:not(:first-child)]:ml-2 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
&peer_class,
class
);
let button_id = if !id.is_empty() { id } else { format!("trigger_{}", ctx.target_id) };
view! {
<button
type="button"
data-name="SelectTrigger"
class=button_class
id=button_id
tabindex="0"
data-select-trigger=ctx.target_id
>
{children()}
<ChevronDown class="text-muted-foreground" />
</button>
}
}
#[component]
pub fn SelectContent(
children: Children,
#[prop(optional, into)] class: String,
#[prop(default = SelectPosition::default())] position: SelectPosition,
#[prop(optional)] on_close: Option<Callback<()>>,
) -> impl IntoView {
let ctx = expect_context::<SelectContext>();
let merged_class = tw_merge!(
"w-[150px] overflow-auto z-50 p-1 rounded-md border bg-card shadow-md h-fit max-h-[300px] absolute top-[calc(100%+4px)] left-0 data-[position=Above]:top-auto data-[position=Above]:bottom-[calc(100%+4px)] transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[state=closed]:data-[position=Below]:origin-top data-[state=open]:data-[position=Below]:origin-top data-[state=closed]:data-[position=Above]:origin-bottom data-[state=open]:data-[position=Above]:origin-bottom [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
class
);
let target_id_for_script = ctx.target_id.clone();
let target_id_for_script_2 = ctx.target_id.clone();
// Scroll indicator signals
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
view! {
<script src="/lock_scroll.js"></script>
<div
data-name="SelectContent"
class=merged_class
on:selectclose=move |_: web_sys::CustomEvent| {
if let Some(cb) = on_close {
cb.run(());
}
}
id=ctx.target_id
data-target="target__select"
data-state="closed"
data-position=position.to_string()
style="pointer-events: none;"
on:scroll=move |ev| on_scroll.run(ev)
>
<div
data-scroll-up="true"
class=move || {
let is_up: bool = can_scroll_up_signal.get();
if is_up {
"sticky -top-1 z-10 flex items-center justify-center py-1 bg-card"
} else {
"hidden"
}
}
>
<ChevronUp class="size-4 text-muted-foreground" />
</div>
{children()}
<div
data-scroll-down="true"
class=move || {
let is_down: bool = can_scroll_down_signal.get();
if is_down {
"sticky -bottom-1 z-10 flex items-center justify-center py-1 bg-card"
} else {
"hidden"
}
}
>
<ChevronDown class="size-4 text-muted-foreground" />
</div>
</div>
<script>
{format!(
r#"
(function() {{
const setupSelect = () => {{
const select = document.querySelector('#{}');
const trigger = document.querySelector('[data-select-trigger="{}"]');
if (!select || !trigger) {{
setTimeout(setupSelect, 50);
return;
}}
if (select.hasAttribute('data-initialized')) {{
return;
}}
select.setAttribute('data-initialized', 'true');
let isOpen = false;
const updatePosition = () => {{
const triggerRect = trigger.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - triggerRect.bottom;
const spaceAbove = triggerRect.top;
if (spaceBelow < 200 && spaceAbove > spaceBelow) {{
select.setAttribute('data-position', 'Above');
}} else {{
select.setAttribute('data-position', 'Below');
}}
select.style.minWidth = `${{triggerRect.width}}px`;
}};
const openSelect = () => {{
isOpen = true;
if (window.ScrollLock) window.ScrollLock.lock();
updatePosition();
select.setAttribute('data-state', 'open');
select.style.pointerEvents = 'auto';
select.dispatchEvent(new Event('scroll'));
setTimeout(() => {{
document.addEventListener('click', handleClickOutside);
}}, 0);
}};
const closeSelect = () => {{
isOpen = false;
select.setAttribute('data-state', 'closed');
select.style.pointerEvents = 'none';
document.removeEventListener('click', handleClickOutside);
select.dispatchEvent(new CustomEvent('selectclose', {{ bubbles: false }}));
if (window.ScrollLock) window.ScrollLock.unlock(200);
}};
const handleClickOutside = (e) => {{
if (!select.contains(e.target) && !trigger.contains(e.target)) {{
closeSelect();
}}
}};
trigger.addEventListener('click', (e) => {{
e.stopPropagation();
if (isOpen) closeSelect(); else openSelect();
}});
const options = select.querySelectorAll('[data-select-option]');
options.forEach(option => {{
option.addEventListener('click', () => closeSelect());
}});
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape' && isOpen) {{
e.preventDefault();
closeSelect();
}}
}});
}};
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', setupSelect);
}} else {{
setupSelect();
}}
}})();
"#,
target_id_for_script,
target_id_for_script_2,
)}
</script>
}.into_any()
}

View File

@@ -1,35 +0,0 @@
use leptos::prelude::*;
use tw_merge::*;
#[component]
pub fn Separator(
#[prop(into, optional)] orientation: Signal<SeparatorOrientation>,
#[prop(into, optional)] class: String,
// children: Children,
) -> impl IntoView {
let merged_class = Memo::new(move |_| {
let orientation = orientation.get();
let separator = SeparatorClass { orientation };
separator.with_class(class.clone())
});
view! { <div class=merged_class role="separator" /> }
}
/* ========================================================== */
/* 🧬 STRUCT 🧬 */
/* ========================================================== */
#[derive(TwClass, Default)]
#[tw(class = "shrink-0 bg-border")]
pub struct SeparatorClass {
orientation: SeparatorOrientation,
}
#[derive(TwVariant)]
pub enum SeparatorOrientation {
#[tw(default, class = "w-full h-[1px]")]
Default,
#[tw(class = "h-full w-[1px]")]
Vertical,
}

View File

@@ -1,25 +0,0 @@
use leptos::prelude::*;
use tw_merge::tw_merge;
#[component]
pub fn SvgIcon(
children: Children,
#[prop(optional, into)] class: String,
) -> impl IntoView {
let class = tw_merge!("size-4", class);
view! {
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class=class
>
{children()}
</svg>
}
}

View File

@@ -1,56 +0,0 @@
use leptos::prelude::*;
use tw_merge::tw_merge;
#[component]
pub fn TableWrapper(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("overflow-hidden rounded-md border w-full", class);
view! { <div class=class>{children()}</div> }
}
#[component]
pub fn Table(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("w-full text-sm border-collapse", class);
view! { <table class=class>{children()}</table> }
}
#[component]
pub fn TableCaption(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("mt-4 text-sm text-muted-foreground", class);
view! { <caption class=class>{children()}</caption> }
}
#[component]
pub fn TableHeader(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("[&_tr]:border-b bg-muted/50", class);
view! { <thead class=class>{children()}</thead> }
}
#[component]
pub fn TableRow(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("border-b transition-colors data-[state=selected]:bg-muted hover:bg-muted/50", class);
view! { <tr class=class>{children()}</tr> }
}
#[component]
pub fn TableHead(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("h-10 px-4 text-left align-middle font-medium text-muted-foreground whitespace-nowrap", class);
view! { <th class=class>{children()}</th> }
}
#[component]
pub fn TableBody(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("[&_tr:last-child]:border-0", class);
view! { <tbody class=class>{children()}</tbody> }
}
#[component]
pub fn TableCell(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("p-2 px-4 align-middle", class);
view! { <td class=class>{children()}</td> }
}
#[component]
pub fn TableFooter(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", class);
view! { <tfoot class=class>{children()}</tfoot> }
}

View File

@@ -1,76 +0,0 @@
use crate::components::ui::svg_icon::SvgIcon;
use leptos::prelude::*;
use crate::components::hooks::use_theme_mode::use_theme_mode;
#[component]
pub fn ThemeToggle() -> impl IntoView {
let theme_mode = use_theme_mode();
view! {
<style>
{"
.theme__toggle_transition {
-webkit-tap-highlight-color: transparent;
svg path {
transform-origin: center;
transition: all .6s ease;
transform: translate3d(0,0,0);
backface-visibility: hidden;
&.sun {
transform: scale(.4) rotate(60deg);
opacity: 0;
}
&.moon {
opacity: 1;
}
}
&.switch {
svg path {
&.sun {
transform: scale(1) rotate(0);
opacity: 1;
}
&.moon {
transform: scale(.4) rotate(-60deg);
opacity: 0;
}
}
}
}
"}
</style>
<button
type="button"
aria-label="Toggle theme"
class=move || {
let base_class = "theme__toggle_transition";
if theme_mode.get() { format!("{base_class} switch") } else { base_class.to_string() }
}
on:click=move |_| theme_mode.toggle()
>
<SvgIcon class="size-4">
<path
d="M12 1.75V3.25M12 20.75V22.25M1.75 12H3.25M20.75 12H22.25M4.75216 4.75216L5.81282 5.81282M18.1872 18.1872L19.2478 19.2478M4.75216 19.2478L5.81282 18.1872M18.1872 5.81282L19.2478 4.75216M16.25 12C16.25 14.3472 14.3472 16.25 12 16.25C9.65279 16.25 7.75 14.3472 7.75 12C7.75 9.65279 9.65279 7.75 12 7.75C14.3472 7.75 16.25 9.65279 16.25 12Z"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
class="sun text-neutral-300"
/>
<path
d="M2.75 12C2.75 17.1086 6.89137 21.25 12 21.25C16.7154 21.25 20.6068 17.7216 21.1778 13.161C20.1198 13.8498 18.8566 14.25 17.5 14.25C13.7721 14.25 10.75 11.2279 10.75 7.5C10.75 5.66012 11.4861 3.99217 12.6799 2.77461C12.4554 2.7583 12.2287 2.75 12 2.75C6.89137 2.75 2.75 6.89137 2.75 12Z"
stroke="currentColor"
stroke-width="1.5"
stroke-linejoin="round"
class="moon text-neutral-700"
/>
</SvgIcon>
</button>
}
}

View File

@@ -1,221 +0,0 @@
use leptos::prelude::*;
use tw_merge::*;
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
#[allow(dead_code)]
pub enum ToastType {
#[default]
Default,
Success,
Error,
Warning,
Info,
Loading,
}
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
#[allow(dead_code)]
pub enum SonnerPosition {
TopLeft,
TopCenter,
TopRight,
#[default]
BottomRight,
BottomCenter,
BottomLeft,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ToastData {
pub id: u64,
pub title: String,
pub description: Option<String>,
pub variant: ToastType,
pub duration: u64, // ms
}
#[derive(Clone, Copy)]
pub struct ToasterStore {
pub toasts: RwSignal<Vec<ToastData>>,
}
#[component]
pub fn SonnerTrigger(
toast: ToastData,
index: usize,
total: usize,
position: SonnerPosition,
#[prop(optional)] on_dismiss: Option<Callback<()>>,
) -> impl IntoView {
let variant_classes = match toast.variant {
ToastType::Default => "bg-background text-foreground border-border",
ToastType::Success => "bg-background text-foreground border-border [&_.icon]:text-success",
ToastType::Error => "bg-background text-foreground border-border [&_.icon]:text-destructive",
ToastType::Warning => "bg-background text-foreground border-border [&_.icon]:text-warning",
ToastType::Info => "bg-background text-foreground border-border [&_.icon]:text-info",
ToastType::Loading => "bg-background text-foreground border-border",
};
// Sonner Stacking Logic
// We calculate inverse index: 0 is the newest (top), 1 is older, etc.
let inverse_index = index;
let offset = inverse_index as f64 * 16.0;
let scale = 1.0 - (inverse_index as f64 * 0.05);
let opacity = if inverse_index > 2 { 0.0 } else { 1.0 - (inverse_index as f64 * 0.2) };
let is_bottom = !position.to_string().contains("Top");
let y_direction = if is_bottom { -1.0 } else { 1.0 };
let translate_y = offset * y_direction;
let style = format!(
"z-index: {}; transform: translateY({}px) scale({}); opacity: {};",
total - index,
translate_y,
scale,
opacity
);
let icon = match toast.variant {
ToastType::Success => Some(view! { <span class="icon text-success">""</span> }.into_any()),
ToastType::Error => Some(view! { <span class="icon text-destructive">""</span> }.into_any()),
ToastType::Warning => Some(view! { <span class="icon text-warning">""</span> }.into_any()),
ToastType::Info => Some(view! { <span class="icon text-info">""</span> }.into_any()),
_ => None,
};
view! {
<div
class=tw_merge!(
"absolute transition-all duration-300 ease-in-out cursor-pointer pointer-events-auto",
"flex items-center gap-3 min-w-[350px] p-4 rounded-lg border shadow-lg bg-card",
if is_bottom { "bottom-0" } else { "top-0" },
variant_classes
)
style=style
on:click=move |_| {
if let Some(cb) = on_dismiss {
cb.run(());
}
}
>
{icon}
<div class="flex flex-col gap-1">
<div class="text-sm font-semibold">{toast.title}</div>
{move || toast.description.as_ref().map(|d| view! { <div class="text-xs opacity-70">{d.clone()}</div> })}
</div>
</div>
}.into_any()
}
// Thread local storage for global access
thread_local! {
static TOASTS: std::cell::RefCell<Option<RwSignal<Vec<ToastData>>>> = std::cell::RefCell::new(None);
}
pub fn provide_toaster() {
let toasts = RwSignal::new(Vec::<ToastData>::new());
TOASTS.with(|t| *t.borrow_mut() = Some(toasts));
provide_context(ToasterStore { toasts });
}
#[component]
pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView {
let store = use_context::<ToasterStore>().expect("Toaster context not found");
let toasts = store.toasts;
let is_hovered = RwSignal::new(false);
let container_class = match position {
SonnerPosition::TopLeft => "left-6 top-6 items-start",
SonnerPosition::TopRight => "right-6 top-6 items-end",
SonnerPosition::TopCenter => "left-1/2 -translate-x-1/2 top-6 items-center",
SonnerPosition::BottomCenter => "left-1/2 -translate-x-1/2 bottom-6 items-center",
SonnerPosition::BottomLeft => "left-6 bottom-6 items-start",
SonnerPosition::BottomRight => "right-6 bottom-6 items-end",
};
view! {
<div
class=tw_merge!("fixed z-[100] flex flex-col pointer-events-none min-h-[200px] w-[400px]", container_class)
on:mouseenter=move |_| is_hovered.set(true)
on:mouseleave=move |_| is_hovered.set(false)
>
<For
each=move || {
let list = toasts.get();
// Reverse the list so newest is at the end (for stacking)
// or newest is at the beginning (for display logic)
list.into_iter().rev().enumerate().collect::<Vec<_>>()
}
key=|(_, toast)| toast.id
children=move |(index, toast)| {
let id = toast.id;
let total = toasts.with(|t| t.len());
// If hovered, expand the stack
let expanded_style = move || {
if is_hovered.get() {
let offset = index as f64 * 70.0;
let is_bottom = !position.to_string().contains("Top");
let y_dir = if is_bottom { -1.0 } else { 1.0 };
format!("transform: translateY({}px) scale(1); opacity: 1;", offset * y_dir)
} else {
"".to_string()
}
};
view! {
<div style=expanded_style>
<SonnerTrigger
toast=toast
index=index
total=total
position=position
on_dismiss=Callback::new(move |_| {
toasts.update(|vec| vec.retain(|t| t.id != id));
})
/>
</div>
}
}
/>
</div>
}.into_any()
}
// Global Helper Functions
pub fn toast(title: impl Into<String>, variant: ToastType) {
let signal_opt = TOASTS.with(|t| *t.borrow());
if let Some(toasts) = signal_opt {
let id = js_sys::Math::random().to_bits();
let new_toast = ToastData {
id,
title: title.into(),
description: None,
variant,
duration: 4000,
};
toasts.update(|t| {
t.push(new_toast.clone());
if t.len() > 5 {
t.remove(0);
}
});
let duration = new_toast.duration;
leptos::task::spawn_local(async move {
gloo_timers::future::TimeoutFuture::new(duration as u32).await;
toasts.update(|vec| vec.retain(|t| t.id != id));
});
}
}
#[allow(dead_code)]
pub fn toast_success(title: impl Into<String>) { toast(title, ToastType::Success); }
#[allow(dead_code)]
pub fn toast_error(title: impl Into<String>) { toast(title, ToastType::Error); }
#[allow(dead_code)]
pub fn toast_warning(title: impl Into<String>) { toast(title, ToastType::Warning); }
#[allow(dead_code)]
pub fn toast_info(title: impl Into<String>) { toast(title, ToastType::Info); }

View File

@@ -1,27 +1,21 @@
#![recursion_limit = "256"]
mod app; mod app;
// mod models; // Removed
mod components; mod components;
pub mod utils; pub mod utils;
pub mod store; pub mod store;
pub mod api;
use leptos::prelude::*; use leptos::*;
use leptos::mount::mount_to_body;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use app::App; use app::App;
#[wasm_bindgen(start)] #[wasm_bindgen(start)]
pub fn main() { pub fn main() {
console_error_panic_hook::set_once(); console_error_panic_hook::set_once();
console_log::init_with_level(log::Level::Debug) console_log::init_with_level(log::Level::Debug).unwrap();
.expect("Failed to initialize logging");
let window = web_sys::window() let window = web_sys::window().unwrap();
.expect("Failed to access window - browser may not be fully loaded"); let document = window.document().unwrap();
let document = window.document() let body = document.body().unwrap();
.expect("Failed to access document");
let body = document.body()
.expect("Failed to access document body");
// Add app-loaded class to body to hide spinner via CSS // Add app-loaded class to body to hide spinner via CSS
let _ = body.class_list().add_1("app-loaded"); let _ = body.class_list().add_1("app-loaded");

View File

@@ -1,34 +1,79 @@
use futures::StreamExt; use futures::StreamExt;
use gloo_net::eventsource::futures::EventSource; use gloo_net::eventsource::futures::EventSource;
use leptos::prelude::*; use leptos::*;
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::ui::toast::{ToastType, toast}; #[derive(Clone, Debug, PartialEq)]
pub struct NotificationItem {
pub fn show_toast(level: NotificationLevel, message: impl Into<String>) { pub id: u64,
let msg = message.into(); pub notification: SystemNotification,
gloo_console::log!("TOAST CALL:", &msg, format!("{:?}", level));
log::info!("Displaying toast: [{:?}] {}", level, msg);
let variant = match level {
NotificationLevel::Success => ToastType::Success,
NotificationLevel::Error => ToastType::Error,
NotificationLevel::Warning => ToastType::Warning,
NotificationLevel::Info => ToastType::Info,
};
toast(msg, variant);
} }
// ============================================================================
// Toast Helper Functions (Clean Code: Single Responsibility)
// ============================================================================
/// Shows a toast notification using a direct signal reference.
/// Use this version inside async blocks (spawn_local) where use_context is unavailable.
/// Auto-removes after 5 seconds.
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 };
pub fn toast_success(message: impl Into<String>) { show_toast(NotificationLevel::Success, message); } notifications.update(|list| list.push(item));
pub fn toast_error(message: impl Into<String>) { show_toast(NotificationLevel::Error, message); }
// Auto-remove after 5 seconds
let _ = set_timeout(
move || {
notifications.update(|list| list.retain(|i| i.id != id));
},
std::time::Duration::from_secs(5),
);
}
/// Shows a toast notification with the given level and message.
/// Only works within reactive scope (components, effects). For async, use show_toast_with_signal.
/// Auto-removes after 5 seconds.
pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
if let Some(store) = use_context::<TorrentStore>() {
show_toast_with_signal(store.notifications, level, message);
}
}
/// Convenience function for success toasts (reactive scope only)
pub fn toast_success(message: impl Into<String>) {
show_toast(NotificationLevel::Success, message);
}
/// Convenience function for error toasts (reactive scope only)
pub fn toast_error(message: impl Into<String>) {
show_toast(NotificationLevel::Error, message);
}
/// Convenience function for info toasts (reactive scope only)
pub fn toast_info(message: impl Into<String>) {
show_toast(NotificationLevel::Info, message);
}
/// Convenience function for warning toasts (reactive scope only)
pub fn toast_warning(message: impl Into<String>) {
show_toast(NotificationLevel::Warning, message);
}
// ============================================================================
// Action Message Mapping (Clean Code: DRY Principle)
// ============================================================================
/// Maps torrent action strings to user-friendly Turkish messages.
/// Returns (success_message, error_message)
pub fn get_action_messages(action: &str) -> (&'static str, &'static str) { pub fn get_action_messages(action: &str) -> (&'static str, &'static str) {
match action { match action {
"start" => ("Torrent başlatıldı", "Torrent başlatılamadı"), "start" => ("Torrent başlatıldı", "Torrent başlatılamadı"),
@@ -43,128 +88,523 @@ pub fn get_action_messages(action: &str) -> (&'static str, &'static str) {
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FilterStatus { pub enum FilterStatus {
All, Downloading, Seeding, Completed, Paused, Inactive, Active, Error, All,
Downloading,
Seeding,
Completed,
Paused,
Inactive,
Active,
Error,
}
impl FilterStatus {
pub fn as_str(&self) -> &'static str {
match self {
FilterStatus::All => "All",
FilterStatus::Downloading => "Downloading",
FilterStatus::Seeding => "Seeding",
FilterStatus::Completed => "Completed",
FilterStatus::Paused => "Paused",
FilterStatus::Inactive => "Inactive",
FilterStatus::Active => "Active",
FilterStatus::Error => "Error",
}
}
} }
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub struct TorrentStore { pub struct TorrentStore {
pub torrents: RwSignal<HashMap<String, Torrent>>, pub torrents: RwSignal<Vec<Torrent>>,
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 fn provide_torrent_store() { pub fn provide_torrent_store() {
let torrents = RwSignal::new(HashMap::new()); let torrents = create_rw_signal(Vec::<Torrent>::new());
let filter = RwSignal::new(FilterStatus::All); let filter = create_rw_signal(FilterStatus::All);
let search_query = RwSignal::new(String::new()); let search_query = create_rw_signal(String::new());
let global_stats = RwSignal::new(GlobalStats::default()); let global_stats = create_rw_signal(GlobalStats::default());
let user = RwSignal::new(Option::<String>::None); let notifications = create_rw_signal(Vec::<NotificationItem>::new());
let selected_torrent = RwSignal::new(Option::<String>::None); let user = create_rw_signal(Option::<String>::None);
let show_browser_notification = crate::utils::notification::use_app_notification(); let store = TorrentStore {
torrents,
let store = TorrentStore { torrents, filter, search_query, global_stats, user, selected_torrent }; filter,
search_query,
global_stats,
notifications,
user,
};
provide_context(store); provide_context(store);
let global_stats_for_sse = global_stats; // Initialize SSE connection with auto-reconnect
let torrents_for_sse = torrents; create_effect(move |_| {
let show_browser_notification = show_browser_notification.clone(); spawn_local(async move {
let mut backoff_ms: u32 = 1000; // Start with 1 second
let max_backoff_ms: u32 = 30000; // Max 30 seconds
let mut was_connected = false;
let mut disconnect_notified = false; // Track if we already showed disconnect toast
let mut got_first_message; // Only count as "connected" after receiving data
spawn_local(async move { loop {
let mut backoff_ms: u32 = 1000; let es_result = EventSource::new("/api/events");
let mut was_connected = false;
let mut disconnect_notified = false;
loop { match es_result {
Ok(mut es) => {
match es.subscribe("message") {
Ok(mut stream) => {
// Don't show "connected" toast yet - wait for first real message
got_first_message = false;
log::debug!("SSE: Creating EventSource..."); // Process messages
let es_result = EventSource::new("/api/events"); while let Some(Ok((_, msg))) = stream.next().await {
match es_result { // First successful message = truly connected
Ok(mut es) => { if !got_first_message {
log::debug!("SSE: EventSource created, subscribing..."); got_first_message = true;
if let Ok(mut stream) = es.subscribe("message") { backoff_ms = 1000; // Reset backoff on real data
log::debug!("SSE: Subscribed to message channel");
let mut got_first_message = false;
while let Some(Ok((_, msg))) = stream.next().await {
log::debug!("SSE: Received message");
if !got_first_message {
got_first_message = true;
backoff_ms = 1000;
if was_connected && disconnect_notified {
show_toast(NotificationLevel::Success, "Sunucu bağlantısı yeniden kuruldu");
disconnect_notified = false;
}
was_connected = true;
}
if let Some(data_str) = msg.data().as_string() { if was_connected && disconnect_notified {
// Decode Base64 // We were previously connected, lost connection, and now truly reconnected
match BASE64.decode(&data_str) { show_toast_with_signal(
Ok(bytes) => { notifications,
// Deserialize MessagePack NotificationLevel::Success,
match rmp_serde::from_slice::<AppEvent>(&bytes) { "Sunucu bağlantısı yeniden kuruldu",
Ok(event) => { );
match event { disconnect_notified = false;
AppEvent::FullList(list, _) => { }
log::info!("SSE: Received FullList with {} torrents", list.len()); was_connected = true;
torrents_for_sse.update(|map| { }
let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
map.retain(|hash, _| new_hashes.contains(hash)); if let Some(data_str) = msg.data().as_string() {
for new_torrent in list { if let Ok(event) = serde_json::from_str::<AppEvent>(&data_str) {
map.insert(new_torrent.hash.clone(), new_torrent); match event {
AppEvent::FullList { torrents: list, .. } => {
torrents.set(list);
}
AppEvent::Update(update) => {
torrents.update(|list| {
if let Some(t) = list.iter_mut().find(|t| t.hash == update.hash)
{
if let Some(name) = update.name {
t.name = name;
}
if let Some(size) = update.size {
t.size = size;
}
if let Some(down_rate) = update.down_rate {
t.down_rate = down_rate;
}
if let Some(up_rate) = update.up_rate {
t.up_rate = up_rate;
}
if let Some(percent_complete) = update.percent_complete {
t.percent_complete = percent_complete;
}
if let Some(completed) = update.completed {
t.completed = completed;
}
if let Some(eta) = update.eta {
t.eta = eta;
}
if let Some(status) = update.status {
t.status = status;
}
if let Some(error_message) = update.error_message {
t.error_message = error_message;
}
if let Some(label) = update.label {
t.label = Some(label);
} }
});
log::debug!("SSE: torrents map now has {} entries", torrents_for_sse.with(|m| m.len()));
}
AppEvent::Update(patch) => {
let hash_opt = patch.hash.clone();
if let Some(hash) = hash_opt {
torrents_for_sse.update(|map| {
if let Some(t) = map.get_mut(&hash) {
t.apply(patch);
}
});
}
}
AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); }
AppEvent::Notification(n) => {
show_toast(n.level.clone(), n.message.clone());
if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
show_browser_notification("VibeTorrent", &n.message);
} }
});
}
AppEvent::Stats(stats) => {
global_stats.set(stats);
}
AppEvent::Notification(n) => {
// Show toast notification
show_toast_with_signal(notifications, n.level.clone(), n.message.clone());
// Show browser notification for critical events
let is_critical = n.message.contains("tamamlandı")
|| n.level == shared::NotificationLevel::Error;
if is_critical {
let title = match n.level {
shared::NotificationLevel::Success => "✅ VibeTorrent",
shared::NotificationLevel::Error => "❌ VibeTorrent",
shared::NotificationLevel::Warning => "⚠️ VibeTorrent",
shared::NotificationLevel::Info => " VibeTorrent",
};
crate::utils::notification::show_notification_if_enabled(
title,
&n.message
);
} }
} }
} }
Err(e) => log::error!("SSE: Failed to deserialize MessagePack: {}", e),
} }
} }
Err(e) => log::error!("SSE: Failed to decode Base64: {}", e), }
// Stream ended - connection lost
if was_connected && !disconnect_notified {
show_toast_with_signal(
notifications,
NotificationLevel::Warning,
"Sunucu bağlantısı kesildi, yeniden bağlanılıyor...",
);
disconnect_notified = true;
}
}
Err(_) => {
// Failed to subscribe - only notify once
if was_connected && !disconnect_notified {
show_toast_with_signal(
notifications,
NotificationLevel::Warning,
"Sunucu bağlantısı kesildi, yeniden bağlanılıyor...",
);
disconnect_notified = true;
} }
} }
} }
}
Err(_) => {
// Failed to create EventSource - only notify once
if was_connected && !disconnect_notified { if was_connected && !disconnect_notified {
show_toast(NotificationLevel::Warning, "Sunucu bağlantısı kesildi, yeniden bağlanılıyor..."); show_toast_with_signal(
notifications,
NotificationLevel::Warning,
"Sunucu bağlantısı kesildi, yeniden bağlanılıyor...",
);
disconnect_notified = true; disconnect_notified = true;
} }
} }
} }
Err(_) => {
if was_connected && !disconnect_notified { // Wait before reconnecting (exponential backoff)
show_toast(NotificationLevel::Warning, "Sunucu bağlantısı kurulamıyor..."); gloo_timers::future::TimeoutFuture::new(backoff_ms).await;
disconnect_notified = true; backoff_ms = std::cmp::min(backoff_ms * 2, max_backoff_ms);
}
}
} }
log::debug!("SSE: Reconnecting in {}ms...", backoff_ms); });
gloo_timers::future::TimeoutFuture::new(backoff_ms).await;
backoff_ms = std::cmp::min(backoff_ms * 2, 30000);
}
}); });
} }
pub async fn subscribe_to_push_notifications() { // ============================================================================
// ... // Push Notification Subscription
// ============================================================================
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
#[derive(Serialize, Deserialize, Debug)]
struct PushSubscriptionData {
endpoint: String,
keys: PushKeys,
}
#[derive(Serialize, Deserialize, Debug)]
struct PushKeys {
p256dh: String,
auth: String,
}
/// Subscribe user to push notifications
/// Requests notification permission if needed, then subscribes to push
pub async fn subscribe_to_push_notifications() {
use gloo_net::http::Request;
// First, request notification permission if not already granted
let window = web_sys::window().expect("window should exist");
let permission_granted = if let Ok(notification_class) = js_sys::Reflect::get(&window, &"Notification".into()) {
if notification_class.is_undefined() {
log::error!("Notification API not available");
return;
}
// Check current permission
let current_permission = js_sys::Reflect::get(&notification_class, &"permission".into())
.ok()
.and_then(|p| p.as_string())
.unwrap_or_default();
if current_permission == "granted" {
log::info!("Notification permission already granted");
true
} else if current_permission == "denied" {
log::warn!("Notification permission was denied");
return;
} else {
// Permission is "default" - need to request
log::info!("Requesting notification permission...");
if let Ok(request_fn) = js_sys::Reflect::get(&notification_class, &"requestPermission".into()) {
if request_fn.is_function() {
let request_fn_typed = js_sys::Function::from(request_fn);
match request_fn_typed.call0(&notification_class) {
Ok(promise_val) => {
let request_future = wasm_bindgen_futures::JsFuture::from(
js_sys::Promise::from(promise_val)
);
match request_future.await {
Ok(result) => {
let result_str = result.as_string().unwrap_or_default();
log::info!("Permission request result: {}", result_str);
result_str == "granted"
}
Err(e) => {
log::error!("Failed to request notification permission: {:?}", e);
false
}
}
}
Err(e) => {
log::error!("Failed to call requestPermission: {:?}", e);
false
}
}
} else {
false
}
} else {
false
}
}
} else {
log::error!("Cannot access Notification class");
return;
};
if !permission_granted {
log::warn!("Notification permission not granted, cannot subscribe to push");
return;
}
log::info!("Notification permission granted! Proceeding with push subscription...");
// Get VAPID public key from backend
let public_key_response = match Request::get("/api/push/public-key").send().await {
Ok(resp) => resp,
Err(e) => {
log::error!("Failed to get VAPID public key: {:?}", e);
return;
}
};
let public_key_data: serde_json::Value = match public_key_response.json().await {
Ok(data) => data,
Err(e) => {
log::error!("Failed to parse VAPID public key: {:?}", e);
return;
}
};
let public_key = match public_key_data.get("publicKey").and_then(|v| v.as_str()) {
Some(key) => key,
None => {
log::error!("Missing publicKey in response");
return;
}
};
log::info!("VAPID public key from backend: {} (len: {})", public_key, public_key.len());
// Convert VAPID public key to Uint8Array
let public_key_array = match url_base64_to_uint8array(public_key) {
Ok(arr) => {
log::info!("VAPID key converted to Uint8Array (length: {})", arr.length());
arr
}
Err(e) => {
log::error!("Failed to convert VAPID key: {:?}", e);
return;
}
};
// Get service worker registration
let window = web_sys::window().expect("window should exist");
let navigator = window.navigator();
let service_worker = navigator.service_worker();
let registration_promise = match service_worker.ready() {
Ok(promise) => promise,
Err(e) => {
log::error!("Failed to get ready promise: {:?}", e);
return;
}
};
let registration_future = wasm_bindgen_futures::JsFuture::from(registration_promise);
let registration = match registration_future.await {
Ok(reg) => reg,
Err(e) => {
log::error!("Failed to get service worker registration: {:?}", e);
return;
}
};
let service_worker_registration = registration
.dyn_into::<web_sys::ServiceWorkerRegistration>()
.expect("should be ServiceWorkerRegistration");
// Subscribe to push
let push_manager = match service_worker_registration.push_manager() {
Ok(pm) => pm,
Err(e) => {
log::error!("Failed to get push manager: {:?}", e);
return;
}
};
let subscribe_options = web_sys::PushSubscriptionOptionsInit::new();
subscribe_options.set_user_visible_only(true);
subscribe_options.set_application_server_key(&public_key_array);
let subscribe_promise = match push_manager.subscribe_with_options(&subscribe_options) {
Ok(promise) => promise,
Err(e) => {
log::error!("Failed to subscribe to push: {:?}", e);
return;
}
};
let subscription_future = wasm_bindgen_futures::JsFuture::from(subscribe_promise);
let subscription = match subscription_future.await {
Ok(sub) => sub,
Err(e) => {
log::error!("Failed to get push subscription: {:?}", e);
return;
}
};
let push_subscription = subscription
.dyn_into::<web_sys::PushSubscription>()
.expect("should be PushSubscription");
// Get subscription JSON using toJSON() method
let json_result = match js_sys::Reflect::get(&push_subscription, &"toJSON".into()) {
Ok(func) if func.is_function() => {
let json_func = js_sys::Function::from(func);
match json_func.call0(&push_subscription) {
Ok(result) => result,
Err(e) => {
log::error!("Failed to call toJSON: {:?}", e);
return;
}
}
}
_ => {
log::error!("toJSON method not found on PushSubscription");
return;
}
};
let json_value = match js_sys::JSON::stringify(&json_result) {
Ok(val) => val,
Err(e) => {
log::error!("Failed to stringify subscription: {:?}", e);
return;
}
};
let subscription_json_str = json_value.as_string().expect("should be string");
log::info!("Push subscription: {}", subscription_json_str);
// Parse and send to backend
let subscription_data: serde_json::Value = match serde_json::from_str(&subscription_json_str) {
Ok(data) => data,
Err(e) => {
log::error!("Failed to parse subscription JSON: {:?}", e);
return;
}
};
// Extract endpoint and keys
let endpoint = subscription_data
.get("endpoint")
.and_then(|v| v.as_str())
.expect("endpoint should exist")
.to_string();
let keys_obj = subscription_data
.get("keys")
.expect("keys should exist");
let p256dh = keys_obj
.get("p256dh")
.and_then(|v| v.as_str())
.expect("p256dh should exist")
.to_string();
let auth = keys_obj
.get("auth")
.and_then(|v| v.as_str())
.expect("auth should exist")
.to_string();
let push_data = PushSubscriptionData {
endpoint,
keys: PushKeys { p256dh, auth },
};
// Send to backend
let response = match Request::post("/api/push/subscribe")
.json(&push_data)
.expect("serialization should succeed")
.send()
.await
{
Ok(resp) => resp,
Err(e) => {
log::error!("Failed to send subscription to backend: {:?}", e);
return;
}
};
if response.ok() {
log::info!("Successfully subscribed to push notifications");
} else {
log::error!("Backend rejected push subscription: {:?}", response.status());
}
}
/// Helper to convert URL-safe base64 string to Uint8Array
/// Uses JavaScript to properly decode binary data (avoids UTF-8 encoding issues)
fn url_base64_to_uint8array(base64_string: &str) -> Result<js_sys::Uint8Array, JsValue> {
// Add padding
let padding = (4 - (base64_string.len() % 4)) % 4;
let mut padded = base64_string.to_string();
padded.push_str(&"=".repeat(padding));
// Replace URL-safe characters
let standard_base64 = padded.replace('-', "+").replace('_', "/");
// Decode using JavaScript to avoid UTF-8 encoding issues
// Create a JavaScript function to decode the base64 and convert to Uint8Array
let js_code = format!(
r#"
(function() {{
const binaryString = atob('{}');
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {{
bytes[i] = binaryString.charCodeAt(i);
}}
return bytes;
}})()
"#,
standard_base64
);
let result = js_sys::eval(&js_code)?;
let array = result.dyn_into::<js_sys::Uint8Array>()?;
Ok(array)
} }

View File

@@ -1,21 +1,37 @@
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use web_sys::{Notification, NotificationOptions}; use web_sys::{Notification, NotificationOptions};
/// 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 {
if !is_notification_supported() { let window = web_sys::window().expect("no global window");
return false;
} // Check if Notification API is available
if let Ok(promise) = Notification::request_permission() { if js_sys::Reflect::has(&window, &JsValue::from_str("Notification")).unwrap_or(false) {
if let Ok(result) = wasm_bindgen_futures::JsFuture::from(promise).await { let notification = js_sys::Reflect::get(&window, &JsValue::from_str("Notification"))
return result.as_string().unwrap_or_default() == "granted"; .expect("Notification should exist");
// Request permission
let promise = js_sys::Reflect::get(&notification, &JsValue::from_str("requestPermission"))
.expect("requestPermission should exist");
if let Ok(function) = promise.dyn_into::<js_sys::Function>() {
if let Ok(promise) = function.call0(&notification) {
if let Ok(promise) = promise.dyn_into::<js_sys::Promise>() {
let result = wasm_bindgen_futures::JsFuture::from(promise).await;
if let Ok(permission) = result {
let permission_str = permission.as_string().unwrap_or_default();
return permission_str == "granted";
}
}
}
} }
} }
false false
} }
/// Check if browser notifications are supported /// Check if browser notifications are supported and permitted
pub fn is_notification_supported() -> bool { pub fn is_notification_supported() -> bool {
let window = web_sys::window().expect("no global window"); let window = web_sys::window().expect("no global window");
js_sys::Reflect::has(&window, &JsValue::from_str("Notification")).unwrap_or(false) js_sys::Reflect::has(&window, &JsValue::from_str("Notification")).unwrap_or(false)
@@ -24,61 +40,66 @@ pub fn is_notification_supported() -> bool {
/// Get current notification permission status /// Get current notification permission status
pub fn get_notification_permission() -> String { pub fn get_notification_permission() -> String {
if !is_notification_supported() { if !is_notification_supported() {
return "denied".to_string(); return "unsupported".to_string();
}
match Notification::permission() {
web_sys::NotificationPermission::Granted => "granted".to_string(),
web_sys::NotificationPermission::Denied => "denied".to_string(),
web_sys::NotificationPermission::Default => "default".to_string(),
_ => "default".to_string(),
} }
let window = web_sys::window().expect("no global window");
let notification = js_sys::Reflect::get(&window, &JsValue::from_str("Notification"))
.expect("Notification should exist");
let permission = js_sys::Reflect::get(&notification, &JsValue::from_str("permission"))
.unwrap_or(JsValue::from_str("default"));
permission.as_string().unwrap_or("default".to_string())
} }
/// Hook for using browser notifications within Leptos components or effects. /// Show a browser notification
/// This uses leptos-use for reactive permission tracking. /// Returns true if notification was shown successfully
pub fn use_app_notification() -> impl Fn(&str, &str) + Clone { pub fn show_browser_notification(title: &str, body: &str, icon: Option<&str>) -> bool {
move |title: &str, body: &str| { // Check permission first
// Check user preference from localStorage let permission = get_notification_permission();
let window = web_sys::window().expect("no global window"); if permission != "granted" {
let storage = window.local_storage().ok().flatten(); log::warn!("Notification permission not granted: {}", permission);
let enabled = storage return false;
.and_then(|s| s.get_item("vibetorrent_browser_notifications").ok().flatten()) }
.unwrap_or_else(|| "true".to_string());
// Create notification options
// Check platform support and permission let opts = NotificationOptions::new();
if enabled == "true" && is_notification_supported() && get_notification_permission() == "granted" { opts.set_body(body);
show_browser_notification(title, body); opts.set_icon(icon.unwrap_or("/icon-192.png"));
opts.set_badge("/icon-192.png");
opts.set_tag("vibetorrent");
opts.set_require_interaction(false);
opts.set_silent(Some(false));
// Create and show notification
match Notification::new_with_options(title, &opts) {
Ok(_notification) => {
true
}
Err(e) => {
log::error!("Failed to create notification: {:?}", e);
false
} }
} }
} }
/// Show a browser notification (non-reactive version) /// Show notification only if enabled in settings and permission granted
pub fn show_browser_notification(title: &str, body: &str) -> bool {
if get_notification_permission() != "granted" {
return false;
}
let opts = NotificationOptions::new();
opts.set_body(body);
opts.set_icon("/icon-192.png");
opts.set_tag("vibetorrent");
match Notification::new_with_options(title, &opts) {
Ok(_) => true,
Err(_) => false,
}
}
/// Legacy helper for showing notification if enabled in settings
pub fn show_notification_if_enabled(title: &str, body: &str) -> bool { pub fn show_notification_if_enabled(title: &str, body: &str) -> bool {
// Check localStorage for user preference
let window = web_sys::window().expect("no global window"); let window = web_sys::window().expect("no global window");
let storage = window.local_storage().ok().flatten(); let storage = window.local_storage().ok().flatten();
let enabled = storage
.and_then(|s| s.get_item("vibetorrent_browser_notifications").ok().flatten()) if let Some(storage) = storage {
.unwrap_or_else(|| "true".to_string()); let enabled = storage
.get_item("vibetorrent_browser_notifications")
.ok()
.flatten()
.unwrap_or("true".to_string());
if enabled == "true" { if enabled == "true" {
return show_browser_notification(title, body); return show_browser_notification(title, body, None);
}
} }
false false

View File

@@ -1,20 +1,6 @@
const path = require("path");
const os = require("os");
// Cargo registry'deki leptos-shadcn crate'lerini Tailwind'e taratmak için
const cargoRegistry = path.join(
os.homedir(),
".cargo/registry/src/*/leptos-shadcn-*/src/**/*.rs"
);
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
darkMode: "class", content: ["./index.html", "./src/**/*.{rs,html}"],
content: [
"./index.html",
"./src/**/*.{rs,html}",
cargoRegistry,
],
theme: { theme: {
extend: { extend: {
colors: { colors: {
@@ -26,7 +12,4 @@ module.exports = {
}, },
}, },
}, },
plugins: [
require("tailwindcss-animate"),
],
}; };

View File

@@ -1,2 +0,0 @@
base_color = "neutral"
base_path_components = "src/components"

View File

@@ -1,30 +0,0 @@
[package]
edition = "2018"
name = "coarsetime"
version = "0.1.37"
description = "Time and duration crate optimized for speed (patched for MIPS)"
license = "BSD-2-Clause"
[features]
wasi-abi2 = ["dep:wasi-abi2"]
[lib]
name = "coarsetime"
path = "src/lib.rs"
[dependencies]
portable-atomic = { version = "1", default-features = false, features = ["fallback"] }
[target.'cfg(all(any(target_arch = "wasm32", target_arch = "wasm64"), target_os = "unknown"))'.dependencies.wasm-bindgen]
version = "0.2"
[target.'cfg(any(target_os = "wasix", target_os = "wasi"))'.dependencies.wasix]
version = "0.13"
[target.'cfg(not(any(target_os = "wasix", target_os = "wasi")))'.dependencies.libc]
version = "0.2"
[target.'cfg(target_os = "wasi")'.dependencies.wasi-abi2]
version = "0.14.7"
optional = true
package = "wasi"

View File

@@ -1,50 +1,8 @@
[package] [package]
name = "shared" name = "shared"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
[dependencies] [dependencies]
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
utoipa = { version = "5.4.0", features = ["axum_extras"] } 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.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 }
thiserror = { version = "2", optional = true }
quick-xml = { version = "0.31", features = ["serde", "serialize"], optional = true }
# Database
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"], optional = true }
anyhow = { version = "1.0", optional = true }
# Auth (SSR)
jsonwebtoken = { version = "9", optional = true }
cookie = { version = "0.18", features = ["percent-encode"], optional = true }
bcrypt = { version = "0.17", optional = true }
[features]
default = []
ssr = [
"dep:tokio",
"dep:thiserror",
"dep:quick-xml",
"dep:leptos_axum",
"dep:sqlx",
"dep:anyhow",
"dep:jsonwebtoken",
"dep:cookie",
"dep:bcrypt",
"dep:axum",
"leptos/ssr",
"leptos_router/ssr",
]
hydrate = ["leptos/hydrate"]

View File

@@ -1,16 +0,0 @@
-- 001_init.sql
-- Initial schema for users and sessions
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at DATETIME NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
);

View File

@@ -1,13 +0,0 @@
-- 002_push_subscriptions.sql
-- Push notification subscriptions storage
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Index for faster lookups by endpoint
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_endpoint ON push_subscriptions(endpoint);

View File

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

View File

@@ -1,34 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use struct_patch::Patch;
use utoipa::ToSchema; use utoipa::ToSchema;
#[cfg(feature = "ssr")] #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
pub mod scgi;
#[cfg(feature = "ssr")]
pub mod xmlrpc;
#[cfg(feature = "ssr")]
pub mod db;
pub mod codec;
pub mod server_fns;
#[derive(Clone, Debug)]
pub struct ServerContext {
pub scgi_socket_path: String,
}
#[cfg(feature = "ssr")]
#[derive(Clone)]
pub struct DbContext {
pub db: db::Db,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, Patch)]
#[patch_derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, Default)]
#[patch_name = "TorrentUpdate"]
pub struct Torrent { pub struct Torrent {
pub hash: String, pub hash: String,
pub name: String, pub name: String,
@@ -55,8 +28,12 @@ pub enum TorrentStatus {
} }
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
#[serde(tag = "type", content = "data")]
pub enum AppEvent { pub enum AppEvent {
FullList(Vec<Torrent>, u64), FullList {
torrents: Vec<Torrent>,
timestamp: u64,
},
Update(TorrentUpdate), Update(TorrentUpdate),
Stats(GlobalStats), Stats(GlobalStats),
Notification(SystemNotification), Notification(SystemNotification),
@@ -85,8 +62,20 @@ pub struct GlobalStats {
pub free_space: Option<i64>, pub free_space: Option<i64>,
} }
// REMOVED: Manual TorrentUpdate struct definition as it's now generated by Patch macro #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct TorrentUpdate {
pub hash: String,
pub name: Option<String>,
pub size: Option<i64>,
pub down_rate: Option<i64>,
pub up_rate: Option<i64>,
pub percent_complete: Option<f64>,
pub completed: Option<i64>,
pub eta: Option<i64>,
pub status: Option<TorrentStatus>,
pub error_message: Option<String>,
pub label: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct TorrentActionRequest { pub struct TorrentActionRequest {
@@ -141,9 +130,3 @@ pub struct SetLabelRequest {
pub hash: String, pub hash: String,
pub label: String, pub label: String,
} }
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct AddTorrentRequest {
#[schema(example = "magnet:?xt=urn:btih:...")]
pub uri: String,
}

View File

@@ -1,169 +0,0 @@
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use crate::codec::MsgPack;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserResponse {
pub id: i64,
pub username: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // username
pub uid: i64, // user id
pub exp: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SetupStatus {
pub completed: bool,
}
#[server(GetSetupStatus, "/api/server_fns", input = MsgPack, output = MsgPack)]
pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
use crate::DbContext;
let db_context = use_context::<DbContext>().ok_or_else(|| ServerFnError::new("DB Context missing"))?;
let has_users = db_context.db.has_users().await
.map_err(|e| ServerFnError::new(format!("DB error: {}", e)))?;
Ok(SetupStatus {
completed: has_users,
})
}
#[server(Setup, "/api/server_fns", input = MsgPack, output = MsgPack)]
pub async fn setup(username: String, password: String) -> Result<(), ServerFnError> {
use crate::DbContext;
let db_context = use_context::<DbContext>().ok_or_else(|| ServerFnError::new("DB Context missing"))?;
// Check if setup is already done
let has_users = db_context.db.has_users().await.unwrap_or(false);
if has_users {
return Err(ServerFnError::new("Setup already completed"));
}
// Hash password (low cost for MIPS)
let password_hash = bcrypt::hash(&password, 6)
.map_err(|_| ServerFnError::new("Hashing error"))?;
db_context.db.create_user(&username, &password_hash).await
.map_err(|e| ServerFnError::new(format!("DB error: {}", e)))?;
Ok(())
}
#[server(Login, "/api/server_fns", input = MsgPack, output = MsgPack)]
pub async fn login(username: String, password: String) -> Result<UserResponse, ServerFnError> {
use crate::DbContext;
use leptos_axum::ResponseOptions;
use jsonwebtoken::{encode, Header, EncodingKey};
use cookie::{Cookie, SameSite};
use std::time::{SystemTime, UNIX_EPOCH};
let db_context = use_context::<DbContext>().ok_or_else(|| ServerFnError::new("DB Context missing"))?;
let user_opt = db_context.db.get_user_by_username(&username).await
.map_err(|e| ServerFnError::new(format!("DB error: {}", e)))?;
if let Some((uid, password_hash)) = user_opt {
let valid = bcrypt::verify(&password, &password_hash).unwrap_or(false);
if !valid {
return Err(ServerFnError::new("Invalid credentials"));
}
let expiration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as usize + 24 * 3600; // 24 hours
let claims = Claims {
sub: username.clone(),
uid,
exp: expiration,
};
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
.map_err(|e| ServerFnError::new(format!("Token error: {}", e)))?;
let cookie = Cookie::build(("auth_token", token))
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.build();
if let Some(options) = use_context::<ResponseOptions>() {
options.insert_header(
axum::http::header::SET_COOKIE,
axum::http::HeaderValue::from_str(&cookie.to_string()).unwrap(),
);
}
Ok(UserResponse {
id: uid,
username,
})
} else {
Err(ServerFnError::new("Invalid credentials"))
}
}
#[server(Logout, "/api/server_fns", input = MsgPack, output = MsgPack)]
pub async fn logout() -> Result<(), ServerFnError> {
use leptos_axum::ResponseOptions;
use cookie::{Cookie, SameSite};
let cookie = Cookie::build(("auth_token", ""))
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.max_age(cookie::time::Duration::seconds(0))
.build();
if let Some(options) = use_context::<ResponseOptions>() {
options.insert_header(
axum::http::header::SET_COOKIE,
axum::http::HeaderValue::from_str(&cookie.to_string()).unwrap(),
);
}
Ok(())
}
#[server(GetUser, "/api/server_fns", input = MsgPack, output = MsgPack)]
pub async fn get_user() -> Result<Option<UserResponse>, ServerFnError> {
use axum::http::HeaderMap;
use leptos_axum::extract;
use jsonwebtoken::{decode, Validation, DecodingKey};
let headers: HeaderMap = extract().await.map_err(|e| ServerFnError::new(format!("Extract error: {}", e)))?;
let cookie_header = headers.get(axum::http::header::COOKIE)
.and_then(|h| h.to_str().ok());
if let Some(cookie_str) = cookie_header {
for c_str in cookie_str.split(';') {
if let Ok(c) = cookie::Cookie::parse(c_str.trim()) {
if c.name() == "auth_token" {
let token = c.value();
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
);
if let Ok(data) = token_data {
return Ok(Some(UserResponse {
id: data.claims.uid,
username: data.claims.sub,
}));
}
}
}
}
}
Ok(None)
}

View File

@@ -1,4 +0,0 @@
pub mod torrent;
pub mod settings;
pub mod push;
pub mod auth;

View File

@@ -1,22 +0,0 @@
use leptos::prelude::*;
#[server(GetPushPublicKey, "/api/server_fns")]
pub async fn get_public_key() -> Result<String, ServerFnError> {
let key = std::env::var("VAPID_PUBLIC_KEY")
.map_err(|_| ServerFnError::new("VAPID_PUBLIC_KEY not configured"))?;
Ok(key)
}
#[server(SubscribePush, "/api/server_fns")]
pub async fn subscribe_push(
endpoint: String,
p256dh: String,
auth: String,
) -> Result<(), ServerFnError> {
let db_ctx = expect_context::<crate::DbContext>();
db_ctx
.db
.save_push_subscription(&endpoint, &p256dh, &auth)
.await
.map_err(|e| ServerFnError::new(format!("Failed to save subscription: {}", e)))
}

View File

@@ -1,58 +0,0 @@
use leptos::prelude::*;
use crate::GlobalLimitRequest;
#[server(GetGlobalLimits, "/api/server_fns")]
pub async fn get_global_limits() -> Result<GlobalLimitRequest, ServerFnError> {
use crate::xmlrpc::{self, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let down = match client.call("throttle.global_down.max_rate", &[]).await {
Ok(xml) => xmlrpc::parse_i64_response(&xml).unwrap_or(0),
Err(_) => -1,
};
let up = match client.call("throttle.global_up.max_rate", &[]).await {
Ok(xml) => xmlrpc::parse_i64_response(&xml).unwrap_or(0),
Err(_) => -1,
};
Ok(GlobalLimitRequest {
max_download_rate: Some(down),
max_upload_rate: Some(up),
})
}
#[server(SetGlobalLimits, "/api/server_fns")]
pub async fn set_global_limits(
max_download_rate: Option<i64>,
max_upload_rate: Option<i64>,
) -> Result<(), ServerFnError> {
use crate::xmlrpc::{RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
if let Some(down) = max_download_rate {
let down_kb = down / 1024;
client
.call(
"throttle.global_down.max_rate.set_kb",
&[RpcParam::from(""), RpcParam::Int(down_kb)],
)
.await
.map_err(|e| ServerFnError::new(format!("Failed to set down limit: {}", e)))?;
}
if let Some(up) = max_upload_rate {
let up_kb = up / 1024;
client
.call(
"throttle.global_up.max_rate.set_kb",
&[RpcParam::from(""), RpcParam::Int(up_kb)],
)
.await
.map_err(|e| ServerFnError::new(format!("Failed to set up limit: {}", e)))?;
}
Ok(())
}

View File

@@ -1,274 +0,0 @@
use leptos::prelude::*;
use crate::{TorrentFile, TorrentPeer, TorrentTracker};
#[server(AddTorrent, "/api/server_fns")]
pub async fn add_torrent(uri: String) -> Result<(), ServerFnError> {
use crate::xmlrpc::{RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let params = vec![RpcParam::from(""), RpcParam::from(uri.as_str())];
match client.call("load.start", &params).await {
Ok(response) => {
if response.contains("faultCode") {
return Err(ServerFnError::new("rTorrent returned fault"));
}
Ok(())
}
Err(e) => Err(ServerFnError::new(format!("Failed to add torrent: {}", e))),
}
}
#[server(TorrentAction, "/api/server_fns")]
pub async fn torrent_action(hash: String, action: String) -> Result<String, ServerFnError> {
use crate::xmlrpc::{RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
if action == "delete_with_data" {
return delete_torrent_with_data_inner(&client, &hash).await;
}
let method = match action.as_str() {
"start" => "d.start",
"stop" => "d.stop",
"delete" => "d.erase",
_ => return Err(ServerFnError::new("Invalid action")),
};
let params = vec![RpcParam::from(hash.as_str())];
match client.call(method, &params).await {
Ok(_) => Ok("Action executed".to_string()),
Err(e) => Err(ServerFnError::new(format!("RPC error: {}", e))),
}
}
#[cfg(feature = "ssr")]
async fn delete_torrent_with_data_inner(
client: &crate::xmlrpc::RtorrentClient,
hash: &str,
) -> Result<String, ServerFnError> {
use crate::xmlrpc::{parse_string_response, RpcParam};
let params_hash = vec![RpcParam::from(hash)];
let path_xml = client
.call("d.base_path", &params_hash)
.await
.map_err(|e| ServerFnError::new(format!("Failed to call rTorrent: {}", e)))?;
let path = parse_string_response(&path_xml)
.map_err(|e| ServerFnError::new(format!("Failed to parse path: {}", e)))?;
let root_xml = client
.call("directory.default", &[])
.await
.map_err(|e| ServerFnError::new(format!("Failed to get download root: {}", e)))?;
let root_path_str = parse_string_response(&root_xml)
.map_err(|e| ServerFnError::new(format!("Failed to parse root path: {}", e)))?;
let root_path = tokio::fs::canonicalize(std::path::Path::new(&root_path_str))
.await
.map_err(|e| ServerFnError::new(format!("Invalid download root: {}", e)))?;
let target_path_raw = std::path::Path::new(&path);
if !tokio::fs::try_exists(target_path_raw).await.unwrap_or(false) {
client
.call("d.erase", &params_hash)
.await
.map_err(|e| ServerFnError::new(format!("Failed to erase torrent: {}", e)))?;
return Ok("Torrent removed (Data not found)".to_string());
}
let target_path = tokio::fs::canonicalize(target_path_raw)
.await
.map_err(|e| ServerFnError::new(format!("Invalid data path: {}", e)))?;
if !target_path.starts_with(&root_path) {
return Err(ServerFnError::new(
"Security Error: Cannot delete files outside download directory",
));
}
if target_path == root_path {
return Err(ServerFnError::new(
"Security Error: Cannot delete the download root directory",
));
}
client
.call("d.erase", &params_hash)
.await
.map_err(|e| ServerFnError::new(format!("Failed to erase torrent: {}", e)))?;
let delete_result = if target_path.is_dir() {
tokio::fs::remove_dir_all(&target_path).await
} else {
tokio::fs::remove_file(&target_path).await
};
match delete_result {
Ok(_) => Ok("Torrent and data deleted".to_string()),
Err(e) => Err(ServerFnError::new(format!("Failed to delete data: {}", e))),
}
}
#[server(GetFiles, "/api/server_fns")]
pub async fn get_files(hash: String) -> Result<Vec<TorrentFile>, ServerFnError> {
use crate::xmlrpc::{parse_multicall_response, RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("f.path="),
RpcParam::from("f.size_bytes="),
RpcParam::from("f.completed_chunks="),
RpcParam::from("f.priority="),
];
let xml = client
.call("f.multicall", &params)
.await
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
let rows = parse_multicall_response(&xml)
.map_err(|e| ServerFnError::new(format!("Parse error: {}", e)))?;
Ok(rows
.into_iter()
.enumerate()
.map(|(idx, row)| TorrentFile {
index: idx as u32,
path: row.get(0).cloned().unwrap_or_default(),
size: row.get(1).and_then(|s| s.parse().ok()).unwrap_or(0),
completed_chunks: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0),
priority: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
})
.collect())
}
#[server(GetPeers, "/api/server_fns")]
pub async fn get_peers(hash: String) -> Result<Vec<TorrentPeer>, ServerFnError> {
use crate::xmlrpc::{parse_multicall_response, RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("p.address="),
RpcParam::from("p.client_version="),
RpcParam::from("p.down_rate="),
RpcParam::from("p.up_rate="),
RpcParam::from("p.completed_percent="),
];
let xml = client
.call("p.multicall", &params)
.await
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
let rows = parse_multicall_response(&xml)
.map_err(|e| ServerFnError::new(format!("Parse error: {}", e)))?;
Ok(rows
.into_iter()
.map(|row| TorrentPeer {
ip: row.get(0).cloned().unwrap_or_default(),
client: row.get(1).cloned().unwrap_or_default(),
down_rate: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0),
up_rate: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
progress: row.get(4).and_then(|s| s.parse().ok()).unwrap_or(0.0),
})
.collect())
}
#[server(GetTrackers, "/api/server_fns")]
pub async fn get_trackers(hash: String) -> Result<Vec<TorrentTracker>, ServerFnError> {
use crate::xmlrpc::{parse_multicall_response, RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("t.url="),
RpcParam::from("t.activity_date_last="),
RpcParam::from("t.message="),
];
let xml = client
.call("t.multicall", &params)
.await
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
let rows = parse_multicall_response(&xml)
.map_err(|e| ServerFnError::new(format!("Parse error: {}", e)))?;
Ok(rows
.into_iter()
.map(|row| TorrentTracker {
url: row.get(0).cloned().unwrap_or_default(),
status: "Unknown".to_string(),
message: row.get(2).cloned().unwrap_or_default(),
})
.collect())
}
#[server(SetFilePriority, "/api/server_fns")]
pub async fn set_file_priority(
hash: String,
file_index: u32,
priority: u8,
) -> Result<(), ServerFnError> {
use crate::xmlrpc::{RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let target = format!("{}:f{}", hash, file_index);
let params = vec![
RpcParam::from(target.as_str()),
RpcParam::from(priority as i64),
];
client
.call("f.set_priority", &params)
.await
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
let _ = client
.call("d.update_priorities", &[RpcParam::from(hash.as_str())])
.await;
Ok(())
}
#[server(SetLabel, "/api/server_fns")]
pub async fn set_label(hash: String, label: String) -> Result<(), ServerFnError> {
use crate::xmlrpc::{RpcParam, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
let params = vec![RpcParam::from(hash.as_str()), RpcParam::from(label)];
client
.call("d.custom1.set", &params)
.await
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
Ok(())
}
#[server(GetVersion, "/api/server_fns")]
pub async fn get_version() -> Result<String, ServerFnError> {
use crate::xmlrpc::{parse_string_response, RtorrentClient};
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
match client.call("system.client_version", &[]).await {
Ok(xml) => {
let version = parse_string_response(&xml).unwrap_or(xml);
Ok(version)
}
Err(e) => Err(ServerFnError::new(format!("Failed to get version: {}", e))),
}
}

1
third_party/coarsetime/.cargo-ok vendored Normal file
View File

@@ -0,0 +1 @@
{"v":1}

View File

@@ -0,0 +1,6 @@
{
"git": {
"sha1": "831c97016aa3d8f7851999aa1deea8407e7cbd42"
},
"path_in_vcs": ""
}

View File

@@ -0,0 +1,8 @@
version: 2
updates:
- package-ecosystem: cargo
directory: "/"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10

View File

@@ -0,0 +1,17 @@
name: Close inactive issues
on:
schedule:
- cron: "30 1 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
repo-token: ${{ secrets.GITHUB_TOKEN }}

4
third_party/coarsetime/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
target
Cargo.lock
.vscode
zig-cache

82
third_party/coarsetime/Cargo.toml vendored Normal file
View File

@@ -0,0 +1,82 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2018"
name = "coarsetime"
version = "0.1.37"
authors = ["Frank Denis <github@pureftpd.org>"]
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Time and duration crate optimized for speed"
homepage = "https://github.com/jedisct1/rust-coarsetime"
readme = "README.md"
keywords = [
"time",
"date",
"duration",
]
categories = [
"concurrency",
"date-and-time",
"os",
]
license = "BSD-2-Clause"
repository = "https://github.com/jedisct1/rust-coarsetime"
[features]
wasi-abi2 = ["dep:wasi-abi2"]
[lib]
name = "coarsetime"
path = "src/lib.rs"
[[bench]]
name = "benchmark"
path = "benches/benchmark.rs"
harness = false
[dev-dependencies.benchmark-simple]
version = "0.1.10"
[dependencies.portable-atomic]
version = "1.6"
[target.'cfg(all(any(target_arch = "wasm32", target_arch = "wasm64"), target_os = "unknown"))'.dependencies.wasm-bindgen]
version = "0.2"
[target.'cfg(any(target_os = "wasix", target_os = "wasi"))'.dependencies.wasix]
version = "0.13"
[target.'cfg(not(any(target_os = "wasix", target_os = "wasi")))'.dependencies.libc]
version = "0.2"
[target.'cfg(target_os = "wasi")'.dependencies.wasi-abi2]
version = "0.14.7"
optional = true
package = "wasi"
[profile.bench]
codegen-units = 1
[profile.dev]
overflow-checks = true
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
panic = "abort"
incremental = false

47
third_party/coarsetime/Cargo.toml.orig generated vendored Normal file
View File

@@ -0,0 +1,47 @@
[package]
name = "coarsetime"
version = "0.1.37"
description = "Time and duration crate optimized for speed"
authors = ["Frank Denis <github@pureftpd.org>"]
keywords = ["time", "date", "duration"]
readme = "README.md"
license = "BSD-2-Clause"
homepage = "https://github.com/jedisct1/rust-coarsetime"
repository = "https://github.com/jedisct1/rust-coarsetime"
categories = ["concurrency", "date-and-time", "os"]
edition = "2018"
[features]
wasi-abi2 = ["dep:wasi-abi2"]
[target.'cfg(not(any(target_os = "wasix", target_os = "wasi")))'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "wasi")'.dependencies]
wasi-abi2 = { package = "wasi", version = "0.14.7", optional = true }
[target.'cfg(any(target_os = "wasix", target_os = "wasi"))'.dependencies]
wasix = "0.13"
[target.'cfg(all(any(target_arch = "wasm32", target_arch = "wasm64"), target_os = "unknown"))'.dependencies]
wasm-bindgen = "0.2"
[dev-dependencies]
benchmark-simple = "0.1.10"
[profile.bench]
codegen-units = 1
[[bench]]
name = "benchmark"
harness = false
[profile.release]
lto = true
panic = "abort"
opt-level = 3
codegen-units = 1
incremental = false
[profile.dev]
overflow-checks=true

25
third_party/coarsetime/LICENSE vendored Normal file
View File

@@ -0,0 +1,25 @@
BSD 2-Clause License
Copyright (c) 2016-2026, Frank Denis
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

90
third_party/coarsetime/README.md vendored Normal file
View File

@@ -0,0 +1,90 @@
[![Documentation](https://docs.rs/coarsetime/badge.svg)](https://docs.rs/coarsetime)
[![Windows build status](https://ci.appveyor.com/api/projects/status/xlbhk9850dvl5ylh?svg=true)](https://ci.appveyor.com/project/jedisct1/rust-coarsetime)
# coarsetime
A Rust crate to make time measurements, that focuses on speed, API stability and portability.
This crate is a partial replacement for the `Time` and `Duration` structures
from the standard library, with the following differences:
* Speed is privileged over accuracy. In particular, `CLOCK_MONOTONIC_COARSE` is
used to retrieve the clock value on Linux systems, and transformations avoid
operations that can be slow on non-Intel systems.
* The number of system calls can be kept to a minimum. The "most recent
timestamp" is always kept in memory. It can be read with just a load operation,
and can be updated only as frequently as necessary.
* The API is stable, and the same for all platforms. Unlike the standard library, it doesn't silently compile functions that do nothing but panic at runtime on some platforms.
# Installation
`coarsetime` is available on [crates.io](https://crates.io/crates/coarsetime)
and works on Rust stable, beta, and nightly.
Windows and Unix-like systems are supported.
Available feature:
* `wasi-abi2`: when targeting WASI, use the second preview of the ABI. Default is to use the regular WASI-core ABI.
# Documentation
[API documentation](https://docs.rs/coarsetime)
# Example
```rust
extern crate coarsetime;
use coarsetime::{Duration, Instant, Updater};
// Get the current instant. This may require a system call, but it may also
// be faster than the stdlib equivalent.
let now = Instant::now();
// Get the latest known instant. This operation is super fast.
// In this case, the value will be identical to `now`, because we haven't
// updated the latest known instant yet.
let ts1 = Instant::recent();
// Update the latest known instant. This may require a system call.
// Note that a call to `Instant::now()` also updates the stored instant.
Instant::update();
// Now, we may get a different instant. This call is also super fast.
let ts2 = Instant::recent();
// Compute the time elapsed between ts2 and ts1.
let elapsed_ts2_ts1 = ts2.duration_since(ts1);
// Operations such as `+` and `-` between `Instant` and `Duration` are also
// available.
let elapsed_ts2_ts1 = ts2 - ts1;
// Returns the time elapsed since ts1.
// This retrieves the actual current time, and may require a system call.
let elapsed_since_ts1 = ts1.elapsed();
// Returns the approximate time elapsed since ts1.
// This uses the latest known instant, and is super fast.
let elapsed_since_recent = ts1.elapsed_since_recent();
// Instant::update() should be called periodically, for example using an
// event loop. Alternatively, the crate provides an easy way to spawn a
// background task that will periodically update the latest known instant.
// Here, the update will happen every 250ms.
let updater = Updater::new(250).start().unwrap();
// From now on, Instant::recent() will always return an approximation of the
// current instant.
let ts3 = Instant::recent();
// Stop the task.
updater.stop().unwrap();
// Returns the elapsed time since the UNIX epoch
let unix_timestamp = Clock::now_since_epoch();
// Returns an approximation of the elapsed time since the UNIX epoch, based on
// the latest time update
let unix_timestamp_approx = Clock::recent_since_epoch();
```

View File

@@ -0,0 +1,61 @@
use benchmark_simple::*;
use coarsetime::*;
use std::time;
fn main() {
let options = &Options {
iterations: 250_000,
warmup_iterations: 25_000,
min_samples: 5,
max_samples: 10,
max_rsd: 1.0,
..Default::default()
};
bench_coarsetime_now(options);
bench_coarsetime_recent(options);
bench_coarsetime_elapsed(options);
bench_coarsetime_elapsed_since_recent(options);
bench_stdlib_now(options);
bench_stdlib_elapsed(options);
}
fn bench_coarsetime_now(options: &Options) {
let b = Bench::new();
Instant::update();
let res = b.run(options, Instant::now);
println!("coarsetime_now(): {}", res.throughput(1));
}
fn bench_coarsetime_recent(options: &Options) {
let b = Bench::new();
Instant::update();
let res = b.run(options, Instant::recent);
println!("coarsetime_recent(): {}", res.throughput(1));
}
fn bench_coarsetime_elapsed(options: &Options) {
let b = Bench::new();
let ts = Instant::now();
let res = b.run(options, || ts.elapsed());
println!("coarsetime_elapsed(): {}", res.throughput(1));
}
fn bench_coarsetime_elapsed_since_recent(options: &Options) {
let b = Bench::new();
let ts = Instant::now();
let res = b.run(options, || ts.elapsed_since_recent());
println!("coarsetime_since_recent(): {}", res.throughput(1));
}
fn bench_stdlib_now(options: &Options) {
let b = Bench::new();
let res = b.run(options, time::Instant::now);
println!("stdlib_now(): {}", res.throughput(1));
}
fn bench_stdlib_elapsed(options: &Options) {
let b = Bench::new();
let ts = time::Instant::now();
let res = b.run(options, || ts.elapsed());
println!("stdlib_elapsed(): {}", res.throughput(1));
}

View File

@@ -4,6 +4,9 @@
)))] )))]
use std::time; use std::time;
#[cfg(target_has_atomic = "64")]
use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(not(target_has_atomic = "64"))]
use portable_atomic::{AtomicU64, Ordering}; use portable_atomic::{AtomicU64, Ordering};
use super::Duration; use super::Duration;

View File

@@ -3,6 +3,9 @@ use std::mem::MaybeUninit;
use std::ops::*; use std::ops::*;
#[allow(unused_imports)] #[allow(unused_imports)]
use std::ptr::*; use std::ptr::*;
#[cfg(target_has_atomic = "64")]
use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(not(target_has_atomic = "64"))]
use portable_atomic::{AtomicU64, Ordering}; use portable_atomic::{AtomicU64, Ordering};
use super::duration::*; use super::duration::*;

55
third_party/coarsetime/src/tests.rs vendored Normal file
View File

@@ -0,0 +1,55 @@
use std::thread::sleep;
use std::time;
#[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))]
use super::Updater;
use super::{Clock, Duration, Instant};
#[test]
fn tests() {
let ts = Instant::now();
let d = Duration::from_secs(2);
sleep(time::Duration::new(3, 0));
let elapsed = ts.elapsed().as_secs();
println!("Elapsed: {elapsed} secs");
assert!(elapsed >= 2);
assert!(elapsed < 100);
assert!(ts.elapsed_since_recent() > d);
let ts = Instant::now();
sleep(time::Duration::new(1, 0));
assert_eq!(Instant::recent(), ts);
Instant::update();
assert!(Instant::recent() > ts);
let clock_now = Clock::recent_since_epoch();
sleep(time::Duration::new(1, 0));
assert_eq!(Clock::recent_since_epoch(), clock_now);
assert!(Clock::now_since_epoch() > clock_now);
#[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))]
tests_updater();
}
#[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))]
#[test]
fn tests_updater() {
let updater = Updater::new(250)
.start()
.expect("Unable to start a background updater");
let ts = Instant::recent();
let clock_recent = Clock::recent_since_epoch();
sleep(time::Duration::new(2, 0));
assert!(Clock::recent_since_epoch() > clock_recent);
assert!(Instant::recent() != ts);
updater.stop().unwrap();
let clock_recent = Clock::recent_since_epoch();
sleep(time::Duration::new(1, 0));
assert_eq!(Clock::recent_since_epoch(), clock_recent);
}
#[test]
fn tests_duration() {
let duration = Duration::from_days(1000);
assert_eq!(duration.as_days(), 1000);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.