Compare commits
15 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bb2d68a65 | ||
|
|
fe117cdaec | ||
|
|
e062a3c8cd | ||
|
|
ae2c9c934d | ||
|
|
f7e1356eae | ||
|
|
98b1f059c7 | ||
|
|
a3735d0931 | ||
|
|
55f00729ee | ||
|
|
275f4a91b2 | ||
|
|
025a0c4a57 | ||
|
|
b29f9f3cc2 | ||
|
|
feede5c5b4 | ||
|
|
c1306a32a9 | ||
|
|
ed5fba4b46 | ||
|
|
f149603ac8 |
@@ -7,6 +7,7 @@ use rust_embed::RustEmbed;
|
|||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod setup;
|
pub mod setup;
|
||||||
|
pub mod notifications;
|
||||||
|
|
||||||
#[derive(RustEmbed)]
|
#[derive(RustEmbed)]
|
||||||
#[folder = "../frontend/dist"]
|
#[folder = "../frontend/dist"]
|
||||||
|
|||||||
48
backend/src/handlers/notifications.rs
Normal file
48
backend/src/handlers/notifications.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{State, Query},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use shared::{AppEvent, SystemNotification, NotificationLevel};
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct TorrentFinishedQuery {
|
||||||
|
pub name: String,
|
||||||
|
pub hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn torrent_finished_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<TorrentFinishedQuery>,
|
||||||
|
) -> StatusCode {
|
||||||
|
tracing::info!("Torrent finished notification received: {} ({})", params.name, params.hash);
|
||||||
|
|
||||||
|
let message = format!("Torrent tamamlandı: {}", params.name);
|
||||||
|
|
||||||
|
// 1. Send to active SSE clients (for Toast)
|
||||||
|
let notification = SystemNotification {
|
||||||
|
level: NotificationLevel::Success,
|
||||||
|
message: message.clone(),
|
||||||
|
};
|
||||||
|
let _ = state.event_bus.send(AppEvent::Notification(notification));
|
||||||
|
|
||||||
|
// 2. Send Web Push Notification (for Background)
|
||||||
|
#[cfg(feature = "push-notifications")]
|
||||||
|
{
|
||||||
|
let push_store = state.push_store.clone();
|
||||||
|
let title = "Torrent Tamamlandı".to_string();
|
||||||
|
let body = message;
|
||||||
|
let torrent_name = params.name.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tracing::info!("Attempting to send Web Push notification for torrent: {}", torrent_name);
|
||||||
|
match crate::push::send_push_notification(&push_store, &title, &body).await {
|
||||||
|
Ok(_) => tracing::info!("Web Push notification sent successfully for: {}", torrent_name),
|
||||||
|
Err(e) => tracing::error!("Failed to send Web Push notification for {}: {:?}", torrent_name, e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusCode::OK
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ async fn auth_middleware(
|
|||||||
|| path.starts_with("/api/server_fns/get_setup_status")
|
|| path.starts_with("/api/server_fns/get_setup_status")
|
||||||
|| path.starts_with("/api/server_fns/Setup")
|
|| path.starts_with("/api/server_fns/Setup")
|
||||||
|| path.starts_with("/api/server_fns/setup")
|
|| path.starts_with("/api/server_fns/setup")
|
||||||
|
|| path.starts_with("/api/internal/")
|
||||||
|| path.starts_with("/swagger-ui")
|
|| path.starts_with("/swagger-ui")
|
||||||
|| path.starts_with("/api-docs")
|
|| path.starts_with("/api-docs")
|
||||||
|| !path.starts_with("/api/")
|
|| !path.starts_with("/api/")
|
||||||
@@ -313,7 +314,7 @@ async fn main() {
|
|||||||
let loop_interval = if active_clients > 0 {
|
let loop_interval = if active_clients > 0 {
|
||||||
Duration::from_secs(1)
|
Duration::from_secs(1)
|
||||||
} else {
|
} else {
|
||||||
Duration::from_secs(30)
|
Duration::from_secs(60)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Fetch Torrents
|
// 1. Fetch Torrents
|
||||||
@@ -434,6 +435,7 @@ async fn main() {
|
|||||||
let db_for_ctx = db.clone();
|
let db_for_ctx = db.clone();
|
||||||
let app = app
|
let app = app
|
||||||
.route("/api/events", get(sse::sse_handler))
|
.route("/api/events", get(sse::sse_handler))
|
||||||
|
.route("/api/internal/torrent-finished", post(handlers::notifications::torrent_finished_handler))
|
||||||
.route("/api/server_fns/{*fn_name}", post({
|
.route("/api/server_fns/{*fn_name}", post({
|
||||||
let scgi_path = scgi_path_for_ctx.clone();
|
let scgi_path = scgi_path_for_ctx.clone();
|
||||||
let db = db_for_ctx.clone();
|
let db = db_for_ctx.clone();
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
|
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
|
||||||
|
|
||||||
<!-- Trunk Assets -->
|
<!-- Trunk Assets -->
|
||||||
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="0" data-no-preload />
|
<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="css" href="public/tailwind.css" />
|
||||||
<link data-trunk rel="copy-file" href="manifest.json" />
|
<link data-trunk rel="copy-file" href="manifest.json" />
|
||||||
<link data-trunk rel="copy-file" href="icon-192.png" />
|
<link data-trunk rel="copy-file" href="icon-192.png" />
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use crate::components::layout::protected::Protected;
|
use crate::components::layout::protected::Protected;
|
||||||
use crate::components::ui::skeleton::Skeleton;
|
use crate::components::ui::skeleton::Skeleton;
|
||||||
use crate::components::ui::card::{Card, CardHeader, CardContent};
|
|
||||||
use crate::components::torrent::table::TorrentTable;
|
use crate::components::torrent::table::TorrentTable;
|
||||||
use crate::components::auth::login::Login;
|
use crate::components::auth::login::Login;
|
||||||
use crate::components::auth::setup::Setup;
|
use crate::components::auth::setup::Setup;
|
||||||
@@ -42,7 +41,7 @@ pub fn App() -> impl IntoView {
|
|||||||
fn InnerApp() -> impl IntoView {
|
fn InnerApp() -> impl IntoView {
|
||||||
crate::store::provide_torrent_store();
|
crate::store::provide_torrent_store();
|
||||||
let store = use_context::<crate::store::TorrentStore>();
|
let store = use_context::<crate::store::TorrentStore>();
|
||||||
let loc = use_location();
|
let _loc = use_location();
|
||||||
|
|
||||||
let is_loading = signal(true);
|
let is_loading = signal(true);
|
||||||
let is_authenticated = signal(false);
|
let is_authenticated = signal(false);
|
||||||
@@ -131,71 +130,71 @@ fn InnerApp() -> impl IntoView {
|
|||||||
view! { <Setup /> }
|
view! { <Setup /> }
|
||||||
} />
|
} />
|
||||||
|
|
||||||
<Route path=leptos_router::path!("/") view=move || {
|
<Route path=leptos_router::path!("/") view=move || {
|
||||||
let navigate = use_navigate();
|
let navigate = use_navigate();
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
if !is_loading.0.get() {
|
if !is_loading.0.get() {
|
||||||
if needs_setup.0.get() {
|
if needs_setup.0.get() {
|
||||||
log::info!("Setup not completed, redirecting to setup");
|
log::info!("Setup not completed, redirecting to setup");
|
||||||
navigate("/setup", Default::default());
|
navigate("/setup", Default::default());
|
||||||
} else if !is_authenticated.0.get() {
|
} else if !is_authenticated.0.get() {
|
||||||
log::info!("Not authenticated, redirecting to login");
|
log::info!("Not authenticated, redirecting to login");
|
||||||
navigate("/login", Default::default());
|
navigate("/login", Default::default());
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
});
|
||||||
view! {
|
|
||||||
<Show when=move || !is_loading.0.get() fallback=|| {
|
view! {
|
||||||
// Standard 1: Always show Dashboard Skeleton
|
<Show when=move || !is_loading.0.get() fallback=|| {
|
||||||
view! {
|
// Standard 1: Always show Dashboard Skeleton
|
||||||
<div class="flex h-screen bg-background text-foreground overflow-hidden">
|
view! {
|
||||||
// Sidebar skeleton
|
<div class="flex h-screen bg-background text-foreground overflow-hidden">
|
||||||
<div class="w-56 border-r border-border p-4 space-y-4">
|
// Sidebar skeleton
|
||||||
<Skeleton class="h-8 w-3/4" />
|
<div class="w-56 border-r border-border p-4 space-y-4">
|
||||||
<div class="space-y-2">
|
<Skeleton class="h-8 w-3/4" />
|
||||||
<Skeleton class="h-6 w-full" />
|
<div class="space-y-2">
|
||||||
<Skeleton class="h-6 w-full" />
|
<Skeleton class="h-6 w-full" />
|
||||||
<Skeleton class="h-6 w-4/5" />
|
<Skeleton class="h-6 w-full" />
|
||||||
<Skeleton class="h-6 w-full" />
|
<Skeleton class="h-6 w-4/5" />
|
||||||
<Skeleton class="h-6 w-3/5" />
|
<Skeleton class="h-6 w-full" />
|
||||||
<Skeleton class="h-6 w-full" />
|
<Skeleton class="h-6 w-3/5" />
|
||||||
</div>
|
<Skeleton class="h-6 w-full" />
|
||||||
</div>
|
|
||||||
// Main content skeleton
|
|
||||||
<div class="flex-1 flex flex-col min-w-0">
|
|
||||||
<div class="border-b border-border p-4 flex items-center gap-4">
|
|
||||||
<Skeleton class="h-8 w-48" />
|
|
||||||
<Skeleton class="h-8 w-64" />
|
|
||||||
<div class="ml-auto"><Skeleton class="h-8 w-24" /></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 p-4 space-y-3">
|
|
||||||
<Skeleton class="h-10 w-full" />
|
|
||||||
<Skeleton class="h-10 w-full" />
|
|
||||||
<Skeleton class="h-10 w-full" />
|
|
||||||
<Skeleton class="h-10 w-full" />
|
|
||||||
<Skeleton class="h-10 w-full" />
|
|
||||||
<Skeleton class="h-10 w-3/4" />
|
|
||||||
</div>
|
|
||||||
<div class="border-t border-border p-3">
|
|
||||||
<Skeleton class="h-5 w-96" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}.into_any()
|
// Main content skeleton
|
||||||
}>
|
<div class="flex-1 flex flex-col min-w-0">
|
||||||
<Show when=move || is_authenticated.0.get() fallback=|| ()>
|
<div class="border-b border-border p-4 flex items-center gap-4">
|
||||||
<Protected>
|
<Skeleton class="h-8 w-48" />
|
||||||
<div class="flex flex-col h-full overflow-hidden">
|
<Skeleton class="h-8 w-64" />
|
||||||
<div class="flex-1 overflow-hidden">
|
<div class="ml-auto"><Skeleton class="h-8 w-24" /></div>
|
||||||
<TorrentTable />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Protected>
|
<div class="flex-1 p-4 space-y-3">
|
||||||
</Show>
|
<Skeleton class="h-10 w-full" />
|
||||||
|
<Skeleton class="h-10 w-full" />
|
||||||
|
<Skeleton class="h-10 w-full" />
|
||||||
|
<Skeleton class="h-10 w-full" />
|
||||||
|
<Skeleton class="h-10 w-full" />
|
||||||
|
<Skeleton class="h-10 w-3/4" />
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-border p-3">
|
||||||
|
<Skeleton class="h-5 w-96" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}>
|
||||||
|
<Show when=move || is_authenticated.0.get() fallback=|| ()>
|
||||||
|
<Protected>
|
||||||
|
<div class="flex flex-col h-full overflow-hidden">
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<TorrentTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Protected>
|
||||||
</Show>
|
</Show>
|
||||||
}.into_any()
|
</Show>
|
||||||
}/>
|
}.into_any()
|
||||||
|
}/>
|
||||||
|
|
||||||
<Route path=leptos_router::path!("/settings") view=move || {
|
<Route path=leptos_router::path!("/settings") view=move || {
|
||||||
let authenticated = is_authenticated.0.get();
|
let authenticated = is_authenticated.0.get();
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use icons::PanelLeft;
|
use icons::{PanelLeft, Plus};
|
||||||
use crate::components::torrent::add_torrent::AddTorrentDialog;
|
use crate::components::torrent::add_torrent::AddTorrentDialogContent;
|
||||||
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
|
use crate::components::ui::button::{ButtonVariant, ButtonSize};
|
||||||
use crate::components::ui::sheet::{Sheet, SheetContent, SheetTrigger, SheetDirection};
|
use crate::components::ui::sheet::{Sheet, SheetContent, SheetTrigger, SheetDirection};
|
||||||
|
use crate::components::ui::dialog::{Dialog, DialogContent, DialogTrigger};
|
||||||
use crate::components::layout::sidebar::Sidebar;
|
use crate::components::layout::sidebar::Sidebar;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Toolbar() -> impl IntoView {
|
pub fn Toolbar() -> impl IntoView {
|
||||||
let show_add_modal = signal(false);
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="flex min-h-14 h-auto items-center border-b border-border bg-background px-4" style="padding-top: env(safe-area-inset-top);">
|
<div class="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
|
// Sol kısım: Menü butonu (Mobil) + Add Torrent
|
||||||
@@ -33,25 +32,24 @@ pub fn Toolbar() -> impl IntoView {
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Dialog>
|
||||||
on:click=move |_| show_add_modal.1.set(true)
|
<DialogTrigger
|
||||||
class="gap-2"
|
variant=ButtonVariant::Default
|
||||||
>
|
class="gap-2"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 md:w-5 md:h-5">
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
<Plus class="w-4 h-4 md:w-5 md:h-5" />
|
||||||
</svg>
|
<span class="hidden sm:inline">"Add Torrent"</span>
|
||||||
<span class="hidden sm:inline">"Add Torrent"</span>
|
<span class="sm:hidden">"Add"</span>
|
||||||
<span class="sm:hidden">"Add"</span>
|
</DialogTrigger>
|
||||||
</Button>
|
<DialogContent id="add-torrent-dialog" class="sm:max-w-[425px]">
|
||||||
|
<AddTorrentDialogContent />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Sağ kısım boş
|
// Sağ kısım boş
|
||||||
<div class="flex flex-1 items-center justify-end gap-2">
|
<div class="flex flex-1 items-center justify-end gap-2">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when=move || show_add_modal.0.get()>
|
|
||||||
<AddTorrentDialog on_close=Callback::new(move |()| show_add_modal.1.set(false)) />
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
use crate::components::ui::input::{Input, InputType};
|
use crate::components::ui::input::{Input, InputType};
|
||||||
use crate::store::TorrentStore;
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
|
use crate::components::ui::button::Button;
|
||||||
use crate::components::ui::button::{Button, ButtonVariant};
|
use crate::components::ui::dialog::{
|
||||||
|
DialogBody, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose
|
||||||
|
};
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AddTorrentDialog(
|
pub fn AddTorrentDialogContent() -> impl IntoView {
|
||||||
on_close: Callback<()>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let _store = use_context::<TorrentStore>().expect("TorrentStore not provided");
|
|
||||||
|
|
||||||
let uri = RwSignal::new(String::new());
|
let uri = RwSignal::new(String::new());
|
||||||
let is_loading = signal(false);
|
let is_loading = signal(false);
|
||||||
let error_msg = signal(Option::<String>::None);
|
let error_msg = signal(Option::<String>::None);
|
||||||
@@ -21,20 +19,30 @@ pub fn AddTorrentDialog(
|
|||||||
let uri_val = uri.get();
|
let uri_val = uri.get();
|
||||||
|
|
||||||
if uri_val.is_empty() {
|
if uri_val.is_empty() {
|
||||||
error_msg.1.set(Some("Please enter a Magnet URI or URL".to_string()));
|
error_msg.1.set(Some("Lütfen bir Magnet URI veya URL girin".to_string()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
is_loading.1.set(true);
|
is_loading.1.set(true);
|
||||||
error_msg.1.set(None);
|
error_msg.1.set(None);
|
||||||
|
|
||||||
let on_close = on_close.clone();
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::torrent::add(&uri_val).await {
|
match api::torrent::add(&uri_val).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
log::info!("Torrent added successfully");
|
log::info!("Torrent added successfully");
|
||||||
crate::store::toast_success("Torrent başarıyla eklendi");
|
crate::store::toast_success("Torrent başarıyla eklendi");
|
||||||
on_close.run(());
|
|
||||||
|
// Programmatically close the dialog by triggering the close button
|
||||||
|
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
|
||||||
|
if let Some(el) = doc.get_element_by_id("add-torrent-dialog") {
|
||||||
|
if let Some(close_btn) = el.query_selector("[data-dialog-close]").ok().flatten() {
|
||||||
|
let _ = close_btn.dyn_into::<web_sys::HtmlElement>().map(|btn| btn.click());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uri.set(String::new());
|
||||||
|
is_loading.1.set(false);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to add torrent: {:?}", e);
|
log::error!("Failed to add torrent: {:?}", e);
|
||||||
@@ -45,29 +53,16 @@ pub fn AddTorrentDialog(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let handle_backdrop = {
|
|
||||||
let on_close = on_close.clone();
|
|
||||||
move |e: web_sys::MouseEvent| {
|
|
||||||
e.stop_propagation();
|
|
||||||
on_close.run(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
// Backdrop overlay
|
<DialogBody>
|
||||||
<div
|
<DialogHeader>
|
||||||
class="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
<DialogTitle>"Add Torrent"</DialogTitle>
|
||||||
on:click=handle_backdrop
|
<DialogDescription>
|
||||||
/>
|
"Enter a Magnet link or a .torrent file URL."
|
||||||
// Dialog panel
|
</DialogDescription>
|
||||||
<div class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-card p-6 shadow-lg rounded-lg sm:max-w-[425px]">
|
</DialogHeader>
|
||||||
// Header
|
|
||||||
<div class="flex flex-col space-y-1.5 text-center sm:text-left">
|
|
||||||
<h2 class="text-lg font-semibold leading-none tracking-tight">"Add Torrent"</h2>
|
|
||||||
<p class="text-sm text-muted-foreground">"Enter a Magnet link or a .torrent file URL."</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form on:submit=handle_submit class="space-y-4">
|
<form on:submit=handle_submit class="space-y-4 pt-4">
|
||||||
<Input
|
<Input
|
||||||
r#type=InputType::Text
|
r#type=InputType::Text
|
||||||
placeholder="magnet:?xt=urn:btih:..."
|
placeholder="magnet:?xt=urn:btih:..."
|
||||||
@@ -81,14 +76,10 @@ pub fn AddTorrentDialog(
|
|||||||
</div>
|
</div>
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
|
<DialogFooter class="pt-2">
|
||||||
<Button
|
<DialogClose>
|
||||||
variant=ButtonVariant::Ghost
|
|
||||||
attr:r#type="button"
|
|
||||||
on:click=move |_| on_close.run(())
|
|
||||||
>
|
|
||||||
"Cancel"
|
"Cancel"
|
||||||
</Button>
|
</DialogClose>
|
||||||
<Button
|
<Button
|
||||||
attr:r#type="submit"
|
attr:r#type="submit"
|
||||||
attr:disabled=move || is_loading.0.get()
|
attr:disabled=move || is_loading.0.get()
|
||||||
@@ -102,21 +93,8 @@ pub fn AddTorrentDialog(
|
|||||||
leptos::either::Either::Right(view! { "Add" })
|
leptos::either::Either::Right(view! { "Add" })
|
||||||
}}
|
}}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
</DialogBody>
|
||||||
// Close button (X)
|
|
||||||
<Button
|
|
||||||
variant=ButtonVariant::Ghost
|
|
||||||
class="absolute right-2 top-2 size-8 p-0 opacity-70 hover:opacity-100"
|
|
||||||
on:click=move |_| on_close.run(())
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
|
|
||||||
<path d="M18 6 6 18"></path>
|
|
||||||
<path d="m6 6 12 12"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="sr-only">"Close"</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use icons::{ArrowUpDown, Inbox, Settings2, Play, Square, Trash2, Ellipsis, ArrowUp, ArrowDown, Check, ListFilter};
|
use icons::{ArrowUpDown, Inbox, Settings2, Play, Square, Trash2, Ellipsis, ArrowUp, ArrowDown, Check, ListFilter};
|
||||||
use crate::store::{get_action_messages, show_toast};
|
use crate::store::{get_action_messages, show_toast};
|
||||||
@@ -14,7 +15,17 @@ use crate::components::ui::empty::*;
|
|||||||
use crate::components::ui::input::Input;
|
use crate::components::ui::input::Input;
|
||||||
use crate::components::ui::multi_select::*;
|
use crate::components::ui::multi_select::*;
|
||||||
use crate::components::ui::dropdown_menu::*;
|
use crate::components::ui::dropdown_menu::*;
|
||||||
use crate::components::ui::alert_dialog::*;
|
use crate::components::ui::alert_dialog::{
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogBody,
|
||||||
|
AlertDialogClose,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
};
|
||||||
use tailwind_fuse::tw_merge;
|
use tailwind_fuse::tw_merge;
|
||||||
|
|
||||||
const ALL_COLUMNS: [(&str, &str); 8] = [
|
const ALL_COLUMNS: [(&str, &str); 8] = [
|
||||||
@@ -220,66 +231,78 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Show when=move || has_selection.get()>
|
<Show when=move || has_selection.get()>
|
||||||
<DropdownMenu>
|
<div class="flex items-center gap-2">
|
||||||
<DropdownMenuTrigger class="w-[140px] h-9 gap-2">
|
<DropdownMenu>
|
||||||
<Ellipsis class="size-4" />
|
<DropdownMenuTrigger class="w-[140px] h-9 gap-2">
|
||||||
{move || format!("Toplu İşlem ({})", selected_count.get())}
|
<Ellipsis class="size-4" />
|
||||||
</DropdownMenuTrigger>
|
{move || format!("Toplu İşlem ({})", selected_count.get())}
|
||||||
<DropdownMenuContent class="w-48">
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuLabel>"Seçili Torrentler"</DropdownMenuLabel>
|
<DropdownMenuContent class="w-48">
|
||||||
<DropdownMenuGroup class="mt-2">
|
<DropdownMenuLabel>"Seçili Torrentler"</DropdownMenuLabel>
|
||||||
<DropdownMenuItem on:click=move |_| bulk_action("start")>
|
<DropdownMenuGroup class="mt-2">
|
||||||
<Play class="mr-2 size-4" /> "Başlat"
|
<DropdownMenuItem on:click=move |_| bulk_action("start")>
|
||||||
</DropdownMenuItem>
|
<Play class="mr-2 size-4" /> "Başlat"
|
||||||
<DropdownMenuItem on:click=move |_| bulk_action("stop")>
|
</DropdownMenuItem>
|
||||||
<Square class="mr-2 size-4" /> "Durdur"
|
<DropdownMenuItem on:click=move |_| bulk_action("stop")>
|
||||||
</DropdownMenuItem>
|
<Square class="mr-2 size-4" /> "Durdur"
|
||||||
|
</DropdownMenuItem>
|
||||||
<div class="my-1 h-px bg-border" />
|
|
||||||
|
<div class="my-1 h-px bg-border" />
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger class="w-full text-left">
|
// Trigger the hidden AlertDialog from this menu item
|
||||||
<div class="inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm transition-colors text-destructive hover:bg-destructive/10 focus:bg-destructive/10">
|
<DropdownMenuItem class="text-destructive focus:bg-destructive/10 cursor-pointer" on:click=move |_| {
|
||||||
<Trash2 class="size-4" /> "Toplu Sil..."
|
if let Some(trigger) = document().get_element_by_id("bulk-delete-trigger") {
|
||||||
</div>
|
let _ = trigger.dyn_into::<web_sys::HtmlElement>().map(|el: web_sys::HtmlElement| el.click());
|
||||||
</AlertDialogTrigger>
|
}
|
||||||
<AlertDialogContent>
|
}>
|
||||||
<AlertDialogHeader>
|
<Trash2 class="mr-2 size-4" /> "Toplu Sil..."
|
||||||
<AlertDialogTitle class="text-destructive flex items-center gap-2">
|
</DropdownMenuItem>
|
||||||
<Trash2 class="size-5" />
|
</DropdownMenuGroup>
|
||||||
"Toplu Silme Onayı"
|
</DropdownMenuContent>
|
||||||
</AlertDialogTitle>
|
</DropdownMenu>
|
||||||
<AlertDialogDescription class="pt-2">
|
|
||||||
{move || format!("Seçili {} adet torrent silinecek. Lütfen silme yöntemini seçin:", selected_count.get())}
|
// Hidden AlertDialog moved outside the DropdownMenuContent to ensure proper centering
|
||||||
<div class="mt-4 p-3 bg-muted/50 rounded-md text-xs border border-border italic">
|
<AlertDialog>
|
||||||
"Dikkat: Verilerle birlikte silme işlemi dosyaları diskten de kalıcı olarak kaldıracaktır."
|
<AlertDialogTrigger attr:id="bulk-delete-trigger" class="hidden">""</AlertDialogTrigger>
|
||||||
</div>
|
<AlertDialogContent class="sm:max-w-[425px]">
|
||||||
</AlertDialogDescription>
|
<AlertDialogBody>
|
||||||
</AlertDialogHeader>
|
<AlertDialogHeader class="space-y-3">
|
||||||
<AlertDialogFooter class="gap-2 sm:gap-0">
|
<AlertDialogTitle class="text-destructive flex items-center gap-2 text-xl">
|
||||||
<div class="flex flex-col sm:flex-row gap-2 w-full justify-end">
|
<Trash2 class="size-6" />
|
||||||
<AlertDialogClose class="order-3 sm:order-1">"Vazgeç"</AlertDialogClose>
|
"Toplu Silme Onayı"
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription class="text-sm leading-relaxed text-left">
|
||||||
|
{move || format!("Seçili {} adet torrent silinecek. Lütfen silme yöntemini seçin:", selected_count.get())}
|
||||||
|
<div class="mt-4 p-4 bg-destructive/5 rounded-lg border border-destructive/10 text-xs text-destructive/80 font-medium">
|
||||||
|
"⚠️ Dikkat: Verilerle birlikte silme işlemi dosyaları diskten de kalıcı olarak kaldıracaktır."
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter class="mt-6">
|
||||||
|
<div class="flex flex-col-reverse sm:flex-row gap-3 w-full sm:justify-end">
|
||||||
|
<AlertDialogClose class="sm:flex-1 md:flex-none">"Vazgeç"</AlertDialogClose>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant=ButtonVariant::Outline
|
variant=ButtonVariant::Secondary
|
||||||
class="order-2 text-foreground"
|
class="w-full sm:w-auto font-medium"
|
||||||
on:click=move |_| bulk_action("delete")
|
on:click=move |_| bulk_action("delete")
|
||||||
>
|
>
|
||||||
"Sadece Listeden Sil"
|
"Sadece Sil"
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant=ButtonVariant::Destructive
|
variant=ButtonVariant::Destructive
|
||||||
class="order-1"
|
class="w-full sm:w-auto font-bold"
|
||||||
on:click=move |_| bulk_action("delete_with_data")
|
on:click=move |_| bulk_action("delete_with_data")
|
||||||
>
|
>
|
||||||
"Verilerle Birlikte Sil"
|
"Verilerle Sil"
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</AlertDialogFooter>
|
</div>
|
||||||
</AlertDialogContent>
|
</AlertDialogFooter>
|
||||||
</AlertDialog>
|
</AlertDialogBody>
|
||||||
</DropdownMenuGroup>
|
</AlertDialogContent>
|
||||||
</DropdownMenuContent>
|
</AlertDialog>
|
||||||
</DropdownMenu>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
// Mobile Sort Menu
|
// Mobile Sort Menu
|
||||||
|
|||||||
@@ -72,13 +72,13 @@ pub fn DialogTrigger(
|
|||||||
pub fn DialogContent(
|
pub fn DialogContent(
|
||||||
children: Children,
|
children: Children,
|
||||||
#[prop(optional, into)] class: String,
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(optional, into)] id: Option<String>,
|
||||||
#[prop(into, optional)] hide_close_button: Option<bool>,
|
#[prop(into, optional)] hide_close_button: Option<bool>,
|
||||||
#[prop(default = true)] close_on_backdrop_click: bool,
|
#[prop(default = true)] close_on_backdrop_click: bool,
|
||||||
#[prop(default = "Dialog")] data_name_prefix: &'static str,
|
#[prop(default = "Dialog")] data_name_prefix: &'static str,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let ctx = expect_context::<DialogContext>();
|
let ctx = expect_context::<DialogContext>();
|
||||||
let merged_class = tw_merge!(
|
let merged_class = tw_merge!(
|
||||||
// "flex flex-col gap-4", // TODO 🐛 Bug when I try to have this.. Using DialogBody instead.
|
|
||||||
"relative bg-background border rounded-2xl shadow-lg p-6 w-full max-w-[calc(100%-2rem)] max-h-[85vh] fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-100 transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100",
|
"relative bg-background border rounded-2xl shadow-lg p-6 w-full max-w-[calc(100%-2rem)] max-h-[85vh] fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-100 transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100",
|
||||||
class
|
class
|
||||||
);
|
);
|
||||||
@@ -88,10 +88,14 @@ pub fn DialogContent(
|
|||||||
|
|
||||||
let target_id_clone = ctx.target_id.clone();
|
let target_id_clone = ctx.target_id.clone();
|
||||||
let backdrop_id = format!("{}_backdrop", ctx.target_id);
|
let backdrop_id = format!("{}_backdrop", ctx.target_id);
|
||||||
let target_id_for_script = ctx.target_id.clone();
|
|
||||||
let backdrop_id_for_script = backdrop_id.clone();
|
let backdrop_id_for_script = backdrop_id.clone();
|
||||||
let backdrop_behavior = if close_on_backdrop_click { "auto" } else { "manual" };
|
let backdrop_behavior = if close_on_backdrop_click { "auto" } else { "manual" };
|
||||||
|
|
||||||
|
// Use provided id or fallback to random target_id
|
||||||
|
let final_id = id.unwrap_or_else(|| ctx.target_id.clone());
|
||||||
|
let final_id_for_script = final_id.clone();
|
||||||
|
let trigger_id_for_script = ctx.target_id.clone();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<script src="/lock_scroll.js"></script>
|
<script src="/lock_scroll.js"></script>
|
||||||
|
|
||||||
@@ -105,7 +109,7 @@ pub fn DialogContent(
|
|||||||
<div
|
<div
|
||||||
data-name=content_data_name
|
data-name=content_data_name
|
||||||
class=merged_class
|
class=merged_class
|
||||||
id=ctx.target_id
|
id=final_id
|
||||||
data-target="target__dialog"
|
data-target="target__dialog"
|
||||||
data-state="closed"
|
data-state="closed"
|
||||||
data-backdrop=backdrop_behavior
|
data-backdrop=backdrop_behavior
|
||||||
@@ -147,9 +151,7 @@ pub fn DialogContent(
|
|||||||
dialog.setAttribute('data-initialized', 'true');
|
dialog.setAttribute('data-initialized', 'true');
|
||||||
|
|
||||||
const openDialog = () => {{
|
const openDialog = () => {{
|
||||||
// Lock scrolling
|
if (window.ScrollLock) window.ScrollLock.lock();
|
||||||
window.ScrollLock.lock();
|
|
||||||
|
|
||||||
dialog.setAttribute('data-state', 'open');
|
dialog.setAttribute('data-state', 'open');
|
||||||
backdrop.setAttribute('data-state', 'open');
|
backdrop.setAttribute('data-state', 'open');
|
||||||
dialog.style.pointerEvents = 'auto';
|
dialog.style.pointerEvents = 'auto';
|
||||||
@@ -161,28 +163,18 @@ pub fn DialogContent(
|
|||||||
backdrop.setAttribute('data-state', 'closed');
|
backdrop.setAttribute('data-state', 'closed');
|
||||||
dialog.style.pointerEvents = 'none';
|
dialog.style.pointerEvents = 'none';
|
||||||
backdrop.style.pointerEvents = 'none';
|
backdrop.style.pointerEvents = 'none';
|
||||||
|
|
||||||
// Unlock scrolling after animation
|
|
||||||
window.ScrollLock.unlock(200);
|
window.ScrollLock.unlock(200);
|
||||||
}};
|
}};
|
||||||
|
|
||||||
// Open dialog when trigger is clicked
|
|
||||||
trigger.addEventListener('click', openDialog);
|
trigger.addEventListener('click', openDialog);
|
||||||
|
dialog.querySelectorAll('[data-dialog-close]').forEach(btn => {{
|
||||||
// Close buttons
|
|
||||||
const closeButtons = dialog.querySelectorAll('[data-dialog-close]');
|
|
||||||
closeButtons.forEach(btn => {{
|
|
||||||
btn.addEventListener('click', closeDialog);
|
btn.addEventListener('click', closeDialog);
|
||||||
}});
|
}});
|
||||||
|
|
||||||
// Close on backdrop click (if data-backdrop="auto")
|
|
||||||
backdrop.addEventListener('click', () => {{
|
backdrop.addEventListener('click', () => {{
|
||||||
if (dialog.getAttribute('data-backdrop') === 'auto') {{
|
if (dialog.getAttribute('data-backdrop') === 'auto') {{
|
||||||
closeDialog();
|
closeDialog();
|
||||||
}}
|
}}
|
||||||
}});
|
}});
|
||||||
|
|
||||||
// Handle ESC key to close
|
|
||||||
document.addEventListener('keydown', (e) => {{
|
document.addEventListener('keydown', (e) => {{
|
||||||
if (e.key === 'Escape' && dialog.getAttribute('data-state') === 'open') {{
|
if (e.key === 'Escape' && dialog.getAttribute('data-state') === 'open') {{
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -190,17 +182,12 @@ pub fn DialogContent(
|
|||||||
}}
|
}}
|
||||||
}});
|
}});
|
||||||
}};
|
}};
|
||||||
|
setupDialog();
|
||||||
if (document.readyState === 'loading') {{
|
|
||||||
document.addEventListener('DOMContentLoaded', setupDialog);
|
|
||||||
}} else {{
|
|
||||||
setupDialog();
|
|
||||||
}}
|
|
||||||
}})();
|
}})();
|
||||||
"#,
|
"#,
|
||||||
target_id_for_script,
|
final_id_for_script,
|
||||||
backdrop_id_for_script,
|
backdrop_id_for_script,
|
||||||
target_id_for_script,
|
trigger_id_for_script,
|
||||||
)}
|
)}
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,9 +170,10 @@ pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosi
|
|||||||
<div
|
<div
|
||||||
class=tw_merge!(
|
class=tw_merge!(
|
||||||
"fixed z-[100] flex gap-3 pointer-events-none w-full sm:w-[400px]",
|
"fixed z-[100] flex gap-3 pointer-events-none w-full sm:w-[400px]",
|
||||||
|
"left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 px-4 sm:px-0", // Mobile centering fix
|
||||||
if is_bottom { "flex-col-reverse" } else { "flex-col" },
|
if is_bottom { "flex-col-reverse" } else { "flex-col" },
|
||||||
container_class,
|
container_class,
|
||||||
"pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)] px-4 sm:px-0"
|
"pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)]"
|
||||||
)
|
)
|
||||||
>
|
>
|
||||||
<For
|
<For
|
||||||
|
|||||||
@@ -88,25 +88,7 @@ self.addEventListener("fetch", (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special strategy for WASM and Main JS to prevent Preload warnings
|
// Cache-first strategy for static assets (JS, CSS, Images)
|
||||||
if (url.pathname.endsWith(".wasm") || (url.pathname.endsWith(".js") && url.pathname.includes("frontend-"))) {
|
|
||||||
event.respondWith(
|
|
||||||
fetch(event.request)
|
|
||||||
.then((response) => {
|
|
||||||
const responseToCache = response.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
|
||||||
cache.put(event.request, responseToCache);
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
return caches.match(event.request);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache-first strategy for other static assets (CSS, Images, etc.)
|
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request).then((response) => {
|
caches.match(event.request).then((response) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user