Compare commits

..

11 Commits

Author SHA1 Message Date
spinline
8fc3571848 fix: restore missing BASE64 import
All checks were successful
Build MIPS Binary / build (push) Successful in 1m51s
2026-02-14 02:23:08 +03:00
spinline
792b6bc97b fix: resolve toolbar syntax error and unused imports
Some checks failed
Build MIPS Binary / build (push) Failing after 34s
2026-02-14 02:20:53 +03:00
spinline
6efa6cd2d9 fix: resolve unused variable warning
Some checks failed
Build MIPS Binary / build (push) Failing after 45s
2026-02-14 02:18:45 +03:00
spinline
89caa17b92 fix: correct malformed import in multi_select.rs
Some checks failed
Build MIPS Binary / build (push) Failing after 46s
2026-02-14 02:16:53 +03:00
spinline
47bb60d7d8 fix: explicit type fixes for toolbar and multi_select
Some checks failed
Build MIPS Binary / build (push) Failing after 45s
2026-02-14 02:14:40 +03:00
spinline
7730250b61 fix: resolve updated compilation errors
Some checks failed
Build MIPS Binary / build (push) Failing after 45s
2026-02-14 02:11:58 +03:00
spinline
73d111124a refactor: simplify toolbar toggle button structure
Some checks failed
Build MIPS Binary / build (push) Failing after 32s
2026-02-14 02:10:43 +03:00
spinline
670c5a653b fix: resolve type inference error in toolbar callback
Some checks failed
Build MIPS Binary / build (push) Failing after 40s
2026-02-14 02:09:12 +03:00
spinline
9394a56e7d fix: compile error in protected layout
Some checks failed
Build MIPS Binary / build (push) Failing after 45s
2026-02-14 02:07:43 +03:00
spinline
105388eec3 feat: refine responsive sidebar behavior with auto-collapse
Some checks failed
Build MIPS Binary / build (push) Failing after 30s
2026-02-14 02:06:20 +03:00
spinline
f5d9cb642c feat: implement collapsible sidebar
Some checks failed
Build MIPS Binary / build (push) Failing after 34s
2026-02-14 01:57:40 +03:00
36 changed files with 3825 additions and 4729 deletions

View File

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

