feat: Implement PWA support, responsive drawer layout, and custom context menu
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
use crate::components::layout::sidebar::Sidebar;
|
||||
use crate::components::layout::toolbar::Toolbar;
|
||||
use crate::components::layout::statusbar::StatusBar;
|
||||
@@ -9,22 +10,32 @@ pub fn App() -> impl IntoView {
|
||||
crate::store::provide_torrent_store();
|
||||
|
||||
view! {
|
||||
<div class="flex flex-col h-screen w-screen overflow-hidden bg-base-100 text-base-content text-sm select-none">
|
||||
// Toolbar at the top
|
||||
<Toolbar />
|
||||
<div class="drawer lg:drawer-open">
|
||||
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
|
||||
|
||||
<div class="drawer-content flex flex-col h-screen overflow-hidden bg-base-100 text-base-content text-sm select-none transition-colors duration-300">
|
||||
// Toolbar at the top
|
||||
<Toolbar />
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
// Sidebar on the left
|
||||
<Sidebar />
|
||||
|
||||
// Main Content Area
|
||||
<main class="flex-1 flex flex-col min-w-0 bg-base-100">
|
||||
<TorrentTable />
|
||||
<main class="flex-1 flex flex-col min-w-0 bg-base-100 overflow-hidden p-4 md:p-6 space-y-6">
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" view=move || view! { <TorrentTable /> } />
|
||||
<Route path="/settings" view=move || view! { <div class="p-4">"Settings Page (Coming Soon)"</div> } />
|
||||
</Routes>
|
||||
</Router>
|
||||
</main>
|
||||
|
||||
// Status Bar at the bottom
|
||||
<StatusBar />
|
||||
</div>
|
||||
|
||||
// Status Bar at the bottom
|
||||
<StatusBar />
|
||||
<div class="drawer-side z-40">
|
||||
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
<div class="menu p-0 min-h-full bg-base-200 text-base-content border-r border-base-300">
|
||||
<Sidebar />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,40 @@
|
||||
use leptos::*;
|
||||
use leptos::html::Div;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
pub fn use_click_outside(
|
||||
target: NodeRef<Div>,
|
||||
callback: impl Fn() + Clone + 'static,
|
||||
) {
|
||||
create_effect(move |_| {
|
||||
if let Some(_) = target.get() {
|
||||
let handle_click = {
|
||||
let callback = callback.clone();
|
||||
let target = target.clone();
|
||||
move |ev: web_sys::MouseEvent| {
|
||||
if let Some(el) = target.get() {
|
||||
let ev_target = ev.target().unwrap().unchecked_into::<web_sys::Node>();
|
||||
let el_node = el.unchecked_ref::<web_sys::Node>();
|
||||
if !el_node.contains(Some(&ev_target)) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let window = web_sys::window().unwrap();
|
||||
let closure = wasm_bindgen::closure::Closure::<dyn FnMut(_)>::new(handle_click);
|
||||
let _ = window.add_event_listener_with_callback("click", closure.as_ref().unchecked_ref());
|
||||
|
||||
// Cleanup
|
||||
on_cleanup(move || {
|
||||
let window = web_sys::window().unwrap();
|
||||
let _ = window.remove_event_listener_with_callback("click", closure.as_ref().unchecked_ref());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
#[component]
|
||||
pub fn ContextMenu(
|
||||
@@ -17,69 +53,72 @@ pub fn ContextMenu(
|
||||
on_close.call(()); // Close menu AFTER
|
||||
};
|
||||
|
||||
let target = create_node_ref::<Div>();
|
||||
|
||||
use_click_outside(target, move || {
|
||||
if visible {
|
||||
on_close.call(());
|
||||
}
|
||||
});
|
||||
|
||||
if !visible {
|
||||
return view! {}.into_view();
|
||||
}
|
||||
|
||||
view! {
|
||||
<div
|
||||
class="fixed inset-0 z-[100]"
|
||||
on:click=move |_| on_close.call(())
|
||||
node_ref=target
|
||||
class="fixed z-[100] bg-[#111116]/95 backdrop-blur-xl border border-white/10 rounded-xl shadow-2xl py-2 min-w-[200px] animate-in fade-in zoom-in-95 duration-100"
|
||||
style=format!("left: {}px; top: {}px", position.0, position.1)
|
||||
on:contextmenu=move |e| e.prevent_default()
|
||||
>
|
||||
<div
|
||||
class="absolute bg-[#111116]/95 backdrop-blur-xl border border-white/10 rounded-xl shadow-2xl py-2 min-w-[200px] animate-in fade-in zoom-in-95 duration-100"
|
||||
style=format!("left: {}px; top: {}px", position.0, position.1)
|
||||
on:click=move |e| e.stop_propagation()
|
||||
<div class="px-3 py-1 text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">"Actions"</div>
|
||||
|
||||
<button
|
||||
class="w-full text-left px-4 py-2.5 hover:bg-white/10 flex items-center gap-3 transition-colors text-white"
|
||||
on:click={
|
||||
let handle_action = handle_action.clone();
|
||||
move |_| handle_action("start")
|
||||
}
|
||||
>
|
||||
<div class="px-3 py-1 text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">"Actions"</div>
|
||||
|
||||
<button
|
||||
class="w-full text-left px-4 py-2.5 hover:bg-white/10 flex items-center gap-3 transition-colors text-white"
|
||||
on:click={
|
||||
let handle_action = handle_action.clone();
|
||||
move |_| handle_action("start")
|
||||
}
|
||||
>
|
||||
<svg class="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
"Resume"
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="w-full text-left px-4 py-2.5 hover:bg-white/10 flex items-center gap-3 transition-colors text-white"
|
||||
on:click={
|
||||
let handle_action = handle_action.clone();
|
||||
move |_| handle_action("stop")
|
||||
}
|
||||
>
|
||||
<svg class="w-4 h-4 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
"Pause"
|
||||
</button>
|
||||
|
||||
<div class="h-px bg-white/10 my-1"></div>
|
||||
|
||||
<button
|
||||
class="w-full text-left px-4 py-2.5 hover:bg-red-500/20 text-red-500 hover:text-red-400 flex items-center gap-3 transition-colors"
|
||||
on:click={
|
||||
let handle_action = handle_action.clone();
|
||||
move |_| handle_action("delete")
|
||||
}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
"Delete"
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="w-full text-left px-4 py-2.5 hover:bg-red-900/20 text-red-600 hover:text-red-400 flex items-center gap-3 transition-colors text-xs"
|
||||
on:click={
|
||||
let handle_action = handle_action.clone();
|
||||
move |_| handle_action("delete_with_data")
|
||||
}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||
<span>"Delete with Data"</span>
|
||||
</button>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
"Resume"
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="w-full text-left px-4 py-2.5 hover:bg-white/10 flex items-center gap-3 transition-colors text-white"
|
||||
on:click={
|
||||
let handle_action = handle_action.clone();
|
||||
move |_| handle_action("stop")
|
||||
}
|
||||
>
|
||||
<svg class="w-4 h-4 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
"Pause"
|
||||
</button>
|
||||
|
||||
<div class="h-px bg-white/10 my-1"></div>
|
||||
|
||||
<button
|
||||
class="w-full text-left px-4 py-2.5 hover:bg-red-500/20 text-red-500 hover:text-red-400 flex items-center gap-3 transition-colors"
|
||||
on:click={
|
||||
let handle_action = handle_action.clone();
|
||||
move |_| handle_action("delete")
|
||||
}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
"Delete"
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="w-full text-left px-4 py-2.5 hover:bg-red-900/20 text-red-600 hover:text-red-400 flex items-center gap-3 transition-colors text-xs"
|
||||
on:click={
|
||||
let handle_action = handle_action.clone();
|
||||
move |_| handle_action("delete_with_data")
|
||||
}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||
<span>"Delete with Data"</span>
|
||||
</button>
|
||||
</div>
|
||||
}.into_view()
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ pub fn Sidebar() -> impl IntoView {
|
||||
};
|
||||
|
||||
view! {
|
||||
<aside class="w-64 bg-base-200 h-full flex flex-col border-r border-base-300">
|
||||
<div class="w-64 h-full flex flex-col">
|
||||
<div class="p-4">
|
||||
<h2 class="text-xl font-bold px-4 mb-2 text-primary">"Filters"</h2>
|
||||
<ul class="menu w-full rounded-box gap-1">
|
||||
@@ -78,6 +78,6 @@ pub fn Sidebar() -> impl IntoView {
|
||||
<li><a>"Error"</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ use leptos::*;
|
||||
pub fn Toolbar() -> impl IntoView {
|
||||
view! {
|
||||
<div class="h-14 min-h-14 flex items-center px-4 border-b border-base-300 bg-base-100 gap-4">
|
||||
<label for="my-drawer" class="btn btn-square btn-ghost lg:hidden drawer-button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-5 h-5 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
|
||||
</label>
|
||||
<div class="join">
|
||||
<button class="join-item btn btn-sm btn-outline gap-2" title="Open Torrent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
|
||||
@@ -106,9 +106,33 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
}
|
||||
};
|
||||
|
||||
let (menu_visible, set_menu_visible) = create_signal(false);
|
||||
let (menu_position, set_menu_position) = create_signal((0, 0));
|
||||
let (active_hash, set_active_hash) = create_signal(String::new());
|
||||
|
||||
let handle_context_menu = move |e: web_sys::MouseEvent, hash: String| {
|
||||
e.prevent_default();
|
||||
set_menu_position.set((e.client_x(), e.client_y()));
|
||||
set_active_hash.set(hash);
|
||||
set_menu_visible.set(true);
|
||||
};
|
||||
|
||||
let on_action = move |(action, hash): (String, String)| {
|
||||
logging::log!("TorrentTable Action: {} on {}", action, hash);
|
||||
// TODO: Implement actual store calls here (start/stop/delete)
|
||||
match action.as_str() {
|
||||
"start" => { /* store.start_torrent(&hash) */ },
|
||||
"stop" => { /* store.stop_torrent(&hash) */ },
|
||||
"delete" => { /* store.delete_torrent(&hash, false) */ },
|
||||
"delete_with_data" => { /* store.delete_torrent(&hash, true) */ },
|
||||
_ => {}
|
||||
}
|
||||
set_menu_visible.set(false);
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="overflow-x-auto h-full bg-base-100">
|
||||
<table class="table table-xs table-pin-rows w-full max-w-full">
|
||||
<div class="overflow-x-auto h-full bg-base-100 relative"> // Added relative for positioning context if needed, though menu is fixed
|
||||
<table class="table table-xs table-pin-rows w-full max-w-full whitespace-nowrap">
|
||||
<thead>
|
||||
<tr class="bg-base-200 text-base-content/70">
|
||||
<th class="w-8">
|
||||
@@ -128,8 +152,6 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
<th class="w-24 cursor-pointer hover:bg-base-300 transition-colors group select-none" on:click=move |_| handle_sort(SortColumn::Status)>
|
||||
<div class="flex items-center">"Status" {move || sort_arrow(SortColumn::Status)}</div>
|
||||
</th>
|
||||
// <th class="w-20">"Seeds"</th> // Not available in shared::Torrent
|
||||
// <th class="w-20">"Peers"</th> // Not available in shared::Torrent
|
||||
<th class="w-24 cursor-pointer hover:bg-base-300 transition-colors group select-none" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
|
||||
<div class="flex items-center">"Down Speed" {move || sort_arrow(SortColumn::DownSpeed)}</div>
|
||||
</th>
|
||||
@@ -152,9 +174,16 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
shared::TorrentStatus::Error => "text-error",
|
||||
_ => "text-base-content/50"
|
||||
};
|
||||
let t_hash = t.hash.clone(); // Clone for closure using it in handler
|
||||
|
||||
view! {
|
||||
<tr class="hover group border-b border-base-200">
|
||||
<tr
|
||||
class="hover group border-b border-base-200 cursor-context-menu"
|
||||
on:contextmenu={
|
||||
let t_hash = t_hash.clone();
|
||||
move |e: web_sys::MouseEvent| handle_context_menu(e, t_hash.clone())
|
||||
}
|
||||
>
|
||||
<th>
|
||||
<label>
|
||||
<input type="checkbox" class="checkbox checkbox-xs rounded-none" />
|
||||
@@ -171,16 +200,22 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
</div>
|
||||
</td>
|
||||
<td class={format!("text-[11px] font-medium {}", status_class)}>{status_str}</td>
|
||||
// <td class="text-right font-mono text-[11px] opacity-80">-</td>
|
||||
// <td class="text-right font-mono text-[11px] opacity-80">-</td>
|
||||
<td class="text-right font-mono text-[11px] opacity-80 text-success">{format_speed(t.down_rate)}</td>
|
||||
<td class="text-right font-mono text-[11px] opacity-80 text-primary">{format_speed(t.up_rate)}</td>
|
||||
<td class="text-right font-mono text-[11px] opacity-80">{if t.eta > 0 { format!("{}s", t.eta) } else { "∞".to_string() }}</td> // Temporary ETA format
|
||||
<td class="text-right font-mono text-[11px] opacity-80">{if t.eta > 0 { format!("{}s", t.eta) } else { "∞".to_string() }}</td>
|
||||
</tr>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<crate::components::context_menu::ContextMenu
|
||||
visible=menu_visible.get()
|
||||
position=menu_position.get()
|
||||
torrent_hash=active_hash.get()
|
||||
on_close=Callback::from(move |_| set_menu_visible.set(false))
|
||||
on_action=Callback::from(on_action)
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user