Compare commits
15 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a7d9957aa | ||
|
|
56e8cc03d1 | ||
|
|
04cb7d51cb | ||
|
|
555505b80e | ||
|
|
fa07fd88dc | ||
|
|
bbb8e8dc98 | ||
|
|
d09ecd21b7 | ||
|
|
9a00e341af | ||
|
|
c78dcda55e | ||
|
|
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"
|
||||||
}
|
}
|
||||||
|
|||||||
253
frontend/public/lock_scroll.js
Normal file
253
frontend/public/lock_scroll.js
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* Scroll Lock Utility
|
||||||
|
* Handles locking and unlocking scroll for both window and all scrollable containers
|
||||||
|
* Similar to react-remove-scroll but in vanilla JavaScript
|
||||||
|
*/
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
// Prevent multiple initializations
|
||||||
|
if (window.ScrollLock) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScrollLock {
|
||||||
|
constructor() {
|
||||||
|
this.locked = false;
|
||||||
|
this.scrollableElements = [];
|
||||||
|
this.scrollPositions = new Map();
|
||||||
|
this.originalStyles = new Map();
|
||||||
|
this.fixedElements = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all scrollable elements in the DOM (optimized)
|
||||||
|
* Uses more targeted selectors instead of querying all elements
|
||||||
|
*/
|
||||||
|
findScrollableElements() {
|
||||||
|
const scrollables = [];
|
||||||
|
|
||||||
|
// More targeted query - only look for elements with overflow properties
|
||||||
|
const candidates = document.querySelectorAll(
|
||||||
|
'[style*="overflow"], [class*="overflow"], [class*="scroll"], main, aside, section, div',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Batch all style reads first to minimize reflows
|
||||||
|
const elementsToCheck = [];
|
||||||
|
for (const el of candidates) {
|
||||||
|
// Skip the element itself or if it's inside these containers
|
||||||
|
const dataName = el.getAttribute("data-name");
|
||||||
|
const isExcludedElement =
|
||||||
|
dataName === "ScrollArea" ||
|
||||||
|
dataName === "CommandList" ||
|
||||||
|
dataName === "SelectContent" ||
|
||||||
|
dataName === "MultiSelectContent" ||
|
||||||
|
dataName === "DropdownMenuContent" ||
|
||||||
|
dataName === "ContextMenuContent";
|
||||||
|
|
||||||
|
if (
|
||||||
|
el !== document.body &&
|
||||||
|
el !== document.documentElement &&
|
||||||
|
!isExcludedElement &&
|
||||||
|
!el.closest('[data-name="ScrollArea"]') &&
|
||||||
|
!el.closest('[data-name="CommandList"]') &&
|
||||||
|
!el.closest('[data-name="SelectContent"]') &&
|
||||||
|
!el.closest('[data-name="MultiSelectContent"]') &&
|
||||||
|
!el.closest('[data-name="DropdownMenuContent"]') &&
|
||||||
|
!el.closest('[data-name="ContextMenuContent"]')
|
||||||
|
) {
|
||||||
|
elementsToCheck.push(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now batch read all computed styles and dimensions
|
||||||
|
elementsToCheck.forEach((el) => {
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
const hasOverflow =
|
||||||
|
style.overflow === "auto" ||
|
||||||
|
style.overflow === "scroll" ||
|
||||||
|
style.overflowY === "auto" ||
|
||||||
|
style.overflowY === "scroll";
|
||||||
|
|
||||||
|
// Only check scrollHeight if overflow is set
|
||||||
|
if (hasOverflow && el.scrollHeight > el.clientHeight) {
|
||||||
|
scrollables.push(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return scrollables;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lock scrolling on all scrollable elements (optimized)
|
||||||
|
* Batches all DOM reads before DOM writes to prevent forced reflows
|
||||||
|
*/
|
||||||
|
lock() {
|
||||||
|
if (this.locked) return;
|
||||||
|
|
||||||
|
this.locked = true;
|
||||||
|
|
||||||
|
// Find all scrollable elements
|
||||||
|
this.scrollableElements = this.findScrollableElements();
|
||||||
|
|
||||||
|
// ===== BATCH 1: READ PHASE - Read all layout properties first =====
|
||||||
|
const windowScrollY = window.scrollY;
|
||||||
|
const scrollbarWidth = window.innerWidth - document.body.clientWidth;
|
||||||
|
|
||||||
|
// Store window scroll position
|
||||||
|
this.scrollPositions.set("window", windowScrollY);
|
||||||
|
|
||||||
|
// Store original body styles
|
||||||
|
this.originalStyles.set("body", {
|
||||||
|
position: document.body.style.position,
|
||||||
|
top: document.body.style.top,
|
||||||
|
width: document.body.style.width,
|
||||||
|
overflow: document.body.style.overflow,
|
||||||
|
paddingRight: document.body.style.paddingRight,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read all fixed-position elements and their padding (only if we have scrollbar)
|
||||||
|
if (scrollbarWidth > 0) {
|
||||||
|
// Use more targeted query for fixed elements
|
||||||
|
const fixedCandidates = document.querySelectorAll(
|
||||||
|
'[style*="fixed"], [class*="fixed"], header, nav, aside, [role="dialog"], [role="alertdialog"]',
|
||||||
|
);
|
||||||
|
|
||||||
|
this.fixedElements = Array.from(fixedCandidates).filter((el) => {
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
return (
|
||||||
|
style.position === "fixed" &&
|
||||||
|
!el.closest('[data-name="DropdownMenuContent"]') &&
|
||||||
|
!el.closest('[data-name="MultiSelectContent"]') &&
|
||||||
|
!el.closest('[data-name="ContextMenuContent"]')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch read all padding values
|
||||||
|
this.fixedElements.forEach((el) => {
|
||||||
|
const computedStyle = window.getComputedStyle(el);
|
||||||
|
const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0;
|
||||||
|
|
||||||
|
this.originalStyles.set(el, {
|
||||||
|
paddingRight: el.style.paddingRight,
|
||||||
|
computedPadding: currentPadding,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read scrollable elements info
|
||||||
|
const scrollableInfo = this.scrollableElements.map((el) => {
|
||||||
|
const scrollTop = el.scrollTop;
|
||||||
|
const elementScrollbarWidth = el.offsetWidth - el.clientWidth;
|
||||||
|
const computedStyle = window.getComputedStyle(el);
|
||||||
|
const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0;
|
||||||
|
|
||||||
|
this.scrollPositions.set(el, scrollTop);
|
||||||
|
this.originalStyles.set(el, {
|
||||||
|
overflow: el.style.overflow,
|
||||||
|
overflowY: el.style.overflowY,
|
||||||
|
paddingRight: el.style.paddingRight,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { el, elementScrollbarWidth, currentPadding };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== BATCH 2: WRITE PHASE - Apply all styles at once =====
|
||||||
|
|
||||||
|
// Apply body lock
|
||||||
|
document.body.style.position = "fixed";
|
||||||
|
document.body.style.top = `-${windowScrollY}px`;
|
||||||
|
document.body.style.width = "100%";
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
if (scrollbarWidth > 0) {
|
||||||
|
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
||||||
|
|
||||||
|
// Apply padding compensation to fixed elements
|
||||||
|
this.fixedElements.forEach((el) => {
|
||||||
|
const stored = this.originalStyles.get(el);
|
||||||
|
if (stored) {
|
||||||
|
el.style.paddingRight = `${stored.computedPadding + scrollbarWidth}px`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock all scrollable containers
|
||||||
|
scrollableInfo.forEach(({ el, elementScrollbarWidth, currentPadding }) => {
|
||||||
|
el.style.overflow = "hidden";
|
||||||
|
|
||||||
|
if (elementScrollbarWidth > 0) {
|
||||||
|
el.style.paddingRight = `${currentPadding + elementScrollbarWidth}px`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlock scrolling on all elements (optimized)
|
||||||
|
* @param {number} delay - Delay in milliseconds before unlocking (for animations)
|
||||||
|
*/
|
||||||
|
unlock(delay = 0) {
|
||||||
|
if (!this.locked) return;
|
||||||
|
|
||||||
|
const performUnlock = () => {
|
||||||
|
// Restore body scroll
|
||||||
|
const bodyStyles = this.originalStyles.get("body");
|
||||||
|
if (bodyStyles) {
|
||||||
|
document.body.style.position = bodyStyles.position;
|
||||||
|
document.body.style.top = bodyStyles.top;
|
||||||
|
document.body.style.width = bodyStyles.width;
|
||||||
|
document.body.style.overflow = bodyStyles.overflow;
|
||||||
|
document.body.style.paddingRight = bodyStyles.paddingRight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore window scroll position
|
||||||
|
const windowScrollY = this.scrollPositions.get("window") || 0;
|
||||||
|
window.scrollTo(0, windowScrollY);
|
||||||
|
|
||||||
|
// Restore all scrollable containers
|
||||||
|
this.scrollableElements.forEach((el) => {
|
||||||
|
const originalStyles = this.originalStyles.get(el);
|
||||||
|
if (originalStyles) {
|
||||||
|
el.style.overflow = originalStyles.overflow;
|
||||||
|
el.style.overflowY = originalStyles.overflowY;
|
||||||
|
el.style.paddingRight = originalStyles.paddingRight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore scroll position
|
||||||
|
const scrollPosition = this.scrollPositions.get(el) || 0;
|
||||||
|
el.scrollTop = scrollPosition;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore fixed-position elements padding
|
||||||
|
this.fixedElements.forEach((el) => {
|
||||||
|
const styles = this.originalStyles.get(el);
|
||||||
|
if (styles && styles.paddingRight !== undefined) {
|
||||||
|
el.style.paddingRight = styles.paddingRight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear storage
|
||||||
|
this.scrollableElements = [];
|
||||||
|
this.fixedElements = [];
|
||||||
|
this.scrollPositions.clear();
|
||||||
|
this.originalStyles.clear();
|
||||||
|
this.locked = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (delay > 0) {
|
||||||
|
setTimeout(performUnlock, delay);
|
||||||
|
} else {
|
||||||
|
performUnlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if scrolling is currently locked
|
||||||
|
*/
|
||||||
|
isLocked() {
|
||||||
|
return this.locked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export as singleton
|
||||||
|
window.ScrollLock = new ScrollLock();
|
||||||
|
})();
|
||||||
@@ -6,20 +6,37 @@ 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;
|
||||||
|
use crate::components::hooks::use_theme_mode::ThemeMode;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn App() -> impl IntoView {
|
pub fn App() -> impl IntoView {
|
||||||
|
crate::components::ui::toast::provide_toaster();
|
||||||
|
let theme_mode = ThemeMode::init();
|
||||||
|
|
||||||
|
// Sync theme with document
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let is_dark = theme_mode.get();
|
||||||
|
if let Some(doc) = document().document_element() {
|
||||||
|
if is_dark {
|
||||||
|
let _ = doc.class_list().add_1("dark");
|
||||||
|
let _ = doc.set_attribute("data-theme", "dark");
|
||||||
|
} else {
|
||||||
|
let _ = doc.class_list().remove_1("dark");
|
||||||
|
let _ = doc.set_attribute("data-theme", "light");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ use leptos::task::spawn_local;
|
|||||||
use crate::components::ui::card::{Card, CardHeader, CardContent};
|
use crate::components::ui::card::{Card, CardHeader, CardContent};
|
||||||
use crate::components::ui::input::{Input, InputType};
|
use crate::components::ui::input::{Input, InputType};
|
||||||
|
|
||||||
|
use crate::components::ui::button::Button;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Login() -> impl IntoView {
|
pub fn Login() -> impl IntoView {
|
||||||
let username = RwSignal::new(String::new());
|
let username = RwSignal::new(String::new());
|
||||||
@@ -74,15 +76,16 @@ pub fn Login() -> impl IntoView {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="pt-2">
|
<div class="pt-2">
|
||||||
<button
|
<Button
|
||||||
class="inline-flex items-center justify-center w-full h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all disabled:pointer-events-none disabled:opacity-50"
|
class="w-full"
|
||||||
disabled=move || loading.0.get()
|
attr:r#type="submit"
|
||||||
|
attr:disabled=move || loading.0.get()
|
||||||
>
|
>
|
||||||
<Show when=move || loading.0.get() fallback=|| "Giriş Yap">
|
<Show when=move || loading.0.get() fallback=|| view! { "Giriş Yap" }.into_any()>
|
||||||
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
|
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
|
||||||
"Giriş Yapılıyor..."
|
"Giriş Yapılıyor..."
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ use leptos::task::spawn_local;
|
|||||||
use crate::components::ui::card::{Card, CardHeader, CardContent};
|
use crate::components::ui::card::{Card, CardHeader, CardContent};
|
||||||
use crate::components::ui::input::{Input, InputType};
|
use crate::components::ui::input::{Input, InputType};
|
||||||
|
|
||||||
|
use crate::components::ui::button::Button;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Setup() -> impl IntoView {
|
pub fn Setup() -> impl IntoView {
|
||||||
let username = RwSignal::new(String::new());
|
let username = RwSignal::new(String::new());
|
||||||
@@ -98,15 +100,16 @@ pub fn Setup() -> impl IntoView {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="pt-2">
|
<div class="pt-2">
|
||||||
<button
|
<Button
|
||||||
class="inline-flex items-center justify-center w-full h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all disabled:pointer-events-none disabled:opacity-50"
|
class="w-full"
|
||||||
disabled=move || loading.0.get()
|
attr:r#type="submit"
|
||||||
|
attr:disabled=move || loading.0.get()
|
||||||
>
|
>
|
||||||
<Show when=move || loading.0.get() fallback=|| "Kurulumu Tamamla">
|
<Show when=move || loading.0.get() fallback=|| view! { "Kurulumu Tamamla" }.into_any()>
|
||||||
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
|
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
|
||||||
"Kuruluyor..."
|
"Kuruluyor..."
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -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,71 @@ 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()}
|
||||||
>
|
</ContextMenuTrigger>
|
||||||
{children()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when=move || open.get()>
|
<ContextMenuContent class="w-56">
|
||||||
{
|
<ContextMenuAction
|
||||||
let (x, y) = position.get();
|
class="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-sm"
|
||||||
// Menü yaklaşık boyutları
|
on:click=move |_| menu_action("start")
|
||||||
let menu_width = 200;
|
>
|
||||||
let menu_height = 220;
|
<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">
|
||||||
let window = web_sys::window().unwrap();
|
<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" />
|
||||||
let vw = window.inner_width().unwrap().as_f64().unwrap() as i32;
|
</svg>
|
||||||
let vh = window.inner_height().unwrap().as_f64().unwrap() as i32;
|
"Start"
|
||||||
// Sağa taşarsa sola aç, alta taşarsa yukarı aç
|
</ContextMenuAction>
|
||||||
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">
|
|
||||||
<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>
|
|
||||||
"Start"
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Stop
|
<ContextMenuAction
|
||||||
<div
|
class="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-sm"
|
||||||
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")
|
||||||
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"
|
</ContextMenuAction>
|
||||||
</div>
|
|
||||||
|
|
||||||
// Recheck
|
<ContextMenuAction
|
||||||
<div
|
class="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-sm"
|
||||||
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")
|
||||||
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"
|
</ContextMenuAction>
|
||||||
</div>
|
|
||||||
|
|
||||||
// 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="px-2 py-1.5 text-destructive hover:bg-destructive/10 hover:text-destructive rounded-sm"
|
||||||
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"
|
</ContextMenuAction>
|
||||||
</div>
|
|
||||||
|
|
||||||
// Remove with Data
|
<ContextMenuHoldAction
|
||||||
<div
|
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
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_hold_complete=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"
|
<span class="ml-auto text-[10px] opacity-50">"Hold"</span>
|
||||||
</div>
|
</ContextMenuHoldAction>
|
||||||
</div>
|
</ContextMenuContent>
|
||||||
}
|
</ContextMenu>
|
||||||
}
|
|
||||||
</Show>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
2
frontend/src/components/hooks/mod.rs
Normal file
2
frontend/src/components/hooks/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod use_random;
|
||||||
|
pub mod use_theme_mode;
|
||||||
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()
|
||||||
|
}
|
||||||
108
frontend/src/components/hooks/use_theme_mode.rs
Normal file
108
frontend/src/components/hooks/use_theme_mode.rs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use web_sys::Storage;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct ThemeMode {
|
||||||
|
state: RwSignal<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCALSTORAGE_KEY: &str = "darkmode";
|
||||||
|
|
||||||
|
/// Hook to access the dark mode context
|
||||||
|
///
|
||||||
|
/// Returns the ThemeMode instance from context for easy access
|
||||||
|
pub fn use_theme_mode() -> ThemeMode {
|
||||||
|
expect_context::<ThemeMode>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================== */
|
||||||
|
/* ✨ FUNCTIONS ✨ */
|
||||||
|
/* ========================================================== */
|
||||||
|
|
||||||
|
impl ThemeMode {
|
||||||
|
#[must_use]
|
||||||
|
/// Initializes a new ThemeMode instance.
|
||||||
|
pub fn init() -> Self {
|
||||||
|
let theme_mode = Self { state: RwSignal::new(false) };
|
||||||
|
|
||||||
|
provide_context(theme_mode);
|
||||||
|
|
||||||
|
// Use Effect to handle browser-only initialization
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let initial = Self::get_storage_state().unwrap_or(Self::prefers_dark_mode());
|
||||||
|
theme_mode.state.set(initial);
|
||||||
|
});
|
||||||
|
|
||||||
|
theme_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle(&self) {
|
||||||
|
self.state.update(|state| {
|
||||||
|
*state = !*state;
|
||||||
|
Self::set_storage_state(*state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_dark(&self) {
|
||||||
|
self.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_light(&self) {
|
||||||
|
self.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// - `dark`: Set to `true` for dark mode, and `false` for light mode.
|
||||||
|
pub fn set(&self, dark: bool) {
|
||||||
|
self.state.set(dark);
|
||||||
|
Self::set_storage_state(dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn get(&self) -> bool {
|
||||||
|
self.state.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_dark(&self) -> bool {
|
||||||
|
self.state.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_light(&self) -> bool {
|
||||||
|
!self.state.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================== */
|
||||||
|
/* ✨ FUNCTIONS ✨ */
|
||||||
|
/* ========================================================== */
|
||||||
|
|
||||||
|
/// Retrieves the local storage object, if available.
|
||||||
|
fn get_storage() -> Option<Storage> {
|
||||||
|
window().local_storage().ok().flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves the dark mode state from local storage, if available.
|
||||||
|
fn get_storage_state() -> Option<bool> {
|
||||||
|
Self::get_storage()
|
||||||
|
.and_then(|storage| storage.get(LOCALSTORAGE_KEY).ok())
|
||||||
|
.flatten()
|
||||||
|
.and_then(|entry| entry.parse::<bool>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks whether the user's system prefers dark mode based on media queries.
|
||||||
|
fn prefers_dark_mode() -> bool {
|
||||||
|
window()
|
||||||
|
.match_media("(prefers-color-scheme: dark)")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|media| media.matches())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores the dark mode state in local storage.
|
||||||
|
fn set_storage_state(state: bool) {
|
||||||
|
if let Some(storage) = Self::get_storage() {
|
||||||
|
storage.set(LOCALSTORAGE_KEY, state.to_string().as_str()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
|
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
|
||||||
use leptos_use::storage::use_local_storage;
|
|
||||||
use ::codee::string::FromToStringCodec;
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Sidebar() -> impl IntoView {
|
pub fn Sidebar() -> impl IntoView {
|
||||||
@@ -67,34 +65,6 @@ pub fn Sidebar() -> impl IntoView {
|
|||||||
username().chars().next().unwrap_or('?').to_uppercase().to_string()
|
username().chars().next().unwrap_or('?').to_uppercase().to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- THEME LOGIC START ---
|
|
||||||
let (current_theme, set_current_theme, _) = use_local_storage::<String, FromToStringCodec>("vibetorrent_theme");
|
|
||||||
|
|
||||||
// Initialize with default if empty
|
|
||||||
let current_theme_val = current_theme.get();
|
|
||||||
if current_theme_val.is_empty() {
|
|
||||||
set_current_theme.set("dark".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Automatically sync theme to document attribute
|
|
||||||
Effect::new(move |_| {
|
|
||||||
let theme = current_theme.get().to_lowercase();
|
|
||||||
if let Some(doc) = document().document_element() {
|
|
||||||
let _ = doc.set_attribute("data-theme", &theme);
|
|
||||||
if theme == "dark" || theme == "dracula" || theme == "dim" || theme == "abyss" || theme == "sunset" || theme == "cyberpunk" || theme == "nord" || theme == "business" || theme == "night" || theme == "black" || theme == "luxury" || theme == "coffee" || theme == "forest" || theme == "halloween" || theme == "synthwave" {
|
|
||||||
let _ = doc.class_list().add_1("dark");
|
|
||||||
} else {
|
|
||||||
let _ = doc.class_list().remove_1("dark");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let toggle_theme = move |_| {
|
|
||||||
let new_theme = if current_theme.get() == "dark" { "light" } else { "dark" };
|
|
||||||
set_current_theme.set(new_theme.to_string());
|
|
||||||
};
|
|
||||||
// --- THEME LOGIC END ---
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="w-full h-full flex flex-col bg-card" style="padding-top: env(safe-area-inset-top);">
|
<div class="w-full h-full flex flex-col bg-card" style="padding-top: env(safe-area-inset-top);">
|
||||||
<div class="p-4 flex-1 overflow-y-auto">
|
<div class="p-4 flex-1 overflow-y-auto">
|
||||||
@@ -164,23 +134,15 @@ pub fn Sidebar() -> impl IntoView {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Theme toggle button
|
// Theme toggle button
|
||||||
<button
|
<div class="inline-flex items-center justify-center size-8 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground hover:text-foreground transition-colors">
|
||||||
class="inline-flex items-center justify-center size-8 rounded-md hover:bg-accent hover:text-accent-foreground text-muted-foreground hover:text-foreground transition-colors"
|
<crate::components::ui::theme_toggle::ThemeToggle />
|
||||||
on:click=toggle_theme
|
</div>
|
||||||
>
|
|
||||||
<Show when=move || current_theme.get() == "dark" fallback=|| view! {
|
|
||||||
<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="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
|
||||||
</svg>
|
|
||||||
}>
|
|
||||||
<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="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
|
||||||
</svg>
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
// Logout button
|
// Logout button
|
||||||
<button
|
<Button
|
||||||
class="inline-flex items-center justify-center size-8 rounded-md hover:bg-accent text-destructive transition-colors"
|
variant=ButtonVariant::Ghost
|
||||||
|
size=ButtonSize::Icon
|
||||||
|
class="text-destructive hover:bg-destructive/10"
|
||||||
|
attr:disabled=move || false
|
||||||
on:click=move |_| {
|
on:click=move |_| {
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if shared::server_fns::auth::logout().await.is_ok() {
|
if shared::server_fns::auth::logout().await.is_ok() {
|
||||||
@@ -193,7 +155,7 @@ pub fn Sidebar() -> impl IntoView {
|
|||||||
<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">
|
<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="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,13 +170,12 @@ fn SidebarButton(
|
|||||||
#[prop(into)] label: &'static str,
|
#[prop(into)] label: &'static str,
|
||||||
count: Signal<usize>,
|
count: Signal<usize>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
|
let variant = move || if active.get() { ButtonVariant::Secondary } else { ButtonVariant::Ghost };
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<button
|
<Button
|
||||||
class=move || if active.get() {
|
variant=Signal::derive(variant)
|
||||||
"inline-flex items-center justify-start gap-2 w-full h-8 rounded-md px-3 text-sm font-medium bg-secondary text-secondary-foreground transition-colors"
|
class="justify-start gap-2 w-full h-8 px-3"
|
||||||
} else {
|
|
||||||
"inline-flex items-center justify-start gap-2 w-full h-8 rounded-md px-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
|
|
||||||
}
|
|
||||||
on:click=on_click
|
on:click=on_click
|
||||||
>
|
>
|
||||||
<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">
|
<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">
|
||||||
@@ -222,6 +183,6 @@ fn SidebarButton(
|
|||||||
</svg>
|
</svg>
|
||||||
{label}
|
{label}
|
||||||
<span class="ml-auto text-xs font-mono opacity-70">{count}</span>
|
<span class="ml-auto text-xs font-mono opacity-70">{count}</span>
|
||||||
</button>
|
</Button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
pub mod hooks;
|
||||||
pub mod context_menu;
|
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;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ use crate::components::ui::input::{Input, InputType};
|
|||||||
use crate::store::TorrentStore;
|
use crate::store::TorrentStore;
|
||||||
use crate::api;
|
use crate::api;
|
||||||
|
|
||||||
|
use crate::components::ui::button::{Button, ButtonVariant};
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AddTorrentDialog(
|
pub fn AddTorrentDialog(
|
||||||
on_close: Callback<()>,
|
on_close: Callback<()>,
|
||||||
@@ -80,17 +82,16 @@ pub fn AddTorrentDialog(
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
<div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
|
<div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant=ButtonVariant::Ghost
|
||||||
class="inline-flex items-center justify-center h-9 px-4 py-2 rounded-md text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
|
attr:r#type="button"
|
||||||
on:click=move |_| on_close.run(())
|
on:click=move |_| on_close.run(())
|
||||||
>
|
>
|
||||||
"Cancel"
|
"Cancel"
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
attr:r#type="submit"
|
||||||
class="inline-flex items-center justify-center h-9 px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 transition-all disabled:pointer-events-none disabled:opacity-50"
|
attr:disabled=move || is_loading.0.get()
|
||||||
disabled=move || is_loading.0.get()
|
|
||||||
>
|
>
|
||||||
{move || if is_loading.0.get() {
|
{move || if is_loading.0.get() {
|
||||||
leptos::either::Either::Left(view! {
|
leptos::either::Either::Left(view! {
|
||||||
@@ -100,13 +101,14 @@ pub fn AddTorrentDialog(
|
|||||||
} else {
|
} else {
|
||||||
leptos::either::Either::Right(view! { "Add" })
|
leptos::either::Either::Right(view! { "Add" })
|
||||||
}}
|
}}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
// Close button (X)
|
// Close button (X)
|
||||||
<button
|
<Button
|
||||||
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none"
|
variant=ButtonVariant::Ghost
|
||||||
|
class="absolute right-2 top-2 size-8 p-0 opacity-70 hover:opacity-100"
|
||||||
on:click=move |_| on_close.run(())
|
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">
|
<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">
|
||||||
@@ -114,7 +116,7 @@ pub fn AddTorrentDialog(
|
|||||||
<path d="m6 6 12 12"></path>
|
<path d="m6 6 12 12"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="sr-only">"Close"</span>
|
<span class="sr-only">"Close"</span>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,8 @@ use crate::store::{get_action_messages, show_toast};
|
|||||||
use crate::api;
|
use crate::api;
|
||||||
use shared::NotificationLevel;
|
use shared::NotificationLevel;
|
||||||
use crate::components::context_menu::TorrentContextMenu;
|
use crate::components::context_menu::TorrentContextMenu;
|
||||||
use crate::components::ui::card::{Card, CardHeader, CardTitle, CardContent};
|
use crate::components::ui::card::{Card, CardHeader, CardTitle, CardContent as CardBody};
|
||||||
|
use crate::components::ui::data_table::*;
|
||||||
|
|
||||||
fn format_bytes(bytes: i64) -> String {
|
fn format_bytes(bytes: i64) -> String {
|
||||||
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||||
@@ -106,10 +107,10 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
let sort_arrow = move |col: SortColumn| {
|
let sort_arrow = move |col: SortColumn| {
|
||||||
if sort_col.0.get() == col {
|
if sort_col.0.get() == col {
|
||||||
match sort_dir.0.get() {
|
match sort_dir.0.get() {
|
||||||
SortDirection::Ascending => view! { <span class="ml-1 text-xs">"▲"</span> }.into_any(),
|
SortDirection::Ascending => view! { <span class="ml-1 text-[10px]">"▲"</span> }.into_any(),
|
||||||
SortDirection::Descending => view! { <span class="ml-1 text-xs">"▼"</span> }.into_any(),
|
SortDirection::Descending => view! { <span class="ml-1 text-[10px]">"▼"</span> }.into_any(),
|
||||||
}
|
}
|
||||||
} else { view! { <span class="ml-1 text-xs opacity-0 group-hover:opacity-50">"▲"</span> }.into_any() }
|
} else { view! { <span class="ml-1 text-[10px] opacity-0 group-hover:opacity-50 transition-opacity">"▲"</span> }.into_any() }
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_action = Callback::new(move |(action, hash): (String, String)| {
|
let on_action = Callback::new(move |(action, hash): (String, String)| {
|
||||||
@@ -132,51 +133,53 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
});
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="h-full bg-background relative flex flex-col overflow-hidden">
|
<div class="h-full bg-background relative flex flex-col overflow-hidden px-4 py-2">
|
||||||
// --- DESKTOP VIEW ---
|
// --- DESKTOP VIEW ---
|
||||||
<div class="hidden md:flex flex-col h-full overflow-hidden">
|
<div class="hidden md:flex flex-col h-full overflow-hidden">
|
||||||
// Header
|
<DataTableWrapper class="flex-1 min-h-0 bg-card/50">
|
||||||
<div class="flex items-center text-xs uppercase text-muted-foreground border-b border-border bg-muted/50 h-9 shrink-0 px-2 font-medium">
|
<div class="h-full overflow-auto">
|
||||||
<div class="flex-1 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Name)>
|
<DataTable>
|
||||||
"Name" {move || sort_arrow(SortColumn::Name)}
|
<DataTableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
|
||||||
|
<DataTableRow class="hover:bg-transparent">
|
||||||
|
<DataTableHead class="cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Name)>
|
||||||
|
<div class="flex items-center">"Name" {move || sort_arrow(SortColumn::Name)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Size)>
|
||||||
|
<div class="flex items-center">"Size" {move || sort_arrow(SortColumn::Size)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
<DataTableHead class="w-48 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Progress)>
|
||||||
|
<div class="flex items-center">"Progress" {move || sort_arrow(SortColumn::Progress)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::Status)>
|
||||||
|
<div class="flex items-center">"Status" {move || sort_arrow(SortColumn::Status)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
|
||||||
|
<div class="flex items-center">"DL Speed" {move || sort_arrow(SortColumn::DownSpeed)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
|
||||||
|
<div class="flex items-center">"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
<DataTableHead class="w-24 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::ETA)>
|
||||||
|
<div class="flex items-center">"ETA" {move || sort_arrow(SortColumn::ETA)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
<DataTableHead class="w-32 cursor-pointer group select-none" on:click=move |_| handle_sort(SortColumn::AddedDate)>
|
||||||
|
<div class="flex items-center">"Date" {move || sort_arrow(SortColumn::AddedDate)}</div>
|
||||||
|
</DataTableHead>
|
||||||
|
</DataTableRow>
|
||||||
|
</DataTableHeader>
|
||||||
|
<DataTableBody>
|
||||||
|
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
||||||
|
let on_action = on_action.clone();
|
||||||
|
move |hash| {
|
||||||
|
view! {
|
||||||
|
<TorrentRow hash=hash.clone() on_action=on_action.clone() />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} />
|
||||||
|
</DataTableBody>
|
||||||
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Size)>
|
</DataTableWrapper>
|
||||||
"Size" {move || sort_arrow(SortColumn::Size)}
|
|
||||||
</div>
|
|
||||||
<div class="w-48 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Progress)>
|
|
||||||
"Progress" {move || sort_arrow(SortColumn::Progress)}
|
|
||||||
</div>
|
|
||||||
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::Status)>
|
|
||||||
"Status" {move || sort_arrow(SortColumn::Status)}
|
|
||||||
</div>
|
|
||||||
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::DownSpeed)>
|
|
||||||
"DL Speed" {move || sort_arrow(SortColumn::DownSpeed)}
|
|
||||||
</div>
|
|
||||||
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::UpSpeed)>
|
|
||||||
"Up Speed" {move || sort_arrow(SortColumn::UpSpeed)}
|
|
||||||
</div>
|
|
||||||
<div class="w-24 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::ETA)>
|
|
||||||
"ETA" {move || sort_arrow(SortColumn::ETA)}
|
|
||||||
</div>
|
|
||||||
<div class="w-32 px-2 cursor-pointer hover:text-foreground group select-none flex items-center" on:click=move |_| handle_sort(SortColumn::AddedDate)>
|
|
||||||
"Date" {move || sort_arrow(SortColumn::AddedDate)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Regular List
|
|
||||||
<div class="flex-1 overflow-y-auto min-h-0">
|
|
||||||
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
|
||||||
let on_action = on_action.clone();
|
|
||||||
move |hash| {
|
|
||||||
let h = hash.clone();
|
|
||||||
view! {
|
|
||||||
<TorrentContextMenu torrent_hash=h on_action=on_action.clone()>
|
|
||||||
<TorrentRow hash=hash.clone() />
|
|
||||||
</TorrentContextMenu>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// --- MOBILE VIEW ---
|
// --- MOBILE VIEW ---
|
||||||
@@ -185,12 +188,9 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
<For each=move || filtered_hashes.get() key=|hash| hash.clone() children={
|
||||||
let on_action = on_action.clone();
|
let on_action = on_action.clone();
|
||||||
move |hash| {
|
move |hash| {
|
||||||
let h = hash.clone();
|
|
||||||
view! {
|
view! {
|
||||||
<div class="pb-3">
|
<div class="pb-3">
|
||||||
<TorrentContextMenu torrent_hash=h on_action=on_action.clone()>
|
<TorrentCard hash=hash.clone() on_action=on_action.clone() />
|
||||||
<TorrentCard hash=hash.clone() />
|
|
||||||
</TorrentContextMenu>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,12 +198,13 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}.into_any()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn TorrentRow(
|
fn TorrentRow(
|
||||||
hash: String,
|
hash: String,
|
||||||
|
on_action: Callback<(String, String)>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||||
let h = hash.clone();
|
let h = hash.clone();
|
||||||
@@ -214,50 +215,70 @@ fn TorrentRow(
|
|||||||
view! {
|
view! {
|
||||||
<Show when=move || torrent.get().is_some() fallback=|| ()>
|
<Show when=move || torrent.get().is_some() fallback=|| ()>
|
||||||
{
|
{
|
||||||
|
let on_action = on_action.clone();
|
||||||
move || {
|
move || {
|
||||||
let t = torrent.get().unwrap();
|
let t = torrent.get().unwrap();
|
||||||
let t_name = t.name.clone();
|
let t_name = t.name.clone();
|
||||||
let status_color = match t.status { shared::TorrentStatus::Seeding => "text-green-500", shared::TorrentStatus::Downloading => "text-blue-500", shared::TorrentStatus::Paused => "text-yellow-500", shared::TorrentStatus::Error => "text-red-500", _ => "text-muted-foreground" };
|
let status_color = match t.status { shared::TorrentStatus::Seeding => "text-green-500", shared::TorrentStatus::Downloading => "text-blue-500", shared::TorrentStatus::Paused => "text-yellow-500", shared::TorrentStatus::Error => "text-red-500", _ => "text-muted-foreground" };
|
||||||
|
|
||||||
|
let is_selected = Memo::new(move |_| {
|
||||||
|
let selected = store.selected_torrent.get();
|
||||||
|
selected.as_deref() == Some(stored_hash.get_value().as_str())
|
||||||
|
});
|
||||||
|
|
||||||
|
let t_name_for_title = t_name.clone();
|
||||||
|
let t_name_for_content = t_name.clone();
|
||||||
|
let h_for_menu = stored_hash.get_value();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div
|
<TorrentContextMenu torrent_hash=h_for_menu on_action=on_action.clone()>
|
||||||
class=move || {
|
<DataTableRow
|
||||||
let selected = store.selected_torrent.get();
|
class="cursor-pointer group"
|
||||||
let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
|
attr:data-state=move || if is_selected.get() { "selected" } else { "" }
|
||||||
if is_selected {
|
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
||||||
"flex items-center text-sm bg-primary/10 border-b border-border h-[48px] px-2 select-none cursor-pointer transition-colors w-full"
|
>
|
||||||
} else {
|
<DataTableCell class="font-medium truncate max-w-[200px] lg:max-w-md" attr:title=t_name_for_title>
|
||||||
"flex items-center text-sm hover:bg-muted/50 border-b border-border h-[48px] px-2 select-none cursor-pointer transition-colors w-full"
|
{t_name_for_content}
|
||||||
}
|
</DataTableCell>
|
||||||
}
|
<DataTableCell class="font-mono text-xs text-muted-foreground">
|
||||||
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
{format_bytes(t.size)}
|
||||||
>
|
</DataTableCell>
|
||||||
<div class="flex-1 min-w-0 px-2 font-medium truncate" title=t_name.clone()>{t_name.clone()}</div>
|
<DataTableCell>
|
||||||
<div class="w-24 px-2 font-mono text-xs text-muted-foreground">{format_bytes(t.size)}</div>
|
<div class="flex items-center gap-2">
|
||||||
<div class="w-48 px-2">
|
<div class="h-1.5 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
<div class="flex items-center gap-2">
|
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", t.percent_complete)></div>
|
||||||
<div class="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
</div>
|
||||||
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", t.percent_complete)></div>
|
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", t.percent_complete)}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[10px] text-muted-foreground w-10 text-right">{format!("{:.1}%", t.percent_complete)}</span>
|
</DataTableCell>
|
||||||
</div>
|
<DataTableCell class={format!("text-xs font-semibold {}", status_color)}>
|
||||||
</div>
|
{format!("{:?}", t.status)}
|
||||||
<div class={format!("w-24 px-2 text-xs font-medium {}", status_color)}>{format!("{:?}", t.status)}</div>
|
</DataTableCell>
|
||||||
<div class="w-24 px-2 text-right font-mono text-xs text-green-600 dark:text-green-500">{format_speed(t.down_rate)}</div>
|
<DataTableCell class="text-right font-mono text-xs text-green-600 dark:text-green-500 whitespace-nowrap">
|
||||||
<div class="w-24 px-2 text-right font-mono text-xs text-blue-600 dark:text-blue-500">{format_speed(t.up_rate)}</div>
|
{format_speed(t.down_rate)}
|
||||||
<div class="w-24 px-2 text-right font-mono text-xs text-muted-foreground">{format_duration(t.eta)}</div>
|
</DataTableCell>
|
||||||
<div class="w-32 px-2 text-right font-mono text-xs text-muted-foreground">{format_date(t.added_date)}</div>
|
<DataTableCell class="text-right font-mono text-xs text-blue-600 dark:text-blue-500 whitespace-nowrap">
|
||||||
</div>
|
{format_speed(t.up_rate)}
|
||||||
}
|
</DataTableCell>
|
||||||
|
<DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{format_duration(t.eta)}
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell class="text-right font-mono text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{format_date(t.added_date)}
|
||||||
|
</DataTableCell>
|
||||||
|
</DataTableRow>
|
||||||
|
</TorrentContextMenu>
|
||||||
|
}.into_any()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}.into_any()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn TorrentCard(
|
fn TorrentCard(
|
||||||
hash: String,
|
hash: String,
|
||||||
|
on_action: Callback<(String, String)>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
|
||||||
let h = hash.clone();
|
let h = hash.clone();
|
||||||
@@ -268,53 +289,57 @@ fn TorrentCard(
|
|||||||
view! {
|
view! {
|
||||||
<Show when=move || torrent.get().is_some() fallback=|| ()>
|
<Show when=move || torrent.get().is_some() fallback=|| ()>
|
||||||
{
|
{
|
||||||
|
let on_action = on_action.clone();
|
||||||
move || {
|
move || {
|
||||||
let t = torrent.get().unwrap();
|
let t = torrent.get().unwrap();
|
||||||
let t_name = t.name.clone();
|
let t_name = t.name.clone();
|
||||||
let status_badge_class = match t.status { shared::TorrentStatus::Seeding => "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border-green-200 dark:border-green-800", shared::TorrentStatus::Downloading => "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 border-blue-200 dark:border-blue-800", shared::TorrentStatus::Paused => "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800", shared::TorrentStatus::Error => "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 border-red-200 dark:border-red-800", _ => "bg-muted text-muted-foreground" };
|
let status_badge_class = match t.status { shared::TorrentStatus::Seeding => "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border-green-200 dark:border-green-800", shared::TorrentStatus::Downloading => "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 border-blue-200 dark:border-blue-800", shared::TorrentStatus::Paused => "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800", shared::TorrentStatus::Error => "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 border-red-200 dark:border-red-800", _ => "bg-muted text-muted-foreground" };
|
||||||
|
let h_for_menu = stored_hash.get_value();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div
|
<TorrentContextMenu torrent_hash=h_for_menu on_action=on_action.clone()>
|
||||||
class=move || {
|
<div
|
||||||
let selected = store.selected_torrent.get();
|
class=move || {
|
||||||
let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
|
let selected = store.selected_torrent.get();
|
||||||
if is_selected {
|
let is_selected = selected.as_deref() == Some(stored_hash.get_value().as_str());
|
||||||
"ring-2 ring-primary rounded-lg transition-all"
|
if is_selected {
|
||||||
} else {
|
"ring-2 ring-primary rounded-lg transition-all"
|
||||||
"transition-all"
|
} else {
|
||||||
|
"transition-all"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
||||||
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
|
>
|
||||||
>
|
<Card class="h-full select-none cursor-pointer hover:border-primary transition-colors">
|
||||||
<Card class="h-full select-none cursor-pointer hover:border-primary transition-colors">
|
<CardHeader class="p-3 pb-0">
|
||||||
<CardHeader class="p-3 pb-0">
|
<div class="flex justify-between items-start gap-2">
|
||||||
<div class="flex justify-between items-start gap-2">
|
<CardTitle class="text-sm font-medium leading-tight line-clamp-2">{t_name.clone()}</CardTitle>
|
||||||
<CardTitle class="text-sm font-medium leading-tight line-clamp-2">{t_name.clone()}</CardTitle>
|
<div class={format!("inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 {}", status_badge_class)}>{format!("{:?}", t.status)}</div>
|
||||||
<div class={format!("inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 {}", status_badge_class)}>{format!("{:?}", t.status)}</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="p-3 pt-2 gap-3 flex flex-col">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<div class="flex justify-between text-[10px] text-muted-foreground">
|
|
||||||
<span>{format_bytes(t.size)}</span>
|
|
||||||
<span>{format!("{:.1}%", t.percent_complete)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="h-1.5 w-full bg-secondary rounded-full overflow-hidden">
|
</CardHeader>
|
||||||
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", t.percent_complete)></div>
|
<CardBody class="p-3 pt-2 gap-3 flex flex-col">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="flex justify-between text-[10px] text-muted-foreground">
|
||||||
|
<span>{format_bytes(t.size)}</span>
|
||||||
|
<span>{format!("{:.1}%", t.percent_complete)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-1.5 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div class="h-full bg-primary transition-all duration-500" style=format!("width: {}%", t.percent_complete)></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="grid grid-cols-4 gap-2 text-[10px] font-mono text-muted-foreground pt-1 border-t border-border/50">
|
||||||
<div class="grid grid-cols-4 gap-2 text-[10px] font-mono text-muted-foreground pt-1 border-t border-border/50">
|
<div class="flex flex-col text-blue-600 dark:text-blue-500"><span>"DL"</span><span>{format_speed(t.down_rate)}</span></div>
|
||||||
<div class="flex flex-col text-blue-600 dark:text-blue-500"><span>"DL"</span><span>{format_speed(t.down_rate)}</span></div>
|
<div class="flex flex-col text-green-600 dark:text-green-500"><span>"UP"</span><span>{format_speed(t.up_rate)}</span></div>
|
||||||
<div class="flex flex-col text-green-600 dark:text-green-500"><span>"UP"</span><span>{format_speed(t.up_rate)}</span></div>
|
<div class="flex flex-col"><span>"ETA"</span><span>{format_duration(t.eta)}</span></div>
|
||||||
<div class="flex flex-col"><span>"ETA"</span><span>{format_duration(t.eta)}</span></div>
|
<div class="flex flex-col text-right"><span>"DATE"</span><span>{format_date(t.added_date)}</span></div>
|
||||||
<div class="flex flex-col text-right"><span>"DATE"</span><span>{format_date(t.added_date)}</span></div>
|
</div>
|
||||||
</div>
|
</CardBody>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</TorrentContextMenu>
|
||||||
}
|
}.into_any()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}.into_any()
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_ui::variants;
|
use leptos_ui::variants;
|
||||||
|
|
||||||
|
// TODO 💪 Loading state (demo_use_timeout_fn.rs and demo_button.rs)
|
||||||
|
|
||||||
variants! {
|
variants! {
|
||||||
Button {
|
Button {
|
||||||
base: "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive w-fit hover:cursor-pointer active:scale-[0.98] active:opacity-100 touch-manipulation [-webkit-tap-highlight-color:transparent] select-none [-webkit-touch-callout:none]",
|
base: "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive w-fit hover:cursor-pointer active:scale-[0.98] active:opacity-100 touch-manipulation [-webkit-tap-highlight-color:transparent] select-none [-webkit-touch-callout:none]", // Using hover:cursor-pointer as workaround for href_support.
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
Default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
Default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
@@ -11,13 +13,21 @@ variants! {
|
|||||||
Outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/5",
|
Outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/5",
|
||||||
Secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
Secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
Ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
Ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
Accent: "bg-accent text-accent-foreground hover:bg-accent/80",
|
||||||
Link: "text-primary underline-offset-4 hover:underline",
|
Link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
//
|
||||||
|
Warning: "bg-warning text-warning-foreground hover:bg-warning/90",
|
||||||
|
Success: "bg-success text-success-foreground hover:bg-success/90",
|
||||||
|
Bordered: "bg-transparent border border-zinc-200 text-muted-foreground",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
Default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
Default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
Sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
Sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
Lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
Lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
Icon: "size-9",
|
Icon: "size-9",
|
||||||
|
//
|
||||||
|
Mobile: "px-6 py-3 rounded-[24px]",
|
||||||
|
Badge: "px-2.5 py-0.5 text-xs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
component: {
|
component: {
|
||||||
|
|||||||
436
frontend/src/components/ui/context_menu.rs
Normal file
436
frontend/src/components/ui/context_menu.rs
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ContextMenuHoldAction(
|
||||||
|
children: Children,
|
||||||
|
#[prop(into)] on_hold_complete: Callback<()>,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
#[prop(default = 1000)] hold_duration: u64,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let is_holding = RwSignal::new(false);
|
||||||
|
let progress = RwSignal::new(0.0);
|
||||||
|
|
||||||
|
let on_mousedown = move |_| {
|
||||||
|
is_holding.set(true);
|
||||||
|
progress.set(0.0);
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_mouseup = move |_| {
|
||||||
|
is_holding.set(false);
|
||||||
|
progress.set(0.0);
|
||||||
|
};
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if is_holding.get() {
|
||||||
|
let start_time = js_sys::Date::now();
|
||||||
|
let duration = hold_duration as f64;
|
||||||
|
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
while is_holding.get_untracked() {
|
||||||
|
let now = js_sys::Date::now();
|
||||||
|
let elapsed = now - start_time;
|
||||||
|
let p = (elapsed / duration).min(1.0);
|
||||||
|
progress.set(p * 100.0);
|
||||||
|
|
||||||
|
if p >= 1.0 {
|
||||||
|
on_hold_complete.run(());
|
||||||
|
is_holding.set(false);
|
||||||
|
close_context_menu();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
gloo_timers::future::TimeoutFuture::new(16).await; // ~60fps
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let class = tw_merge!(
|
||||||
|
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors overflow-hidden",
|
||||||
|
class
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class=class
|
||||||
|
on:mousedown=on_mousedown
|
||||||
|
on:mouseup=on_mouseup
|
||||||
|
on:mouseleave=on_mouseup
|
||||||
|
on:touchstart=move |_| on_mousedown(web_sys::MouseEvent::new("mousedown").unwrap())
|
||||||
|
on:touchend=move |_| on_mouseup(web_sys::MouseEvent::new("mouseup").unwrap())
|
||||||
|
>
|
||||||
|
// Progress background
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 left-0 bg-destructive/20 transition-all duration-75 ease-linear pointer-events-none"
|
||||||
|
style=move || format!("width: {}%;", progress.get())
|
||||||
|
/>
|
||||||
|
<span class="relative z-10 flex items-center gap-2 w-full">
|
||||||
|
{children()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}.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!("block w-full h-full", class);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class=trigger_class
|
||||||
|
data-name="ContextMenuTrigger"
|
||||||
|
data-context-trigger=ctx.target_id
|
||||||
|
on:contextmenu=move |e: web_sys::MouseEvent| {
|
||||||
|
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 = "fixed z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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="/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>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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>
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/src/components/ui/data_table.rs
Normal file
6
frontend/src/components/ui/data_table.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// * Reuse @table.rs
|
||||||
|
pub use crate::components::ui::table::{
|
||||||
|
Table as DataTable, TableBody as DataTableBody, TableCaption as DataTableCaption, TableCell as DataTableCell,
|
||||||
|
TableFooter as DataTableFooter, TableHead as DataTableHead, TableHeader as DataTableHeader,
|
||||||
|
TableRow as DataTableRow, TableWrapper as DataTableWrapper,
|
||||||
|
};
|
||||||
@@ -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,9 @@
|
|||||||
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;
|
||||||
|
pub mod theme_toggle;
|
||||||
|
pub mod svg_icon;
|
||||||
|
pub mod table;
|
||||||
|
pub mod data_table;
|
||||||
25
frontend/src/components/ui/svg_icon.rs
Normal file
25
frontend/src/components/ui/svg_icon.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use tw_merge::tw_merge;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SvgIcon(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional, into)] class: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let class = tw_merge!("size-4", class);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class=class
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
}
|
||||||
56
frontend/src/components/ui/table.rs
Normal file
56
frontend/src/components/ui/table.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use tw_merge::tw_merge;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TableWrapper(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("overflow-hidden rounded-md border w-full", class);
|
||||||
|
view! { <div class=class>{children()}</div> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Table(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("w-full text-sm border-collapse", class);
|
||||||
|
view! { <table class=class>{children()}</table> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TableCaption(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("mt-4 text-sm text-muted-foreground", class);
|
||||||
|
view! { <caption class=class>{children()}</caption> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TableHeader(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("[&_tr]:border-b bg-muted/50", class);
|
||||||
|
view! { <thead class=class>{children()}</thead> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TableRow(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("border-b transition-colors data-[state=selected]:bg-muted hover:bg-muted/50", class);
|
||||||
|
view! { <tr class=class>{children()}</tr> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TableHead(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("h-10 px-4 text-left align-middle font-medium text-muted-foreground whitespace-nowrap", class);
|
||||||
|
view! { <th class=class>{children()}</th> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TableBody(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("[&_tr:last-child]:border-0", class);
|
||||||
|
view! { <tbody class=class>{children()}</tbody> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TableCell(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("p-2 px-4 align-middle", class);
|
||||||
|
view! { <td class=class>{children()}</td> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TableFooter(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
|
||||||
|
let class = tw_merge!("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", class);
|
||||||
|
view! { <tfoot class=class>{children()}</tfoot> }
|
||||||
|
}
|
||||||
76
frontend/src/components/ui/theme_toggle.rs
Normal file
76
frontend/src/components/ui/theme_toggle.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
use crate::components::ui::svg_icon::SvgIcon;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::hooks::use_theme_mode::use_theme_mode;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ThemeToggle() -> impl IntoView {
|
||||||
|
let theme_mode = use_theme_mode();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<style>
|
||||||
|
{"
|
||||||
|
.theme__toggle_transition {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
|
||||||
|
svg path {
|
||||||
|
transform-origin: center;
|
||||||
|
transition: all .6s ease;
|
||||||
|
transform: translate3d(0,0,0);
|
||||||
|
backface-visibility: hidden;
|
||||||
|
|
||||||
|
&.sun {
|
||||||
|
transform: scale(.4) rotate(60deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.moon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.switch {
|
||||||
|
svg path {
|
||||||
|
&.sun {
|
||||||
|
transform: scale(1) rotate(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.moon {
|
||||||
|
transform: scale(.4) rotate(-60deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
class=move || {
|
||||||
|
let base_class = "theme__toggle_transition";
|
||||||
|
if theme_mode.get() { format!("{base_class} switch") } else { base_class.to_string() }
|
||||||
|
}
|
||||||
|
on:click=move |_| theme_mode.toggle()
|
||||||
|
>
|
||||||
|
<SvgIcon class="size-4">
|
||||||
|
<path
|
||||||
|
d="M12 1.75V3.25M12 20.75V22.25M1.75 12H3.25M20.75 12H22.25M4.75216 4.75216L5.81282 5.81282M18.1872 18.1872L19.2478 19.2478M4.75216 19.2478L5.81282 18.1872M18.1872 5.81282L19.2478 4.75216M16.25 12C16.25 14.3472 14.3472 16.25 12 16.25C9.65279 16.25 7.75 14.3472 7.75 12C7.75 9.65279 9.65279 7.75 12 7.75C14.3472 7.75 16.25 9.65279 16.25 12Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
class="sun text-neutral-300"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M2.75 12C2.75 17.1086 6.89137 21.25 12 21.25C16.7154 21.25 20.6068 17.7216 21.1778 13.161C20.1198 13.8498 18.8566 14.25 17.5 14.25C13.7721 14.25 10.75 11.2279 10.75 7.5C10.75 5.66012 11.4861 3.99217 12.6799 2.77461C12.4554 2.7583 12.2287 2.75 12 2.75C6.89137 2.75 2.75 6.89137 2.75 12Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="moon text-neutral-700"
|
||||||
|
/>
|
||||||
|
</SvgIcon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
221
frontend/src/components/ui/toast.rs
Normal file
221
frontend/src/components/ui/toast.rs
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
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, 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(
|
||||||
|
toast: ToastData,
|
||||||
|
index: usize,
|
||||||
|
total: usize,
|
||||||
|
position: SonnerPosition,
|
||||||
|
#[prop(optional)] on_dismiss: Option<Callback<()>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let variant_classes = match toast.variant {
|
||||||
|
ToastType::Default => "bg-background text-foreground border-border",
|
||||||
|
ToastType::Success => "bg-background text-foreground border-border [&_.icon]:text-success",
|
||||||
|
ToastType::Error => "bg-background text-foreground border-border [&_.icon]:text-destructive",
|
||||||
|
ToastType::Warning => "bg-background text-foreground border-border [&_.icon]:text-warning",
|
||||||
|
ToastType::Info => "bg-background text-foreground border-border [&_.icon]:text-info",
|
||||||
|
ToastType::Loading => "bg-background text-foreground border-border",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sonner Stacking Logic
|
||||||
|
// We calculate inverse index: 0 is the newest (top), 1 is older, etc.
|
||||||
|
let inverse_index = index;
|
||||||
|
let offset = inverse_index as f64 * 16.0;
|
||||||
|
let scale = 1.0 - (inverse_index as f64 * 0.05);
|
||||||
|
let opacity = if inverse_index > 2 { 0.0 } else { 1.0 - (inverse_index as f64 * 0.2) };
|
||||||
|
|
||||||
|
let is_bottom = !position.to_string().contains("Top");
|
||||||
|
let y_direction = if is_bottom { -1.0 } else { 1.0 };
|
||||||
|
let translate_y = offset * y_direction;
|
||||||
|
|
||||||
|
let style = format!(
|
||||||
|
"z-index: {}; transform: translateY({}px) scale({}); opacity: {};",
|
||||||
|
total - index,
|
||||||
|
translate_y,
|
||||||
|
scale,
|
||||||
|
opacity
|
||||||
|
);
|
||||||
|
|
||||||
|
let icon = match toast.variant {
|
||||||
|
ToastType::Success => Some(view! { <span class="icon text-success">"✓"</span> }.into_any()),
|
||||||
|
ToastType::Error => Some(view! { <span class="icon text-destructive">"✕"</span> }.into_any()),
|
||||||
|
ToastType::Warning => Some(view! { <span class="icon text-warning">"⚠"</span> }.into_any()),
|
||||||
|
ToastType::Info => Some(view! { <span class="icon text-info">"ℹ"</span> }.into_any()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class=tw_merge!(
|
||||||
|
"absolute transition-all duration-300 ease-in-out cursor-pointer pointer-events-auto",
|
||||||
|
"flex items-center gap-3 min-w-[350px] p-4 rounded-lg border shadow-lg bg-card",
|
||||||
|
if is_bottom { "bottom-0" } else { "top-0" },
|
||||||
|
variant_classes
|
||||||
|
)
|
||||||
|
style=style
|
||||||
|
on:click=move |_| {
|
||||||
|
if let Some(cb) = on_dismiss {
|
||||||
|
cb.run(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-sm font-semibold">{toast.title}</div>
|
||||||
|
{move || toast.description.as_ref().map(|d| view! { <div class="text-xs opacity-70">{d.clone()}</div> })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thread local storage for global access
|
||||||
|
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());
|
||||||
|
TOASTS.with(|t| *t.borrow_mut() = Some(toasts));
|
||||||
|
provide_context(ToasterStore { toasts });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView {
|
||||||
|
let store = use_context::<ToasterStore>().expect("Toaster context not found");
|
||||||
|
let toasts = store.toasts;
|
||||||
|
let is_hovered = RwSignal::new(false);
|
||||||
|
|
||||||
|
let container_class = match position {
|
||||||
|
SonnerPosition::TopLeft => "left-6 top-6 items-start",
|
||||||
|
SonnerPosition::TopRight => "right-6 top-6 items-end",
|
||||||
|
SonnerPosition::TopCenter => "left-1/2 -translate-x-1/2 top-6 items-center",
|
||||||
|
SonnerPosition::BottomCenter => "left-1/2 -translate-x-1/2 bottom-6 items-center",
|
||||||
|
SonnerPosition::BottomLeft => "left-6 bottom-6 items-start",
|
||||||
|
SonnerPosition::BottomRight => "right-6 bottom-6 items-end",
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class=tw_merge!("fixed z-[100] flex flex-col pointer-events-none min-h-[200px] w-[400px]", container_class)
|
||||||
|
on:mouseenter=move |_| is_hovered.set(true)
|
||||||
|
on:mouseleave=move |_| is_hovered.set(false)
|
||||||
|
>
|
||||||
|
<For
|
||||||
|
each=move || {
|
||||||
|
let list = toasts.get();
|
||||||
|
// Reverse the list so newest is at the end (for stacking)
|
||||||
|
// or newest is at the beginning (for display logic)
|
||||||
|
list.into_iter().rev().enumerate().collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
key=|(_, toast)| toast.id
|
||||||
|
children=move |(index, toast)| {
|
||||||
|
let id = toast.id;
|
||||||
|
let total = toasts.with(|t| t.len());
|
||||||
|
|
||||||
|
// If hovered, expand the stack
|
||||||
|
let expanded_style = move || {
|
||||||
|
if is_hovered.get() {
|
||||||
|
let offset = index as f64 * 70.0;
|
||||||
|
let is_bottom = !position.to_string().contains("Top");
|
||||||
|
let y_dir = if is_bottom { -1.0 } else { 1.0 };
|
||||||
|
format!("transform: translateY({}px) scale(1); opacity: 1;", offset * y_dir)
|
||||||
|
} else {
|
||||||
|
"".to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div style=expanded_style>
|
||||||
|
<SonnerTrigger
|
||||||
|
toast=toast
|
||||||
|
index=index
|
||||||
|
total=total
|
||||||
|
position=position
|
||||||
|
on_dismiss=Callback::new(move |_| {
|
||||||
|
toasts.update(|vec| vec.retain(|t| t.id != id));
|
||||||
|
})
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
if t.len() > 5 {
|
||||||
|
t.remove(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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