@@ -243,14 +243,12 @@ async fn main() {
let socket_path = std::path::Path::new(&args.socket);
if !socket_path.exists() {
tracing::error!("CRITICAL: rTorrent socket not found at {:?}.", socket_path);
tracing::error!(
tracing::warn!(
"HINT: Make sure rTorrent is running and the SCGI socket is enabled in .rtorrent.rc"
);
tracing::error!(
tracing::warn!(
"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 {
tracing::info!("Socket file exists. Testing connection...");
let client = xmlrpc::RtorrentClient::new(&args.socket);
@@ -261,11 +259,7 @@ async fn main() {
let version = xmlrpc::parse_string_response(&xml).unwrap_or(xml);
tracing::info!("Connected to rTorrent successfully. Version: {}", version);
}
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);
}
Err(e) => tracing::error!("Socket exists but failed to connect to rTorrent: {}", e),
}
}

View File

@@ -51,21 +51,6 @@ mod fields {
pub const IDX_LABEL: usize = 12;
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::*;
@@ -87,11 +72,6 @@ const RTORRENT_FIELDS: &[&str] = &[
CMD_CREATION_DATE,
CMD_HASHING,
CMD_LABEL,
CMD_RATIO,
CMD_UPLOADED,
CMD_WASTED,
CMD_SAVE_PATH,
CMD_FREE_DISK,
];
fn parse_long(s: Option<&String>) -> i64 {
@@ -118,11 +98,6 @@ fn from_rtorrent_row(row: Vec<String>) -> Torrent {
let added_date = parse_long(row.get(IDX_CREATION_DATE));
let is_hashing = parse_long(row.get(IDX_HASHING));
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() {
None
@@ -169,11 +144,6 @@ fn from_rtorrent_row(row: Vec<String>) -> Torrent {
error_message: message,
added_date,
label,
ratio,
uploaded,
wasted,
save_path,
free_disk_space,
}
}

View File

@@ -7,11 +7,6 @@ stage = "build"
command = "sh"
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]
target = "index.html"
dist = "dist"

View File

@@ -20,7 +20,7 @@
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
<!-- Trunk Assets -->
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="0" />
<script data-trunk rel="rust" src="Cargo.toml" data-wasm-opt="0" data-preload="false"></script>
<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="icon-192.png" />

View File

@@ -45,15 +45,8 @@
--ring: oklch(0.556 0 0);
}
@theme inline {
--animate-shimmer: shimmer 2s infinite;
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -83,7 +76,6 @@
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
@@ -96,4 +88,4 @@
dialog {
margin: auto;
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

@@ -3,20 +3,53 @@ use crate::components::layout::sidebar::Sidebar;
use crate::components::layout::toolbar::Toolbar;
use crate::components::layout::footer::Footer;
use crate::components::ui::sidenav::{SidenavWrapper, Sidenav, SidenavInset};
use wasm_bindgen::JsCast;
#[component]
pub fn Protected(children: Children) -> impl IntoView {
let (collapsed, set_collapsed) = signal(false);
// Responsive Sidebar Logic
Effect::new(move |_| {
let window = web_sys::window().expect("window missing");
// Initial check
let width = window.inner_width().unwrap().as_f64().unwrap_or(1920.0);
if width < 1280.0 {
set_collapsed.set(true);
} else {
set_collapsed.set(false);
}
// Listener
let closure = wasm_bindgen::closure::Closure::<dyn FnMut(_)>::new(move |_: web_sys::Event| {
let window = web_sys::window().expect("window missing");
let width = window.inner_width().unwrap().as_f64().unwrap_or(1920.0);
if width < 1280.0 {
set_collapsed.set(true);
} else {
set_collapsed.set(false);
}
});
let _ = window.add_event_listener_with_callback("resize", closure.as_ref().unchecked_ref());
closure.forget(); // Leak memory intentionally for global listener (or store in a cleanup handle if needed, but for layout component it's fine)
});
view! {
<SidenavWrapper attr:style="--sidenav-width:16rem; --sidenav-width-icon:3rem;">
// Masaüstü Sidenav
<Sidenav>
<Sidenav
data_collapsible=crate::components::ui::sidenav::SidenavCollapsible::Icon
data_state=if collapsed.get() { crate::components::ui::sidenav::SidenavState::Collapsed } else { crate::components::ui::sidenav::SidenavState::Expanded }
>
<Sidebar />
</Sidenav>
// İçerik Alanı
<SidenavInset class="flex flex-col h-screen overflow-hidden">
// Toolbar (Üst Bar)
<Toolbar />
<Toolbar on_toggle_sidebar=Callback::new(move |_| set_collapsed.update(|c| *c = !*c)) />
// Ana İçerik
<main class="flex-1 overflow-y-auto relative bg-background flex flex-col">

View File

@@ -87,7 +87,7 @@ pub fn Sidebar() -> impl IntoView {
<path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.25 0 003 2.48z" />
</svg>
</div>
<div class="grid flex-1 text-left text-sm leading-tight overflow-hidden">
<div class="grid flex-1 text-left text-sm leading-tight overflow-hidden group-data-[state=Collapsed]:hidden">
<span class="truncate font-semibold text-foreground text-base">"VibeTorrent"</span>
<span class="truncate text-[10px] text-muted-foreground opacity-70">"v3.0.0"</span>
</div>
@@ -150,26 +150,28 @@ pub fn Sidebar() -> impl IntoView {
<div class="flex flex-col gap-4 p-4">
// Push Notification Toggle
<div class="flex items-center justify-between px-2 py-1 bg-muted/20 rounded-md border border-border/50">
<div class="flex flex-col gap-0.5">
<div class="flex flex-col gap-0.5 group-data-[state=Collapsed]:hidden">
<span class="text-[10px] font-bold uppercase tracking-wider text-foreground/70">"Bildirimler"</span>
<span class="text-[9px] text-muted-foreground">"Web Push"</span>
</div>
<Switch
checked=Signal::from(store.push_enabled)
on_checked_change=Callback::new(on_push_toggle)
/>
<div class="group-data-[state=Collapsed]:hidden">
<Switch
checked=Signal::from(store.push_enabled)
on_checked_change=Callback::new(on_push_toggle)
/>
</div>
</div>
<div class="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 shadow-xs overflow-hidden">
<div class="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 shadow-xs overflow-hidden group-data-[state=Collapsed]:gap-0 group-data-[state=Collapsed]:justify-center group-data-[state=Collapsed]:p-0 group-data-[state=Collapsed]:border-none group-data-[state=Collapsed]:bg-transparent group-data-[state=Collapsed]:shadow-none">
<div class="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0 border border-primary-foreground/10">
{first_letter}
</div>
<div class="flex-1 overflow-hidden">
<div class="flex-1 overflow-hidden group-data-[state=Collapsed]:hidden">
<div class="font-medium text-[11px] truncate text-foreground leading-tight">{username}</div>
<div class="text-[9px] text-muted-foreground truncate opacity-70">"Yönetici"</div>
</div>
<div class="flex items-center gap-1">
<div class="flex items-center gap-1 group-data-[state=Collapsed]:hidden">
<ThemeToggle />
<Button
@@ -217,8 +219,8 @@ fn SidebarItem(
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d=icon.clone() />
</svg>
<span class="flex-1 truncate">{label}</span>
<span class="text-[10px] font-mono opacity-50">{count}</span>
<span class="flex-1 truncate group-data-[state=Collapsed]:hidden">{label}</span>
<span class="text-[10px] font-mono opacity-50 group-data-[state=Collapsed]:hidden">{count}</span>
</SidenavMenuButton>
</SidenavMenuItem>
}

View File

@@ -1,24 +1,38 @@
use leptos::prelude::*;
use icons::{PanelLeft, Plus};
use crate::components::torrent::add_torrent::AddTorrentDialogContent;
use crate::components::ui::button::{ButtonVariant, ButtonSize};
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
use crate::components::ui::sheet::{Sheet, SheetContent, SheetTrigger, SheetDirection};
use crate::components::ui::dialog::{Dialog, DialogContent, DialogTrigger};
use crate::components::layout::sidebar::Sidebar;
#[component]
pub fn Toolbar() -> impl IntoView {
pub fn Toolbar(
on_toggle_sidebar: Callback<()>,
) -> impl IntoView {
view! {
<div class="flex min-h-14 h-auto items-center border-b border-border bg-background px-4" style="padding-top: env(safe-area-inset-top);">
// Sol kısım: Menü butonu (Mobil) + Add Torrent
<div class="flex items-center gap-3">
// --- MOBILE SHEET (SIDEBAR) ---
// Desktop Toggle
<div class="hidden lg:block">
<Button
variant=ButtonVariant::Ghost
size=ButtonSize::Icon
class="size-9"
on:click=move |_| { on_toggle_sidebar.run(()); }
>
<PanelLeft class="size-5" />
<span class="hidden">"Toggle Sidebar"</span>
</Button>
</div>
// Mobile Toggle (Sheet)
<div class="lg:hidden">
<Sheet>
<SheetTrigger variant=ButtonVariant::Ghost size=ButtonSize::Icon class="size-9">
<PanelLeft class="size-5" />
<span class="hidden">"Menüyü Aç"</span>
<span class="hidden">"Open Menu"</span>
</SheetTrigger>
<SheetContent
direction=SheetDirection::Left

View File

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

View File

@@ -1,239 +0,0 @@
use leptos::prelude::*;
use crate::components::ui::sheet::*;
use crate::components::ui::tabs::*;
use crate::components::ui::skeleton::*;
use shared::Torrent;
#[component]
pub fn TorrentDetailsSheet() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
// Setup an effect to open the sheet when a torrent is selected
Effect::new(move |_| {
if store.selected_torrent.get().is_some() {
if let Some(trigger) = document().get_element_by_id("torrent-details-trigger") {
use wasm_bindgen::JsCast;
let _ = trigger.dyn_into::<web_sys::HtmlElement>().map(|el| el.click());
}
}
});
let selected_torrent = Memo::new(move |_| {
let hash = store.selected_torrent.get()?;
store.torrents.with(|map| map.get(&hash).cloned())
});
view! {
<Sheet>
<SheetTrigger attr:id="torrent-details-trigger" class="hidden">""</SheetTrigger>
<SheetContent
direction=SheetDirection::Bottom
class="h-[80vh] sm:h-[60vh] rounded-t-xl sm:rounded-t-2xl p-0 flex flex-col gap-0 border-t border-border shadow-2xl"
hide_close_button=true
>
<div class="px-6 py-4 border-b flex items-center justify-between sticky top-0 bg-card z-10">
<div class="flex flex-col gap-1 min-w-0">
<Show when=move || selected_torrent.get().is_some() fallback=move || view! { <Skeleton class="h-6 w-48" /> }>
<h2 class="font-bold text-lg truncate">
{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-4 w-24" /> }>
<p class="text-xs text-muted-foreground uppercase tracking-widest font-semibold flex items-center gap-2">
{move || selected_torrent.get().map(|t| format!("{:?}", t.status)).unwrap_or_default()}
<span class="bg-primary/20 text-primary px-1.5 py-0.5 rounded text-[10px] lowercase">{move || selected_torrent.get().map(|t| format!("{:.1}%", t.percent_complete)).unwrap_or_default()}</span>
</p>
</Show>
</div>
// Custom close button that also resets store.selected_torrent
<SheetClose class="rounded-full p-2 hover:bg-muted transition-colors border-none shadow-none cursor-pointer">
<icons::X class="size-5 opacity-70" on:click=move |_| store.selected_torrent.set(None) />
</SheetClose>
</div>
<div class="flex-1 overflow-hidden flex flex-col pt-4 px-6 pb-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">
<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">
"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">
"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">
"İ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">
"Eşler"
</TabsTrigger>
</TabsList>
<crate::components::ui::scroll_area::ScrollArea class="flex-1 min-h-0 mt-4 pb-12 pr-4">
<TabsContent value="general" class="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
<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-6">
// Aktarım
<div>
<h3 class="text-sm font-bold border-b pb-2 mb-4">"Aktarım"</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<InfoItem label="Geçen Süre" value="N/A".to_string() />
<InfoItem label="Kalan" value=format_duration(t.eta) />
<InfoItem label="Paylaşım Oranı" value=format!("{:.3}", t.ratio) />
<div class="hidden md:block"></div>
<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="Boşa Giden" value=format_bytes(t.wasted) />
<div class="hidden md:block"></div>
<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" />
<div class="hidden md:block"></div>
<div class="hidden md:block"></div>
<InfoItem label="Ortaklar" value="0 / 0 bağlı".to_string() />
<InfoItem label="Eşler" value="0 / 0 bağlı".to_string() />
</div>
</div>
// İzleyici
<div>
<h3 class="text-sm font-bold border-b pb-2 mb-4">"İzleyici"</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<InfoItem label="İzleyici URL'si" value="İzleyiciler sekmesine bakınız".to_string() class="col-span-1 md:col-span-2 break-all text-xs" />
<InfoItem label="İzleyici Durumu" value="N/A".to_string() />
</div>
</div>
// Genel
<div>
<h3 class="text-sm font-bold border-b pb-2 mb-4">"Genel"</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<InfoItem label="Kaydedilen Yer" value=t.save_path class="col-span-1 md:col-span-2 break-all font-mono text-xs" />
<InfoItem label="Boş Disk Alanı" value=format_bytes(t.free_disk_space) />
<InfoItem label="Oluşturulma Tarihi" value=format_date(t.added_date) />
<InfoItem label="Hash" value=t.hash class="col-span-1 md:col-span-2 break-all font-mono text-xs" />
<InfoItem label="Yorum" value="Yok".to_string() class="col-span-1 md:col-span-2 break-words text-xs" />
</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">
<icons::File class="size-12 mb-3 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">
<icons::Settings2 class="size-12 mb-3 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">
<icons::Users class="size-12 mb-3 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>
</SheetContent>
</Sheet>
}
}
#[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-1", class)>
<span class="text-xs font-semibold text-muted-foreground uppercase opacity-80">{label}</span>
<span class="text-sm font-medium">{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

@@ -1,216 +0,0 @@
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,5 +1,2 @@
pub mod table;
pub mod add_torrent;
pub mod details;
pub mod files;
pub mod trackers;

View File

@@ -553,8 +553,6 @@ pub fn TorrentTable() -> impl IntoView {
</div>
<div class="opacity-50">"VibeTorrent v3"</div>
</div>
<crate::components::torrent::details::TorrentDetailsSheet />
</div>
}.into_any()
}
@@ -597,12 +595,10 @@ fn TorrentRow(
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
>
<DataTableCell class="w-12 px-4">
<div on:click=move |e| e.stop_propagation()>
<Checkbox
checked=is_selected
on_checked_change=on_select
/>
</div>
<Checkbox
checked=is_selected
on_checked_change=on_select
/>
</DataTableCell>
{move || visible_columns.get().contains("Name").then({
@@ -732,23 +728,17 @@ fn TorrentCard(
}
)
on:click=move |_| {
let current = is_selected.get();
on_select.run(!current);
store.selected_torrent.set(Some(stored_hash.get_value()));
}
>
<div class="p-4 space-y-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">
<h3 class="text-sm font-bold leading-tight line-clamp-2 break-all">{t_name.clone()}</h3>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-bold leading-tight line-clamp-2 break-all">{t_name.clone()}</h3>
</div>
<Badge variant=status_variant class="uppercase tracking-wider text-[10px] shrink-0">
<Badge variant=status_variant class="uppercase tracking-wider text-[10px]">
{format!("{:?}", t.status)}
</Badge>
</div>

View File

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

@@ -3,17 +3,13 @@ use leptos_ui::clx;
mod components {
use super::*;
clx! {Card, div, "bg-card text-card-foreground flex flex-col gap-4 rounded-xl border py-6 shadow-sm"}
// 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! {CardHeader, div, "@container/card-header flex flex-col items-start gap-1.5 px-6 [.border-b]:pb-6"}
clx! {CardTitle, h2, "leading-none font-semibold"}
clx! {CardContent, div, "px-6"}
clx! {CardDescription, p, "text-muted-foreground text-sm"}
clx! {CardFooter, footer, "flex items-center px-6 [.border-t]:pt-6", "gap-2"}
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

@@ -215,20 +215,12 @@ pub fn ContextMenuContent(
// Adjust if menu would go off right edge
if (x + menuRect.width > viewportWidth) {{
left = Math.max(0, x - menuRect.width);
left = x - menuRect.width;
}}
// Adjust if menu would go off bottom edge
if (y + menuRect.height > viewportHeight) {{
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;
top = y - menuRect.height;
}}
menu.style.left = `${{left}}px`;
@@ -236,152 +228,105 @@ pub fn ContextMenuContent(
menu.style.transformOrigin = 'top left';
}};
const openMenu = (x, y) => {{
isOpen = true;
const openMenu = (x, y) => {{
isOpen = true;
// Close any other open context menus
const allMenus = document.querySelectorAll('[data-target="target__context"]');
allMenus.forEach(m => {{
if (m !== menu && m.getAttribute('data-state') === 'open') {{
m.setAttribute('data-state', 'closed');
m.style.pointerEvents = 'none';
}}
}});
menu.setAttribute('data-state', 'open');
menu.style.visibility = 'hidden';
menu.style.pointerEvents = 'auto';
// Force reflow
menu.offsetHeight;
updatePosition(x, y);
menu.style.visibility = 'visible';
// Lock scroll
if (window.ScrollLock) {{
window.ScrollLock.lock();
}}
setTimeout(() => {{
document.addEventListener('click', handleClickOutside);
document.addEventListener('contextmenu', handleContextOutside);
}}, 0);
}};
const closeMenu = () => {{
isOpen = false;
menu.setAttribute('data-state', 'closed');
menu.style.pointerEvents = 'none';
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('contextmenu', handleContextOutside);
// Dispatch custom event for Leptos to listen to
menu.dispatchEvent(new CustomEvent('contextmenuclose', {{ bubbles: false }}));
if (window.ScrollLock) {{
window.ScrollLock.unlock(200);
}}
}};
const handleClickOutside = (e) => {{
if (!menu.contains(e.target)) {{
closeMenu();
}}
}};
const handleContextOutside = (e) => {{
if (!trigger.contains(e.target)) {{
closeMenu();
}}
}};
// Right-click on trigger (desktop)
trigger.addEventListener('contextmenu', (e) => {{
e.preventDefault();
e.stopPropagation();
if (isOpen) {{
closeMenu();
}}
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;
}}
// Close any other open context menus
const allMenus = document.querySelectorAll('[data-target="target__context"]');
allMenus.forEach(m => {{
if (m !== menu && m.getAttribute('data-state') === 'open') {{
m.setAttribute('data-state', 'closed');
m.style.pointerEvents = 'none';
}}
}});
trigger.addEventListener('touchend', () => {{
if (touchTimer) {{
clearTimeout(touchTimer);
touchTimer = null;
}}
}});
menu.setAttribute('data-state', 'open');
menu.style.visibility = 'hidden';
menu.style.pointerEvents = 'auto';
trigger.addEventListener('touchcancel', () => {{
if (touchTimer) {{
clearTimeout(touchTimer);
touchTimer = null;
}}
}});
// Force reflow
menu.offsetHeight;
// Close when action is clicked
const actions = menu.querySelectorAll('[data-context-close]');
actions.forEach(action => {{
action.addEventListener('click', () => {{
closeMenu();
}});
}});
updatePosition(x, y);
menu.style.visibility = 'visible';
// Handle ESC key
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape' && isOpen) {{
e.preventDefault();
closeMenu();
}}
}});
// Lock scroll
if (window.ScrollLock) {{
window.ScrollLock.lock();
}}
setTimeout(() => {{
document.addEventListener('click', handleClickOutside);
document.addEventListener('contextmenu', handleContextOutside);
}}, 0);
}};
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', setupContextMenu);
}} else {{
setupContextMenu();
}}
}})();
"#,
target_id_for_script,
target_id_for_script,
)}
</script>
const closeMenu = () => {{
isOpen = false;
menu.setAttribute('data-state', 'closed');
menu.style.pointerEvents = 'none';
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('contextmenu', handleContextOutside);
// Dispatch custom event for Leptos to listen to
menu.dispatchEvent(new CustomEvent('contextmenuclose', {{ bubbles: false }}));
if (window.ScrollLock) {{
window.ScrollLock.unlock(200);
}}
}};
const handleClickOutside = (e) => {{
if (!menu.contains(e.target)) {{
closeMenu();
}}
}};
const handleContextOutside = (e) => {{
if (!trigger.contains(e.target)) {{
closeMenu();
}}
}};
// Right-click on trigger
trigger.addEventListener('contextmenu', (e) => {{
e.preventDefault();
e.stopPropagation();
if (isOpen) {{
closeMenu();
}}
openMenu(e.clientX, e.clientY);
}});
// Close when action is clicked
const actions = menu.querySelectorAll('[data-context-close]');
actions.forEach(action => {{
action.addEventListener('click', () => {{
closeMenu();
}});
}});
// Handle ESC key
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape' && isOpen) {{
e.preventDefault();
closeMenu();
}}
}});
}};
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', setupContextMenu);
}} else {{
setupContextMenu();
}}
}})();
"#,
target_id_for_script,
target_id_for_script,
)}
</script>
}
}

