Compare commits

..

39 Commits

Author SHA1 Message Date
spinline
71456ff4d1 fix: mobil detay paneli tam ekran genişliğine alındı
All checks were successful
Build MIPS Binary / build (push) Successful in 2m3s
2026-02-21 22:03:09 +03:00
spinline
1a3099d926 fix: mobil detay paneli sağdan açılacak şekilde güncellendi
All checks were successful
Build MIPS Binary / build (push) Successful in 2m0s
2026-02-21 21:58:50 +03:00
spinline
4ef4ee8d45 feat: sheet yerine sayfaya sabit inline detay paneli eklendi
All checks were successful
Build MIPS Binary / build (push) Successful in 2m3s
2026-02-21 21:48:18 +03:00
spinline
253067b417 chore(frontend): remove debug console.log calls from app initialization
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 21:19:44 +03:00
spinline
8d5edc659f fix(frontend): use proper link tag for Cargo.toml instead of script tag to prevent syntax error
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 21:09:07 +03:00
spinline
c122290f37 build(frontend): add post_build hook to strip modulepreload tags preventing Safari warnings
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 21:03:48 +03:00
spinline
999cef34a7 fix(frontend): remove fake ScrollBar and use native styled webkit scrollbar in ScrollArea
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 20:51:27 +03:00
spinline
93a43d1b38 feat(frontend): implement rust-ui ScrollArea for details tab scrolling
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 20:44:20 +03:00
spinline
91ca6ff96f fix(frontend): adjust flex shrink and min heights for safe mobile scrolling in details tab
All checks were successful
Build MIPS Binary / build (push) Successful in 2m0s
2026-02-21 20:36:39 +03:00
spinline
8c0d35cca5 fix(frontend): force absolute positioning for tabs scroll
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 20:31:49 +03:00
spinline
444ea4326d fix(frontend): make general tab scrollable on mobile/safari
All checks were successful
Build MIPS Binary / build (push) Successful in 2m2s
2026-02-21 20:22:18 +03:00
spinline
401ccb69b2 fix(backend): Support TCP SCGI sockets and remove verbose unstandardized tracker fields
All checks were successful
Build MIPS Binary / build (push) Successful in 2m4s
2026-02-21 20:00:00 +03:00
spinline
9cfea2aed5 feat: implement trackers tab with details
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 16:58:14 +03:00
spinline
ce212cb2d6 feat: enhance torrent details general tab
All checks were successful
Build MIPS Binary / build (push) Successful in 1m57s
2026-02-21 16:33:10 +03:00
spinline
851d79029a feat(ui): add mobile long-press support for context menus
All checks were successful
Build MIPS Binary / build (push) Successful in 1m58s
2026-02-21 01:46:21 +03:00
spinline
ab27cf3eb4 fix: use f.priority.set instead of deprecated f.set_priority for rTorrent API
All checks were successful
Build MIPS Binary / build (push) Successful in 2m0s
2026-02-21 01:42:07 +03:00
spinline
7b4c9ff336 fix(files): trigger refetch only after set_priority completes on server
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 01:36:21 +03:00
spinline
743596d701 fix(ci): remove tracing calls that caused compile error in shared crate
All checks were successful
Build MIPS Binary / build (push) Successful in 1m58s
2026-02-21 01:31:25 +03:00
spinline
598f038ea6 fix(files): use refetch callback instead of Action to avoid reactive disposal panic
Some checks failed
Build MIPS Binary / build (push) Failing after 1m10s
2026-02-21 01:29:26 +03:00
spinline
7f8c721115 debug: add tracing logs to set_file_priority for diagnosis
Some checks failed
Build MIPS Binary / build (push) Failing after 1m10s
2026-02-21 01:27:22 +03:00
spinline
ba7f1ffd91 fix(ui): adjust context menu position for CSS transformed containers
All checks were successful
Build MIPS Binary / build (push) Successful in 1m59s
2026-02-21 01:22:31 +03:00
spinline
daa24dd7ec fix(ui): refactor FileContextMenu to auticlose and match homepage
All checks were successful
Build MIPS Binary / build (push) Successful in 1m59s
2026-02-21 01:13:57 +03:00
spinline
45271b5060 fix: add missing files.rs and resolve Show type inference
All checks were successful
Build MIPS Binary / build (push) Successful in 2m0s
2026-02-21 01:05:58 +03:00
spinline
4d02bc655d fix: resolve E0282 type inference on CI and clear warnings
Some checks failed
Build MIPS Binary / build (push) Failing after 35s
2026-02-21 01:04:00 +03:00
spinline
3c2ba477f5 feat(ui): add files tab and priority context menu
Some checks failed
Build MIPS Binary / build (push) Failing after 36s
2026-02-21 00:58:35 +03:00
spinline
6106d1cd22 fix(ui): prevent selection checkboxes from opening torrent details sheet
All checks were successful
Build MIPS Binary / build (push) Successful in 1m56s
2026-02-21 00:50:26 +03:00
spinline
50b83ebacf chore(ui): remove debugging logs for sse connection
All checks were successful
Build MIPS Binary / build (push) Successful in 1m56s
2026-02-21 00:44:50 +03:00
spinline
566308d889 feat(backend): require rTorrent to be running for backend to start
All checks were successful
Build MIPS Binary / build (push) Successful in 1m54s
2026-02-21 00:42:42 +03:00
spinline
e878d1fe33 chore(ui): add debug logs for SSE connection lifecycle
All checks were successful
Build MIPS Binary / build (push) Successful in 1m56s
2026-02-21 00:29:27 +03:00
spinline
d88084fb9a fix(ui): fix service worker crashes for chrome extensions and bump cache version
All checks were successful
Build MIPS Binary / build (push) Successful in 1m57s
2026-02-21 00:25:28 +03:00
spinline
f8639f2967 chore(ui): add debug logs for SSE deserialization errors
All checks were successful
Build MIPS Binary / build (push) Successful in 1m56s
2026-02-21 00:23:23 +03:00
spinline
7129c9a8eb fix(ui): prevent panic on unwrap when selected torrent is None
All checks were successful
Build MIPS Binary / build (push) Successful in 1m57s
2026-02-21 00:19:14 +03:00
spinline
91202e7cf8 refactor(ui): wrap torrent details content with official RustUI Shimmer
All checks were successful
Build MIPS Binary / build (push) Successful in 2m7s
2026-02-21 00:02:27 +03:00
spinline
3c2fec8b8c feat(ui): use official rustui shimmer component for torrent details 2026-02-20 23:57:59 +03:00
spinline
ec23285a6a feat(ui): add shimmer component and integrate into torrent details 2026-02-20 23:53:37 +03:00
spinline
f075a87668 feat(ui): add bottom sheet and tabs for torrent details 2026-02-20 23:50:23 +03:00
spinline
3ce980239c fix: ensure context menu closes after triggering start or stop actions
All checks were successful
Build MIPS Binary / build (push) Successful in 1m51s
2026-02-14 01:25:47 +03:00
spinline
d00fc41010 style: update CSS configuration and theme variables
All checks were successful
Build MIPS Binary / build (push) Successful in 1m55s
2026-02-13 20:11:02 +03:00
spinline
0636020a86 chore: re-add Leptos metadata to Cargo.toml for ui-cli compatibility
All checks were successful
Build MIPS Binary / build (push) Successful in 1m52s
2026-02-13 20:03:01 +03:00
38 changed files with 5197 additions and 4932 deletions

View File

@@ -0,0 +1,83 @@
use shared::xmlrpc::{RtorrentClient, RpcParam, parse_multicall_response};
#[tokio::main]
async fn main() {
let mut client = None;
for path in ["127.0.0.1:8000", "0.0.0.0:8000", "localhost:8000"] {
let test_client = RtorrentClient::new(path);
match test_client.call("system.client_version", &[]).await {
Ok(res) => {
println!("SUCCESS: Connected to rTorrent at {} (Version: {})", path, res);
client = Some(test_client);
break;
}
Err(_) => {
// println!("Failed to connect to {}", path);
}
}
}
let client = match client {
Some(c) => c,
None => {
println!("Could not connect to rTorrent on port 8000.");
return;
}
};
let mut hash = String::new();
match client.call("d.multicall2", &[RpcParam::from(""), RpcParam::from("main"), RpcParam::from("d.hash=")]).await {
Ok(xml) => {
if let Ok(rows) = parse_multicall_response(&xml) {
if let Some(row) = rows.first() {
if let Some(h) = row.first() {
hash = h.clone();
println!("Using torrent hash: {}", hash);
}
}
}
},
Err(e) => {
println!("Error getting torrents: {:?}", e);
return;
},
}
if hash.is_empty() {
println!("No torrents found to test trackers.");
return;
}
// Now test Tracker fields one by one to see which one is failing
let fields = vec![
"t.url=",
"t.is_enabled=",
"t.group=",
"t.scrape_complete=",
"t.scrape_incomplete=",
"t.scrape_downloaded=",
"t.activity_date_last=",
"t.normal_interval=",
"t.message=",
];
for field in &fields {
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from(*field),
];
print!("Testing field {:<22} : ", field);
match client.call("t.multicall", &params).await {
Ok(xml) => {
if xml.contains("faultCode") {
println!("FAILED");
} else {
println!("SUCCESS");
}
},
Err(e) => println!("ERROR: {:?}", e),
}
}
}

View File

