Compare commits
8 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2dfa7963e | ||
|
|
d3792e78e0 | ||
|
|
384165a958 | ||
|
|
7169e44f4e | ||
|
|
51fb85c2d8 | ||
|
|
46882caea0 | ||
|
|
e339ca1c49 | ||
|
|
a08fd9698f |
110
Cargo.lock
generated
110
Cargo.lock
generated
@@ -566,6 +566,15 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codee"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d3ad3122b0001c7f140cf4d605ef9a9e2c24d96ab0b4fb4347b76de2425f445"
|
||||
dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "collection_literals"
|
||||
version = "1.0.3"
|
||||
@@ -790,6 +799,41 @@ version = "1.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8"
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "5.5.3"
|
||||
@@ -823,6 +867,18 @@ version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "default-struct-builder"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0df63c21a4383f94bd5388564829423f35c316aed85dc4f8427aded372c7c0d"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.4.5"
|
||||
@@ -1144,7 +1200,9 @@ dependencies = [
|
||||
name = "frontend"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"codee",
|
||||
"console_error_panic_hook",
|
||||
"console_log",
|
||||
"futures",
|
||||
@@ -1152,9 +1210,11 @@ dependencies = [
|
||||
"gloo-timers",
|
||||
"js-sys",
|
||||
"leptos",
|
||||
"leptos-use",
|
||||
"leptos_router",
|
||||
"log",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
"shared",
|
||||
"tailwind_fuse",
|
||||
@@ -1819,6 +1879,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -1986,6 +2052,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-use"
|
||||
version = "0.13.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "789bf9f4337e6ebd8f1b407e3f762fdc538d48dc145f9d1dce2338014b38f4dd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"chrono",
|
||||
"codee",
|
||||
"cookie",
|
||||
"default-struct-builder",
|
||||
"futures-util",
|
||||
"gloo-timers",
|
||||
"js-sys",
|
||||
"lazy_static",
|
||||
"leptos",
|
||||
"paste",
|
||||
"thiserror 1.0.69",
|
||||
"unic-langid",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos_config"
|
||||
version = "0.6.15"
|
||||
@@ -3934,6 +4024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -4298,6 +4389,24 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unic-langid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05"
|
||||
dependencies = [
|
||||
"unic-langid-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unic-langid-impl"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658"
|
||||
dependencies = [
|
||||
"tinystr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
@@ -5055,6 +5164,7 @@ version = "0.11.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
|
||||
@@ -22,33 +22,7 @@ wasm-bindgen-futures = "0.4"
|
||||
uuid = { version = "1", features = ["v4", "js"] }
|
||||
futures = "0.3"
|
||||
chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] }
|
||||
web-sys = { version = "0.3", features = [
|
||||
"HtmlDivElement",
|
||||
"HtmlUListElement",
|
||||
"HtmlLiElement",
|
||||
"HtmlAnchorElement",
|
||||
"MouseEvent",
|
||||
"Event",
|
||||
"Window",
|
||||
"Document",
|
||||
"Element",
|
||||
"DomTokenList",
|
||||
"CssStyleDeclaration",
|
||||
"Storage",
|
||||
"TouchEvent",
|
||||
"TouchList",
|
||||
"Touch",
|
||||
"Navigator",
|
||||
"Notification",
|
||||
"NotificationOptions",
|
||||
"NotificationPermission",
|
||||
"ServiceWorkerContainer",
|
||||
"ServiceWorkerRegistration",
|
||||
"PushManager",
|
||||
"PushSubscription",
|
||||
"PushSubscriptionOptions",
|
||||
"PushSubscriptionOptionsInit"
|
||||
] }
|
||||
web-sys = { version = "0.3", features = ["HtmlDivElement", "HtmlUListElement", "HtmlLiElement", "HtmlAnchorElement", "MouseEvent", "Event", "Window", "Document", "Element", "DomTokenList", "CssStyleDeclaration", "Storage", "TouchEvent", "TouchList", "Touch", "Navigator", "Notification", "NotificationOptions", "NotificationPermission", "ServiceWorkerContainer", "ServiceWorkerRegistration", "PushManager", "PushSubscription", "PushSubscriptionOptions", "PushSubscriptionOptionsInit", "HtmlDetailsElement"] }
|
||||
shared = { path = "../shared" }
|
||||
tailwind_fuse = "0.3.2"
|
||||
js-sys = "0.3.85"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>VibeTorrent</title>
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body style="cursor: pointer;">
|
||||
<div
|
||||
id="app-loading"
|
||||
style="
|
||||
@@ -142,6 +142,19 @@
|
||||
body.app-loaded #app-loading {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* iOS Safari Click Fixes */
|
||||
body {
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
summary {
|
||||
list-style: none;
|
||||
}
|
||||
summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@@ -174,6 +187,17 @@
|
||||
|
||||
<!-- Service Worker Registration & PWA Setup -->
|
||||
<script>
|
||||
// Global Dropdown Closer for iOS/Mobile
|
||||
document.addEventListener('click', function(event) {
|
||||
const details = document.querySelectorAll('details[open]');
|
||||
details.forEach(detail => {
|
||||
// Eğer tıklanan yer bu details'in içinde değilse kapat
|
||||
if (!detail.contains(event.target)) {
|
||||
detail.removeAttribute('open');
|
||||
}
|
||||
});
|
||||
}, true); // Use capture phase for better mobile support
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use leptos::*;
|
||||
use leptos_use::on_click_outside;
|
||||
|
||||
#[component]
|
||||
pub fn ContextMenu(
|
||||
@@ -8,6 +9,10 @@ pub fn ContextMenu(
|
||||
on_close: Callback<()>,
|
||||
on_action: Callback<(String, String)>, // (Action, Hash)
|
||||
) -> impl IntoView {
|
||||
let container_ref = create_node_ref::<html::Div>();
|
||||
|
||||
let _ = on_click_outside(container_ref, move |_| on_close.call(()));
|
||||
|
||||
let handle_action = move |action: &str| {
|
||||
let hash = torrent_hash.clone();
|
||||
let action_str = action.to_string();
|
||||
@@ -22,16 +27,8 @@ pub fn ContextMenu(
|
||||
}
|
||||
|
||||
view! {
|
||||
// Backdrop to catch clicks outside
|
||||
<div
|
||||
class="fixed inset-0 z-[99] cursor-default"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
on:click=move |_| on_close.call(())
|
||||
on:contextmenu=move |e| e.prevent_default()
|
||||
></div>
|
||||
|
||||
<div
|
||||
node_ref=container_ref
|
||||
class="fixed z-[100] min-w-[200px] animate-in fade-in zoom-in-95 duration-100"
|
||||
style=format!("left: {}px; top: {}px", position.0, position.1)
|
||||
on:contextmenu=move |e| e.prevent_default()
|
||||
|
||||
@@ -96,45 +96,24 @@ pub fn StatusBar() -> impl IntoView {
|
||||
});
|
||||
};
|
||||
|
||||
// Signal-based dropdown state: 0=none, 1=download, 2=upload, 3=theme
|
||||
let (active_dropdown, set_active_dropdown) = create_signal(0u8);
|
||||
// Refs for click outside detection (Handled globally via JS in index.html for better iOS support)
|
||||
let down_details_ref = create_node_ref::<html::Details>();
|
||||
let up_details_ref = create_node_ref::<html::Details>();
|
||||
let theme_details_ref = create_node_ref::<html::Details>();
|
||||
|
||||
// Toggle a specific dropdown
|
||||
let toggle = move |id: u8| {
|
||||
let current = active_dropdown.get_untracked();
|
||||
if current == id {
|
||||
set_active_dropdown.set(0);
|
||||
} else {
|
||||
set_active_dropdown.set(id);
|
||||
// Helper to close a details element
|
||||
let close_details = |node_ref: NodeRef<html::Details>| {
|
||||
if let Some(el) = node_ref.get_untracked() {
|
||||
el.set_open(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Close all dropdowns
|
||||
let close_all = move || {
|
||||
set_active_dropdown.set(0);
|
||||
};
|
||||
|
||||
view! {
|
||||
// Transparent overlay to close dropdowns when clicking outside
|
||||
<Show when=move || active_dropdown.get() != 0>
|
||||
<div
|
||||
class="fixed inset-0 z-[98] cursor-default"
|
||||
on:pointerdown=move |_| close_all()
|
||||
></div>
|
||||
</Show>
|
||||
|
||||
<div class="fixed bottom-0 left-0 right-0 h-8 min-h-8 bg-base-200 border-t border-base-300 flex items-center px-4 text-xs gap-4 text-base-content/70 z-[99]">
|
||||
<div class="fixed bottom-0 left-0 right-0 h-8 min-h-8 bg-base-200 border-t border-base-300 flex items-center px-4 text-xs gap-4 text-base-content/70 z-[99] cursor-pointer">
|
||||
|
||||
// --- DOWNLOAD SPEED DROPDOWN ---
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none"
|
||||
title="Global Download Speed - Click to Set Limit"
|
||||
on:pointerdown=move |e| {
|
||||
e.stop_propagation();
|
||||
toggle(1);
|
||||
}
|
||||
>
|
||||
<details class="dropdown dropdown-top" node_ref=down_details_ref>
|
||||
<summary class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
|
||||
</svg>
|
||||
@@ -144,12 +123,9 @@ pub fn StatusBar() -> impl IntoView {
|
||||
{move || format!("(Limit: {})", format_speed(stats.get().down_limit.unwrap_or(0)))}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<ul
|
||||
class="absolute bottom-full left-0 z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300"
|
||||
style=move || if active_dropdown.get() == 1 { "display: block" } else { "display: none" }
|
||||
>
|
||||
<ul class="dropdown-content z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300">
|
||||
{
|
||||
limits.clone().into_iter().map(|(val, label)| {
|
||||
let is_active = move || {
|
||||
@@ -160,10 +136,9 @@ pub fn StatusBar() -> impl IntoView {
|
||||
<li>
|
||||
<button
|
||||
class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" }
|
||||
on:pointerdown=move |e| {
|
||||
e.stop_propagation();
|
||||
on:click=move |_| {
|
||||
set_limit("down", val);
|
||||
close_all();
|
||||
close_details(down_details_ref);
|
||||
}
|
||||
>
|
||||
{label}
|
||||
@@ -176,18 +151,11 @@ pub fn StatusBar() -> impl IntoView {
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
// --- UPLOAD SPEED DROPDOWN ---
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none"
|
||||
title="Global Upload Speed - Click to Set Limit"
|
||||
on:pointerdown=move |e| {
|
||||
e.stop_propagation();
|
||||
toggle(2);
|
||||
}
|
||||
>
|
||||
<details class="dropdown dropdown-top" node_ref=up_details_ref>
|
||||
<summary class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none list-none [&::-webkit-details-marker]:hidden outline-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18" />
|
||||
</svg>
|
||||
@@ -197,12 +165,9 @@ pub fn StatusBar() -> impl IntoView {
|
||||
{move || format!("(Limit: {})", format_speed(stats.get().up_limit.unwrap_or(0)))}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<ul
|
||||
class="absolute bottom-full left-0 z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300"
|
||||
style=move || if active_dropdown.get() == 2 { "display: block" } else { "display: none" }
|
||||
>
|
||||
<ul class="dropdown-content z-[100] menu p-2 shadow bg-base-200 rounded-box w-40 mb-2 border border-base-300">
|
||||
{
|
||||
limits.clone().into_iter().map(|(val, label)| {
|
||||
let is_active = move || {
|
||||
@@ -213,10 +178,9 @@ pub fn StatusBar() -> impl IntoView {
|
||||
<li>
|
||||
<button
|
||||
class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" }
|
||||
on:pointerdown=move |e| {
|
||||
e.stop_propagation();
|
||||
on:click=move |_| {
|
||||
set_limit("up", val);
|
||||
close_all();
|
||||
close_details(up_details_ref);
|
||||
}
|
||||
>
|
||||
{label}
|
||||
@@ -229,27 +193,17 @@ pub fn StatusBar() -> impl IntoView {
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="btn btn-ghost btn-xs btn-square"
|
||||
title="Change Theme"
|
||||
on:pointerdown=move |e| {
|
||||
e.stop_propagation();
|
||||
toggle(3);
|
||||
}
|
||||
>
|
||||
<details class="dropdown dropdown-top dropdown-end" node_ref=theme_details_ref>
|
||||
<summary class="btn btn-ghost btn-xs btn-square cursor-pointer outline-none list-none [&::-webkit-details-marker]:hidden">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
|
||||
</svg>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<ul
|
||||
class="absolute bottom-full right-0 z-[100] menu p-2 shadow bg-base-200 rounded-box w-52 mb-2 border border-base-300 max-h-96 overflow-y-auto"
|
||||
style=move || if active_dropdown.get() == 3 { "display: block" } else { "display: none" }
|
||||
>
|
||||
<ul class="dropdown-content z-[100] menu p-2 shadow bg-base-200 rounded-box w-52 mb-2 border border-base-300 max-h-96 overflow-y-auto">
|
||||
{
|
||||
let themes = vec![
|
||||
"light", "dark", "dim", "nord", "cupcake", "dracula", "cyberpunk", "emerald", "sunset", "abyss"
|
||||
@@ -259,10 +213,9 @@ pub fn StatusBar() -> impl IntoView {
|
||||
<li>
|
||||
<button
|
||||
class=move || if current_theme.get() == theme { "bg-primary/10 text-primary font-bold text-xs capitalize" } else { "text-xs capitalize" }
|
||||
on:pointerdown=move |e| {
|
||||
e.stop_propagation();
|
||||
on:click=move |_| {
|
||||
set_current_theme.set(theme.to_string());
|
||||
close_all();
|
||||
close_details(theme_details_ref);
|
||||
}
|
||||
>
|
||||
{theme}
|
||||
@@ -272,7 +225,7 @@ pub fn StatusBar() -> impl IntoView {
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs btn-square"
|
||||
title="Settings & Notification Permissions"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use leptos::*;
|
||||
use leptos_use::use_timeout_fn;
|
||||
use leptos_use::{on_click_outside, use_timeout_fn};
|
||||
use crate::store::{get_action_messages, show_toast_with_signal};
|
||||
use shared::NotificationLevel;
|
||||
|
||||
@@ -164,8 +164,13 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
}
|
||||
};
|
||||
|
||||
// Signal-based sort dropdown for mobile
|
||||
let (sort_open, set_sort_open) = create_signal(false);
|
||||
// Refs for click outside detection
|
||||
let sort_details_ref = create_node_ref::<html::Details>();
|
||||
let _ = on_click_outside(sort_details_ref, move |_| {
|
||||
if let Some(el) = sort_details_ref.get_untracked() {
|
||||
el.set_open(false);
|
||||
}
|
||||
});
|
||||
|
||||
let sort_arrow = move |col: SortColumn| {
|
||||
if sort_col.get() == col {
|
||||
@@ -340,29 +345,18 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="md:hidden flex flex-col h-full bg-base-200 relative">
|
||||
<div class="px-3 py-2 border-b border-base-200 flex justify-between items-center bg-base-100/95 backdrop-blur z-10 shrink-0">
|
||||
<div class="md:hidden flex flex-col h-full bg-base-200 relative cursor-pointer">
|
||||
<div class="px-3 py-2 border-b border-base-200 flex justify-between items-center bg-base-100/95 backdrop-blur z-10 shrink-0 cursor-default">
|
||||
<span class="text-xs font-bold opacity-50 uppercase tracking-wider">"Torrents"</span>
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
role="button"
|
||||
class="btn btn-ghost btn-xs gap-1 opacity-70 font-normal"
|
||||
on:pointerdown=move |e| {
|
||||
e.stop_propagation();
|
||||
let cur = sort_open.get_untracked();
|
||||
set_sort_open.set(!cur);
|
||||
}
|
||||
>
|
||||
<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">
|
||||
<details class="dropdown dropdown-end" node_ref=sort_details_ref>
|
||||
<summary class="btn btn-ghost btn-xs gap-1 opacity-70 font-normal list-none [&::-webkit-details-marker]:hidden cursor-pointer">
|
||||
<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 pointer-events-none">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5" />
|
||||
</svg>
|
||||
"Sort"
|
||||
</div>
|
||||
<ul
|
||||
class="absolute top-full right-0 z-[100] menu p-2 shadow bg-base-100 rounded-box w-48 mt-1 border border-base-200 text-xs"
|
||||
style=move || if sort_open.get() { "display: block" } else { "display: none" }
|
||||
>
|
||||
<span class="pointer-events-none">"Sort"</span>
|
||||
</summary>
|
||||
<ul class="dropdown-content z-[100] menu p-2 shadow bg-base-100 rounded-box w-48 mt-1 border border-base-200 text-xs cursor-default">
|
||||
<li class="menu-title px-2 py-1 opacity-50 text-[10px] uppercase font-bold">"Sort By"</li>
|
||||
{
|
||||
let columns = vec![
|
||||
@@ -385,10 +379,11 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
<button
|
||||
type="button"
|
||||
class=move || if is_active() { "bg-primary/10 text-primary font-bold flex justify-between" } else { "flex justify-between" }
|
||||
on:pointerdown=move |e| {
|
||||
e.stop_propagation();
|
||||
on:click=move |_| {
|
||||
handle_sort(col);
|
||||
set_sort_open.set(false);
|
||||
if let Some(el) = sort_details_ref.get_untracked() {
|
||||
el.set_open(false);
|
||||
}
|
||||
}
|
||||
>
|
||||
{label}
|
||||
@@ -406,10 +401,10 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto p-3 pb-20 flex-1 grid grid-cols-1 content-start gap-3"> {move || filtered_torrents().into_iter().map(|t| {
|
||||
<div class="overflow-y-auto p-3 pb-20 flex-1 grid grid-cols-1 content-start gap-3 cursor-pointer"> {move || filtered_torrents().into_iter().map(|t| {
|
||||
let progress_class = if t.percent_complete >= 100.0 { "progress-success" } else { "progress-primary" };
|
||||
let status_str = format!("{:?}", t.status);
|
||||
let status_badge_class = match t.status {
|
||||
@@ -464,7 +459,7 @@ pub fn TorrentTable() -> impl IntoView {
|
||||
view! {
|
||||
<div
|
||||
class=move || {
|
||||
"card card-compact bg-base-100 shadow-sm border border-base-200 transition-transform active:scale-[0.99] select-none"
|
||||
"card card-compact bg-base-100 shadow-sm border border-base-200 transition-transform active:scale-[0.99] select-none cursor-pointer"
|
||||
}
|
||||
style="user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;"
|
||||
on:contextmenu={
|
||||
|
||||
@@ -131,6 +131,9 @@ pub fn provide_torrent_store() {
|
||||
let notifications = create_rw_signal(Vec::<NotificationItem>::new());
|
||||
let user = create_rw_signal(Option::<String>::None);
|
||||
|
||||
// Browser notification hook
|
||||
let show_browser_notification = crate::utils::notification::use_app_notification();
|
||||
|
||||
let store = TorrentStore {
|
||||
torrents,
|
||||
filter,
|
||||
@@ -149,6 +152,7 @@ pub fn provide_torrent_store() {
|
||||
return;
|
||||
}
|
||||
|
||||
let show_browser_notification = show_browser_notification.clone();
|
||||
spawn_local(async move {
|
||||
let mut backoff_ms: u32 = 1000; // Start with 1 second
|
||||
let max_backoff_ms: u32 = 30000; // Max 30 seconds
|
||||
@@ -247,7 +251,7 @@ pub fn provide_torrent_store() {
|
||||
shared::NotificationLevel::Info => "ℹ️ VibeTorrent",
|
||||
};
|
||||
|
||||
crate::utils::notification::show_notification_if_enabled(
|
||||
show_browser_notification(
|
||||
title,
|
||||
&n.message
|
||||
);
|
||||
@@ -329,49 +333,21 @@ pub async fn subscribe_to_push_notifications() {
|
||||
// First, request notification permission if not already granted
|
||||
let window = web_sys::window().expect("window should exist");
|
||||
|
||||
// Notification.permission is a static property, but web_sys exposes it via the Notification class instance or we check it manually.
|
||||
// Actually, Notification::permission() is a static method in web_sys.
|
||||
match web_sys::Notification::permission() {
|
||||
web_sys::NotificationPermission::Granted => {
|
||||
log::info!("Notification permission already granted");
|
||||
}
|
||||
web_sys::NotificationPermission::Denied => {
|
||||
log::warn!("Notification permission was denied");
|
||||
return;
|
||||
}
|
||||
web_sys::NotificationPermission::Default => {
|
||||
let permission = crate::utils::notification::get_notification_permission();
|
||||
if permission == "default" {
|
||||
log::info!("Requesting notification permission...");
|
||||
let permission_promise = match web_sys::Notification::request_permission() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::error!("Failed to request notification permission: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match wasm_bindgen_futures::JsFuture::from(permission_promise).await {
|
||||
Ok(val) => {
|
||||
let permission = val.as_string().unwrap_or_default();
|
||||
if permission != "granted" {
|
||||
if !crate::utils::notification::request_notification_permission().await {
|
||||
log::warn!("Notification permission denied by user");
|
||||
return;
|
||||
}
|
||||
log::info!("Notification permission granted by user");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to await notification permission: {:?}", e);
|
||||
} else if permission == "denied" {
|
||||
log::warn!("Notification permission was denied");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
log::warn!("Unknown notification permission status");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Notification permission granted! Proceeding with push subscription...");
|
||||
|
||||
|
||||
// Get VAPID public key from backend
|
||||
let public_key_response = match Request::get("/api/push/public-key").send().await {
|
||||
Ok(resp) => resp,
|
||||
|
||||
@@ -1,37 +1,19 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::{Notification, NotificationOptions};
|
||||
use leptos::*;
|
||||
use leptos_use::{use_web_notification, UseWebNotificationReturn, NotificationPermission};
|
||||
|
||||
/// Request browser notification permission from user
|
||||
pub async fn request_notification_permission() -> bool {
|
||||
let window = web_sys::window().expect("no global window");
|
||||
|
||||
// Check if Notification API is available
|
||||
if js_sys::Reflect::has(&window, &JsValue::from_str("Notification")).unwrap_or(false) {
|
||||
let notification = js_sys::Reflect::get(&window, &JsValue::from_str("Notification"))
|
||||
.expect("Notification should exist");
|
||||
|
||||
// Request permission
|
||||
let promise = js_sys::Reflect::get(¬ification, &JsValue::from_str("requestPermission"))
|
||||
.expect("requestPermission should exist");
|
||||
|
||||
if let Ok(function) = promise.dyn_into::<js_sys::Function>() {
|
||||
if let Ok(promise) = function.call0(¬ification) {
|
||||
if let Ok(promise) = promise.dyn_into::<js_sys::Promise>() {
|
||||
let result = wasm_bindgen_futures::JsFuture::from(promise).await;
|
||||
|
||||
if let Ok(permission) = result {
|
||||
let permission_str = permission.as_string().unwrap_or_default();
|
||||
return permission_str == "granted";
|
||||
if let Ok(promise) = Notification::request_permission() {
|
||||
if let Ok(result) = wasm_bindgen_futures::JsFuture::from(promise).await {
|
||||
return result.as_string().unwrap_or_default() == "granted";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if browser notifications are supported and permitted
|
||||
/// Check if browser notifications are supported
|
||||
pub fn is_notification_supported() -> bool {
|
||||
let window = web_sys::window().expect("no global window");
|
||||
js_sys::Reflect::has(&window, &JsValue::from_str("Notification")).unwrap_or(false)
|
||||
@@ -39,67 +21,61 @@ pub fn is_notification_supported() -> bool {
|
||||
|
||||
/// Get current notification permission status
|
||||
pub fn get_notification_permission() -> String {
|
||||
if !is_notification_supported() {
|
||||
return "unsupported".to_string();
|
||||
match Notification::permission() {
|
||||
web_sys::NotificationPermission::Granted => "granted".to_string(),
|
||||
web_sys::NotificationPermission::Denied => "denied".to_string(),
|
||||
web_sys::NotificationPermission::Default => "default".to_string(),
|
||||
_ => "default".to_string(),
|
||||
}
|
||||
|
||||
let window = web_sys::window().expect("no global window");
|
||||
let notification = js_sys::Reflect::get(&window, &JsValue::from_str("Notification"))
|
||||
.expect("Notification should exist");
|
||||
|
||||
let permission = js_sys::Reflect::get(¬ification, &JsValue::from_str("permission"))
|
||||
.unwrap_or(JsValue::from_str("default"));
|
||||
|
||||
permission.as_string().unwrap_or("default".to_string())
|
||||
}
|
||||
|
||||
/// Show a browser notification
|
||||
/// Returns true if notification was shown successfully
|
||||
pub fn show_browser_notification(title: &str, body: &str, icon: Option<&str>) -> bool {
|
||||
// Check permission first
|
||||
let permission = get_notification_permission();
|
||||
if permission != "granted" {
|
||||
log::warn!("Notification permission not granted: {}", permission);
|
||||
/// Hook for using browser notifications within Leptos components or effects.
|
||||
/// This uses leptos-use for reactive permission tracking.
|
||||
pub fn use_app_notification() -> impl Fn(&str, &str) + Clone {
|
||||
let UseWebNotificationReturn { permission, .. } = use_web_notification();
|
||||
|
||||
move |title: &str, body: &str| {
|
||||
// Check user preference from localStorage
|
||||
let window = web_sys::window().expect("no global window");
|
||||
let storage = window.local_storage().ok().flatten();
|
||||
let enabled = storage
|
||||
.and_then(|s| s.get_item("vibetorrent_browser_notifications").ok().flatten())
|
||||
.unwrap_or_else(|| "true".to_string());
|
||||
|
||||
// Use the reactive permission signal from leptos-use
|
||||
if enabled == "true" && permission.get() == NotificationPermission::Granted {
|
||||
show_browser_notification(title, body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a browser notification (non-reactive version)
|
||||
pub fn show_browser_notification(title: &str, body: &str) -> bool {
|
||||
if get_notification_permission() != "granted" {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create notification options
|
||||
let opts = NotificationOptions::new();
|
||||
opts.set_body(body);
|
||||
opts.set_icon(icon.unwrap_or("/icon-192.png"));
|
||||
opts.set_badge("/icon-192.png");
|
||||
opts.set_icon("/icon-192.png");
|
||||
opts.set_tag("vibetorrent");
|
||||
opts.set_require_interaction(false);
|
||||
opts.set_silent(Some(false));
|
||||
|
||||
// Create and show notification
|
||||
match Notification::new_with_options(title, &opts) {
|
||||
Ok(_notification) => {
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to create notification: {:?}", e);
|
||||
false
|
||||
}
|
||||
Ok(_) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Show notification only if enabled in settings and permission granted
|
||||
/// Legacy helper for showing notification if enabled in settings
|
||||
pub fn show_notification_if_enabled(title: &str, body: &str) -> bool {
|
||||
// Check localStorage for user preference
|
||||
let window = web_sys::window().expect("no global window");
|
||||
let storage = window.local_storage().ok().flatten();
|
||||
|
||||
if let Some(storage) = storage {
|
||||
let enabled = storage
|
||||
.get_item("vibetorrent_browser_notifications")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or("true".to_string());
|
||||
.and_then(|s| s.get_item("vibetorrent_browser_notifications").ok().flatten())
|
||||
.unwrap_or_else(|| "true".to_string());
|
||||
|
||||
if enabled == "true" {
|
||||
return show_browser_notification(title, body, None);
|
||||
}
|
||||
return show_browser_notification(title, body);
|
||||
}
|
||||
|
||||
false
|
||||
|
||||
Reference in New Issue
Block a user