View File

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

View File

@@ -5,7 +5,7 @@ use leptos_ui::clx;
use tw_merge::*;
use crate::components::hooks::use_random::use_random_id_for;
pub use crate::components::ui::separator::Separator as DropdownMenuSeparator;
// pub use crate::components::ui::separator::Separator as DropdownMenuSeparator;
mod components {
use super::*;

View File

@@ -14,14 +14,11 @@ pub mod input;
pub mod multi_select;
pub mod select;
pub mod separator;
pub mod scroll_area;
pub mod sheet;
pub mod sidenav;
pub mod skeleton;
pub mod shimmer;
pub mod svg_icon;
pub mod switch;
pub mod table;
pub mod tabs;
pub mod theme_toggle;
pub mod toast;

View File

@@ -9,7 +9,7 @@ use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
use crate::components::hooks::use_random::use_random_id_for;
// * Reuse @select.rs
pub use crate::components::ui::select::{
SelectGroup as MultiSelectGroup, SelectItem as MultiSelectItem, SelectLabel as MultiSelectLabel,
SelectGroup as MultiSelectGroup, SelectItem as MultiSelectItem,
};
#[derive(Clone, Copy, PartialEq, Eq, Default)]

View File

@@ -1,102 +0,0 @@
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,6 +16,8 @@ mod components {
clx! {SheetFooter, footer, "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"}
}
// pub use components::*;
/* ========================================================== */
/* ✨ CONTEXT ✨ */
/* ========================================================== */

View File

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

@@ -145,7 +145,8 @@ pub fn Sidenav(
data-name="Sidenav"
data-sidenav=data_state.to_string()
data-side=data_side.to_string()
class="hidden md:block group peer text-sidenav-foreground data-[state=Collapsed]:hidden"
data-collapsible=data_collapsible.to_string()
class="hidden md:block group peer text-sidenav-foreground group-data-[collapsible=Offcanvas]:data-[state=Collapsed]:hidden"
>
// * SidenavGap: This is what handles the sidenav gap on desktop
<div
@@ -155,9 +156,9 @@ pub fn Sidenav(
"group-data-[collapsible=Offcanvas]:w-0",
"group-data-[side=Right]:rotate-180",
match variant {
SidenavVariant::Sidenav => "group-data-[collapsible=Icon]:w-(--sidenav-width-icon)",
SidenavVariant::Sidenav => "group-data-[collapsible=Icon]:group-data-[state=Collapsed]:w-(--sidenav-width-icon)",
SidenavVariant::Floating | SidenavVariant::Inset =>
"group-data-[collapsible=Icon]:w-[calc(var(--sidenav-width-icon)+(--spacing(4)))]",
"group-data-[collapsible=Icon]:group-data-[state=Collapsed]:w-[calc(var(--sidenav-width-icon)+(--spacing(4)))]",
}
)
/>
@@ -171,9 +172,9 @@ pub fn Sidenav(
SidenavSide::Right => "right-0 group-data-[collapsible=Offcanvas]:right-[calc(var(--sidenav-width)*-1)]"
},
match variant {
SidenavVariant::Sidenav => "group-data-[collapsible=Icon]:w-(--sidenav-width-icon) group-data-[side=Left]:border-r group-data-[side=Right]:border-l",
SidenavVariant::Sidenav => "group-data-[collapsible=Icon]:group-data-[state=Collapsed]:w-(--sidenav-width-icon) group-data-[side=Left]:border-r group-data-[side=Right]:border-l",
SidenavVariant::Floating | SidenavVariant::Inset =>
"p-2 group-data-[collapsible=Icon]:w-[calc(var(--sidenav-width-icon)+(--spacing(4))+2px)]",
"p-2 group-data-[collapsible=Icon]:group-data-[state=Collapsed]:w-[calc(var(--sidenav-width-icon)+(--spacing(4))+2px)]",
},
)
>

View File

@@ -1,108 +0,0 @@
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,7 +5,8 @@ use leptos::task::spawn_local;
use shared::{AppEvent, GlobalStats, NotificationLevel, Torrent};
use std::collections::HashMap;
use struct_patch::traits::Patch;
use base64::{Engine as _, engine::general_purpose::{URL_SAFE_NO_PAD as BASE64_URL, STANDARD as BASE64}};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use wasm_bindgen::JsCast;
use crate::components::ui::toast::{ToastType, toast};

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = "vibetorrent-v3";
const CACHE_NAME = "vibetorrent-v2";
const ASSETS_TO_CACHE = [
"/",
"/index.html",
@@ -51,11 +51,6 @@ self.addEventListener("activate", (event) => {
self.addEventListener("fetch", (event) => {
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
if (url.pathname.startsWith("/api/")) {
event.respondWith(
@@ -80,12 +75,10 @@ self.addEventListener("fetch", (event) => {
fetch(event.request)
.then((response) => {
// Cache the latest version of the HTML
if (response && response.status === 200) {
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
}
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
})
.catch(() => {

View File

@@ -42,11 +42,6 @@ pub struct Torrent {
pub error_message: String,
pub added_date: i64,
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)]
@@ -126,13 +121,6 @@ pub struct TorrentTracker {
pub url: String,
pub status: 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)]

View File

@@ -83,19 +83,12 @@ impl ScgiRequest {
pub async fn send_request(socket_path: &str, request: ScgiRequest) -> Result<Bytes, ScgiError> {
let perform_request = async {
let mut stream = UnixStream::connect(socket_path).await?;
let data = request.encode();
let mut response = Vec::new();
stream.write_all(&data).await?;
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?;
} else {
let mut stream = tokio::net::UnixStream::connect(socket_path).await?;
stream.write_all(&data).await?;
stream.read_to_end(&mut response).await?;
}
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
Ok::<Vec<u8>, std::io::Error>(response)
};

View File

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

View File

@@ -1,26 +0,0 @@
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),
}
}