@@ -1,4 +1,3 @@
mod components;
mod diff; mod diff;
mod handlers; mod handlers;
#[cfg(feature = "push-notifications")] #[cfg(feature = "push-notifications")]
@@ -244,12 +243,14 @@ async fn main() {
let socket_path = std::path::Path::new(&args.socket); let socket_path = std::path::Path::new(&args.socket);
if !socket_path.exists() { if !socket_path.exists() {
tracing::error!("CRITICAL: rTorrent socket not found at {:?}.", socket_path); tracing::error!("CRITICAL: rTorrent socket not found at {:?}.", socket_path);
tracing::warn!( tracing::error!(
"HINT: Make sure rTorrent is running and the SCGI socket is enabled in .rtorrent.rc" "HINT: Make sure rTorrent is running and the SCGI socket is enabled in .rtorrent.rc"
); );
tracing::warn!( tracing::error!(
"HINT: You can configure the socket path via --socket ARG or RTORRENT_SOCKET ENV." "HINT: You can configure the socket path via --socket ARG or RTORRENT_SOCKET ENV."
); );
tracing::error!("FATAL: VibeTorrent cannot start without a running rTorrent instance. Exiting.");
std::process::exit(1);
} else { } else {
tracing::info!("Socket file exists. Testing connection..."); tracing::info!("Socket file exists. Testing connection...");
let client = xmlrpc::RtorrentClient::new(&args.socket); let client = xmlrpc::RtorrentClient::new(&args.socket);
@@ -260,7 +261,11 @@ async fn main() {
let version = xmlrpc::parse_string_response(&xml).unwrap_or(xml); let version = xmlrpc::parse_string_response(&xml).unwrap_or(xml);
tracing::info!("Connected to rTorrent successfully. Version: {}", version); tracing::info!("Connected to rTorrent successfully. Version: {}", version);
} }
Err(e) => tracing::error!("Socket exists but failed to connect to rTorrent: {}", e), Err(e) => {
tracing::error!("CRITICAL: Socket exists but failed to connect to rTorrent: {}", e);
tracing::error!("FATAL: Ensure rTorrent is fully started and the socket has correct permissions. Exiting.");
std::process::exit(1);
}
} }
} }

View File

