Compare commits
6 Commits
release-20
...
57abbb3335
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57abbb3335 | ||
|
|
315a2421c4 | ||
|
|
c135c96d27 | ||
|
|
315a2f9a53 | ||
|
|
9d160a7ef5 | ||
|
|
a24e4101e8 |
@@ -52,18 +52,22 @@ async fn auth_middleware(
|
|||||||
request: Request<Body>,
|
request: Request<Body>,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
// Skip auth for public paths
|
// Skip auth for public server functions
|
||||||
let path = request.uri().path();
|
let path = request.uri().path();
|
||||||
if path.starts_with("/api/server_fns/Login") // Login server fn
|
if path.starts_with("/api/server_fns/Login")
|
||||||
|
|| path.starts_with("/api/server_fns/login")
|
||||||
|| path.starts_with("/api/server_fns/GetSetupStatus")
|
|| path.starts_with("/api/server_fns/GetSetupStatus")
|
||||||
|
|| 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("/swagger-ui")
|
|| path.starts_with("/swagger-ui")
|
||||||
|| path.starts_with("/api-docs")
|
|| path.starts_with("/api-docs")
|
||||||
|| !path.starts_with("/api/") // Allow static files (frontend)
|
|| !path.starts_with("/api/")
|
||||||
{
|
{
|
||||||
return Ok(next.run(request).await);
|
return Ok(next.run(request).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Check token
|
// Check token
|
||||||
if let Some(token) = jar.get("auth_token") {
|
if let Some(token) = jar.get("auth_token") {
|
||||||
use jsonwebtoken::{decode, Validation, DecodingKey};
|
use jsonwebtoken::{decode, Validation, DecodingKey};
|
||||||
@@ -221,6 +225,18 @@ async fn main() {
|
|||||||
tracing::info!("Socket: {}", args.socket);
|
tracing::info!("Socket: {}", args.socket);
|
||||||
tracing::info!("Port: {}", args.port);
|
tracing::info!("Port: {}", args.port);
|
||||||
|
|
||||||
|
// Force linking of server functions from shared crate for registration on Mac
|
||||||
|
{
|
||||||
|
use shared::server_fns::auth::*;
|
||||||
|
let _ = get_setup_status;
|
||||||
|
let _ = setup;
|
||||||
|
let _ = login;
|
||||||
|
let _ = logout;
|
||||||
|
let _ = get_user;
|
||||||
|
tracing::info!("Server functions linked successfully.");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ... rest of the main function ...
|
// ... rest of the main function ...
|
||||||
// Startup Health Check
|
// Startup Health Check
|
||||||
let socket_path = std::path::Path::new(&args.socket);
|
let socket_path = std::path::Path::new(&args.socket);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ edition = "2021"
|
|||||||
crate-type = ["cdylib", "rlib"]
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
leptos = { version = "0.8.15", features = ["csr", "msgpack"] }
|
leptos = { version = "0.8.15", features = ["csr", "msgpack", "nightly"] }
|
||||||
leptos_router = { version = "0.8.11" }
|
leptos_router = { version = "0.8.11" }
|
||||||
|
|
||||||
console_error_panic_hook = "0.1"
|
console_error_panic_hook = "0.1"
|
||||||
@@ -39,3 +39,7 @@ struct-patch = "0.5"
|
|||||||
leptos_ui = "0.3"
|
leptos_ui = "0.3"
|
||||||
tw_merge = "0.1"
|
tw_merge = "0.1"
|
||||||
strum = { version = "0.26", features = ["derive"] }
|
strum = { version = "0.26", features = ["derive"] }
|
||||||
|
icons = { version = "0.18.0", features = ["leptos"] }
|
||||||
|
|
||||||
|
[package.metadata.leptos]
|
||||||
|
tailwind-input-file = "input.css"
|
||||||
|
|||||||
12
frontend/package-lock.json
generated
12
frontend/package-lock.json
generated
@@ -13,7 +13,8 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
@@ -3737,6 +3738,15 @@
|
|||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tw-animate-css": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/Wombosvideo"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/universalify": {
|
"node_modules/universalify": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "",
|
|
||||||
"main": "tailwind.config.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"dependencies": {
|
||||||
|
"@tailwindcss/cli": "^4.1.18",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
|
},
|
||||||
|
"description": "",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
@@ -17,11 +17,13 @@
|
|||||||
"postcss-preset-env": "^10.1.3",
|
"postcss-preset-env": "^10.1.3",
|
||||||
"tailwindcss": "^4.1.18"
|
"tailwindcss": "^4.1.18"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"keywords": [],
|
||||||
"@tailwindcss/cli": "^4.1.18",
|
"license": "ISC",
|
||||||
"class-variance-authority": "^0.7.1",
|
"main": "tailwind.config.js",
|
||||||
"clsx": "^2.1.1",
|
"name": "frontend",
|
||||||
"tailwind-merge": "^3.4.0",
|
"scripts": {
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
}
|
},
|
||||||
|
"type": "module",
|
||||||
|
"version": "1.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,20 +6,20 @@ use leptos::prelude::*;
|
|||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use leptos_router::components::{Router, Routes, Route};
|
use leptos_router::components::{Router, Routes, Route};
|
||||||
use leptos_router::hooks::use_navigate;
|
use leptos_router::hooks::use_navigate;
|
||||||
use crate::components::toast::Toaster;
|
use crate::components::ui::toast::Toaster;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn App() -> impl IntoView {
|
pub fn App() -> impl IntoView {
|
||||||
|
crate::components::ui::toast::provide_toaster();
|
||||||
view! {
|
view! {
|
||||||
<InnerApp />
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
<InnerApp />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn InnerApp() -> impl IntoView {
|
fn InnerApp() -> impl IntoView {
|
||||||
crate::store::provide_torrent_store();
|
crate::store::provide_torrent_store();
|
||||||
crate::components::toast::provide_toast_context();
|
|
||||||
let store = use_context::<crate::store::TorrentStore>();
|
let store = use_context::<crate::store::TorrentStore>();
|
||||||
|
|
||||||
let is_loading = signal(true);
|
let is_loading = signal(true);
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use web_sys::MouseEvent;
|
use crate::components::ui::context_menu::*;
|
||||||
use wasm_bindgen::prelude::*;
|
|
||||||
use wasm_bindgen::JsCast;
|
|
||||||
|
|
||||||
// ── Kendi reaktif Context Menu implementasyonumuz ──
|
|
||||||
// leptos-shadcn-context-menu v0.8.1'de ContextMenuContent'te
|
|
||||||
// `if open.get()` statik kontrolü reaktif değil. Aşağıda
|
|
||||||
// `Show` bileşeni ile düzgün reaktif versiyon yer alıyor.
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn TorrentContextMenu(
|
pub fn TorrentContextMenu(
|
||||||
@@ -15,144 +8,61 @@ pub fn TorrentContextMenu(
|
|||||||
on_action: Callback<(String, String)>,
|
on_action: Callback<(String, String)>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let hash = StoredValue::new(torrent_hash);
|
let hash = StoredValue::new(torrent_hash);
|
||||||
let on_action = StoredValue::new(on_action);
|
|
||||||
|
|
||||||
let open = RwSignal::new(false);
|
|
||||||
let position = RwSignal::new((0i32, 0i32));
|
|
||||||
|
|
||||||
// Sağ tıklama handler
|
|
||||||
let on_contextmenu = move |e: MouseEvent| {
|
|
||||||
e.prevent_default();
|
|
||||||
e.stop_propagation();
|
|
||||||
position.set((e.client_x(), e.client_y()));
|
|
||||||
open.set(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Menü dışına tıklandığında kapanma
|
|
||||||
Effect::new(move |_| {
|
|
||||||
if open.get() {
|
|
||||||
let cb = Closure::wrap(Box::new(move |_: MouseEvent| {
|
|
||||||
open.set(false);
|
|
||||||
}) as Box<dyn Fn(MouseEvent)>);
|
|
||||||
|
|
||||||
let window = web_sys::window().unwrap();
|
|
||||||
let document = window.document().unwrap();
|
|
||||||
let _ = document.add_event_listener_with_callback(
|
|
||||||
"click",
|
|
||||||
cb.as_ref().unchecked_ref(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cleanup: tek sefer dinleyici — click yakalandığında otomatik kapanıp listener kalıyor
|
|
||||||
// ama open=false olduğunda effect tekrar çalışmaz, böylece sorun yok.
|
|
||||||
cb.forget();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let menu_action = move |action: &'static str| {
|
let menu_action = move |action: &'static str| {
|
||||||
open.set(false);
|
on_action.run((action.to_string(), hash.get_value()));
|
||||||
on_action.get_value().run((action.to_string(), hash.get_value()));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div
|
<ContextMenu>
|
||||||
class="w-full"
|
<ContextMenuTrigger>
|
||||||
on:contextmenu=on_contextmenu
|
|
||||||
>
|
|
||||||
{children()}
|
{children()}
|
||||||
</div>
|
</ContextMenuTrigger>
|
||||||
|
|
||||||
<Show when=move || open.get()>
|
<ContextMenuContent>
|
||||||
{
|
<ContextMenuAction on:click=move |_| menu_action("start")>
|
||||||
let (x, y) = position.get();
|
|
||||||
// Menü yaklaşık boyutları
|
|
||||||
let menu_width = 200;
|
|
||||||
let menu_height = 220;
|
|
||||||
let window = web_sys::window().unwrap();
|
|
||||||
let vw = window.inner_width().unwrap().as_f64().unwrap() as i32;
|
|
||||||
let vh = window.inner_height().unwrap().as_f64().unwrap() as i32;
|
|
||||||
// Sağa taşarsa sola aç, alta taşarsa yukarı aç
|
|
||||||
let final_x = if x + menu_width > vw { x - menu_width } else { x };
|
|
||||||
let final_y = if y + menu_height > vh { y - menu_height } else { y };
|
|
||||||
let final_x = final_x.max(0);
|
|
||||||
let final_y = final_y.max(0);
|
|
||||||
view! {
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 z-[99]"
|
|
||||||
on:click=move |e: MouseEvent| {
|
|
||||||
e.stop_propagation();
|
|
||||||
open.set(false);
|
|
||||||
}
|
|
||||||
on:contextmenu=move |e: MouseEvent| {
|
|
||||||
e.prevent_default();
|
|
||||||
e.stop_propagation();
|
|
||||||
open.set(false);
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="fixed z-[100] min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
|
|
||||||
style=format!("left: {}px; top: {}px;", final_x, final_y)
|
|
||||||
on:click=move |e: MouseEvent| e.stop_propagation()
|
|
||||||
>
|
|
||||||
// Start
|
|
||||||
<div
|
|
||||||
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
|
|
||||||
on:click=move |_| menu_action("start")
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||||
</svg>
|
</svg>
|
||||||
"Start"
|
"Start"
|
||||||
</div>
|
</ContextMenuAction>
|
||||||
|
|
||||||
// Stop
|
<ContextMenuAction on:click=move |_| menu_action("stop")>
|
||||||
<div
|
|
||||||
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
|
|
||||||
on:click=move |_| menu_action("stop")
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
||||||
</svg>
|
</svg>
|
||||||
"Stop"
|
"Stop"
|
||||||
</div>
|
</ContextMenuAction>
|
||||||
|
|
||||||
// Recheck
|
<ContextMenuAction on:click=move |_| menu_action("recheck")>
|
||||||
<div
|
|
||||||
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
|
|
||||||
on:click=move |_| menu_action("recheck")
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||||
</svg>
|
</svg>
|
||||||
"Recheck"
|
"Recheck"
|
||||||
</div>
|
</ContextMenuAction>
|
||||||
|
|
||||||
// Separator
|
|
||||||
<div class="-mx-1 my-1 h-px bg-border" />
|
<div class="-mx-1 my-1 h-px bg-border" />
|
||||||
|
|
||||||
// Remove
|
<ContextMenuAction
|
||||||
<div
|
class="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||||
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
|
||||||
on:click=move |_| menu_action("delete")
|
on:click=move |_| menu_action("delete")
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.164h-2.34c-1.18 0-2.09.984-2.09 2.164v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.164h-2.34c-1.18 0-2.09.984-2.09 2.164v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||||
</svg>
|
</svg>
|
||||||
"Remove"
|
"Remove"
|
||||||
</div>
|
</ContextMenuAction>
|
||||||
|
|
||||||
// Remove with Data
|
<ContextMenuAction
|
||||||
<div
|
class="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||||
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
|
||||||
on:click=move |_| menu_action("delete_with_data")
|
on:click=move |_| menu_action("delete_with_data")
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-2 h-4 w-4 opacity-70">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25 2.25M12 13.875l2.25-2.25M12 13.875l-2.25-2.25M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m6 4.125l2.25 2.25m0 0l2.25 2.25M12 13.875l2.25-2.25M12 13.875l-2.25-2.25M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||||
</svg>
|
</svg>
|
||||||
"Remove with Data"
|
"Remove with Data"
|
||||||
</div>
|
</ContextMenuAction>
|
||||||
</div>
|
</ContextMenuContent>
|
||||||
}
|
</ContextMenu>
|
||||||
}
|
|
||||||
</Show>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1
frontend/src/components/hooks/mod.rs
Normal file
1
frontend/src/components/hooks/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod use_random;
|
||||||
31
frontend/src/components/hooks/use_random.rs
Normal file
31
frontend/src/components/hooks/use_random.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
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(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()
|
||||||
|
}
|
||||||
@@ -2,5 +2,5 @@ pub mod context_menu;
|
|||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod torrent;
|
pub mod torrent;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod toast;
|
// pub mod toast; (Removed)
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|||||||
366
frontend/src/components/ui/context_menu.rs
Normal file
366
frontend/src/components/ui/context_menu.rs
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
use icons::ChevronRight;
|
||||||
|
use leptos::context::Provider;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_ui::clx;
|
||||||
|
use tw_merge::*;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
use crate::components::hooks::use_random::use_random_id_for;
|
||||||
|
|
||||||
|
/// Programmatically close any open context menu.
|
||||||
|
pub fn close_context_menu() {
|
||||||
|
let Some(document) = window().document() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(menu) = document.query_selector("[data-target='target__context'][data-state='open']").ok().flatten()
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = menu.set_attribute("data-state", "closed");
|
||||||
|
if let Some(el) = menu.dyn_ref::<web_sys::HtmlElement>() {
|
||||||
|
let _ = el.style().set_property("pointer-events", "none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod components {
|
||||||
|
use super::*;
|
||||||
|
clx! {ContextMenuLabel, span, "px-2 py-1.5 text-sm font-medium data-inset:pl-8", "mb-1"}
|
||||||
|
clx! {ContextMenuGroup, ul, "group"}
|
||||||
|
clx! {ContextMenuItem, li, "inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4"}
|
||||||
|
clx! {ContextMenuSubContent, ul, "context__menu_sub_content", "rounded-md border bg-card shadow-lg p-1 absolute z-[100] min-w-[160px] opacity-0 invisible translate-x-[-8px] transition-all duration-200 ease-out pointer-events-none"}
|
||||||
|
clx! {ContextMenuLink, a, "w-full inline-flex gap-2 items-center"}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub use components::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ContextMenuAction(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(optional, into)] aria_selected: Option<Signal<bool>>,
|
||||||
|
#[prop(optional, into)] href: Option<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let _ctx = expect_context::<ContextMenuContext>();
|
||||||
|
|
||||||
|
let class = tw_merge!(
|
||||||
|
"inline-flex gap-2 items-center w-full text-sm text-left transition-colors duration-200 focus:outline-none focus-visible:outline-none text-popover-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
let aria_selected_attr = move || aria_selected.map(|s| s.get()).unwrap_or(false).to_string();
|
||||||
|
|
||||||
|
if let Some(href) = href {
|
||||||
|
view! {
|
||||||
|
<a
|
||||||
|
data-name="ContextMenuAction"
|
||||||
|
class=class
|
||||||
|
href=href
|
||||||
|
aria-selected=aria_selected_attr
|
||||||
|
data-context-close="true"
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-name="ContextMenuAction"
|
||||||
|
class=class
|
||||||
|
data-context-close="true"
|
||||||
|
aria-selected=aria_selected_attr
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ContextMenuContext {
|
||||||
|
target_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ContextMenu(children: Children) -> impl IntoView {
|
||||||
|
let context_target_id = use_random_id_for("context");
|
||||||
|
|
||||||
|
let ctx = ContextMenuContext { target_id: context_target_id.clone() };
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Provider value=ctx>
|
||||||
|
<style>
|
||||||
|
"
|
||||||
|
/* Submenu Styles */
|
||||||
|
.context__menu_sub_content {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: calc(100% + 8px);
|
||||||
|
inset-block-start: -4px;
|
||||||
|
z-index: 100;
|
||||||
|
min-inline-size: 160px;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateX(-8px);
|
||||||
|
transition: all 0.2s ease-out;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context__menu_sub_trigger:hover .context__menu_sub_content {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateX(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
"
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div data-name="ContextMenu" class="contents">
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
</Provider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapper that triggers the context menu on right-click.
|
||||||
|
/// The `on_open` callback is triggered when the context menu opens (right-click).
|
||||||
|
#[component]
|
||||||
|
pub fn ContextMenuTrigger(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(optional)] on_open: Option<Callback<()>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let ctx = expect_context::<ContextMenuContext>();
|
||||||
|
let trigger_class = tw_merge!("contents", class);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class=trigger_class
|
||||||
|
data-name="ContextMenuTrigger"
|
||||||
|
data-context-trigger=ctx.target_id
|
||||||
|
on:contextmenu=move |_| {
|
||||||
|
if let Some(cb) = on_open {
|
||||||
|
cb.run(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content of the context menu that appears on right-click.
|
||||||
|
/// The `on_close` callback is triggered when the menu closes (click outside, ESC key, or action click).
|
||||||
|
#[component]
|
||||||
|
pub fn ContextMenuContent(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(optional)] on_close: Option<Callback<()>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let ctx = expect_context::<ContextMenuContext>();
|
||||||
|
|
||||||
|
let base_classes = "z-50 p-1 rounded-md border bg-card shadow-md w-[200px] fixed transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100";
|
||||||
|
|
||||||
|
let class = tw_merge!(base_classes, class);
|
||||||
|
|
||||||
|
let target_id_for_script = ctx.target_id.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<script src="/hooks/lock_scroll.js"></script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-name="ContextMenuContent"
|
||||||
|
class=class
|
||||||
|
// Listen for custom 'contextmenuclose' event dispatched by JS when menu closes
|
||||||
|
on:contextmenuclose=move |_: web_sys::CustomEvent| {
|
||||||
|
if let Some(cb) = on_close {
|
||||||
|
cb.run(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id=ctx.target_id
|
||||||
|
data-target="target__context"
|
||||||
|
data-state="closed"
|
||||||
|
style="pointer-events: none;"
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{format!(
|
||||||
|
r#"
|
||||||
|
(function() {{
|
||||||
|
const setupContextMenu = () => {{
|
||||||
|
const menu = document.querySelector('#{}');
|
||||||
|
const trigger = document.querySelector('[data-context-trigger="{}"]');
|
||||||
|
|
||||||
|
if (!menu || !trigger) {{
|
||||||
|
setTimeout(setupContextMenu, 50);
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (menu.hasAttribute('data-initialized')) {{
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
menu.setAttribute('data-initialized', 'true');
|
||||||
|
|
||||||
|
let isOpen = false;
|
||||||
|
|
||||||
|
const updatePosition = (x, y) => {{
|
||||||
|
const menuRect = menu.getBoundingClientRect();
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
|
||||||
|
// Calculate position, ensuring menu stays within viewport
|
||||||
|
let left = x;
|
||||||
|
let top = y;
|
||||||
|
|
||||||
|
// Adjust if menu would go off right edge
|
||||||
|
if (x + menuRect.width > viewportWidth) {{
|
||||||
|
left = x - menuRect.width;
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Adjust if menu would go off bottom edge
|
||||||
|
if (y + menuRect.height > viewportHeight) {{
|
||||||
|
top = y - menuRect.height;
|
||||||
|
}}
|
||||||
|
|
||||||
|
menu.style.left = `${{left}}px`;
|
||||||
|
menu.style.top = `${{top}}px`;
|
||||||
|
menu.style.transformOrigin = 'top left';
|
||||||
|
}};
|
||||||
|
|
||||||
|
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
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ContextMenuSub(children: Children) -> impl IntoView {
|
||||||
|
clx! {ContextMenuSubRoot, li, "context__menu_sub_trigger", " relative inline-flex relative gap-2 items-center py-1.5 px-2 w-full text-sm no-underline rounded-sm transition-colors duration-200 cursor-pointer text-popover-foreground [&_svg:not([class*='size-'])]:size-4 hover:bg-accent hover:text-accent-foreground"}
|
||||||
|
|
||||||
|
view! { <ContextMenuSubRoot>{children()}</ContextMenuSubRoot> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ContextMenuSubTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("flex items-center justify-between w-full", class);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<span data-name="ContextMenuSubTrigger" class=class>
|
||||||
|
<span class="flex gap-2 items-center">{children()}</span>
|
||||||
|
<ChevronRight class="opacity-70 size-4" />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ContextMenuSubItem(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!(
|
||||||
|
"inline-flex gap-2 items-center w-full rounded-sm px-3 py-2 text-sm transition-all duration-150 ease text-popover-foreground hover:bg-accent hover:text-accent-foreground cursor-pointer hover:translate-x-[2px]",
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<li data-name="ContextMenuSubItem" class=class data-context-close="true">
|
||||||
|
{children()}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ use tw_merge::tw_merge;
|
|||||||
|
|
||||||
#[derive(Default, Clone, Copy, PartialEq, Eq, AsRefStr)]
|
#[derive(Default, Clone, Copy, PartialEq, Eq, AsRefStr)]
|
||||||
#[strum(serialize_all = "lowercase")]
|
#[strum(serialize_all = "lowercase")]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub enum InputType {
|
pub enum InputType {
|
||||||
#[default]
|
#[default]
|
||||||
Text,
|
Text,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
pub mod button;
|
pub mod button;
|
||||||
pub mod card;
|
pub mod card;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
|
pub mod toast;
|
||||||
|
pub mod context_menu;
|
||||||
|
|||||||
247
frontend/src/components/ui/toast.rs
Normal file
247
frontend/src/components/ui/toast.rs
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use tw_merge::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum ToastType {
|
||||||
|
#[default]
|
||||||
|
Default,
|
||||||
|
Success,
|
||||||
|
Error,
|
||||||
|
Warning,
|
||||||
|
Info,
|
||||||
|
Loading,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum SonnerPosition {
|
||||||
|
TopLeft,
|
||||||
|
TopCenter,
|
||||||
|
TopRight,
|
||||||
|
#[default]
|
||||||
|
BottomRight,
|
||||||
|
BottomCenter,
|
||||||
|
BottomLeft,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Debug)]
|
||||||
|
pub enum SonnerDirection {
|
||||||
|
TopDown,
|
||||||
|
#[default]
|
||||||
|
BottomUp,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct ToastData {
|
||||||
|
pub id: u64,
|
||||||
|
pub title: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub variant: ToastType,
|
||||||
|
pub duration: u64, // ms
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct ToasterStore {
|
||||||
|
pub toasts: RwSignal<Vec<ToastData>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SonnerTrigger(
|
||||||
|
#[prop(into, optional)] class: String,
|
||||||
|
#[prop(optional, default = ToastType::default())] variant: ToastType,
|
||||||
|
#[prop(into)] title: String,
|
||||||
|
description: Option<String>,
|
||||||
|
#[prop(into, optional)] position: String,
|
||||||
|
on_dismiss: Option<Callback<()>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let variant_classes = match variant {
|
||||||
|
ToastType::Default => "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
ToastType::Success => "bg-green-500 text-white hover:bg-green-600",
|
||||||
|
ToastType::Error => "bg-red-500 text-white shadow-xs hover:bg-red-600",
|
||||||
|
ToastType::Warning => "bg-yellow-500 text-white hover:bg-yellow-600",
|
||||||
|
ToastType::Info => "bg-blue-500 text-white shadow-xs hover:bg-blue-600",
|
||||||
|
ToastType::Loading => "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
};
|
||||||
|
|
||||||
|
let animation_direction = if position.contains("Top") {
|
||||||
|
"slide-in-from-top-5"
|
||||||
|
} else {
|
||||||
|
"slide-in-from-bottom-5"
|
||||||
|
};
|
||||||
|
|
||||||
|
let merged_class = tw_merge!(
|
||||||
|
"inline-flex flex-col items-start justify-center gap-1 min-w-[300px] rounded-md text-sm font-medium shadow-lg p-4 cursor-pointer pointer-events-auto border border-border/50 transition-all",
|
||||||
|
"animate-in fade-in duration-300 ease-out hover:scale-[1.02] active:scale-[0.98]",
|
||||||
|
animation_direction,
|
||||||
|
variant_classes,
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only set position attribute if not empty
|
||||||
|
let position_attr = if position.is_empty() { None } else { Some(position) };
|
||||||
|
|
||||||
|
// Clone title for data attribute usage, original moved into view
|
||||||
|
let title_clone = title.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class=merged_class
|
||||||
|
data-name="SonnerTrigger"
|
||||||
|
data-variant=variant.to_string()
|
||||||
|
data-toast-title=title_clone
|
||||||
|
data-toast-position=position_attr
|
||||||
|
on:click=move |_| {
|
||||||
|
if let Some(cb) = on_dismiss {
|
||||||
|
cb.run(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="font-semibold">{title}</div>
|
||||||
|
{move || description.as_ref().map(|d| view! { <div class="text-xs opacity-90">{d.clone()}</div> })}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SonnerContainer(
|
||||||
|
children: Children,
|
||||||
|
#[prop(into, optional)] class: String,
|
||||||
|
#[prop(optional, default = SonnerPosition::default())] position: SonnerPosition,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let merged_class = tw_merge!("toast__container fixed z-[9999] flex flex-col gap-2 p-4 outline-none pointer-events-none", class);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class=merged_class data-position=position.to_string()>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SonnerList(
|
||||||
|
children: Children,
|
||||||
|
#[prop(into, optional)] class: String,
|
||||||
|
#[prop(optional, default = SonnerPosition::default())] position: SonnerPosition,
|
||||||
|
#[prop(optional, default = SonnerDirection::default())] direction: SonnerDirection,
|
||||||
|
#[prop(into, default = "false".to_string())] expanded: String,
|
||||||
|
#[prop(into, optional)] style: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let merged_class = tw_merge!(
|
||||||
|
"contents",
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class=merged_class
|
||||||
|
data-name="SonnerList"
|
||||||
|
data-sonner-toaster="true"
|
||||||
|
data-sonner-theme="light"
|
||||||
|
data-position=position.to_string()
|
||||||
|
data-expanded=expanded
|
||||||
|
data-direction=direction.to_string()
|
||||||
|
style=style
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thread local storage for global access without Context
|
||||||
|
thread_local! {
|
||||||
|
static TOASTS: std::cell::RefCell<Option<RwSignal<Vec<ToastData>>>> = std::cell::RefCell::new(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn provide_toaster() {
|
||||||
|
let toasts = RwSignal::new(Vec::<ToastData>::new());
|
||||||
|
|
||||||
|
// Set global thread_local
|
||||||
|
TOASTS.with(|t| *t.borrow_mut() = Some(toasts));
|
||||||
|
|
||||||
|
// Also provide context for components
|
||||||
|
provide_context(ToasterStore { toasts });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView {
|
||||||
|
// Global store'u al
|
||||||
|
let store = use_context::<ToasterStore>().expect("Toaster context not found. Call provide_toaster() in App root.");
|
||||||
|
let toasts = store.toasts;
|
||||||
|
|
||||||
|
// Auto-derive direction from position
|
||||||
|
let direction = match position {
|
||||||
|
SonnerPosition::TopLeft | SonnerPosition::TopCenter | SonnerPosition::TopRight => SonnerDirection::TopDown,
|
||||||
|
_ => SonnerDirection::BottomUp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let container_class = match position {
|
||||||
|
SonnerPosition::TopLeft => "left-0 top-0 items-start",
|
||||||
|
SonnerPosition::TopRight => "right-0 top-0 items-end",
|
||||||
|
SonnerPosition::TopCenter => "left-1/2 -translate-x-1/2 top-0 items-center",
|
||||||
|
SonnerPosition::BottomCenter => "left-1/2 -translate-x-1/2 bottom-0 items-center",
|
||||||
|
SonnerPosition::BottomLeft => "left-0 bottom-0 items-start",
|
||||||
|
SonnerPosition::BottomRight => "right-0 bottom-0 items-end",
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<SonnerContainer class=container_class position=position>
|
||||||
|
<SonnerList position=position direction=direction>
|
||||||
|
<For
|
||||||
|
each=move || toasts.get()
|
||||||
|
key=|toast| toast.id
|
||||||
|
children=move |toast| {
|
||||||
|
let id = toast.id;
|
||||||
|
view! {
|
||||||
|
<SonnerTrigger
|
||||||
|
variant=toast.variant
|
||||||
|
title=toast.title
|
||||||
|
description=toast.description
|
||||||
|
position=position.to_string()
|
||||||
|
on_dismiss=Some(Callback::new(move |_| {
|
||||||
|
toasts.update(|vec| vec.retain(|t| t.id != id));
|
||||||
|
}))
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SonnerList>
|
||||||
|
</SonnerContainer>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global Helper Functions
|
||||||
|
pub fn toast(title: impl Into<String>, variant: ToastType) {
|
||||||
|
let signal_opt = TOASTS.with(|t| *t.borrow());
|
||||||
|
|
||||||
|
if let Some(toasts) = signal_opt {
|
||||||
|
let id = js_sys::Math::random().to_bits();
|
||||||
|
let new_toast = ToastData {
|
||||||
|
id,
|
||||||
|
title: title.into(),
|
||||||
|
description: None,
|
||||||
|
variant,
|
||||||
|
duration: 4000,
|
||||||
|
};
|
||||||
|
|
||||||
|
toasts.update(|t| t.push(new_toast.clone()));
|
||||||
|
|
||||||
|
// Auto remove after duration
|
||||||
|
let duration = new_toast.duration;
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
gloo_timers::future::TimeoutFuture::new(duration as u32).await;
|
||||||
|
toasts.update(|vec| vec.retain(|t| t.id != id));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
gloo_console::warn!("ToasterStore not found (global static). Make sure provide_toaster() is called.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn toast_success(title: impl Into<String>) { toast(title, ToastType::Success); }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn toast_error(title: impl Into<String>) { toast(title, ToastType::Error); }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn toast_warning(title: impl Into<String>) { toast(title, ToastType::Warning); }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn toast_info(title: impl Into<String>) { toast(title, ToastType::Info); }
|
||||||
@@ -7,19 +7,21 @@ use std::collections::HashMap;
|
|||||||
use struct_patch::traits::Patch;
|
use struct_patch::traits::Patch;
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||||
|
|
||||||
use crate::components::toast::ToastContext;
|
use crate::components::ui::toast::{ToastType, toast};
|
||||||
|
|
||||||
pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
|
pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
|
||||||
let msg = message.into();
|
let msg = message.into();
|
||||||
gloo_console::log!("TOAST CALL:", &msg, format!("{:?}", level));
|
gloo_console::log!("TOAST CALL:", &msg, format!("{:?}", level));
|
||||||
log::info!("Displaying toast: [{:?}] {}", level, msg);
|
log::info!("Displaying toast: [{:?}] {}", level, msg);
|
||||||
|
|
||||||
if let Some(context) = use_context::<ToastContext>() {
|
let variant = match level {
|
||||||
context.add(msg, level);
|
NotificationLevel::Success => ToastType::Success,
|
||||||
} else {
|
NotificationLevel::Error => ToastType::Error,
|
||||||
log::error!("ToastContext not found!");
|
NotificationLevel::Warning => ToastType::Warning,
|
||||||
gloo_console::error!("ToastContext not found!");
|
NotificationLevel::Info => ToastType::Info,
|
||||||
}
|
};
|
||||||
|
|
||||||
|
toast(msg, variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
2
frontend/ui_config.toml
Normal file
2
frontend/ui_config.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
base_color = "neutral"
|
||||||
|
base_path_components = "src/components"
|
||||||
@@ -20,7 +20,7 @@ pub struct SetupStatus {
|
|||||||
pub completed: bool,
|
pub completed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[server(GetSetupStatus, "/api/server_fns/GetSetupStatus", input = MsgPack, output = MsgPack)]
|
#[server(GetSetupStatus, "/api/server_fns", input = MsgPack, output = MsgPack)]
|
||||||
pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
|
pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
|
||||||
use crate::DbContext;
|
use crate::DbContext;
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ pub async fn get_setup_status() -> Result<SetupStatus, ServerFnError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[server(Setup, "/api/server_fns/Setup", input = MsgPack, output = MsgPack)]
|
#[server(Setup, "/api/server_fns", input = MsgPack, output = MsgPack)]
|
||||||
pub async fn setup(username: String, password: String) -> Result<(), ServerFnError> {
|
pub async fn setup(username: String, password: String) -> Result<(), ServerFnError> {
|
||||||
use crate::DbContext;
|
use crate::DbContext;
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ pub async fn setup(username: String, password: String) -> Result<(), ServerFnErr
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[server(Login, "/api/server_fns/Login", input = MsgPack, output = MsgPack)]
|
#[server(Login, "/api/server_fns", input = MsgPack, output = MsgPack)]
|
||||||
pub async fn login(username: String, password: String) -> Result<UserResponse, ServerFnError> {
|
pub async fn login(username: String, password: String) -> Result<UserResponse, ServerFnError> {
|
||||||
use crate::DbContext;
|
use crate::DbContext;
|
||||||
use leptos_axum::ResponseOptions;
|
use leptos_axum::ResponseOptions;
|
||||||
@@ -111,7 +111,7 @@ pub async fn login(username: String, password: String) -> Result<UserResponse, S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[server(Logout, "/api/server_fns/Logout", input = MsgPack, output = MsgPack)]
|
#[server(Logout, "/api/server_fns", input = MsgPack, output = MsgPack)]
|
||||||
pub async fn logout() -> Result<(), ServerFnError> {
|
pub async fn logout() -> Result<(), ServerFnError> {
|
||||||
use leptos_axum::ResponseOptions;
|
use leptos_axum::ResponseOptions;
|
||||||
use cookie::{Cookie, SameSite};
|
use cookie::{Cookie, SameSite};
|
||||||
@@ -132,7 +132,7 @@ pub async fn logout() -> Result<(), ServerFnError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[server(GetUser, "/api/server_fns/GetUser", input = MsgPack, output = MsgPack)]
|
#[server(GetUser, "/api/server_fns", input = MsgPack, output = MsgPack)]
|
||||||
pub async fn get_user() -> Result<Option<UserResponse>, ServerFnError> {
|
pub async fn get_user() -> Result<Option<UserResponse>, ServerFnError> {
|
||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
use leptos_axum::extract;
|
use leptos_axum::extract;
|
||||||
|
|||||||
BIN
vibetorrent.db-shm
Normal file
BIN
vibetorrent.db-shm
Normal file
Binary file not shown.
BIN
vibetorrent.db-wal
Normal file
BIN
vibetorrent.db-wal
Normal file
Binary file not shown.
Reference in New Issue
Block a user