@@ -51,6 +51,21 @@ mod fields {
pub const IDX_LABEL: usize = 12; pub const IDX_LABEL: usize = 12;
pub const CMD_LABEL: &str = "d.custom1="; pub const CMD_LABEL: &str = "d.custom1=";
pub const IDX_RATIO: usize = 13;
pub const CMD_RATIO: &str = "d.ratio=";
pub const IDX_UPLOADED: usize = 14;
pub const CMD_UPLOADED: &str = "d.up.total=";
pub const IDX_WASTED: usize = 15;
pub const CMD_WASTED: &str = "d.skip.total=";
pub const IDX_SAVE_PATH: usize = 16;
pub const CMD_SAVE_PATH: &str = "d.base_path=";
pub const IDX_FREE_DISK: usize = 17;
pub const CMD_FREE_DISK: &str = "d.free_diskspace=";
} }
use fields::*; use fields::*;
@@ -72,6 +87,11 @@ const RTORRENT_FIELDS: &[&str] = &[
CMD_CREATION_DATE, CMD_CREATION_DATE,
CMD_HASHING, CMD_HASHING,
CMD_LABEL, CMD_LABEL,
CMD_RATIO,
CMD_UPLOADED,
CMD_WASTED,
CMD_SAVE_PATH,
CMD_FREE_DISK,
]; ];
fn parse_long(s: Option<&String>) -> i64 { fn parse_long(s: Option<&String>) -> i64 {
@@ -98,6 +118,11 @@ fn from_rtorrent_row(row: Vec<String>) -> Torrent {
let added_date = parse_long(row.get(IDX_CREATION_DATE)); let added_date = parse_long(row.get(IDX_CREATION_DATE));
let is_hashing = parse_long(row.get(IDX_HASHING)); let is_hashing = parse_long(row.get(IDX_HASHING));
let label_raw = parse_string(row.get(IDX_LABEL)); let label_raw = parse_string(row.get(IDX_LABEL));
let ratio = parse_long(row.get(IDX_RATIO)) as f64 / 1000.0;
let uploaded = parse_long(row.get(IDX_UPLOADED));
let wasted = parse_long(row.get(IDX_WASTED));
let save_path = parse_string(row.get(IDX_SAVE_PATH));
let free_disk_space = parse_long(row.get(IDX_FREE_DISK));
let label = if label_raw.is_empty() { let label = if label_raw.is_empty() {
None None
@@ -144,6 +169,11 @@ fn from_rtorrent_row(row: Vec<String>) -> Torrent {
error_message: message, error_message: message,
added_date, added_date,
label, label,
ratio,
uploaded,
wasted,
save_path,
free_disk_space,
} }
} }

View File

@@ -7,6 +7,11 @@ stage = "build"
command = "sh" command = "sh"
command_arguments = ["-c", "npx @tailwindcss/cli -i input.css -o public/tailwind.css"] command_arguments = ["-c", "npx @tailwindcss/cli -i input.css -o public/tailwind.css"]
[[hooks]]
stage = "post_build"
command = "sh"
command_arguments = ["-c", "sed -i '' -e 's/<link rel=\"modulepreload\"[^>]*>//g' -e 's/<link rel=\"preload\"[^>]*>//g' \"$TRUNK_STAGING_DIR/index.html\" || sed -i -e 's/<link rel=\"modulepreload\"[^>]*>//g' -e 's/<link rel=\"preload\"[^>]*>//g' \"$TRUNK_STAGING_DIR/index.html\""]
[build] [build]
target = "index.html" target = "index.html"
dist = "dist" dist = "dist"

View File

@@ -20,7 +20,7 @@
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" /> <link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
<!-- Trunk Assets --> <!-- Trunk Assets -->
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="0" data-preload="false" /> <link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="0" />
<link data-trunk rel="css" href="public/tailwind.css" /> <link data-trunk rel="css" href="public/tailwind.css" />
<link data-trunk rel="copy-file" href="manifest.json" /> <link data-trunk rel="copy-file" href="manifest.json" />
<link data-trunk rel="copy-file" href="icon-192.png" /> <link data-trunk rel="copy-file" href="icon-192.png" />

View File

@@ -1,6 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0); --background: oklch(1 0 0);
@@ -45,6 +46,14 @@
} }
@theme inline { @theme inline {
--animate-shimmer: shimmer 2s infinite;
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
@@ -74,16 +83,17 @@
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
button:not(:disabled), button:not(:disabled),
[role="button"]:not(:disabled) { [role="button"]:not(:disabled) {
@apply cursor-pointer; cursor: pointer;
} }
dialog { dialog {
@apply m-auto; margin: auto;
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -49,14 +49,10 @@ fn InnerApp() -> impl IntoView {
Effect::new(move |_| { Effect::new(move |_| {
spawn_local(async move { spawn_local(async move {
log::info!("App initialization started...");
gloo_console::log!("APP INIT: Checking setup status...");
// Check if setup is needed via Server Function // Check if setup is needed via Server Function
match shared::server_fns::auth::get_setup_status().await { match shared::server_fns::auth::get_setup_status().await {
Ok(status) => { Ok(status) => {
if !status.completed { if !status.completed {
log::info!("Setup not completed");
needs_setup.1.set(true); needs_setup.1.set(true);
is_loading.1.set(false); is_loading.1.set(false);
return; return;
@@ -68,15 +64,12 @@ fn InnerApp() -> impl IntoView {
// Check authentication via GetUser Server Function // Check authentication via GetUser Server Function
match shared::server_fns::auth::get_user().await { match shared::server_fns::auth::get_user().await {
Ok(Some(user_info)) => { Ok(Some(user_info)) => {
log::info!("Authenticated as {}", user_info.username);
if let Some(s) = store { if let Some(s) = store {
s.user.set(Some(user_info.username)); s.user.set(Some(user_info.username));
} }
is_authenticated.1.set(true); is_authenticated.1.set(true);
} }
Ok(None) => { Ok(None) => {}
log::info!("Not authenticated");
}
Err(e) => { Err(e) => {
log::error!("Auth check failed: {:?}", e); log::error!("Auth check failed: {:?}", e);
} }
@@ -111,7 +104,6 @@ fn InnerApp() -> impl IntoView {
let navigate = use_navigate(); let navigate = use_navigate();
navigate("/setup", Default::default()); navigate("/setup", Default::default());
} else if authenticated { } else if authenticated {
log::info!("Already authenticated, redirecting to home");
let navigate = use_navigate(); let navigate = use_navigate();
navigate("/", Default::default()); navigate("/", Default::default());
} }
@@ -135,10 +127,8 @@ fn InnerApp() -> impl IntoView {
Effect::new(move |_| { Effect::new(move |_| {
if !is_loading.0.get() { if !is_loading.0.get() {
if needs_setup.0.get() { if needs_setup.0.get() {
log::info!("Setup not completed, redirecting to setup");
navigate("/setup", Default::default()); navigate("/setup", Default::default());
} else if !is_authenticated.0.get() { } else if !is_authenticated.0.get() {
log::info!("Not authenticated, redirecting to login");
navigate("/login", Default::default()); navigate("/login", Default::default());
} }
} }

View File

@@ -1,34 +1,24 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use crate::components::ui::card::{Card, CardHeader, CardContent}; use crate::components::ui::card::{Card, CardHeader, CardContent};
use crate::components::ui::auto_form::{AutoForm, AutoFormField}; use crate::components::ui::input::{Input, InputType};
use crate::components::ui::button::Button;
#[component] #[component]
pub fn Login() -> impl IntoView { pub fn Login() -> impl IntoView {
let username = RwSignal::new(String::new());
let password = RwSignal::new(String::new());
let error = signal(Option::<String>::None); let error = signal(Option::<String>::None);
let loading = signal(false); let loading = signal(false);
let fields = vec![ let handle_login = move |ev: web_sys::SubmitEvent| {
AutoFormField::Text { ev.prevent_default();
name: "username".to_string(),
label: "Kullanıcı Adı".to_string(),
placeholder: Some("Kullanıcı adınız".to_string()),
required: true,
},
AutoFormField::Password {
name: "password".to_string(),
label: "Şifre".to_string(),
placeholder: Some("******".to_string()),
required: true,
},
];
let on_submit = move |data: std::collections::HashMap<String, String>| {
loading.1.set(true); loading.1.set(true);
error.1.set(None); error.1.set(None);
let user = data.get("username").cloned().unwrap_or_default(); let user = username.get();
let pass = data.get("password").cloned().unwrap_or_default(); let pass = password.get();
spawn_local(async move { spawn_local(async move {
match shared::server_fns::auth::login(user, pass).await { match shared::server_fns::auth::login(user, pass).await {
@@ -59,18 +49,45 @@ pub fn Login() -> impl IntoView {
</CardHeader> </CardHeader>
<CardContent class="pt-4"> <CardContent class="pt-4">
<AutoForm <form on:submit=handle_login class="space-y-4">
fields=fields <div class="space-y-2">
submit_label="Giriş Yap" <label class="text-sm font-medium leading-none">"Kullanıcı Adı"</label>
on_submit=on_submit <Input
loading=loading.0.into() r#type=InputType::Text
placeholder="Kullanıcı adınız"
bind_value=username
disabled=loading.0.get()
/> />
</div>
<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>
<Show when=move || error.0.get().is_some()> <Show when=move || error.0.get().is_some()>
<div class="mt-4 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{move || error.0.get().unwrap_or_default()} {move || error.0.get().unwrap_or_default()}
</div> </div>
</Show> </Show>
<div class="pt-2">
<Button
class="w-full"
attr:r#type="submit"
attr:disabled=move || loading.0.get()
>
<Show when=move || loading.0.get() fallback=|| view! { "Giriş Yap" }.into_any()>
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
"Giriş Yapılıyor..."
</Show>
</Button>
</div>
</form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -1,38 +1,23 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use crate::components::ui::card::{Card, CardHeader, CardContent}; use crate::components::ui::card::{Card, CardHeader, CardContent};
use crate::components::ui::auto_form::{AutoForm, AutoFormField}; use crate::components::ui::input::{Input, InputType};
use crate::components::ui::button::Button;
#[component] #[component]
pub fn Setup() -> impl IntoView { pub fn Setup() -> impl IntoView {
let username = RwSignal::new(String::new());
let password = RwSignal::new(String::new());
let confirm_password = RwSignal::new(String::new());
let error = signal(Option::<String>::None); let error = signal(Option::<String>::None);
let loading = signal(false); let loading = signal(false);
let fields = vec![ let handle_setup = move |ev: web_sys::SubmitEvent| {
AutoFormField::Text { ev.prevent_default();
name: "username".to_string(),
label: "Yönetici Kullanıcı Adı".to_string(),
placeholder: Some("admin".to_string()),
required: true,
},
AutoFormField::Password {
name: "password".to_string(),
label: "Şifre".to_string(),
placeholder: Some("******".to_string()),
required: true,
},
AutoFormField::Password {
name: "confirm_password".to_string(),
label: "Şifre Onay".to_string(),
placeholder: Some("******".to_string()),
required: true,
},
];
let on_submit = move |data: std::collections::HashMap<String, String>| { let pass = password.get();
let user = data.get("username").cloned().unwrap_or_default(); let confirm = confirm_password.get();
let pass = data.get("password").cloned().unwrap_or_default();
let confirm = data.get("confirm_password").cloned().unwrap_or_default();
if pass != confirm { if pass != confirm {
error.1.set(Some("Şifreler eşleşmiyor".to_string())); error.1.set(Some("Şifreler eşleşmiyor".to_string()));
@@ -47,6 +32,8 @@ pub fn Setup() -> impl IntoView {
loading.1.set(true); loading.1.set(true);
error.1.set(None); 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 { match shared::server_fns::auth::setup(user, pass).await {
Ok(_) => { Ok(_) => {
@@ -77,18 +64,54 @@ pub fn Setup() -> impl IntoView {
</CardHeader> </CardHeader>
<CardContent class="pt-4"> <CardContent class="pt-4">
<AutoForm <form on:submit=handle_setup class="space-y-4">
fields=fields <div class="space-y-2">
submit_label="Kurulumu Tamamla" <label class="text-sm font-medium leading-none">"Yönetici Kullanıcı Adı"</label>
on_submit=on_submit <Input
loading=loading.0.into() r#type=InputType::Text
placeholder="admin"
bind_value=username
disabled=loading.0.get()
/> />
</div>
<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 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>
<Show when=move || error.0.get().is_some() fallback=|| ()> <Show when=move || error.0.get().is_some() fallback=|| ()>
<div class="mt-4 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<span>{move || error.0.get().unwrap_or_default()}</span> <span>{move || error.0.get().unwrap_or_default()}</span>
</div> </div>
</Show> </Show>
<div class="pt-2">
<Button
class="w-full"
attr:r#type="submit"
attr:disabled=move || loading.0.get()
>
<Show when=move || loading.0.get() fallback=|| view! { "Kurulumu Tamamla" }.into_any()>
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
"Kuruluyor..."
</Show>
</Button>
</div>
</form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -24,10 +24,16 @@ pub fn TorrentContextMenu(
{children()} {children()}
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent class="w-56 p-1.5"> <ContextMenuContent class="w-56 p-1.5">
<ContextMenuItem on:click={let h = hash_c1; move |_| on_action_stored.get_value().run(("start".to_string(), h.clone()))}> <ContextMenuItem on:click={let h = hash_c1; move |_| {
on_action_stored.get_value().run(("start".to_string(), h.clone()));
crate::components::ui::context_menu::close_context_menu();
}}>
"Başlat" "Başlat"
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem on:click={let h = hash_c2; move |_| on_action_stored.get_value().run(("stop".to_string(), h.clone()))}> <ContextMenuItem on:click={let h = hash_c2; move |_| {
on_action_stored.get_value().run(("stop".to_string(), h.clone()));
crate::components::ui::context_menu::close_context_menu();
}}>
"Durdur" "Durdur"
</ContextMenuItem> </ContextMenuItem>

View File

@@ -0,0 +1,91 @@
use leptos::prelude::*;
use leptos::task::spawn_local;
use serde::{Deserialize, Serialize};
use crate::components::ui::button::{Button, ButtonVariant};
use crate::components::ui::card::{Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle};
use crate::components::ui::shimmer::Shimmer;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardData {
pub title: String,
pub description: String,
}
/// Simulates a database fetch with 1 second delay
#[server]
pub async fn fetch_card_data() -> Result<CardData, ServerFnError> {
// Simulate network/database latency (only on server)
#[cfg(feature = "ssr")]
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(CardData {
title: "Fetched Title".to_string(),
description: "This content was fetched from the server after a 1 second simulated delay. The shimmer effect automatically showed during the loading period.".to_string(),
})
}
#[component]
pub fn DemoShimmer() -> impl IntoView {
// Loading state
let loading = RwSignal::new(false);
// Store fetched data
let card_data = RwSignal::new(None::<CardData>);
// Fetch handler using spawn_local for reliable repeated calls
let on_fetch = move |_| {
spawn_local(async move {
loading.set(true);
let result = fetch_card_data().await;
if let Ok(data) = result {
card_data.set(Some(data));
}
loading.set(false);
});
};
view! {
<div class="flex flex-col gap-4">
<div class="flex gap-2">
<Button variant=ButtonVariant::Outline on:click=move |_| loading.set(!loading.get())>
"Toggle Loading"
</Button>
<Button variant=ButtonVariant::Default on:click=on_fetch>
"Fetch Data (1s)"
</Button>
</div>
<Shimmer loading=Signal::from(loading)>
<Card class="max-w-lg lg:max-w-2xl">
<CardHeader>
<CardTitle>
{move || {
card_data.get().map(|data| data.title).unwrap_or_else(|| "Card Title".to_string())
}}
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription>
{move || {
card_data
.get()
.map(|data| data.description)
.unwrap_or_else(|| {
"Click 'Toggle Loading' for manual control, or 'Fetch Data' to simulate a real server call with 1 second delay."
.to_string()
})
}}
</CardDescription>
</CardContent>
<CardFooter class="justify-end">
<Button variant=ButtonVariant::Outline>"Cancel"</Button>
<Button>"Confirm"</Button>
</CardFooter>
</Card>
</Shimmer>
</div>
}
}

View File

@@ -0,0 +1 @@
pub mod demo_shimmer;

View File

@@ -1,5 +1,31 @@
use leptos::prelude::*; use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicUsize, Ordering};
pub fn use_random_id_for(prefix: &str) -> String { const PREFIX: &str = "rust_ui"; // Must NOT contain "/" or "-"
format!("{}_{}", prefix, js_sys::Math::random().to_string().replace(".", ""))
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

@@ -5,3 +5,4 @@ pub mod torrent;
pub mod auth; pub mod auth;
// pub mod toast; (Removed) // pub mod toast; (Removed)
pub mod ui; pub mod ui;
pub mod demos;

View File

@@ -0,0 +1,260 @@
use leptos::prelude::*;
use crate::components::ui::tabs::*;
use crate::components::ui::skeleton::*;
#[component]
pub fn TorrentDetailsPanel() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let selected_torrent = Memo::new(move |_| {
let hash = store.selected_torrent.get()?;
store.torrents.with(|map| map.get(&hash).cloned())
});
let is_open = Signal::derive(move || store.selected_torrent.get().is_some());
view! {
// Mobil overlay backdrop
<div
class=move || if is_open.get() {
"fixed inset-0 bg-black/40 z-30 md:hidden backdrop-blur-sm transition-opacity duration-300 opacity-100"
} else {
"fixed inset-0 bg-black/0 z-30 md:hidden pointer-events-none transition-opacity duration-300 opacity-0"
}
on:click=move |_| store.selected_torrent.set(None)
/>
// Panel — masaüstünde sağ kolonda sabit, mobilde sağdan açılan overlay
<div class=move || {
if is_open.get() {
// Açık: masaüstünde görünür, mobilde sağdan gelir
"w-full md:w-[380px] md:min-w-[380px] shrink-0 \
flex flex-col border-l border-border bg-card \
fixed top-0 right-0 bottom-0 z-40 \
translate-x-0 \
md:static md:z-auto md:translate-x-0 \
transition-transform duration-300 ease-out shadow-2xl md:shadow-none"
} else {
// Kapalı: masaüstünde gizli, mobilde sağa kayar
"w-full md:w-0 shrink-0 overflow-hidden border-none \
fixed top-0 right-0 bottom-0 z-40 \
translate-x-full \
md:static md:z-auto md:translate-x-0 \
transition-transform duration-300 ease-in pointer-events-none"
}
}>
// İpucu: panel kapalıyken içeriği render etme
<Show when=move || is_open.get()>
// Başlık
<div class="px-4 py-3 border-b flex items-center justify-between shrink-0 bg-card">
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
<Show
when=move || selected_torrent.get().is_some()
fallback=move || view! { <Skeleton class="h-5 w-40" /> }
>
<h2 class="font-bold text-sm truncate leading-tight">
{move || selected_torrent.get().map(|t| t.name).unwrap_or_default()}
</h2>
</Show>
<Show
when=move || selected_torrent.get().is_some()
fallback=move || view! { <Skeleton class="h-3 w-20 mt-1" /> }
>
<p class="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold flex items-center gap-1.5">
{move || selected_torrent.get().map(|t| format!("{:?}", t.status)).unwrap_or_default()}
<span class="bg-primary/20 text-primary px-1 py-0.5 rounded text-[9px] lowercase">
{move || selected_torrent.get().map(|t| format!("{:.1}%", t.percent_complete)).unwrap_or_default()}
</span>
</p>
</Show>
</div>
// Kapat butonu
<button
class="rounded-full p-1.5 hover:bg-muted transition-colors text-muted-foreground hover:text-foreground shrink-0 ml-2"
on:click=move |_| store.selected_torrent.set(None)
>
<icons::X class="size-4" />
</button>
</div>
// Sekmeler + içerik
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
<Tabs default_value="general" class="flex-1 h-full min-h-0 flex flex-col">
<TabsList class="w-full justify-start rounded-none border-b bg-transparent p-0 shrink-0 px-2">
<TabsTrigger
value="general"
class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none text-xs h-9"
>
"Genel"
</TabsTrigger>
<TabsTrigger
value="files"
class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none text-xs h-9"
>
"Dosyalar"
</TabsTrigger>
<TabsTrigger
value="trackers"
class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none text-xs h-9"
>
"İzleyiciler"
</TabsTrigger>
<TabsTrigger
value="peers"
class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none text-xs h-9"
>
"Eşler"
</TabsTrigger>
</TabsList>
<crate::components::ui::scroll_area::ScrollArea class="flex-1 min-h-0">
<TabsContent value="general" class="p-4 space-y-5 animate-in fade-in duration-200">
<crate::components::ui::shimmer::Shimmer
loading=Signal::derive(move || selected_torrent.get().is_none())
shimmer_color="rgba(0,0,0,0.06)"
background_color="rgba(0,0,0,0.04)"
>
{move || {
let t = selected_torrent.get().unwrap_or_else(|| shared::Torrent {
hash: "----------------------------------------".to_string(),
name: "Yükleniyor...".to_string(),
size: 0,
completed: 0,
down_rate: 0,
up_rate: 0,
eta: 0,
percent_complete: 0.0,
status: shared::TorrentStatus::Downloading,
error_message: "".to_string(),
added_date: 0,
label: None,
ratio: 0.0,
uploaded: 0,
wasted: 0,
save_path: "Yükleniyor...".to_string(),
free_disk_space: 0,
});
view! {
<div class="flex flex-col gap-5">
// Aktarım
<div>
<h3 class="text-[10px] font-bold border-b pb-1.5 mb-3 uppercase tracking-widest text-muted-foreground">"Aktarım"</h3>
<div class="grid grid-cols-2 gap-3">
<InfoItem label="Kalan" value=format_duration(t.eta) />
<InfoItem label="Paylaşım Oranı" value=format!("{:.3}", t.ratio) />
<InfoItem label="İndirilen" value=format_bytes(t.completed) />
<InfoItem label="İndirme Hızı" value=format_speed(t.down_rate) class="text-blue-500" />
<InfoItem label="Gönderilen" value=format_bytes(t.uploaded) />
<InfoItem label="Gönderme Hızı" value=format_speed(t.up_rate) class="text-green-500" />
<InfoItem label="Boşa Giden" value=format_bytes(t.wasted) />
</div>
</div>
// Genel
<div>
<h3 class="text-[10px] font-bold border-b pb-1.5 mb-3 uppercase tracking-widest text-muted-foreground">"Genel"</h3>
<div class="flex flex-col gap-3">
<InfoItem label="Kaydedilen Yer" value=t.save_path class="break-all font-mono text-xs" />
<div class="grid grid-cols-2 gap-3">
<InfoItem label="Boş Disk Alanı" value=format_bytes(t.free_disk_space) />
<InfoItem label="Oluşturulma Tarihi" value=format_date(t.added_date) />
</div>
<InfoItem label="Hash" value=t.hash class="break-all font-mono text-[10px]" />
</div>
</div>
</div>
}
}}
</crate::components::ui::shimmer::Shimmer>
</TabsContent>
<TabsContent value="files" class="h-full">
{move || match selected_torrent.get() {
Some(t) => leptos::either::Either::Left(view! {
<div class="h-full">
<crate::components::torrent::files::TorrentFilesTab hash=t.hash />
</div>
}),
None => leptos::either::Either::Right(view! {
<div class="flex flex-col items-center justify-center h-48 opacity-60 gap-2">
<icons::File class="size-10 text-muted-foreground" />
<p class="text-sm font-medium">"Dosya yükleniyor..."</p>
</div>
}),
}}
</TabsContent>
<TabsContent value="trackers" class="h-full">
{move || match selected_torrent.get() {
Some(t) => leptos::either::Either::Left(view! {
<div class="h-full">
<crate::components::torrent::trackers::TorrentTrackersTab hash=t.hash />
</div>
}),
None => leptos::either::Either::Right(view! {
<div class="flex flex-col items-center justify-center h-48 opacity-60 gap-2">
<icons::Settings2 class="size-10 text-muted-foreground" />
<p class="text-sm font-medium">"İzleyici yükleniyor..."</p>
</div>
}),
}}
</TabsContent>
<TabsContent value="peers" class="h-full">
<div class="flex flex-col items-center justify-center h-48 opacity-60 gap-2">
<icons::Users class="size-10 text-muted-foreground" />
<p class="text-sm font-medium">"Eş listesi yakında eklenecek"</p>
</div>
</TabsContent>
</crate::components::ui::scroll_area::ScrollArea>
</Tabs>
</div>
</Show>
</div>
}
}
#[component]
fn InfoItem(
label: &'static str,
value: String,
#[prop(optional)] class: &'static str
) -> impl IntoView {
view! {
<div class=tailwind_fuse::tw_merge!("flex flex-col gap-0.5", class)>
<span class="text-[9px] font-semibold text-muted-foreground uppercase tracking-wider opacity-70">{label}</span>
<span class="text-xs font-medium leading-tight">{value}</span>
</div>
}
}
fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
if bytes < 1024 { return format!("{} B", bytes); }
let i = (bytes as f64).log2().div_euclid(10.0) as usize;
format!("{:.1} {}", (bytes as f64) / 1024_f64.powi(i as i32), UNITS[i])
}
fn format_speed(bytes_per_sec: i64) -> String {
if bytes_per_sec == 0 { return "0 B/s".to_string(); }
format!("{}/s", format_bytes(bytes_per_sec))
}
fn format_duration(seconds: i64) -> String {
if seconds <= 0 { return "".to_string(); }
let days = seconds / 86400;
let hours = (seconds % 86400) / 3600;
let minutes = (seconds % 3600) / 60;
let secs = seconds % 60;
if days > 0 { format!("{}g {}s", days, hours) }
else if hours > 0 { format!("{}s {}d", hours, minutes) }
else if minutes > 0 { format!("{}d {}sn", minutes, secs) }
else { format!("{}sn", secs) }
}
fn format_date(timestamp: i64) -> String {
if timestamp <= 0 { return "N/A".to_string(); }
let dt = chrono::DateTime::from_timestamp(timestamp, 0);
match dt { Some(dt) => dt.format("%d/%m/%Y %H:%M").to_string(), None => "N/A".to_string() }
}

View File

@@ -0,0 +1,216 @@
use leptos::prelude::*;
use crate::components::ui::table::*;
use crate::components::ui::badge::*;
use crate::components::ui::shimmer::*;
use crate::components::ui::context_menu::*;
use shared::TorrentFile;
#[component]
pub fn TorrentFilesTab(hash: String) -> impl IntoView {
let hash_clone = hash.clone();
// Fetch files resource
let files_resource = Resource::new(
move || hash_clone.clone(),
|h| async move { shared::server_fns::torrent::get_files(h).await.unwrap_or_default() }
);
// Callback to trigger a refetch — safe, doesn't destroy existing components
let on_refresh = Callback::new(move |_: ()| {
files_resource.refetch();
});
let stored_hash = StoredValue::new(hash);
view! {
<Suspense fallback=move || view! { <FilesFallback /> }>
{move || {
let files = files_resource.get().unwrap_or_default();
if files.is_empty() {
return view! {
<div class="flex flex-col items-center justify-center h-48 opacity-60">
<icons::File class="size-12 mb-3 text-muted-foreground" />
<p class="text-sm font-medium">"Bu torrent için dosya bulunamadı."</p>
</div>
}.into_any();
}
let files_len = files.len();
view! {
<div class="space-y-4">
<TableWrapper class="bg-card/50">
<Table>
<TableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
<TableRow class="hover:bg-transparent">
<TableHead class="w-12 text-center text-xs">"#"</TableHead>
<TableHead class="text-xs">"Dosya Adı"</TableHead>
<TableHead class="w-24 text-right text-xs">"Boyut"</TableHead>
<TableHead class="w-24 text-right text-xs">"Tamamlanan"</TableHead>
<TableHead class="w-24 text-center text-xs">"Öncelik"</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For
each=move || files.clone()
key=|f| f.index
children={move |f| {
let p_hash = stored_hash.get_value();
view! {
<FileRow
file=f
hash=p_hash
on_refresh=on_refresh.clone()
/>
}
}}
/>
</TableBody>
</Table>
</TableWrapper>
<div class="flex items-center justify-between text-xs text-muted-foreground px-1">
<span>{format!("Toplam {} dosya", files_len)}</span>
</div>
</div>
}.into_any()
}}
</Suspense>
}
}
#[component]
fn FileRow(file: TorrentFile, hash: String, on_refresh: Callback<()>) -> impl IntoView {
let f_idx = file.index;
let path_clone = file.path.clone();
// on_refresh is called AFTER the server responds, not before
let on_refresh_stored = StoredValue::new(on_refresh);
let set_priority = Action::new(move |req: &(String, u32, u8)| {
let (h, idx, p) = req.clone();
async move {
let res = shared::server_fns::torrent::set_file_priority(h, idx, p).await;
if let Err(e) = &res {
crate::store::show_toast(shared::NotificationLevel::Error, format!("Öncelik değiştirilemedi: {:?}", e));
} else {
crate::store::show_toast(shared::NotificationLevel::Success, "Dosya önceliği güncellendi.".to_string());
// Refetch AFTER the server has saved the priority
on_refresh_stored.get_value().run(());
}
res
}
});
view! {
<FileContextMenu
torrent_hash=hash
file_index=f_idx
set_priority=set_priority
>
<TableRow class="hover:bg-muted/50 transition-colors group">
<TableCell class="text-center text-xs text-muted-foreground">{file.index}</TableCell>
<TableCell class="font-medium text-xs break-all max-w-[200px] md:max-w-md" attr:title=move || path_clone.clone()>
{file.path.clone()}
</TableCell>
<TableCell class="text-right text-xs text-muted-foreground whitespace-nowrap">
{format_bytes(file.size)}
</TableCell>
<TableCell class="text-right text-xs whitespace-nowrap">
<span class="text-primary font-medium">{format_bytes(file.completed_chunks)}</span>
</TableCell>
<TableCell class="text-center">
{
let (variant, label) = match file.priority {
0 => (BadgeVariant::Destructive, "İndirme"),
2 => (BadgeVariant::Success, "Yüksek"),
_ => (BadgeVariant::Secondary, "Normal"),
};
view! { <Badge variant=variant class="text-[10px] uppercase">{label}</Badge> }
}
</TableCell>
</TableRow>
</FileContextMenu>
}
}
#[component]
fn FileContextMenu(
children: Children,
torrent_hash: String,
file_index: u32,
set_priority: Action<(String, u32, u8), Result<(), ServerFnError>>,
) -> impl IntoView {
let hash_c1 = torrent_hash.clone();
let hash_c2 = torrent_hash.clone();
let hash_c3 = torrent_hash.clone();
view! {
<ContextMenu>
<ContextMenuTrigger>
{children()}
</ContextMenuTrigger>
<ContextMenuContent class="w-48">
<ContextMenuLabel>"Dosya Önceliği"</ContextMenuLabel>
<ContextMenuGroup>
<ContextMenuItem on:click={
let h = hash_c1;
let sp = set_priority.clone();
move |_| {
sp.dispatch((h.clone(), file_index, 2));
crate::components::ui::context_menu::close_context_menu();
}
}>
<icons::ChevronsUp class="text-green-500" />
<span>"Yüksek"</span>
</ContextMenuItem>
<ContextMenuItem on:click={
let h = hash_c2;
let sp = set_priority.clone();
move |_| {
sp.dispatch((h.clone(), file_index, 1));
crate::components::ui::context_menu::close_context_menu();
}
}>
<icons::Minus class="text-blue-500" />
<span>"Normal"</span>
</ContextMenuItem>
<ContextMenuItem class="text-destructive focus:bg-destructive/10" on:click={
let h = hash_c3;
let sp = set_priority.clone();
move |_| {
sp.dispatch((h.clone(), file_index, 0));
crate::components::ui::context_menu::close_context_menu();
}
}>
<icons::X />
<span>"İndirme (Kapalı)"</span>
</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
}
}
#[component]
fn FilesFallback() -> impl IntoView {
view! {
<Shimmer loading=Signal::derive(|| true) class="space-y-2">
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
</Shimmer>
}
}
fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
if bytes < 1024 { return format!("{} B", bytes); }
let i = (bytes as f64).log2().div_euclid(10.0) as usize;
format!("{:.1} {}", (bytes as f64) / 1024_f64.powi(i as i32), UNITS[i])
}

View File

@@ -1,2 +1,5 @@
pub mod table; pub mod table;
pub mod add_torrent; pub mod add_torrent;
pub mod details;
pub mod files;
pub mod trackers;

View File

@@ -218,7 +218,9 @@ pub fn TorrentTable() -> impl IntoView {
}); });
view! { view! {
<div class="h-full bg-background relative flex flex-col overflow-hidden px-4 py-4 gap-4"> <div class="h-full bg-background flex flex-row overflow-hidden">
// Sol: liste alanı
<div class="flex-1 min-w-0 flex flex-col overflow-hidden px-4 py-4 gap-4">
// --- TOPBAR --- // --- TOPBAR ---
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2 flex-1 max-w-md"> <div class="flex items-center gap-2 flex-1 max-w-md">
@@ -249,7 +251,6 @@ pub fn TorrentTable() -> impl IntoView {
<div class="my-1 h-px bg-border" /> <div class="my-1 h-px bg-border" />
// Trigger the hidden AlertDialog from this menu item
<DropdownMenuItem class="text-destructive focus:bg-destructive/10 cursor-pointer" on:click=move |_| { <DropdownMenuItem class="text-destructive focus:bg-destructive/10 cursor-pointer" on:click=move |_| {
if let Some(trigger) = document().get_element_by_id("bulk-delete-trigger") { if let Some(trigger) = document().get_element_by_id("bulk-delete-trigger") {
let _ = trigger.dyn_into::<web_sys::HtmlElement>().map(|el: web_sys::HtmlElement| el.click()); let _ = trigger.dyn_into::<web_sys::HtmlElement>().map(|el: web_sys::HtmlElement| el.click());
@@ -261,7 +262,6 @@ pub fn TorrentTable() -> impl IntoView {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
// Hidden AlertDialog moved outside the DropdownMenuContent to ensure proper centering
<AlertDialog> <AlertDialog>
<AlertDialogTrigger attr:id="bulk-delete-trigger" class="hidden">""</AlertDialogTrigger> <AlertDialogTrigger attr:id="bulk-delete-trigger" class="hidden">""</AlertDialogTrigger>
<AlertDialogContent class="sm:max-w-[425px]"> <AlertDialogContent class="sm:max-w-[425px]">
@@ -554,6 +554,10 @@ pub fn TorrentTable() -> impl IntoView {
<div class="opacity-50">"VibeTorrent v3"</div> <div class="opacity-50">"VibeTorrent v3"</div>
</div> </div>
</div> </div>
// Sağ: sabit detay paneli
<crate::components::torrent::details::TorrentDetailsPanel />
</div>
}.into_any() }.into_any()
} }
@@ -595,10 +599,12 @@ fn TorrentRow(
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value())) on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
> >
<DataTableCell class="w-12 px-4"> <DataTableCell class="w-12 px-4">
<div on:click=move |e| e.stop_propagation()>
<Checkbox <Checkbox
checked=is_selected checked=is_selected
on_checked_change=on_select on_checked_change=on_select
/> />
</div>
</DataTableCell> </DataTableCell>
{move || visible_columns.get().contains("Name").then({ {move || visible_columns.get().contains("Name").then({
@@ -728,17 +734,23 @@ fn TorrentCard(
} }
) )
on:click=move |_| { on:click=move |_| {
let current = is_selected.get();
on_select.run(!current);
store.selected_torrent.set(Some(stored_hash.get_value())); store.selected_torrent.set(Some(stored_hash.get_value()));
} }
> >
<div class="p-4 space-y-3"> <div class="p-4 space-y-3">
<div class="flex justify-between items-start gap-3"> <div class="flex justify-between items-start gap-3">
<div class="flex items-start gap-3 flex-1 min-w-0">
<div on:click=move |e| e.stop_propagation() class="mt-0.5">
<Checkbox
checked=is_selected
on_checked_change=on_select
/>
</div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 class="text-sm font-bold leading-tight line-clamp-2 break-all">{t_name.clone()}</h3> <h3 class="text-sm font-bold leading-tight line-clamp-2 break-all">{t_name.clone()}</h3>
</div> </div>
<Badge variant=status_variant class="uppercase tracking-wider text-[10px]"> </div>
<Badge variant=status_variant class="uppercase tracking-wider text-[10px] shrink-0">
{format!("{:?}", t.status)} {format!("{:?}", t.status)}
</Badge> </Badge>
</div> </div>

View File

@@ -0,0 +1,121 @@
use leptos::prelude::*;
use crate::components::ui::table::*;
use crate::components::ui::shimmer::*;
use shared::TorrentTracker;
#[component]
pub fn TorrentTrackersTab(hash: String) -> impl IntoView {
let hash_clone = hash.clone();
let trackers_resource = Resource::new(
move || hash_clone.clone(),
|h| async move { shared::server_fns::torrent::get_trackers(h).await.unwrap_or_default() }
);
view! {
<Suspense fallback=move || view! { <TrackersFallback /> }>
{move || {
let trackers = trackers_resource.get().unwrap_or_default();
if trackers.is_empty() {
return view! {
<div class="flex flex-col items-center justify-center h-48 opacity-60">
<icons::Settings2 class="size-12 mb-3 text-muted-foreground" />
<p class="text-sm font-medium">"Bu torrent için izleyici bulunamadı."</p>
</div>
}.into_any();
}
view! {
<div class="h-full overflow-auto">
<TableWrapper class="bg-card/50 whitespace-nowrap">
<Table>
<TableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
<TableRow class="hover:bg-transparent text-xs">
<TableHead>"İsim"</TableHead>
<TableHead class="text-center">"Tür"</TableHead>
<TableHead class="text-center">"Etkin"</TableHead>
<TableHead class="text-center">"Grup"</TableHead>
<TableHead class="text-center">"Ortaklar"</TableHead>
<TableHead class="text-center">"Eşler"</TableHead>
<TableHead class="text-center">"İndirilen"</TableHead>
<TableHead class="text-center">"Son Güncelleme"</TableHead>
<TableHead class="text-center">"Sıklık"</TableHead>
<TableHead class="text-center">"Özel"</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For
each=move || trackers.clone()
key=|t| t.url.clone()
children=move |t| {
let t_type = if t.url.starts_with("http") { "http" }
else if t.url.starts_with("udp") { "udp" }
else if t.url.starts_with("dht") { "dht" }
else { "diğer" };
let is_enabled = if t.is_enabled { "evet" } else { "hayır" };
// Format timestamp difference for last update
let now = chrono::Utc::now().timestamp();
let diff = now - t.last_updated;
let last_update_str = if t.last_updated == 0 {
"Güncellenmedi".to_string()
} else if diff >= 0 {
format_duration_short(diff)
} else {
"N/A".to_string()
};
let url_clone = t.url.clone();
view! {
<TableRow class="hover:bg-muted/50 transition-colors group text-xs text-muted-foreground">
<TableCell class="font-medium text-foreground max-w-[200px] md:max-w-md truncate" attr:title=url_clone>
{t.url.clone()}
</TableCell>
<TableCell class="text-center">{t_type}</TableCell>
<TableCell class="text-center">{is_enabled}</TableCell>
<TableCell class="text-center">{t.group}</TableCell>
<TableCell class="text-center">{t.seeders}</TableCell>
<TableCell class="text-center">{t.peers}</TableCell>
<TableCell class="text-center">{t.downloaded}</TableCell>
<TableCell class="text-center">{last_update_str}</TableCell>
<TableCell class="text-center">{format_duration_short(t.interval)}</TableCell>
<TableCell class="text-center">"bilinmiyor"</TableCell> // Özel flag isn't cleanly via XMLRPC per tracker usually
</TableRow>
}
}
/>
</TableBody>
</Table>
</TableWrapper>
</div>
}.into_any()
}}
</Suspense>
}
}
#[component]
fn TrackersFallback() -> impl IntoView {
view! {
<Shimmer loading=Signal::derive(|| true) class="space-y-2">
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
</Shimmer>
}
}
fn format_duration_short(seconds: i64) -> String {
if seconds <= 0 { return "0sn".to_string(); }
let days = seconds / 86400;
let hours = (seconds % 86400) / 3600;
let minutes = (seconds % 3600) / 60;
let secs = seconds % 60;
if days > 0 { format!("{}g {}s", days, hours) }
else if hours > 0 { format!("{}s {}dk", hours, minutes) }
else if minutes > 0 { format!("{}dk {}sn", minutes, secs) }
else { format!("{}sn", secs) }
}

View File

@@ -1,93 +0,0 @@
use leptos::prelude::*;
use tailwind_fuse::tw_merge;
use crate::components::ui::button::Button;
use crate::components::ui::input::{Input, InputType};
#[derive(Clone, Debug)]
pub enum AutoFormField {
Text {
name: String,
label: String,
placeholder: Option<String>,
required: bool,
},
Password {
name: String,
label: String,
placeholder: Option<String>,
required: bool,
},
}
#[component]
pub fn AutoForm(
#[prop(into)] fields: Vec<AutoFormField>,
#[prop(into)] submit_label: String,
#[prop(into)] on_submit: Callback<std::collections::HashMap<String, String>>,
#[prop(optional)] loading: Signal<bool>,
#[prop(optional, into)] class: String,
) -> impl IntoView {
let field_values = fields.iter().map(|f| {
let name = match f {
AutoFormField::Text { name, .. } => name,
AutoFormField::Password { name, .. } => name,
};
(name.clone(), RwSignal::new(String::new()))
}).collect::<std::collections::HashMap<String, RwSignal<String>>>();
let handle_submit = {
let field_values = field_values.clone();
move |ev: web_sys::SubmitEvent| {
ev.prevent_default();
let mut data = std::collections::HashMap::new();
for (name, signal) in &field_values {
data.insert(name.clone(), signal.get());
}
on_submit.run(data);
}
};
view! {
<form on:submit=handle_submit class=tw_merge!("space-y-4", class)>
{fields.into_iter().map(|field| {
let (name, label, placeholder, r#type, required) = match field {
AutoFormField::Text { name, label, placeholder, required } => (name, label, placeholder, InputType::Text, required),
AutoFormField::Password { name, label, placeholder, required } => (name, label, placeholder, InputType::Password, required),
};
let signal = field_values.get(&name).cloned().unwrap();
view! {
<div class="space-y-2">
<label class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{label}
</label>
<Input
r#type=r#type
placeholder=placeholder.unwrap_or_default()
bind_value=signal
required=required
disabled=loading.get()
/>
</div>
}
}).collect_view()}
<div class="pt-2">
<Button
class="w-full"
attr:r#type="submit"
attr:disabled=move || loading.get()
>
<Show when=move || loading.get() fallback=move || {
let label = submit_label.clone();
view! { <span>{label}</span> }.into_any()
}>
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
"İşleniyor..."
</Show>
</Button>
</div>
</form>
}
}

View File

@@ -3,13 +3,17 @@ use leptos_ui::clx;
mod components { mod components {
use super::*; use super::*;
clx! {Card, div, "bg-card text-card-foreground flex flex-col gap-4 rounded-xl border py-6 shadow-sm"} 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"} // TODO. Change data-slot=card-action by data-name="CardAction".
clx! {CardHeader, div, "@container/card-header flex flex-col items-start gap-1.5 px-6 [.border-b]:pb-6 sm:grid sm:auto-rows-min sm:grid-rows-[auto_auto] has-data-[slot=card-action]:sm:grid-cols-[1fr_auto]"}
clx! {CardTitle, h2, "leading-none font-semibold"} clx! {CardTitle, h2, "leading-none font-semibold"}
clx! {CardContent, div, "px-6"} clx! {CardContent, div, "px-6"}
clx! {CardDescription, p, "text-muted-foreground text-sm"} clx! {CardDescription, p, "text-muted-foreground text-sm"}
clx! {CardFooter, footer, "flex items-center px-6 [.border-t]:pt-6", "gap-2"} clx! {CardFooter, footer, "flex items-center px-6 [.border-t]:pt-6", "gap-2"}
clx! {CardAction, div, "self-start sm:col-start-2 sm:row-span-2 sm:row-start-1 sm:justify-self-end"}
clx! {CardList, ul, "flex flex-col gap-4"}
clx! {CardItem, li, "flex items-center [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0"}
} }
pub use components::*; pub use components::*;

View File

@@ -139,7 +139,7 @@ pub fn ContextMenuTrigger(
class=trigger_class class=trigger_class
data-name="ContextMenuTrigger" data-name="ContextMenuTrigger"
data-context-trigger=ctx.target_id data-context-trigger=ctx.target_id
on:contextmenu=move |e: web_sys::MouseEvent| { on:contextmenu=move |_e: web_sys::MouseEvent| {
if let Some(cb) = on_open { if let Some(cb) = on_open {
cb.run(()); cb.run(());
} }
@@ -215,12 +215,20 @@ pub fn ContextMenuContent(
// Adjust if menu would go off right edge // Adjust if menu would go off right edge
if (x + menuRect.width > viewportWidth) {{ if (x + menuRect.width > viewportWidth) {{
left = x - menuRect.width; left = Math.max(0, x - menuRect.width);
}} }}
// Adjust if menu would go off bottom edge // Adjust if menu would go off bottom edge
if (y + menuRect.height > viewportHeight) {{ if (y + menuRect.height > viewportHeight) {{
top = y - menuRect.height; top = Math.max(0, y - menuRect.height);
}}
// Adjust for CSS transformed containing block
const offsetParent = menu.offsetParent;
if (offsetParent && offsetParent !== document.body && offsetParent !== document.documentElement) {{
const parentRect = offsetParent.getBoundingClientRect();
left -= parentRect.left;
top -= parentRect.top;
}} }}
menu.style.left = `${{left}}px`; menu.style.left = `${{left}}px`;
@@ -288,7 +296,7 @@ pub fn ContextMenuContent(
}} }}
}}; }};
// Right-click on trigger // Right-click on trigger (desktop)
trigger.addEventListener('contextmenu', (e) => {{ trigger.addEventListener('contextmenu', (e) => {{
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -299,6 +307,53 @@ pub fn ContextMenuContent(
openMenu(e.clientX, e.clientY); openMenu(e.clientX, e.clientY);
}}); }});
// Long-press on trigger (mobile)
let touchTimer = null;
let touchStartX = 0;
let touchStartY = 0;
const LONG_PRESS_DURATION = 500;
const MOVE_THRESHOLD = 10;
trigger.addEventListener('touchstart', (e) => {{
const touch = e.touches[0];
touchStartX = touch.clientX;
touchStartY = touch.clientY;
touchTimer = setTimeout(() => {{
e.preventDefault();
if (isOpen) {{
closeMenu();
}}
openMenu(touchStartX, touchStartY);
}}, LONG_PRESS_DURATION);
}}, {{ passive: false }});
trigger.addEventListener('touchmove', (e) => {{
if (touchTimer) {{
const touch = e.touches[0];
const dx = Math.abs(touch.clientX - touchStartX);
const dy = Math.abs(touch.clientY - touchStartY);
if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) {{
clearTimeout(touchTimer);
touchTimer = null;
}}
}}
}});
trigger.addEventListener('touchend', () => {{
if (touchTimer) {{
clearTimeout(touchTimer);
touchTimer = null;
}}
}});
trigger.addEventListener('touchcancel', () => {{
if (touchTimer) {{
clearTimeout(touchTimer);
touchTimer = null;
}}
}});
// Close when action is clicked // Close when action is clicked
const actions = menu.querySelectorAll('[data-context-close]'); const actions = menu.querySelectorAll('[data-context-close]');
actions.forEach(action => {{ actions.forEach(action => {{

View File

@@ -1,6 +1,5 @@
pub mod accordion; pub mod accordion;
pub mod alert_dialog; pub mod alert_dialog;
pub mod auto_form;
pub mod badge; pub mod badge;
pub mod button; pub mod button;
pub mod button_action; pub mod button_action;
@@ -15,11 +14,14 @@ pub mod input;
pub mod multi_select; pub mod multi_select;
pub mod select; pub mod select;
pub mod separator; pub mod separator;
pub mod scroll_area;
pub mod sheet; pub mod sheet;
pub mod sidenav; pub mod sidenav;
pub mod skeleton; pub mod skeleton;
pub mod shimmer;
pub mod svg_icon; pub mod svg_icon;
pub mod switch; pub mod switch;
pub mod table; pub mod table;
pub mod tabs;
pub mod theme_toggle; pub mod theme_toggle;
pub mod toast; pub mod toast;

View File

@@ -1,26 +0,0 @@
use leptos::prelude::*;
use tailwind_fuse::tw_merge;
#[component]
pub fn Progress(
#[prop(into)] value: Signal<f64>,
#[prop(optional, into)] class: String,
) -> impl IntoView {
let progress_style = move || format!("transform: translateX(-{}%);", 100.0 - value.get().clamp(0.0, 100.0));
view! {
<div
data-name="Progress"
class=tw_merge!(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
class
)
>
<div
data-name="ProgressIndicator"
class="h-full w-full flex-1 bg-primary transition-all duration-500 ease-in-out"
style=progress_style
/>
</div>
}
}

View File

@@ -0,0 +1,102 @@
use leptos::prelude::*;
use leptos_ui::void;
use tw_merge::*;
// Removed unused fake components
/* ========================================================== */
/* ✨ COMPONENTS ✨ */
/* ========================================================== */
#[component]
pub fn ScrollArea(children: Children, #[prop(into, optional)] class: String) -> impl IntoView {
let merged_class = tw_merge!("relative overflow-hidden", class);
view! {
<div data-name="ScrollArea" class=merged_class>
<ScrollAreaViewport class="pr-3 pb-3 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-border/60 [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-border/80">{children()}</ScrollAreaViewport>
</div>
}
}
#[component]
pub fn ScrollAreaViewport(children: Children, #[prop(into, optional)] class: String) -> impl IntoView {
let merged_class = tw_merge!(
"focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 overflow-auto",
class
);
view! {
<div data-name="ScrollAreaViewport" class=merged_class>
{children()}
</div>
}
}
/* ========================================================== */
/* 🧬 ENUMS 🧬 */
/* ========================================================== */
// Real scrollbars are now utilized in the viewport directly.
/* ========================================================== */
/* 🧬 STRUCT 🧬 */
/* ========================================================== */
#[component]
pub fn SnapScrollArea(
#[prop(into, default = SnapAreaVariant::default())] variant: SnapAreaVariant,
#[prop(into, optional)] class: String,
children: Children,
) -> impl IntoView {
let snap_item = SnapAreaClass { variant };
let merged_class = snap_item.with_class(class);
view! {
<div data-name="SnapScrollArea" class=merged_class>
{children()}
</div>
}
}
#[derive(TwClass, Default)]
#[tw(class = "")]
pub struct SnapAreaClass {
variant: SnapAreaVariant,
}
#[derive(TwVariant)]
pub enum SnapAreaVariant {
// * snap-x by default
#[tw(default, class = "overflow-x-auto snap-x")]
Center,
}
/* ========================================================== */
/* 🧬 STRUCT 🧬 */
/* ========================================================== */
#[component]
pub fn SnapItem(
#[prop(into, default = SnapVariant::default())] variant: SnapVariant,
#[prop(into, optional)] class: String,
children: Children,
) -> impl IntoView {
let snap_item = SnapItemClass { variant };
let merged_class = snap_item.with_class(class);
view! {
<div data-name="SnapItem" class=merged_class>
{children()}
</div>
}
}
#[derive(TwClass, Default)]
#[tw(class = "shrink-0")]
pub struct SnapItemClass {
variant: SnapVariant,
}
#[derive(TwVariant)]
pub enum SnapVariant {
// * snap-center by default
#[tw(default, class = "snap-center")]
Center,
}

View File

@@ -16,8 +16,6 @@ mod components {
clx! {SheetFooter, footer, "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"} clx! {SheetFooter, footer, "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"}
} }
pub use components::*;
/* ========================================================== */ /* ========================================================== */
/* ✨ CONTEXT ✨ */ /* ✨ CONTEXT ✨ */
/* ========================================================== */ /* ========================================================== */

View File

@@ -0,0 +1,52 @@
use leptos::prelude::*;
use tw_merge::*;
use crate::components::hooks::use_random::use_random_id_for;
#[component]
pub fn Shimmer(
/// Controls shimmer visibility (works with any bool signal)
#[prop(into)]
loading: Signal<bool>,
/// Color of the shimmer wave (default: "rgba(255,255,255,0.15)")
#[prop(into, optional)]
shimmer_color: Option<String>,
/// Background color of shimmer blocks (default: "rgba(255,255,255,0.08)")
#[prop(into, optional)]
background_color: Option<String>,
/// Animation duration in seconds (default: 1.5)
#[prop(optional)]
duration: Option<f64>,
/// Fallback border-radius for text elements in px (default: 4)
#[prop(optional)]
fallback_border_radius: Option<f64>,
/// Additional classes
#[prop(into, optional)]
class: String,
/// Children to wrap
children: Children,
) -> impl IntoView {
let shimmer_id = use_random_id_for("Shimmer");
let merged_class = tw_merge!("relative", class);
view! {
<div
id=shimmer_id
class=merged_class
data-name="Shimmer"
data-shimmer-loading=move || loading.get().to_string()
data-shimmer-color=shimmer_color
data-shimmer-bg-color=background_color
data-shimmer-duration=duration.map(|d| d.to_string())
data-shimmer-fallback-radius=fallback_border_radius.map(|r| r.to_string())
>
{children()}
</div>
}
}

View File

@@ -0,0 +1,108 @@
use leptos::context::Provider;
use leptos::prelude::*;
use tw_merge::tw_merge;
#[derive(Clone)]
pub struct TabsContext {
pub active_tab: RwSignal<String>,
}
#[component]
pub fn Tabs(
#[prop(into)] default_value: String,
children: Children,
#[prop(optional, into)] class: String,
) -> impl IntoView {
let active_tab = RwSignal::new(default_value);
let ctx = TabsContext { active_tab };
let merged_class = tw_merge!("w-full", &class);
view! {
<Provider value=ctx>
<div data-name="Tabs" class=merged_class>
{children()}
</div>
</Provider>
}
}
#[component]
pub fn TabsList(
children: Children,
#[prop(optional, into)] class: String,
) -> impl IntoView {
let merged_class = tw_merge!(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
&class
);
view! {
<div data-name="TabsList" class=merged_class>
{children()}
</div>
}
}
#[component]
pub fn TabsTrigger(
#[prop(into)] value: String,
children: Children,
#[prop(optional, into)] class: String,
) -> impl IntoView {
let ctx = expect_context::<TabsContext>();
let v_clone = value.clone();
let is_active = Memo::new(move |_| ctx.active_tab.get() == v_clone);
let merged_class = move || tw_merge!(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer select-none",
if is_active.get() {
"bg-background text-foreground shadow-sm"
} else {
"hover:bg-background/50 hover:text-foreground"
},
&class
);
view! {
<button
data-name="TabsTrigger"
type="button"
class=merged_class
data-state=move || if is_active.get() { "active" } else { "inactive" }
on:click=move |_| ctx.active_tab.set(value.clone())
>
{children()}
</button>
}
}
#[component]
pub fn TabsContent(
#[prop(into)] value: String,
children: Children,
#[prop(optional, into)] class: String,
) -> impl IntoView {
let ctx = expect_context::<TabsContext>();
let v_clone = value.clone();
let is_active = Memo::new(move |_| ctx.active_tab.get() == v_clone);
let merged_class = move || tw_merge!(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
if !is_active.get() { "hidden" } else { "" },
&class
);
view! {
<div
data-name="TabsContent"
class=merged_class
data-state=move || if is_active.get() { "active" } else { "inactive" }
tabindex=move || if is_active.get() { "0" } else { "-1" }
>
{children()}
</div>
}
}

View File

@@ -5,8 +5,7 @@ use leptos::task::spawn_local;
use shared::{AppEvent, GlobalStats, NotificationLevel, Torrent}; use shared::{AppEvent, GlobalStats, NotificationLevel, Torrent};
use std::collections::HashMap; use std::collections::HashMap;
use struct_patch::traits::Patch; use struct_patch::traits::Patch;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL}; use base64::{Engine as _, engine::general_purpose::{URL_SAFE_NO_PAD as BASE64_URL, STANDARD as BASE64}};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
use crate::components::ui::toast::{ToastType, toast}; use crate::components::ui::toast::{ToastType, toast};
@@ -192,7 +191,7 @@ pub async fn subscribe_to_push_notifications() {
let key_array = js_sys::Uint8Array::from(&decoded_key[..]); let key_array = js_sys::Uint8Array::from(&decoded_key[..]);
// 3. Prepare Options // 3. Prepare Options
let mut options = web_sys::PushSubscriptionOptionsInit::new(); let options = web_sys::PushSubscriptionOptionsInit::new();
options.set_user_visible_only(true); options.set_user_visible_only(true);
options.set_application_server_key(&key_array.into()); options.set_application_server_key(&key_array.into());

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = "vibetorrent-v2"; const CACHE_NAME = "vibetorrent-v3";
const ASSETS_TO_CACHE = [ const ASSETS_TO_CACHE = [
"/", "/",
"/index.html", "/index.html",
@@ -51,6 +51,11 @@ self.addEventListener("activate", (event) => {
self.addEventListener("fetch", (event) => { self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url); const url = new URL(event.request.url);
// Skip unsupported schemes (like chrome-extension://)
if (!url.protocol.startsWith("http")) {
return;
}
// Network-first strategy for API calls // Network-first strategy for API calls
if (url.pathname.startsWith("/api/")) { if (url.pathname.startsWith("/api/")) {
event.respondWith( event.respondWith(
@@ -75,10 +80,12 @@ self.addEventListener("fetch", (event) => {
fetch(event.request) fetch(event.request)
.then((response) => { .then((response) => {
// Cache the latest version of the HTML // Cache the latest version of the HTML
if (response && response.status === 200) {
const responseToCache = response.clone(); const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => { caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache); cache.put(event.request, responseToCache);
}); });
}
return response; return response;
}) })
.catch(() => { .catch(() => {

629
package-lock.json generated
View File

@@ -90,26 +90,6 @@
"@parcel/watcher-win32-x64": "2.5.6" "@parcel/watcher-win32-x64": "2.5.6"
} }
}, },
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": { "node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.6", "version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
@@ -130,226 +110,6 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/cli": { "node_modules/@tailwindcss/cli": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.18.tgz",
@@ -406,22 +166,6 @@
"@tailwindcss/oxide-win32-x64-msvc": "4.1.18" "@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
} }
}, },
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
"integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": { "node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
@@ -438,179 +182,6 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
"integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
"integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
"integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
"integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
"integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
"integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
"integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
"integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.1.0",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.4.0"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
"integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
"integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -698,26 +269,6 @@
"lightningcss-win32-x64-msvc": "1.30.2" "lightningcss-win32-x64-msvc": "1.30.2"
} }
}, },
"node_modules/lightningcss-android-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": { "node_modules/lightningcss-darwin-arm64": {
"version": "1.30.2", "version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
@@ -738,186 +289,6 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/lightningcss-darwin-x64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
"cpu": [
"arm"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",

View File

@@ -1,8 +1,8 @@
{ {
"type": "module",
"dependencies": { "dependencies": {
"@tailwindcss/cli": "^4.1.18", "@tailwindcss/cli": "^4.1.18",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
}, }
"type": "module"
} }

View File

@@ -42,6 +42,11 @@ pub struct Torrent {
pub error_message: String, pub error_message: String,
pub added_date: i64, pub added_date: i64,
pub label: Option<String>, pub label: Option<String>,
pub ratio: f64,
pub uploaded: i64,
pub wasted: i64,
pub save_path: String,
pub free_disk_space: i64,
} }
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
@@ -121,6 +126,13 @@ pub struct TorrentTracker {
pub url: String, pub url: String,
pub status: String, pub status: String,
pub message: String, pub message: String,
pub is_enabled: bool,
pub group: i64,
pub seeders: i64,
pub peers: i64,
pub downloaded: i64,
pub last_updated: i64,
pub interval: i64,
} }
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]

View File

@@ -83,12 +83,19 @@ 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 perform_request = async {
let mut stream = UnixStream::connect(socket_path).await?;
let data = request.encode(); let data = request.encode();
stream.write_all(&data).await?;
let mut response = Vec::new(); let mut response = Vec::new();
if socket_path.contains(':') {
let mut stream = tokio::net::TcpStream::connect(socket_path).await?;
stream.write_all(&data).await?;
stream.read_to_end(&mut response).await?; stream.read_to_end(&mut response).await?;
} else {
let mut stream = tokio::net::UnixStream::connect(socket_path).await?;
stream.write_all(&data).await?;
stream.read_to_end(&mut response).await?;
}
Ok::<Vec<u8>, std::io::Error>(response) Ok::<Vec<u8>, std::io::Error>(response)
}; };

View File

@@ -193,8 +193,12 @@ pub async fn get_trackers(hash: String) -> Result<Vec<TorrentTracker>, ServerFnE
RpcParam::from(hash.as_str()), RpcParam::from(hash.as_str()),
RpcParam::from(""), RpcParam::from(""),
RpcParam::from("t.url="), RpcParam::from("t.url="),
RpcParam::from("t.activity_date_last="), RpcParam::from("t.is_enabled="),
RpcParam::from("t.message="), RpcParam::from("t.group="),
RpcParam::from("t.scrape_complete="),
RpcParam::from("t.scrape_incomplete="),
RpcParam::from("t.scrape_downloaded="),
RpcParam::from("t.normal_interval="),
]; ];
let xml = client let xml = client
@@ -205,14 +209,23 @@ pub async fn get_trackers(hash: String) -> Result<Vec<TorrentTracker>, ServerFnE
let rows = parse_multicall_response(&xml) let rows = parse_multicall_response(&xml)
.map_err(|e| ServerFnError::new(format!("Parse error: {}", e)))?; .map_err(|e| ServerFnError::new(format!("Parse error: {}", e)))?;
Ok(rows let result: Vec<TorrentTracker> = rows
.into_iter() .into_iter()
.map(|row| TorrentTracker { .map(|row| TorrentTracker {
url: row.get(0).cloned().unwrap_or_default(), url: row.get(0).cloned().unwrap_or_default(),
status: "Unknown".to_string(), is_enabled: row.get(1).and_then(|s| s.parse::<i64>().ok()).unwrap_or(0) != 0,
message: row.get(2).cloned().unwrap_or_default(), group: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0),
seeders: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
peers: row.get(4).and_then(|s| s.parse().ok()).unwrap_or(0),
downloaded: row.get(5).and_then(|s| s.parse().ok()).unwrap_or(0),
interval: row.get(6).and_then(|s| s.parse().ok()).unwrap_or(0),
last_updated: 0,
status: "Unknown".to_string(), // Can derive from activity later, or keep unknown
message: "".to_string(),
}) })
.collect()) .collect();
Ok(result)
} }
#[server(SetFilePriority, "/api/server_fns")] #[server(SetFilePriority, "/api/server_fns")]
@@ -225,6 +238,7 @@ pub async fn set_file_priority(
let ctx = expect_context::<crate::ServerContext>(); let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path); let client = RtorrentClient::new(&ctx.scgi_socket_path);
// rTorrent f.set_priority takes: target = "HASH:fINDEX", value = priority
let target = format!("{}:f{}", hash, file_index); let target = format!("{}:f{}", hash, file_index);
let params = vec![ let params = vec![
RpcParam::from(target.as_str()), RpcParam::from(target.as_str()),
@@ -232,10 +246,11 @@ pub async fn set_file_priority(
]; ];
client client
.call("f.set_priority", &params) .call("f.priority.set", &params)
.await .await
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?; .map_err(|e| ServerFnError::new(format!("RPC error setting priority: {}", e)))?;
// Notify rTorrent to update its internal priority state
let _ = client let _ = client
.call("d.update_priorities", &[RpcParam::from(hash.as_str())]) .call("d.update_priorities", &[RpcParam::from(hash.as_str())])
.await; .await;

26
test_rpc.rs Normal file
View File

@@ -0,0 +1,26 @@
use shared::xmlrpc::{RtorrentClient, RpcParam, parse_multicall_response};
#[tokio::main]
async fn main() {
let client = RtorrentClient::new("/tmp/rtorrent.sock");
// Hardcode a known hash from the UI, e.g. "C3315ABFAD70C54505813D1303C1457900C5B795" (from first image)
let hash = "C3315ABFAD70C54505813D1303C1457900C5B795";
let params = vec![
RpcParam::from(hash),
RpcParam::from(""),
RpcParam::from("t.url="),
];
match client.call("t.multicall", &params).await {
Ok(xml) => {
println!("Response XML:\n{}", xml);
match parse_multicall_response(&xml) {
Ok(rows) => println!("Rows ({})", rows.len()),
Err(e) => println!("Parse error: {:?}", e),
}
},
Err(e) => println!("Error: {:?}", e),
}
}