Compare commits

..

61 Commits

Author SHA1 Message Date
spinline
71456ff4d1 fix: mobil detay paneli tam ekran genişliğine alındı
All checks were successful
Build MIPS Binary / build (push) Successful in 2m3s
2026-02-21 22:03:09 +03:00
spinline
1a3099d926 fix: mobil detay paneli sağdan açılacak şekilde güncellendi
All checks were successful
Build MIPS Binary / build (push) Successful in 2m0s
2026-02-21 21:58:50 +03:00
spinline
4ef4ee8d45 feat: sheet yerine sayfaya sabit inline detay paneli eklendi
All checks were successful
Build MIPS Binary / build (push) Successful in 2m3s
2026-02-21 21:48:18 +03:00
spinline
253067b417 chore(frontend): remove debug console.log calls from app initialization
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 21:19:44 +03:00
spinline
8d5edc659f fix(frontend): use proper link tag for Cargo.toml instead of script tag to prevent syntax error
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 21:09:07 +03:00
spinline
c122290f37 build(frontend): add post_build hook to strip modulepreload tags preventing Safari warnings
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 21:03:48 +03:00
spinline
999cef34a7 fix(frontend): remove fake ScrollBar and use native styled webkit scrollbar in ScrollArea
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 20:51:27 +03:00
spinline
93a43d1b38 feat(frontend): implement rust-ui ScrollArea for details tab scrolling
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 20:44:20 +03:00
spinline
91ca6ff96f fix(frontend): adjust flex shrink and min heights for safe mobile scrolling in details tab
All checks were successful
Build MIPS Binary / build (push) Successful in 2m0s
2026-02-21 20:36:39 +03:00
spinline
8c0d35cca5 fix(frontend): force absolute positioning for tabs scroll
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 20:31:49 +03:00
spinline
444ea4326d fix(frontend): make general tab scrollable on mobile/safari
All checks were successful
Build MIPS Binary / build (push) Successful in 2m2s
2026-02-21 20:22:18 +03:00
spinline
401ccb69b2 fix(backend): Support TCP SCGI sockets and remove verbose unstandardized tracker fields
All checks were successful
Build MIPS Binary / build (push) Successful in 2m4s
2026-02-21 20:00:00 +03:00
spinline
9cfea2aed5 feat: implement trackers tab with details
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 16:58:14 +03:00
spinline
ce212cb2d6 feat: enhance torrent details general tab
All checks were successful
Build MIPS Binary / build (push) Successful in 1m57s
2026-02-21 16:33:10 +03:00
spinline
851d79029a feat(ui): add mobile long-press support for context menus
All checks were successful
Build MIPS Binary / build (push) Successful in 1m58s
2026-02-21 01:46:21 +03:00
spinline
ab27cf3eb4 fix: use f.priority.set instead of deprecated f.set_priority for rTorrent API
All checks were successful
Build MIPS Binary / build (push) Successful in 2m0s
2026-02-21 01:42:07 +03:00
spinline
7b4c9ff336 fix(files): trigger refetch only after set_priority completes on server
All checks were successful
Build MIPS Binary / build (push) Successful in 2m1s
2026-02-21 01:36:21 +03:00
spinline
743596d701 fix(ci): remove tracing calls that caused compile error in shared crate
All checks were successful
Build MIPS Binary / build (push) Successful in 1m58s
2026-02-21 01:31:25 +03:00
spinline
598f038ea6 fix(files): use refetch callback instead of Action to avoid reactive disposal panic
Some checks failed
Build MIPS Binary / build (push) Failing after 1m10s
2026-02-21 01:29:26 +03:00
spinline
7f8c721115 debug: add tracing logs to set_file_priority for diagnosis
Some checks failed
Build MIPS Binary / build (push) Failing after 1m10s
2026-02-21 01:27:22 +03:00
spinline
ba7f1ffd91 fix(ui): adjust context menu position for CSS transformed containers
All checks were successful
Build MIPS Binary / build (push) Successful in 1m59s
2026-02-21 01:22:31 +03:00
spinline
daa24dd7ec fix(ui): refactor FileContextMenu to auticlose and match homepage
All checks were successful
Build MIPS Binary / build (push) Successful in 1m59s
2026-02-21 01:13:57 +03:00
spinline
45271b5060 fix: add missing files.rs and resolve Show type inference
All checks were successful
Build MIPS Binary / build (push) Successful in 2m0s
2026-02-21 01:05:58 +03:00
spinline
4d02bc655d fix: resolve E0282 type inference on CI and clear warnings
Some checks failed
Build MIPS Binary / build (push) Failing after 35s
2026-02-21 01:04:00 +03:00
spinline
3c2ba477f5 feat(ui): add files tab and priority context menu
Some checks failed
Build MIPS Binary / build (push) Failing after 36s
2026-02-21 00:58:35 +03:00
spinline
6106d1cd22 fix(ui): prevent selection checkboxes from opening torrent details sheet
All checks were successful
Build MIPS Binary / build (push) Successful in 1m56s
2026-02-21 00:50:26 +03:00
spinline
50b83ebacf chore(ui): remove debugging logs for sse connection
All checks were successful
Build MIPS Binary / build (push) Successful in 1m56s
2026-02-21 00:44:50 +03:00
spinline
566308d889 feat(backend): require rTorrent to be running for backend to start
All checks were successful
Build MIPS Binary / build (push) Successful in 1m54s
2026-02-21 00:42:42 +03:00
spinline
e878d1fe33 chore(ui): add debug logs for SSE connection lifecycle
All checks were successful
Build MIPS Binary / build (push) Successful in 1m56s
2026-02-21 00:29:27 +03:00
spinline
d88084fb9a fix(ui): fix service worker crashes for chrome extensions and bump cache version
All checks were successful
Build MIPS Binary / build (push) Successful in 1m57s
2026-02-21 00:25:28 +03:00
spinline
f8639f2967 chore(ui): add debug logs for SSE deserialization errors
All checks were successful
Build MIPS Binary / build (push) Successful in 1m56s
2026-02-21 00:23:23 +03:00
spinline
7129c9a8eb fix(ui): prevent panic on unwrap when selected torrent is None
All checks were successful
Build MIPS Binary / build (push) Successful in 1m57s
2026-02-21 00:19:14 +03:00
spinline
91202e7cf8 refactor(ui): wrap torrent details content with official RustUI Shimmer
All checks were successful
Build MIPS Binary / build (push) Successful in 2m7s
2026-02-21 00:02:27 +03:00
spinline
3c2fec8b8c feat(ui): use official rustui shimmer component for torrent details 2026-02-20 23:57:59 +03:00
spinline
ec23285a6a feat(ui): add shimmer component and integrate into torrent details 2026-02-20 23:53:37 +03:00
spinline
f075a87668 feat(ui): add bottom sheet and tabs for torrent details 2026-02-20 23:50:23 +03:00
spinline
3ce980239c fix: ensure context menu closes after triggering start or stop actions
All checks were successful
Build MIPS Binary / build (push) Successful in 1m51s
2026-02-14 01:25:47 +03:00
spinline
d00fc41010 style: update CSS configuration and theme variables
All checks were successful
Build MIPS Binary / build (push) Successful in 1m55s
2026-02-13 20:11:02 +03:00
spinline
0636020a86 chore: re-add Leptos metadata to Cargo.toml for ui-cli compatibility
All checks were successful
Build MIPS Binary / build (push) Successful in 1m52s
2026-02-13 20:03:01 +03:00
spinline
322e0ab4a3 fix: make torrent status bar visible on mobile
All checks were successful
Build MIPS Binary / build (push) Successful in 1m52s
2026-02-13 14:05:07 +03:00
spinline
89f0a5423d style: make footer even more minimal and elegant
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-13 14:03:54 +03:00
spinline
80f9e5cda2 fix: resolve race condition in ButtonAction using a generation counter for safety
All checks were successful
Build MIPS Binary / build (push) Successful in 1m52s
2026-02-13 13:50:47 +03:00
spinline
a12265573c fix: resolve type mismatch between Mouse and Touch events in ButtonAction
All checks were successful
Build MIPS Binary / build (push) Successful in 1m51s
2026-02-13 13:44:15 +03:00
spinline
e45ec46793 feat: replace legacy hold-to-delete logic with modern ButtonAction component
Some checks failed
Build MIPS Binary / build (push) Failing after 34s
2026-02-13 13:41:46 +03:00
spinline
0e075d6092 feat: implement high-performance CSS-based ButtonAction component
Some checks failed
Build MIPS Binary / build (push) Failing after 34s
2026-02-13 13:39:53 +03:00
spinline
dbbc722f50 perf: refactor hold-to-action animation using CSS for silky-smooth performance
Some checks failed
Build MIPS Binary / build (push) Failing after 35s
2026-02-13 13:32:40 +03:00
spinline
dd3b3f3504 fix: resolve all compilation errors in push notification logic and stabilize UI components
All checks were successful
Build MIPS Binary / build (push) Successful in 1m51s
2026-02-13 13:26:22 +03:00
spinline
bb9e06c9ed fix: resolve syntax error and restore sidebar content in sidebar.rs
Some checks failed
Build MIPS Binary / build (push) Failing after 37s
2026-02-13 13:13:14 +03:00
spinline
a834d185e3 feat: add Notifications switch to sidebar and implement robust push subscription logic
Some checks failed
Build MIPS Binary / build (push) Failing after 27s
2026-02-13 13:09:54 +03:00
spinline
4e81565ab6 fix: ensure problematic push subscriptions are always removed on any error
All checks were successful
Build MIPS Binary / build (push) Successful in 1m51s
2026-02-13 13:01:47 +03:00
spinline
795eef4bda fix: refine push error matching and maximize webhook logging for debugging
All checks were successful
Build MIPS Binary / build (push) Successful in 1m50s
2026-02-13 12:51:46 +03:00
spinline
3ad8424d17 fix: resolve borrow-after-move error in notification handler
All checks were successful
Build MIPS Binary / build (push) Successful in 1m54s
2026-02-13 12:44:55 +03:00
spinline
83feb5a5cf fix: broaden push notification error handling to clear invalid subscriptions more effectively
Some checks failed
Build MIPS Binary / build (push) Failing after 1m5s
2026-02-13 12:41:11 +03:00
spinline
0dd97f3d7e chore: improve webhook logging for better debugging
Some checks failed
Build MIPS Binary / build (push) Failing after 1m5s
2026-02-13 12:38:29 +03:00
spinline
bb32c1f7f6 fix: improve push notification reliability by removing invalid subscriptions and update rTorrent webhook logging
All checks were successful
Build MIPS Binary / build (push) Successful in 1m50s
2026-02-13 12:31:06 +03:00
spinline
3bb2d68a65 perf: increase background polling interval to 60 seconds
All checks were successful
Build MIPS Binary / build (push) Successful in 1m51s
2026-02-13 12:26:09 +03:00
spinline
fe117cdaec chore: add detailed logging for web push notifications in webhook handler
All checks were successful
Build MIPS Binary / build (push) Successful in 1m50s
2026-02-13 12:11:14 +03:00
spinline
e062a3c8cd feat: add internal notification endpoint for rTorrent event hooks
All checks were successful
Build MIPS Binary / build (push) Successful in 1m51s
2026-02-13 12:08:40 +03:00
spinline
ae2c9c934d fix: center toast notifications on mobile screens
All checks were successful
Build MIPS Binary / build (push) Successful in 1m54s
2026-02-13 00:08:54 +03:00
spinline
f7e1356eae fix: restrict Add Torrent dialog width for better UI
All checks were successful
Build MIPS Binary / build (push) Successful in 1m50s
2026-02-13 00:06:40 +03:00
spinline
98b1f059c7 feat: modernize Add Torrent dialog and perform final code cleanup of warnings and dead code
All checks were successful
Build MIPS Binary / build (push) Successful in 1m52s
2026-02-13 00:03:26 +03:00
59 changed files with 6981 additions and 4253 deletions

3
Cargo.lock generated
View File

@@ -320,9 +320,11 @@ dependencies = [
"dotenvy",
"futures",
"governor",
"icons",
"jsonwebtoken",
"leptos",
"leptos_axum",
"leptos_ui",
"mime_guess",
"openssl",
"quick-xml",
@@ -343,6 +345,7 @@ dependencies = [
"tower_governor",
"tracing",
"tracing-subscriber",
"tw_merge",
"utoipa",
"utoipa-swagger-ui",
"web-push",

View File

@@ -2,6 +2,9 @@
members = ["backend", "frontend", "shared"]
resolver = "2"
[[workspace.metadata.leptos]]
tailwind-input-file = "frontend/input.css"
[profile.release]
# En küçük binary boyutu
opt-level = "z"

View File

@@ -46,4 +46,7 @@ governor = "0.10.4"
# Leptos
leptos = { version = "0.8.15", features = ["nightly"] }
leptos_axum = { version = "0.8.7" }
jsonwebtoken = "9"
jsonwebtoken = "9"
tw_merge = { version = "0.1.17", features = ["variant"] }
icons = { version = "0.18.0", features = ["leptos"] }
leptos_ui = "0.3.20"

View File

@@ -0,0 +1,83 @@
use shared::xmlrpc::{RtorrentClient, RpcParam, parse_multicall_response};
#[tokio::main]
async fn main() {
let mut client = None;
for path in ["127.0.0.1:8000", "0.0.0.0:8000", "localhost:8000"] {
let test_client = RtorrentClient::new(path);
match test_client.call("system.client_version", &[]).await {
Ok(res) => {
println!("SUCCESS: Connected to rTorrent at {} (Version: {})", path, res);
client = Some(test_client);
break;
}
Err(_) => {
// println!("Failed to connect to {}", path);
}
}
}
let client = match client {
Some(c) => c,
None => {
println!("Could not connect to rTorrent on port 8000.");
return;
}
};
let mut hash = String::new();
match client.call("d.multicall2", &[RpcParam::from(""), RpcParam::from("main"), RpcParam::from("d.hash=")]).await {
Ok(xml) => {
if let Ok(rows) = parse_multicall_response(&xml) {
if let Some(row) = rows.first() {
if let Some(h) = row.first() {
hash = h.clone();
println!("Using torrent hash: {}", hash);
}
}
}
},
Err(e) => {
println!("Error getting torrents: {:?}", e);
return;
},
}
if hash.is_empty() {
println!("No torrents found to test trackers.");
return;
}
// Now test Tracker fields one by one to see which one is failing
let fields = vec![
"t.url=",
"t.is_enabled=",
"t.group=",
"t.scrape_complete=",
"t.scrape_incomplete=",
"t.scrape_downloaded=",
"t.activity_date_last=",
"t.normal_interval=",
"t.message=",
];
for field in &fields {
let params = vec![
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from(*field),
];
print!("Testing field {:<22} : ", field);
match client.call("t.multicall", &params).await {
Ok(xml) => {
if xml.contains("faultCode") {
println!("FAILED");
} else {
println!("SUCCESS");
}
},
Err(e) => println!("ERROR: {:?}", e),
}
}
}

View File

@@ -7,6 +7,7 @@ use rust_embed::RustEmbed;
pub mod auth;
pub mod setup;
pub mod notifications;
#[derive(RustEmbed)]
#[folder = "../frontend/dist"]

View File

@@ -0,0 +1,54 @@
use axum::{
extract::{State, Query},
http::StatusCode,
};
use serde::Deserialize;
use shared::{AppEvent, SystemNotification, NotificationLevel};
use crate::AppState;
#[derive(Deserialize)]
pub struct TorrentFinishedQuery {
pub name: String,
pub hash: String,
}
pub async fn torrent_finished_handler(
State(state): State<AppState>,
Query(params): Query<TorrentFinishedQuery>,
) -> StatusCode {
tracing::info!("WEBHOOK: Received notification from rTorrent. Name: {:?}, Hash: {:?}", params.name, params.hash);
let torrent_name = if params.name.is_empty() || params.name == "$d.name=" {
"Bilinmeyen Torrent".to_string()
} else {
params.name.clone()
};
let message = format!("Torrent tamamlandı: {}", torrent_name);
// 1. Send to active SSE clients (for Toast)
let notification = SystemNotification {
level: NotificationLevel::Success,
message: message.clone(),
};
let _ = state.event_bus.send(AppEvent::Notification(notification));
// 2. Send Web Push Notification (for Background)
#[cfg(feature = "push-notifications")]
{
let push_store = state.push_store.clone();
let title = "Torrent Tamamlandı".to_string();
let body = message;
let name_for_log = torrent_name.clone();
tokio::spawn(async move {
tracing::info!("Attempting to send Web Push notification for torrent: {}", name_for_log);
match crate::push::send_push_notification(&push_store, &title, &body).await {
Ok(_) => tracing::info!("Web Push notification task completed for: {}", name_for_log),
Err(e) => tracing::error!("Failed to send Web Push notification for {}: {:?}", name_for_log, e),
}
});
}
StatusCode::OK
}

View File

@@ -60,6 +60,7 @@ async fn auth_middleware(
|| 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/internal/")
|| path.starts_with("/swagger-ui")
|| path.starts_with("/api-docs")
|| !path.starts_with("/api/")
@@ -242,12 +243,14 @@ async fn main() {
let socket_path = std::path::Path::new(&args.socket);
if !socket_path.exists() {
tracing::error!("CRITICAL: rTorrent socket not found at {:?}.", socket_path);
tracing::warn!(
tracing::error!(
"HINT: Make sure rTorrent is running and the SCGI socket is enabled in .rtorrent.rc"
);
tracing::warn!(
tracing::error!(
"HINT: You can configure the socket path via --socket ARG or RTORRENT_SOCKET ENV."
);
tracing::error!("FATAL: VibeTorrent cannot start without a running rTorrent instance. Exiting.");
std::process::exit(1);
} else {
tracing::info!("Socket file exists. Testing connection...");
let client = xmlrpc::RtorrentClient::new(&args.socket);
@@ -258,7 +261,11 @@ async fn main() {
let version = xmlrpc::parse_string_response(&xml).unwrap_or(xml);
tracing::info!("Connected to rTorrent successfully. Version: {}", version);
}
Err(e) => tracing::error!("Socket exists but failed to connect to rTorrent: {}", e),
Err(e) => {
tracing::error!("CRITICAL: Socket exists but failed to connect to rTorrent: {}", e);
tracing::error!("FATAL: Ensure rTorrent is fully started and the socket has correct permissions. Exiting.");
std::process::exit(1);
}
}
}
@@ -313,7 +320,7 @@ async fn main() {
let loop_interval = if active_clients > 0 {
Duration::from_secs(1)
} else {
Duration::from_secs(30)
Duration::from_secs(60)
};
// 1. Fetch Torrents
@@ -434,6 +441,7 @@ async fn main() {
let db_for_ctx = db.clone();
let app = app
.route("/api/events", get(sse::sse_handler))
.route("/api/internal/torrent-finished", post(handlers::notifications::torrent_finished_handler))
.route("/api/server_fns/{*fn_name}", post({
let scgi_path = scgi_path_for_ctx.clone();
let db = db_for_ctx.clone();

View File

@@ -191,11 +191,21 @@ pub async fn send_push_notification(
tracing::debug!("Push notification sent to: {}", subscription.endpoint);
}
Err(e) => {
tracing::error!("Failed to send push notification to {}: {}", subscription.endpoint, e);
let err_msg = format!("{:?}", e);
tracing::error!("Delivery failed for {}: {}", subscription.endpoint, err_msg);
// Always remove on delivery failure (Gone, Unauthorized, etc.)
tracing::info!("Removing problematic subscription after delivery failure: {}", subscription.endpoint);
let _ = store.remove_subscription(&subscription.endpoint).await;
}
}
}
Err(e) => tracing::error!("Failed to build push message: {}", e),
Err(e) => {
let err_msg = format!("{:?}", e);
tracing::error!("Encryption/Build failed for {}: {}", subscription.endpoint, err_msg);
// Always remove on encryption failure
tracing::info!("Removing problematic subscription after encryption failure: {}", subscription.endpoint);
let _ = store.remove_subscription(&subscription.endpoint).await;
}
}
}
Err(e) => tracing::error!("Failed to build VAPID signature: {}", e),

View File

@@ -51,6 +51,21 @@ mod fields {
pub const IDX_LABEL: usize = 12;
pub const CMD_LABEL: &str = "d.custom1=";
pub const IDX_RATIO: usize = 13;
pub const CMD_RATIO: &str = "d.ratio=";
pub const IDX_UPLOADED: usize = 14;
pub const CMD_UPLOADED: &str = "d.up.total=";
pub const IDX_WASTED: usize = 15;
pub const CMD_WASTED: &str = "d.skip.total=";
pub const IDX_SAVE_PATH: usize = 16;
pub const CMD_SAVE_PATH: &str = "d.base_path=";
pub const IDX_FREE_DISK: usize = 17;
pub const CMD_FREE_DISK: &str = "d.free_diskspace=";
}
use fields::*;
@@ -72,6 +87,11 @@ const RTORRENT_FIELDS: &[&str] = &[
CMD_CREATION_DATE,
CMD_HASHING,
CMD_LABEL,
CMD_RATIO,
CMD_UPLOADED,
CMD_WASTED,
CMD_SAVE_PATH,
CMD_FREE_DISK,
];
fn parse_long(s: Option<&String>) -> i64 {
@@ -98,6 +118,11 @@ fn from_rtorrent_row(row: Vec<String>) -> Torrent {
let added_date = parse_long(row.get(IDX_CREATION_DATE));
let is_hashing = parse_long(row.get(IDX_HASHING));
let label_raw = parse_string(row.get(IDX_LABEL));
let ratio = parse_long(row.get(IDX_RATIO)) as f64 / 1000.0;
let uploaded = parse_long(row.get(IDX_UPLOADED));
let wasted = parse_long(row.get(IDX_WASTED));
let save_path = parse_string(row.get(IDX_SAVE_PATH));
let free_disk_space = parse_long(row.get(IDX_FREE_DISK));
let label = if label_raw.is_empty() {
None
@@ -144,6 +169,11 @@ fn from_rtorrent_row(row: Vec<String>) -> Torrent {
error_message: message,
added_date,
label,
ratio,
uploaded,
wasted,
save_path,
free_disk_space,
}
}

View File

@@ -7,6 +7,11 @@ stage = "build"
command = "sh"
command_arguments = ["-c", "npx @tailwindcss/cli -i input.css -o public/tailwind.css"]
[[hooks]]
stage = "post_build"
command = "sh"
command_arguments = ["-c", "sed -i '' -e 's/<link rel=\"modulepreload\"[^>]*>//g' -e 's/<link rel=\"preload\"[^>]*>//g' \"$TRUNK_STAGING_DIR/index.html\" || sed -i -e 's/<link rel=\"modulepreload\"[^>]*>//g' -e 's/<link rel=\"preload\"[^>]*>//g' \"$TRUNK_STAGING_DIR/index.html\""]
[build]
target = "index.html"
dist = "dist"

View File

@@ -20,7 +20,7 @@
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
<!-- Trunk Assets -->
<script data-trunk rel="rust" src="Cargo.toml" data-wasm-opt="0" data-preload="false"></script>
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="0" />
<link data-trunk rel="css" href="public/tailwind.css" />
<link data-trunk rel="copy-file" href="manifest.json" />
<link data-trunk rel="copy-file" href="icon-192.png" />

View File

@@ -3,70 +3,76 @@
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
}
@theme inline {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--animate-shimmer: shimmer 2s infinite;
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
@@ -77,6 +83,7 @@
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
@@ -89,4 +96,4 @@
dialog {
margin: auto;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ use crate::components::auth::setup::Setup;
use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_router::components::{Router, Routes, Route};
use leptos_router::hooks::use_navigate;
use leptos_router::hooks::{use_navigate, use_location};
use crate::components::ui::toast::Toaster;
use crate::components::hooks::use_theme_mode::ThemeMode;
@@ -41,6 +41,7 @@ pub fn App() -> impl IntoView {
fn InnerApp() -> impl IntoView {
crate::store::provide_torrent_store();
let store = use_context::<crate::store::TorrentStore>();
let _loc = use_location();
let is_loading = signal(true);
let is_authenticated = signal(false);
@@ -48,14 +49,10 @@ fn InnerApp() -> impl IntoView {
Effect::new(move |_| {
spawn_local(async move {
log::info!("App initialization started...");
gloo_console::log!("APP INIT: Checking setup status...");
// Check if setup is needed via Server Function
match shared::server_fns::auth::get_setup_status().await {
Ok(status) => {
if !status.completed {
log::info!("Setup not completed");
needs_setup.1.set(true);
is_loading.1.set(false);
return;
@@ -67,15 +64,12 @@ fn InnerApp() -> impl IntoView {
// Check authentication via GetUser Server Function
match shared::server_fns::auth::get_user().await {
Ok(Some(user_info)) => {
log::info!("Authenticated as {}", user_info.username);
if let Some(s) = store {
s.user.set(Some(user_info.username));
}
is_authenticated.1.set(true);
}
Ok(None) => {
log::info!("Not authenticated");
}
Ok(None) => {}
Err(e) => {
log::error!("Auth check failed: {:?}", e);
}
@@ -110,7 +104,6 @@ fn InnerApp() -> impl IntoView {
let navigate = use_navigate();
navigate("/setup", Default::default());
} else if authenticated {
log::info!("Already authenticated, redirecting to home");
let navigate = use_navigate();
navigate("/", Default::default());
}
@@ -134,10 +127,8 @@ fn InnerApp() -> impl IntoView {
Effect::new(move |_| {
if !is_loading.0.get() {
if needs_setup.0.get() {
log::info!("Setup not completed, redirecting to setup");
navigate("/setup", Default::default());
} else if !is_authenticated.0.get() {
log::info!("Not authenticated, redirecting to login");
navigate("/login", Default::default());
}
}

View File

@@ -2,6 +2,8 @@ use leptos::prelude::*;
use crate::components::ui::context_menu::{
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,
};
use crate::components::ui::button_action::ButtonAction;
use crate::components::ui::button::ButtonVariant;
#[component]
pub fn TorrentContextMenu(
@@ -9,36 +11,60 @@ pub fn TorrentContextMenu(
torrent_hash: String,
on_action: Callback<(String, String)>,
) -> impl IntoView {
let hash = torrent_hash.clone();
let hash_c1 = torrent_hash.clone();
let hash_c2 = torrent_hash.clone();
let hash_c3 = torrent_hash.clone();
let hash_c4 = torrent_hash.clone();
let on_action_stored = StoredValue::new(on_action);
let on_click = move |action: &str| {
on_action_stored.get_value().run((action.to_string(), hash.clone()));
};
let start_click = { let on_click = on_click.clone(); move |_| on_click("start") };
let stop_click = { let on_click = on_click.clone(); move |_| on_click("stop") };
let delete_click = { let on_click = on_click.clone(); move |_| on_click("delete") };
let delete_data_click = { let on_click = on_click.clone(); move |_| on_click("delete_with_data") };
view! {
<ContextMenu>
<ContextMenuTrigger>
{children()}
</ContextMenuTrigger>
<ContextMenuContent class="w-48">
<ContextMenuItem on:click=start_click>
<ContextMenuContent class="w-56 p-1.5">
<ContextMenuItem on:click={let h = hash_c1; move |_| {
on_action_stored.get_value().run(("start".to_string(), h.clone()));
crate::components::ui::context_menu::close_context_menu();
}}>
"Başlat"
</ContextMenuItem>
<ContextMenuItem on:click=stop_click>
<ContextMenuItem on:click={let h = hash_c2; move |_| {
on_action_stored.get_value().run(("stop".to_string(), h.clone()));
crate::components::ui::context_menu::close_context_menu();
}}>
"Durdur"
</ContextMenuItem>
<ContextMenuItem class="text-destructive" on:click=delete_click>
"Sil"
</ContextMenuItem>
<ContextMenuItem class="text-destructive font-bold" on:click=delete_data_click>
"Verilerle Birlikte Sil"
</ContextMenuItem>
<div class="my-1.5 h-px bg-border/50" />
// --- Modern Hold-to-Action Buttons ---
<div class="space-y-1">
<ButtonAction
variant=ButtonVariant::Ghost
class="w-full justify-start h-8 px-2 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive transition-none"
hold_duration=800
on_action={let h = hash_c3; move || {
on_action_stored.get_value().run(("delete".to_string(), h.clone()));
crate::components::ui::context_menu::close_context_menu();
}}
>
"Sil (Basılı Tut)"
</ButtonAction>
<ButtonAction
variant=ButtonVariant::Destructive
class="w-full justify-start h-8 px-2 text-xs font-bold"
hold_duration=1200
on_action={let h = hash_c4; move || {
on_action_stored.get_value().run(("delete_with_data".to_string(), h.clone()));
crate::components::ui::context_menu::close_context_menu();
}}
>
"Verilerle Sil (Basılı Tut)"
</ButtonAction>
</div>
</ContextMenuContent>
</ContextMenu>
}

View File

@@ -0,0 +1,91 @@
use leptos::prelude::*;
use leptos::task::spawn_local;
use serde::{Deserialize, Serialize};
use crate::components::ui::button::{Button, ButtonVariant};
use crate::components::ui::card::{Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle};
use crate::components::ui::shimmer::Shimmer;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardData {
pub title: String,
pub description: String,
}
/// Simulates a database fetch with 1 second delay
#[server]
pub async fn fetch_card_data() -> Result<CardData, ServerFnError> {
// Simulate network/database latency (only on server)
#[cfg(feature = "ssr")]
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(CardData {
title: "Fetched Title".to_string(),
description: "This content was fetched from the server after a 1 second simulated delay. The shimmer effect automatically showed during the loading period.".to_string(),
})
}
#[component]
pub fn DemoShimmer() -> impl IntoView {
// Loading state
let loading = RwSignal::new(false);
// Store fetched data
let card_data = RwSignal::new(None::<CardData>);
// Fetch handler using spawn_local for reliable repeated calls
let on_fetch = move |_| {
spawn_local(async move {
loading.set(true);
let result = fetch_card_data().await;
if let Ok(data) = result {
card_data.set(Some(data));
}
loading.set(false);
});
};
view! {
<div class="flex flex-col gap-4">
<div class="flex gap-2">
<Button variant=ButtonVariant::Outline on:click=move |_| loading.set(!loading.get())>
"Toggle Loading"
</Button>
<Button variant=ButtonVariant::Default on:click=on_fetch>
"Fetch Data (1s)"
</Button>
</div>
<Shimmer loading=Signal::from(loading)>
<Card class="max-w-lg lg:max-w-2xl">
<CardHeader>
<CardTitle>
{move || {
card_data.get().map(|data| data.title).unwrap_or_else(|| "Card Title".to_string())
}}
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription>
{move || {
card_data
.get()
.map(|data| data.description)
.unwrap_or_else(|| {
"Click 'Toggle Loading' for manual control, or 'Fetch Data' to simulate a real server call with 1 second delay."
.to_string()
})
}}
</CardDescription>
</CardContent>
<CardFooter class="justify-end">
<Button variant=ButtonVariant::Outline>"Cancel"</Button>
<Button>"Confirm"</Button>
</CardFooter>
</Card>
</Shimmer>
</div>
}
}

View File

@@ -0,0 +1 @@
pub mod demo_shimmer;

View File

@@ -1,3 +1,31 @@
pub fn use_random_id_for(prefix: &str) -> String {
format!("{}_{}", prefix, js_sys::Math::random().to_string().replace(".", ""))
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()
}

View File

@@ -8,15 +8,26 @@ pub struct ThemeMode {
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);
@@ -32,14 +43,45 @@ impl ThemeMode {
});
}
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())
@@ -47,6 +89,7 @@ impl ThemeMode {
.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)")
@@ -56,9 +99,10 @@ impl ThemeMode {
.unwrap_or_default()
}
/// Stores the dark mode state in local storage.
fn set_storage_state(state: bool) {
if let Some(storage) = Self::get_storage() {
let _ = storage.set(LOCALSTORAGE_KEY, state.to_string().as_str());
storage.set(LOCALSTORAGE_KEY, state.to_string().as_str()).ok();
}
}
}
}

View File

@@ -6,24 +6,12 @@ pub fn Footer() -> impl IntoView {
let year = chrono::Local::now().format("%Y").to_string();
view! {
<footer class="mt-auto px-4 py-6 md:px-8">
<Separator class="mb-6 opacity-50" />
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
<p class="text-center text-sm leading-loose text-muted-foreground md:text-left">
{format!("© {} VibeTorrent. Tüm hakları saklıdır.", year)}
</p>
<div class="flex items-center gap-4 text-sm font-medium text-muted-foreground">
<a
href="https://git.karatatar.com/admin/vibetorrent"
target="_blank"
rel="noreferrer"
class="underline underline-offset-4 hover:text-foreground transition-colors"
>
"Gitea"
</a>
<span class="size-1 rounded-full bg-muted-foreground/30" />
<span class="text-[10px] tracking-widest uppercase opacity-70">"v3.0.0-beta"</span>
</div>
<footer class="mt-auto pb-6 px-4">
<Separator class="mb-4 opacity-30" />
<div class="flex items-center justify-center gap-2 text-[10px] uppercase tracking-widest text-muted-foreground/60 font-medium">
<span>{format!("© {} VibeTorrent", year)}</span>
<span class="size-1 rounded-full bg-muted-foreground/30" />
<span>"v3.0.0-beta"</span>
</div>
</footer>
}

View File

@@ -3,6 +3,7 @@ use leptos::task::spawn_local;
use crate::components::ui::sidenav::*;
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
use crate::components::ui::theme_toggle::ThemeToggle;
use crate::components::ui::switch::Switch;
#[component]
pub fn Sidebar() -> impl IntoView {
@@ -65,6 +66,19 @@ pub fn Sidebar() -> impl IntoView {
username().chars().next().unwrap_or('?').to_uppercase().to_string()
};
let on_push_toggle = move |checked: bool| {
spawn_local(async move {
if checked {
crate::store::subscribe_to_push_notifications().await;
} else {
crate::store::unsubscribe_from_push_notifications().await;
}
if let Ok(enabled) = crate::store::is_push_subscribed().await {
store.push_enabled.set(enabled);
}
});
};
view! {
<SidenavHeader>
<div class="flex items-center gap-2 px-2 py-4">
@@ -133,35 +147,49 @@ pub fn Sidebar() -> impl IntoView {
</SidenavContent>
<SidenavFooter>
<div class="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 shadow-xs overflow-hidden">
<div class="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0 border border-primary-foreground/10">
{first_letter}
<div class="flex flex-col gap-4 p-4">
// Push Notification Toggle
<div class="flex items-center justify-between px-2 py-1 bg-muted/20 rounded-md border border-border/50">
<div class="flex flex-col gap-0.5">
<span class="text-[10px] font-bold uppercase tracking-wider text-foreground/70">"Bildirimler"</span>
<span class="text-[9px] text-muted-foreground">"Web Push"</span>
</div>
<Switch
checked=Signal::from(store.push_enabled)
on_checked_change=Callback::new(on_push_toggle)
/>
</div>
<div class="flex-1 overflow-hidden">
<div class="font-medium text-[11px] truncate text-foreground leading-tight">{username}</div>
<div class="text-[9px] text-muted-foreground truncate opacity-70">"Yönetici"</div>
</div>
<div class="flex items-center gap-1">
<ThemeToggle />
<div class="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 shadow-xs overflow-hidden">
<div class="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0 border border-primary-foreground/10">
{first_letter}
</div>
<div class="flex-1 overflow-hidden">
<div class="font-medium text-[11px] truncate text-foreground leading-tight">{username}</div>
<div class="text-[9px] text-muted-foreground truncate opacity-70">"Yönetici"</div>
</div>
<Button
variant=ButtonVariant::Ghost
size=ButtonSize::Icon
class="size-7 text-destructive hover:bg-destructive/10"
on:click=move |_| {
spawn_local(async move {
if shared::server_fns::auth::logout().await.is_ok() {
let window = web_sys::window().expect("window should exist");
let _ = window.location().set_href("/login");
}
});
}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-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" />
</svg>
</Button>
<div class="flex items-center gap-1">
<ThemeToggle />
<Button
variant=ButtonVariant::Ghost
size=ButtonSize::Icon
class="size-7 text-destructive hover:bg-destructive/10"
on:click=move |_| {
spawn_local(async move {
if shared::server_fns::auth::logout().await.is_ok() {
let window = web_sys::window().expect("window should exist");
let _ = window.location().set_href("/login");
}
});
}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-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" />
</svg>
</Button>
</div>
</div>
</div>
</SidenavFooter>

View File

@@ -1,14 +1,13 @@
use leptos::prelude::*;
use icons::PanelLeft;
use crate::components::torrent::add_torrent::AddTorrentDialog;
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
use icons::{PanelLeft, Plus};
use crate::components::torrent::add_torrent::AddTorrentDialogContent;
use crate::components::ui::button::{ButtonVariant, ButtonSize};
use crate::components::ui::sheet::{Sheet, SheetContent, SheetTrigger, SheetDirection};
use crate::components::ui::dialog::{Dialog, DialogContent, DialogTrigger};
use crate::components::layout::sidebar::Sidebar;
#[component]
pub fn Toolbar() -> impl IntoView {
let show_add_modal = signal(false);
view! {
<div class="flex min-h-14 h-auto items-center border-b border-border bg-background px-4" style="padding-top: env(safe-area-inset-top);">
// Sol kısım: Menü butonu (Mobil) + Add Torrent
@@ -33,25 +32,24 @@ pub fn Toolbar() -> impl IntoView {
</Sheet>
</div>
<Button
on:click=move |_| show_add_modal.1.set(true)
class="gap-2"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 md:w-5 md:h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span class="hidden sm:inline">"Add Torrent"</span>
<span class="sm:hidden">"Add"</span>
</Button>
<Dialog>
<DialogTrigger
variant=ButtonVariant::Default
class="gap-2"
>
<Plus class="w-4 h-4 md:w-5 md:h-5" />
<span class="hidden sm:inline">"Add Torrent"</span>
<span class="sm:hidden">"Add"</span>
</DialogTrigger>
<DialogContent id="add-torrent-dialog" class="sm:max-w-[425px]">
<AddTorrentDialogContent />
</DialogContent>
</Dialog>
</div>
// Sağ kısım boş
<div class="flex flex-1 items-center justify-end gap-2">
</div>
<Show when=move || show_add_modal.0.get()>
<AddTorrentDialog on_close=Callback::new(move |()| show_add_modal.1.set(false)) />
</Show>
</div>
}
}
}

View File

@@ -5,3 +5,4 @@ pub mod torrent;
pub mod auth;
// pub mod toast; (Removed)
pub mod ui;
pub mod demos;

View File

@@ -1,17 +1,15 @@
use leptos::prelude::*;
use leptos::task::spawn_local;
use wasm_bindgen::JsCast;
use crate::components::ui::input::{Input, InputType};
use crate::store::TorrentStore;
use crate::api;
use crate::components::ui::button::{Button, ButtonVariant};
use crate::components::ui::button::Button;
use crate::components::ui::dialog::{
DialogBody, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose
};
#[component]
pub fn AddTorrentDialog(
on_close: Callback<()>,
) -> impl IntoView {
let _store = use_context::<TorrentStore>().expect("TorrentStore not provided");
pub fn AddTorrentDialogContent() -> impl IntoView {
let uri = RwSignal::new(String::new());
let is_loading = signal(false);
let error_msg = signal(Option::<String>::None);
@@ -21,20 +19,30 @@ pub fn AddTorrentDialog(
let uri_val = uri.get();
if uri_val.is_empty() {
error_msg.1.set(Some("Please enter a Magnet URI or URL".to_string()));
error_msg.1.set(Some("Lütfen bir Magnet URI veya URL girin".to_string()));
return;
}
is_loading.1.set(true);
error_msg.1.set(None);
let on_close = on_close.clone();
spawn_local(async move {
match api::torrent::add(&uri_val).await {
Ok(_) => {
log::info!("Torrent added successfully");
crate::store::toast_success("Torrent başarıyla eklendi");
on_close.run(());
// Programmatically close the dialog by triggering the close button
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Some(el) = doc.get_element_by_id("add-torrent-dialog") {
if let Some(close_btn) = el.query_selector("[data-dialog-close]").ok().flatten() {
let _ = close_btn.dyn_into::<web_sys::HtmlElement>().map(|btn| btn.click());
}
}
}
uri.set(String::new());
is_loading.1.set(false);
}
Err(e) => {
log::error!("Failed to add torrent: {:?}", e);
@@ -45,29 +53,16 @@ pub fn AddTorrentDialog(
});
};
let handle_backdrop = {
let on_close = on_close.clone();
move |e: web_sys::MouseEvent| {
e.stop_propagation();
on_close.run(());
}
};
view! {
// Backdrop overlay
<div
class="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
on:click=handle_backdrop
/>
// Dialog panel
<div class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-card p-6 shadow-lg rounded-lg sm:max-w-[425px]">
// Header
<div class="flex flex-col space-y-1.5 text-center sm:text-left">
<h2 class="text-lg font-semibold leading-none tracking-tight">"Add Torrent"</h2>
<p class="text-sm text-muted-foreground">"Enter a Magnet link or a .torrent file URL."</p>
</div>
<DialogBody>
<DialogHeader>
<DialogTitle>"Add Torrent"</DialogTitle>
<DialogDescription>
"Enter a Magnet link or a .torrent file URL."
</DialogDescription>
</DialogHeader>
<form on:submit=handle_submit class="space-y-4">
<form on:submit=handle_submit class="space-y-4 pt-4">
<Input
r#type=InputType::Text
placeholder="magnet:?xt=urn:btih:..."
@@ -81,14 +76,10 @@ pub fn AddTorrentDialog(
</div>
})}
<div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
<Button
variant=ButtonVariant::Ghost
attr:r#type="button"
on:click=move |_| on_close.run(())
>
<DialogFooter class="pt-2">
<DialogClose>
"Cancel"
</Button>
</DialogClose>
<Button
attr:r#type="submit"
attr:disabled=move || is_loading.0.get()
@@ -102,21 +93,8 @@ pub fn AddTorrentDialog(
leptos::either::Either::Right(view! { "Add" })
}}
</Button>
</div>
</DialogFooter>
</form>
// Close button (X)
<Button
variant=ButtonVariant::Ghost
class="absolute right-2 top-2 size-8 p-0 opacity-70 hover:opacity-100"
on:click=move |_| on_close.run(())
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
<span class="sr-only">"Close"</span>
</Button>
</div>
</DialogBody>
}
}
}

View File

@@ -0,0 +1,260 @@
use leptos::prelude::*;
use crate::components::ui::tabs::*;
use crate::components::ui::skeleton::*;
#[component]
pub fn TorrentDetailsPanel() -> impl IntoView {
let store = use_context::<crate::store::TorrentStore>().expect("store not provided");
let selected_torrent = Memo::new(move |_| {
let hash = store.selected_torrent.get()?;
store.torrents.with(|map| map.get(&hash).cloned())
});
let is_open = Signal::derive(move || store.selected_torrent.get().is_some());
view! {
// Mobil overlay backdrop
<div
class=move || if is_open.get() {
"fixed inset-0 bg-black/40 z-30 md:hidden backdrop-blur-sm transition-opacity duration-300 opacity-100"
} else {
"fixed inset-0 bg-black/0 z-30 md:hidden pointer-events-none transition-opacity duration-300 opacity-0"
}
on:click=move |_| store.selected_torrent.set(None)
/>
// Panel — masaüstünde sağ kolonda sabit, mobilde sağdan açılan overlay
<div class=move || {
if is_open.get() {
// Açık: masaüstünde görünür, mobilde sağdan gelir
"w-full md:w-[380px] md:min-w-[380px] shrink-0 \
flex flex-col border-l border-border bg-card \
fixed top-0 right-0 bottom-0 z-40 \
translate-x-0 \
md:static md:z-auto md:translate-x-0 \
transition-transform duration-300 ease-out shadow-2xl md:shadow-none"
} else {
// Kapalı: masaüstünde gizli, mobilde sağa kayar
"w-full md:w-0 shrink-0 overflow-hidden border-none \
fixed top-0 right-0 bottom-0 z-40 \
translate-x-full \
md:static md:z-auto md:translate-x-0 \
transition-transform duration-300 ease-in pointer-events-none"
}
}>
// İpucu: panel kapalıyken içeriği render etme
<Show when=move || is_open.get()>
// Başlık
<div class="px-4 py-3 border-b flex items-center justify-between shrink-0 bg-card">
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
<Show
when=move || selected_torrent.get().is_some()
fallback=move || view! { <Skeleton class="h-5 w-40" /> }
>
<h2 class="font-bold text-sm truncate leading-tight">
{move || selected_torrent.get().map(|t| t.name).unwrap_or_default()}
</h2>
</Show>
<Show
when=move || selected_torrent.get().is_some()
fallback=move || view! { <Skeleton class="h-3 w-20 mt-1" /> }
>
<p class="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold flex items-center gap-1.5">
{move || selected_torrent.get().map(|t| format!("{:?}", t.status)).unwrap_or_default()}
<span class="bg-primary/20 text-primary px-1 py-0.5 rounded text-[9px] lowercase">
{move || selected_torrent.get().map(|t| format!("{:.1}%", t.percent_complete)).unwrap_or_default()}
</span>
</p>
</Show>
</div>
// Kapat butonu
<button
class="rounded-full p-1.5 hover:bg-muted transition-colors text-muted-foreground hover:text-foreground shrink-0 ml-2"
on:click=move |_| store.selected_torrent.set(None)
>
<icons::X class="size-4" />
</button>
</div>
// Sekmeler + içerik
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
<Tabs default_value="general" class="flex-1 h-full min-h-0 flex flex-col">
<TabsList class="w-full justify-start rounded-none border-b bg-transparent p-0 shrink-0 px-2">
<TabsTrigger
value="general"
class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none text-xs h-9"
>
"Genel"
</TabsTrigger>
<TabsTrigger
value="files"
class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none text-xs h-9"
>
"Dosyalar"
</TabsTrigger>
<TabsTrigger
value="trackers"
class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none text-xs h-9"
>
"İzleyiciler"
</TabsTrigger>
<TabsTrigger
value="peers"
class="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none text-xs h-9"
>
"Eşler"
</TabsTrigger>
</TabsList>
<crate::components::ui::scroll_area::ScrollArea class="flex-1 min-h-0">
<TabsContent value="general" class="p-4 space-y-5 animate-in fade-in duration-200">
<crate::components::ui::shimmer::Shimmer
loading=Signal::derive(move || selected_torrent.get().is_none())
shimmer_color="rgba(0,0,0,0.06)"
background_color="rgba(0,0,0,0.04)"
>
{move || {
let t = selected_torrent.get().unwrap_or_else(|| shared::Torrent {
hash: "----------------------------------------".to_string(),
name: "Yükleniyor...".to_string(),
size: 0,
completed: 0,
down_rate: 0,
up_rate: 0,
eta: 0,
percent_complete: 0.0,
status: shared::TorrentStatus::Downloading,
error_message: "".to_string(),
added_date: 0,
label: None,
ratio: 0.0,
uploaded: 0,
wasted: 0,
save_path: "Yükleniyor...".to_string(),
free_disk_space: 0,
});
view! {
<div class="flex flex-col gap-5">
// Aktarım
<div>
<h3 class="text-[10px] font-bold border-b pb-1.5 mb-3 uppercase tracking-widest text-muted-foreground">"Aktarım"</h3>
<div class="grid grid-cols-2 gap-3">
<InfoItem label="Kalan" value=format_duration(t.eta) />
<InfoItem label="Paylaşım Oranı" value=format!("{:.3}", t.ratio) />
<InfoItem label="İndirilen" value=format_bytes(t.completed) />
<InfoItem label="İndirme Hızı" value=format_speed(t.down_rate) class="text-blue-500" />
<InfoItem label="Gönderilen" value=format_bytes(t.uploaded) />
<InfoItem label="Gönderme Hızı" value=format_speed(t.up_rate) class="text-green-500" />
<InfoItem label="Boşa Giden" value=format_bytes(t.wasted) />
</div>
</div>
// Genel
<div>
<h3 class="text-[10px] font-bold border-b pb-1.5 mb-3 uppercase tracking-widest text-muted-foreground">"Genel"</h3>
<div class="flex flex-col gap-3">
<InfoItem label="Kaydedilen Yer" value=t.save_path class="break-all font-mono text-xs" />
<div class="grid grid-cols-2 gap-3">
<InfoItem label="Boş Disk Alanı" value=format_bytes(t.free_disk_space) />
<InfoItem label="Oluşturulma Tarihi" value=format_date(t.added_date) />
</div>
<InfoItem label="Hash" value=t.hash class="break-all font-mono text-[10px]" />
</div>
</div>
</div>
}
}}
</crate::components::ui::shimmer::Shimmer>
</TabsContent>
<TabsContent value="files" class="h-full">
{move || match selected_torrent.get() {
Some(t) => leptos::either::Either::Left(view! {
<div class="h-full">
<crate::components::torrent::files::TorrentFilesTab hash=t.hash />
</div>
}),
None => leptos::either::Either::Right(view! {
<div class="flex flex-col items-center justify-center h-48 opacity-60 gap-2">
<icons::File class="size-10 text-muted-foreground" />
<p class="text-sm font-medium">"Dosya yükleniyor..."</p>
</div>
}),
}}
</TabsContent>
<TabsContent value="trackers" class="h-full">
{move || match selected_torrent.get() {
Some(t) => leptos::either::Either::Left(view! {
<div class="h-full">
<crate::components::torrent::trackers::TorrentTrackersTab hash=t.hash />
</div>
}),
None => leptos::either::Either::Right(view! {
<div class="flex flex-col items-center justify-center h-48 opacity-60 gap-2">
<icons::Settings2 class="size-10 text-muted-foreground" />
<p class="text-sm font-medium">"İzleyici yükleniyor..."</p>
</div>
}),
}}
</TabsContent>
<TabsContent value="peers" class="h-full">
<div class="flex flex-col items-center justify-center h-48 opacity-60 gap-2">
<icons::Users class="size-10 text-muted-foreground" />
<p class="text-sm font-medium">"Eş listesi yakında eklenecek"</p>
</div>
</TabsContent>
</crate::components::ui::scroll_area::ScrollArea>
</Tabs>
</div>
</Show>
</div>
}
}
#[component]
fn InfoItem(
label: &'static str,
value: String,
#[prop(optional)] class: &'static str
) -> impl IntoView {
view! {
<div class=tailwind_fuse::tw_merge!("flex flex-col gap-0.5", class)>
<span class="text-[9px] font-semibold text-muted-foreground uppercase tracking-wider opacity-70">{label}</span>
<span class="text-xs font-medium leading-tight">{value}</span>
</div>
}
}
fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
if bytes < 1024 { return format!("{} B", bytes); }
let i = (bytes as f64).log2().div_euclid(10.0) as usize;
format!("{:.1} {}", (bytes as f64) / 1024_f64.powi(i as i32), UNITS[i])
}
fn format_speed(bytes_per_sec: i64) -> String {
if bytes_per_sec == 0 { return "0 B/s".to_string(); }
format!("{}/s", format_bytes(bytes_per_sec))
}
fn format_duration(seconds: i64) -> String {
if seconds <= 0 { return "".to_string(); }
let days = seconds / 86400;
let hours = (seconds % 86400) / 3600;
let minutes = (seconds % 3600) / 60;
let secs = seconds % 60;
if days > 0 { format!("{}g {}s", days, hours) }
else if hours > 0 { format!("{}s {}d", hours, minutes) }
else if minutes > 0 { format!("{}d {}sn", minutes, secs) }
else { format!("{}sn", secs) }
}
fn format_date(timestamp: i64) -> String {
if timestamp <= 0 { return "N/A".to_string(); }
let dt = chrono::DateTime::from_timestamp(timestamp, 0);
match dt { Some(dt) => dt.format("%d/%m/%Y %H:%M").to_string(), None => "N/A".to_string() }
}

View File

@@ -0,0 +1,216 @@
use leptos::prelude::*;
use crate::components::ui::table::*;
use crate::components::ui::badge::*;
use crate::components::ui::shimmer::*;
use crate::components::ui::context_menu::*;
use shared::TorrentFile;
#[component]
pub fn TorrentFilesTab(hash: String) -> impl IntoView {
let hash_clone = hash.clone();
// Fetch files resource
let files_resource = Resource::new(
move || hash_clone.clone(),
|h| async move { shared::server_fns::torrent::get_files(h).await.unwrap_or_default() }
);
// Callback to trigger a refetch — safe, doesn't destroy existing components
let on_refresh = Callback::new(move |_: ()| {
files_resource.refetch();
});
let stored_hash = StoredValue::new(hash);
view! {
<Suspense fallback=move || view! { <FilesFallback /> }>
{move || {
let files = files_resource.get().unwrap_or_default();
if files.is_empty() {
return view! {
<div class="flex flex-col items-center justify-center h-48 opacity-60">
<icons::File class="size-12 mb-3 text-muted-foreground" />
<p class="text-sm font-medium">"Bu torrent için dosya bulunamadı."</p>
</div>
}.into_any();
}
let files_len = files.len();
view! {
<div class="space-y-4">
<TableWrapper class="bg-card/50">
<Table>
<TableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
<TableRow class="hover:bg-transparent">
<TableHead class="w-12 text-center text-xs">"#"</TableHead>
<TableHead class="text-xs">"Dosya Adı"</TableHead>
<TableHead class="w-24 text-right text-xs">"Boyut"</TableHead>
<TableHead class="w-24 text-right text-xs">"Tamamlanan"</TableHead>
<TableHead class="w-24 text-center text-xs">"Öncelik"</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For
each=move || files.clone()
key=|f| f.index
children={move |f| {
let p_hash = stored_hash.get_value();
view! {
<FileRow
file=f
hash=p_hash
on_refresh=on_refresh.clone()
/>
}
}}
/>
</TableBody>
</Table>
</TableWrapper>
<div class="flex items-center justify-between text-xs text-muted-foreground px-1">
<span>{format!("Toplam {} dosya", files_len)}</span>
</div>
</div>
}.into_any()
}}
</Suspense>
}
}
#[component]
fn FileRow(file: TorrentFile, hash: String, on_refresh: Callback<()>) -> impl IntoView {
let f_idx = file.index;
let path_clone = file.path.clone();
// on_refresh is called AFTER the server responds, not before
let on_refresh_stored = StoredValue::new(on_refresh);
let set_priority = Action::new(move |req: &(String, u32, u8)| {
let (h, idx, p) = req.clone();
async move {
let res = shared::server_fns::torrent::set_file_priority(h, idx, p).await;
if let Err(e) = &res {
crate::store::show_toast(shared::NotificationLevel::Error, format!("Öncelik değiştirilemedi: {:?}", e));
} else {
crate::store::show_toast(shared::NotificationLevel::Success, "Dosya önceliği güncellendi.".to_string());
// Refetch AFTER the server has saved the priority
on_refresh_stored.get_value().run(());
}
res
}
});
view! {
<FileContextMenu
torrent_hash=hash
file_index=f_idx
set_priority=set_priority
>
<TableRow class="hover:bg-muted/50 transition-colors group">
<TableCell class="text-center text-xs text-muted-foreground">{file.index}</TableCell>
<TableCell class="font-medium text-xs break-all max-w-[200px] md:max-w-md" attr:title=move || path_clone.clone()>
{file.path.clone()}
</TableCell>
<TableCell class="text-right text-xs text-muted-foreground whitespace-nowrap">
{format_bytes(file.size)}
</TableCell>
<TableCell class="text-right text-xs whitespace-nowrap">
<span class="text-primary font-medium">{format_bytes(file.completed_chunks)}</span>
</TableCell>
<TableCell class="text-center">
{
let (variant, label) = match file.priority {
0 => (BadgeVariant::Destructive, "İndirme"),
2 => (BadgeVariant::Success, "Yüksek"),
_ => (BadgeVariant::Secondary, "Normal"),
};
view! { <Badge variant=variant class="text-[10px] uppercase">{label}</Badge> }
}
</TableCell>
</TableRow>
</FileContextMenu>
}
}
#[component]
fn FileContextMenu(
children: Children,
torrent_hash: String,
file_index: u32,
set_priority: Action<(String, u32, u8), Result<(), ServerFnError>>,
) -> impl IntoView {
let hash_c1 = torrent_hash.clone();
let hash_c2 = torrent_hash.clone();
let hash_c3 = torrent_hash.clone();
view! {
<ContextMenu>
<ContextMenuTrigger>
{children()}
</ContextMenuTrigger>
<ContextMenuContent class="w-48">
<ContextMenuLabel>"Dosya Önceliği"</ContextMenuLabel>
<ContextMenuGroup>
<ContextMenuItem on:click={
let h = hash_c1;
let sp = set_priority.clone();
move |_| {
sp.dispatch((h.clone(), file_index, 2));
crate::components::ui::context_menu::close_context_menu();
}
}>
<icons::ChevronsUp class="text-green-500" />
<span>"Yüksek"</span>
</ContextMenuItem>
<ContextMenuItem on:click={
let h = hash_c2;
let sp = set_priority.clone();
move |_| {
sp.dispatch((h.clone(), file_index, 1));
crate::components::ui::context_menu::close_context_menu();
}
}>
<icons::Minus class="text-blue-500" />
<span>"Normal"</span>
</ContextMenuItem>
<ContextMenuItem class="text-destructive focus:bg-destructive/10" on:click={
let h = hash_c3;
let sp = set_priority.clone();
move |_| {
sp.dispatch((h.clone(), file_index, 0));
crate::components::ui::context_menu::close_context_menu();
}
}>
<icons::X />
<span>"İndirme (Kapalı)"</span>
</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
}
}
#[component]
fn FilesFallback() -> impl IntoView {
view! {
<Shimmer loading=Signal::derive(|| true) class="space-y-2">
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
</Shimmer>
}
}
fn format_bytes(bytes: i64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
if bytes < 1024 { return format!("{} B", bytes); }
let i = (bytes as f64).log2().div_euclid(10.0) as usize;
format!("{:.1} {}", (bytes as f64) / 1024_f64.powi(i as i32), UNITS[i])
}

View File

@@ -1,2 +1,5 @@
pub mod table;
pub mod add_torrent;
pub mod details;
pub mod files;
pub mod trackers;

View File

@@ -218,7 +218,9 @@ pub fn TorrentTable() -> impl IntoView {
});
view! {
<div class="h-full bg-background relative flex flex-col overflow-hidden px-4 py-4 gap-4">
<div class="h-full bg-background flex flex-row overflow-hidden">
// Sol: liste alanı
<div class="flex-1 min-w-0 flex flex-col overflow-hidden px-4 py-4 gap-4">
// --- TOPBAR ---
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2 flex-1 max-w-md">
@@ -249,7 +251,6 @@ pub fn TorrentTable() -> impl IntoView {
<div class="my-1 h-px bg-border" />
// Trigger the hidden AlertDialog from this menu item
<DropdownMenuItem class="text-destructive focus:bg-destructive/10 cursor-pointer" on:click=move |_| {
if let Some(trigger) = document().get_element_by_id("bulk-delete-trigger") {
let _ = trigger.dyn_into::<web_sys::HtmlElement>().map(|el: web_sys::HtmlElement| el.click());
@@ -261,7 +262,6 @@ pub fn TorrentTable() -> impl IntoView {
</DropdownMenuContent>
</DropdownMenu>
// Hidden AlertDialog moved outside the DropdownMenuContent to ensure proper centering
<AlertDialog>
<AlertDialogTrigger attr:id="bulk-delete-trigger" class="hidden">""</AlertDialogTrigger>
<AlertDialogContent class="sm:max-w-[425px]">
@@ -544,16 +544,20 @@ pub fn TorrentTable() -> impl IntoView {
</div>
</div>
<div class="hidden md:flex items-center justify-between px-2 py-1 text-[11px] text-muted-foreground bg-muted/20 border rounded-md">
<div class="flex gap-4">
<div class="flex items-center justify-between px-2 py-1.5 text-[10px] md:text-[11px] text-muted-foreground bg-muted/20 border rounded-md">
<div class="flex gap-3 md:gap-4">
<span>{move || format!("Toplam: {} torrent", filtered_hashes.get().len())}</span>
<Show when=move || has_selection.get()>
<span class="text-primary font-medium">{move || format!("{} torrent seçili", selected_count.get())}</span>
<span class="text-primary font-bold">{move || format!("{} seçili", selected_count.get())}</span>
</Show>
</div>
<div>"VibeTorrent v3"</div>
<div class="opacity-50">"VibeTorrent v3"</div>
</div>
</div>
// Sağ: sabit detay paneli
<crate::components::torrent::details::TorrentDetailsPanel />
</div>
}.into_any()
}
@@ -595,10 +599,12 @@ fn TorrentRow(
on:click=move |_| store.selected_torrent.set(Some(stored_hash.get_value()))
>
<DataTableCell class="w-12 px-4">
<Checkbox
checked=is_selected
on_checked_change=on_select
/>
<div on:click=move |e| e.stop_propagation()>
<Checkbox
checked=is_selected
on_checked_change=on_select
/>
</div>
</DataTableCell>
{move || visible_columns.get().contains("Name").then({
@@ -728,17 +734,23 @@ fn TorrentCard(
}
)
on:click=move |_| {
let current = is_selected.get();
on_select.run(!current);
store.selected_torrent.set(Some(stored_hash.get_value()));
}
>
<div class="p-4 space-y-3">
<div class="flex justify-between items-start gap-3">
<div class="flex-1 min-w-0">
<h3 class="text-sm font-bold leading-tight line-clamp-2 break-all">{t_name.clone()}</h3>
<div class="flex items-start gap-3 flex-1 min-w-0">
<div on:click=move |e| e.stop_propagation() class="mt-0.5">
<Checkbox
checked=is_selected
on_checked_change=on_select
/>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-bold leading-tight line-clamp-2 break-all">{t_name.clone()}</h3>
</div>
</div>
<Badge variant=status_variant class="uppercase tracking-wider text-[10px]">
<Badge variant=status_variant class="uppercase tracking-wider text-[10px] shrink-0">
{format!("{:?}", t.status)}
</Badge>
</div>

View File

@@ -0,0 +1,121 @@
use leptos::prelude::*;
use crate::components::ui::table::*;
use crate::components::ui::shimmer::*;
use shared::TorrentTracker;
#[component]
pub fn TorrentTrackersTab(hash: String) -> impl IntoView {
let hash_clone = hash.clone();
let trackers_resource = Resource::new(
move || hash_clone.clone(),
|h| async move { shared::server_fns::torrent::get_trackers(h).await.unwrap_or_default() }
);
view! {
<Suspense fallback=move || view! { <TrackersFallback /> }>
{move || {
let trackers = trackers_resource.get().unwrap_or_default();
if trackers.is_empty() {
return view! {
<div class="flex flex-col items-center justify-center h-48 opacity-60">
<icons::Settings2 class="size-12 mb-3 text-muted-foreground" />
<p class="text-sm font-medium">"Bu torrent için izleyici bulunamadı."</p>
</div>
}.into_any();
}
view! {
<div class="h-full overflow-auto">
<TableWrapper class="bg-card/50 whitespace-nowrap">
<Table>
<TableHeader class="sticky top-0 bg-muted/80 backdrop-blur-sm z-10">
<TableRow class="hover:bg-transparent text-xs">
<TableHead>"İsim"</TableHead>
<TableHead class="text-center">"Tür"</TableHead>
<TableHead class="text-center">"Etkin"</TableHead>
<TableHead class="text-center">"Grup"</TableHead>
<TableHead class="text-center">"Ortaklar"</TableHead>
<TableHead class="text-center">"Eşler"</TableHead>
<TableHead class="text-center">"İndirilen"</TableHead>
<TableHead class="text-center">"Son Güncelleme"</TableHead>
<TableHead class="text-center">"Sıklık"</TableHead>
<TableHead class="text-center">"Özel"</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<For
each=move || trackers.clone()
key=|t| t.url.clone()
children=move |t| {
let t_type = if t.url.starts_with("http") { "http" }
else if t.url.starts_with("udp") { "udp" }
else if t.url.starts_with("dht") { "dht" }
else { "diğer" };
let is_enabled = if t.is_enabled { "evet" } else { "hayır" };
// Format timestamp difference for last update
let now = chrono::Utc::now().timestamp();
let diff = now - t.last_updated;
let last_update_str = if t.last_updated == 0 {
"Güncellenmedi".to_string()
} else if diff >= 0 {
format_duration_short(diff)
} else {
"N/A".to_string()
};
let url_clone = t.url.clone();
view! {
<TableRow class="hover:bg-muted/50 transition-colors group text-xs text-muted-foreground">
<TableCell class="font-medium text-foreground max-w-[200px] md:max-w-md truncate" attr:title=url_clone>
{t.url.clone()}
</TableCell>
<TableCell class="text-center">{t_type}</TableCell>
<TableCell class="text-center">{is_enabled}</TableCell>
<TableCell class="text-center">{t.group}</TableCell>
<TableCell class="text-center">{t.seeders}</TableCell>
<TableCell class="text-center">{t.peers}</TableCell>
<TableCell class="text-center">{t.downloaded}</TableCell>
<TableCell class="text-center">{last_update_str}</TableCell>
<TableCell class="text-center">{format_duration_short(t.interval)}</TableCell>
<TableCell class="text-center">"bilinmiyor"</TableCell> // Özel flag isn't cleanly via XMLRPC per tracker usually
</TableRow>
}
}
/>
</TableBody>
</Table>
</TableWrapper>
</div>
}.into_any()
}}
</Suspense>
}
}
#[component]
fn TrackersFallback() -> impl IntoView {
view! {
<Shimmer loading=Signal::derive(|| true) class="space-y-2">
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
<div class="h-10 w-full bg-muted/20 rounded-md"></div>
</Shimmer>
}
}
fn format_duration_short(seconds: i64) -> String {
if seconds <= 0 { return "0sn".to_string(); }
let days = seconds / 86400;
let hours = (seconds % 86400) / 3600;
let minutes = (seconds % 3600) / 60;
let secs = seconds % 60;
if days > 0 { format!("{}g {}s", days, hours) }
else if hours > 0 { format!("{}s {}dk", hours, minutes) }
else if minutes > 0 { format!("{}dk {}sn", minutes, secs) }
else { format!("{}sn", secs) }
}

View File

@@ -1,31 +1,39 @@
use leptos::prelude::*;
use tw_merge::tw_merge;
#[component]
pub fn Accordion(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let _ = (children, class);
view! { <div>"Accordion Not Implemented"</div> }
let class = tw_merge!("w-full", class);
view! { <div class=class>{children()}</div> }
}
#[component]
pub fn AccordionItem(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let _ = (children, class);
view! { <div>"AccordionItem Not Implemented"</div> }
let class = tw_merge!("border-b", class);
view! { <div class=class>{children()}</div> }
}
#[component]
pub fn AccordionHeader(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let _ = (children, class);
view! { <div>"AccordionHeader Not Implemented"</div> }
let class = tw_merge!("flex", class);
view! { <div class=class>{children()}</div> }
}
#[component]
pub fn AccordionTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let _ = (children, class);
view! { <div>"AccordionTrigger Not Implemented"</div> }
let class = tw_merge!(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
class
);
view! {
<button type="button" class=class>
{children()}
</button>
}
}
#[component]
pub fn AccordionContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let _ = (children, class);
view! { <div>"AccordionContent Not Implemented"</div> }
let class = tw_merge!("overflow-hidden text-sm transition-all", class);
view! { <div class=class>{children()}</div> }
}

View File

@@ -6,6 +6,7 @@ pub enum BadgeVariant {
#[default]
Default,
Secondary,
Outline,
Destructive,
Success,
Warning,
@@ -21,6 +22,7 @@ pub fn Badge(
let variant_classes = match variant {
BadgeVariant::Default => "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
BadgeVariant::Secondary => "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
BadgeVariant::Outline => "text-foreground",
BadgeVariant::Destructive => "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
BadgeVariant::Success => "border-transparent bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
BadgeVariant::Warning => "border-transparent bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20",

View File

@@ -0,0 +1,72 @@
use leptos::prelude::*;
use tailwind_fuse::tw_merge;
use crate::components::ui::button::{Button, ButtonVariant};
#[component]
pub fn ButtonAction(
children: Children,
#[prop(into)] on_action: Callback<()>,
#[prop(optional, into)] class: String,
#[prop(default = 1000)] hold_duration: u64,
#[prop(default = ButtonVariant::Default)] variant: ButtonVariant,
) -> impl IntoView {
let is_holding = RwSignal::new(false);
let generation = StoredValue::new(0u64);
let on_down = move |_| {
generation.update_value(|g| *g += 1);
is_holding.set(true);
};
let on_up = move |_| is_holding.set(false);
Effect::new(move |_| {
if is_holding.get() {
let current_gen = generation.get_value();
leptos::task::spawn_local(async move {
gloo_timers::future::TimeoutFuture::new(hold_duration as u32).await;
// Double validation: Is user still holding AND is it the SAME hold attempt?
if is_holding.get_untracked() && generation.get_value() == current_gen {
on_action.run(());
is_holding.set(false);
}
});
}
});
let merged_class = move || tw_merge!(
"relative overflow-hidden transition-all active:scale-[0.98]",
class.clone()
);
view! {
<style>
"@keyframes button-hold-progress {
from { width: 0%; }
to { width: 100%; }
}
.animate-button-hold {
animation: button-hold-progress var(--button-hold-duration) linear forwards;
}"
</style>
<Button
variant=variant
class=merged_class()
attr:style=format!("--button-hold-duration: {}ms", hold_duration)
on:mousedown=on_down
on:mouseup=on_up
on:mouseleave=on_up
on:touchstart=move |_| is_holding.set(true)
on:touchend=move |_| is_holding.set(false)
>
// Progress Overlay
<Show when=move || is_holding.get()>
<div class="absolute inset-0 bg-white/20 dark:bg-black/20 pointer-events-none animate-button-hold" />
</Show>
<span class="relative z-10 flex items-center justify-center gap-2">
{children()}
</span>
</Button>
}
}

View File

@@ -3,13 +3,17 @@ use leptos_ui::clx;
mod components {
use super::*;
clx! {Card, div, "bg-card text-card-foreground flex flex-col gap-4 rounded-xl border py-6 shadow-sm"}
clx! {CardHeader, div, "@container/card-header flex flex-col items-start gap-1.5 px-6 [.border-b]:pb-6"}
// TODO. Change data-slot=card-action by data-name="CardAction".
clx! {CardHeader, div, "@container/card-header flex flex-col items-start gap-1.5 px-6 [.border-b]:pb-6 sm:grid sm:auto-rows-min sm:grid-rows-[auto_auto] has-data-[slot=card-action]:sm:grid-cols-[1fr_auto]"}
clx! {CardTitle, h2, "leading-none font-semibold"}
clx! {CardContent, div, "px-6"}
clx! {CardDescription, p, "text-muted-foreground text-sm"}
clx! {CardFooter, footer, "flex items-center px-6 [.border-t]:pt-6", "gap-2"}
clx! {CardAction, div, "self-start sm:col-start-2 sm:row-span-2 sm:row-start-1 sm:justify-self-end"}
clx! {CardList, ul, "flex flex-col gap-4"}
clx! {CardItem, li, "flex items-center [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0"}
}
pub use components::*;
pub use components::*;

View File

@@ -78,76 +78,6 @@ pub fn ContextMenuAction(
}
}
#[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,
@@ -285,12 +215,20 @@ pub fn ContextMenuContent(
// Adjust if menu would go off right edge
if (x + menuRect.width > viewportWidth) {{
left = x - menuRect.width;
left = Math.max(0, x - menuRect.width);
}}
// Adjust if menu would go off bottom edge
if (y + menuRect.height > viewportHeight) {{
top = y - menuRect.height;
top = Math.max(0, y - menuRect.height);
}}
// Adjust for CSS transformed containing block
const offsetParent = menu.offsetParent;
if (offsetParent && offsetParent !== document.body && offsetParent !== document.documentElement) {{
const parentRect = offsetParent.getBoundingClientRect();
left -= parentRect.left;
top -= parentRect.top;
}}
menu.style.left = `${{left}}px`;
@@ -298,106 +236,153 @@ pub fn ContextMenuContent(
menu.style.transformOrigin = 'top left';
}};
const openMenu = (x, y) => {{
isOpen = true;
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';
// 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 (desktop)
trigger.addEventListener('contextmenu', (e) => {{
e.preventDefault();
e.stopPropagation();
if (isOpen) {{
closeMenu();
}}
openMenu(e.clientX, e.clientY);
}});
// Long-press on trigger (mobile)
let touchTimer = null;
let touchStartX = 0;
let touchStartY = 0;
const LONG_PRESS_DURATION = 500;
const MOVE_THRESHOLD = 10;
trigger.addEventListener('touchstart', (e) => {{
const touch = e.touches[0];
touchStartX = touch.clientX;
touchStartY = touch.clientY;
touchTimer = setTimeout(() => {{
e.preventDefault();
if (isOpen) {{
closeMenu();
}}
openMenu(touchStartX, touchStartY);
}}, LONG_PRESS_DURATION);
}}, {{ passive: false }});
trigger.addEventListener('touchmove', (e) => {{
if (touchTimer) {{
const touch = e.touches[0];
const dx = Math.abs(touch.clientX - touchStartX);
const dy = Math.abs(touch.clientY - touchStartY);
if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) {{
clearTimeout(touchTimer);
touchTimer = null;
}}
}}
}});
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();
trigger.addEventListener('touchend', () => {{
if (touchTimer) {{
clearTimeout(touchTimer);
touchTimer = null;
}}
}});
}});
// Handle ESC key
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape' && isOpen) {{
e.preventDefault();
closeMenu();
}}
}});
}};
trigger.addEventListener('touchcancel', () => {{
if (touchTimer) {{
clearTimeout(touchTimer);
touchTimer = null;
}}
}});
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', setupContextMenu);
}} else {{
setupContextMenu();
}}
}})();
"#,
target_id_for_script,
target_id_for_script,
)}
</script>
}.into_any()
// Close when action is clicked
const actions = menu.querySelectorAll('[data-context-close]');
actions.forEach(action => {{
action.addEventListener('click', () => {{
closeMenu();
}});
}});
// Handle ESC key
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape' && isOpen) {{
e.preventDefault();
closeMenu();
}}
}});
}};
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', setupContextMenu);
}} else {{
setupContextMenu();
}}
}})();
"#,
target_id_for_script,
target_id_for_script,
)}
</script>
}
}
#[component]

View File

@@ -1,6 +1,6 @@
// * Reuse @table.rs
pub use crate::components::ui::table::{
Table as DataTable, TableBody as DataTableBody, TableCell as DataTableCell,
TableHead as DataTableHead, TableHeader as DataTableHeader,
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,
};
};

View File

@@ -18,6 +18,10 @@ mod components {
pub use components::*;
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
#[derive(Clone)]
struct DialogContext {
target_id: String,
@@ -26,8 +30,11 @@ struct DialogContext {
#[component]
pub fn Dialog(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let dialog_target_id = use_random_id_for("dialog");
let ctx = DialogContext { target_id: dialog_target_id.clone() };
let merged_class = tw_merge!("w-fit", class);
view! {
<Provider value=ctx>
<div class=merged_class data-name="__Dialog">
@@ -46,8 +53,16 @@ pub fn DialogTrigger(
) -> impl IntoView {
let ctx = expect_context::<DialogContext>();
let trigger_id = format!("trigger_{}", ctx.target_id);
view! {
<Button class=class attr:id=trigger_id attr:tabindex="0" attr:data-dialog-trigger=ctx.target_id variant=variant size=size>
<Button
class=class
attr:id=trigger_id
attr:tabindex="0"
attr:data-dialog-trigger=ctx.target_id
variant=variant
size=size
>
{children()}
</Button>
}
@@ -57,6 +72,7 @@ pub fn DialogTrigger(
pub fn DialogContent(
children: Children,
#[prop(optional, into)] class: String,
#[prop(optional, into)] id: Option<String>,
#[prop(into, optional)] hide_close_button: Option<bool>,
#[prop(default = true)] close_on_backdrop_click: bool,
#[prop(default = "Dialog")] data_name_prefix: &'static str,
@@ -66,32 +82,74 @@ pub fn DialogContent(
"relative bg-background border rounded-2xl shadow-lg p-6 w-full max-w-[calc(100%-2rem)] max-h-[85vh] fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-100 transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100",
class
);
let backdrop_data_name = format!("{}Backdrop", data_name_prefix);
let content_data_name = format!("{}Content", data_name_prefix);
let target_id_clone = ctx.target_id.clone();
let backdrop_id = format!("{}_backdrop", ctx.target_id);
let target_id_for_script = ctx.target_id.clone();
let backdrop_id_for_script = backdrop_id.clone();
let backdrop_behavior = if close_on_backdrop_click { "auto" } else { "manual" };
// Use provided id or fallback to random target_id
let final_id = id.unwrap_or_else(|| ctx.target_id.clone());
let final_id_for_script = final_id.clone();
let trigger_id_for_script = ctx.target_id.clone();
view! {
<script src="/lock_scroll.js"></script>
<div data-name=backdrop_data_name id=backdrop_id class="fixed inset-0 transition-opacity duration-200 pointer-events-none z-60 bg-black/50 data-[state=closed]:opacity-0 data-[state=open]:opacity-100" data-state="closed" />
<div data-name=content_data_name class=merged_class id=ctx.target_id data-target="target__dialog" data-state="closed" data-backdrop=backdrop_behavior style="pointer-events: none;">
<button type="button" class=format!("absolute top-4 right-4 p-1 rounded-sm focus:ring-2 focus:ring-offset-2 focus:outline-none [&_svg:not([class*='size-'])]:size-4 focus:ring-ring{}", if hide_close_button.unwrap_or(false) { " hidden" } else { "" }) data-dialog-close=target_id_clone.clone() aria-label="Close dialog">
<div
data-name=backdrop_data_name
id=backdrop_id
class="fixed inset-0 transition-opacity duration-200 pointer-events-none z-60 bg-black/50 data-[state=closed]:opacity-0 data-[state=open]:opacity-100"
data-state="closed"
/>
<div
data-name=content_data_name
class=merged_class
id=final_id
data-target="target__dialog"
data-state="closed"
data-backdrop=backdrop_behavior
style="pointer-events: none;"
>
<button
type="button"
class=format!(
"absolute top-4 right-4 p-1 rounded-sm focus:ring-2 focus:ring-offset-2 focus:outline-none [&_svg:not([class*='size-'])]:size-4 focus:ring-ring{}",
if hide_close_button.unwrap_or(false) { " hidden" } else { "" },
)
data-dialog-close=target_id_clone.clone()
aria-label="Close dialog"
>
<span class="hidden">"Close Dialog"</span>
<X />
</button>
{children()}
</div>
<script>
{format!(r#"
{format!(
r#"
(function() {{
const setupDialog = () => {{
const dialog = document.querySelector('#{}');
const backdrop = document.querySelector('#{}');
const trigger = document.querySelector('[data-dialog-trigger="{}"]');
if (!dialog || !backdrop || !trigger || dialog.hasAttribute('data-initialized')) return;
if (!dialog || !backdrop || !trigger) {{
setTimeout(setupDialog, 50);
return;
}}
if (dialog.hasAttribute('data-initialized')) {{
return;
}}
dialog.setAttribute('data-initialized', 'true');
const openDialog = () => {{
if (window.ScrollLock) window.ScrollLock.lock();
dialog.setAttribute('data-state', 'open');
@@ -99,20 +157,38 @@ pub fn DialogContent(
dialog.style.pointerEvents = 'auto';
backdrop.style.pointerEvents = 'auto';
}};
const closeDialog = () => {{
dialog.setAttribute('data-state', 'closed');
backdrop.setAttribute('data-state', 'closed');
dialog.style.pointerEvents = 'none';
backdrop.style.pointerEvents = 'none';
if (window.ScrollLock) window.ScrollLock.unlock(200);
window.ScrollLock.unlock(200);
}};
trigger.addEventListener('click', openDialog);
dialog.querySelectorAll('[data-dialog-close]').forEach(btn => btn.addEventListener('click', closeDialog));
backdrop.addEventListener('click', () => {{ if (dialog.getAttribute('data-backdrop') === 'auto') closeDialog(); }});
dialog.querySelectorAll('[data-dialog-close]').forEach(btn => {{
btn.addEventListener('click', closeDialog);
}});
backdrop.addEventListener('click', () => {{
if (dialog.getAttribute('data-backdrop') === 'auto') {{
closeDialog();
}}
}});
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape' && dialog.getAttribute('data-state') === 'open') {{
e.preventDefault();
closeDialog();
}}
}});
}};
setupDialog();
}})();
"#, target_id_for_script, backdrop_id_for_script, target_id_for_script)}
"#,
final_id_for_script,
backdrop_id_for_script,
trigger_id_for_script,
)}
</script>
}
}
@@ -125,8 +201,15 @@ pub fn DialogClose(
#[prop(default = ButtonSize::Default)] size: ButtonSize,
) -> impl IntoView {
let ctx = expect_context::<DialogContext>();
view! {
<Button class=class attr:data-dialog-close=ctx.target_id attr:aria-label="Close dialog" variant=variant size=size>
<Button
class=class
attr:data-dialog-close=ctx.target_id
attr:aria-label="Close dialog"
variant=variant
size=size
>
{children()}
</Button>
}
@@ -139,11 +222,17 @@ pub fn DialogAction(
#[prop(default = ButtonVariant::Default)] variant: ButtonVariant,
#[prop(default = ButtonSize::Default)] size: ButtonSize,
) -> impl IntoView {
let _ = (class, variant, size);
let ctx = expect_context::<DialogContext>();
view! {
<Button attr:data-dialog-close=ctx.target_id attr:aria-label="Close dialog">
<Button
class=class
attr:data-dialog-close=ctx.target_id
attr:aria-label="Close dialog"
variant=variant
size=size
>
{children()}
</Button>
}
}
}

View File

@@ -1,3 +1,4 @@
use icons::{Check, ChevronRight};
use leptos::context::Provider;
use leptos::prelude::*;
use leptos_ui::clx;
@@ -17,20 +18,27 @@ mod components {
pub use components::*;
/* ========================================================== */
/* RADIO GROUP */
/* ========================================================== */
#[derive(Clone)]
struct DropdownMenuRadioContext<T: Clone + PartialEq + Send + Sync + 'static> {
value_signal: RwSignal<T>,
}
/// A group of radio items where only one can be selected at a time.
#[component]
pub fn DropdownMenuRadioGroup<T>(
children: Children,
/// The signal holding the current selected value
value: RwSignal<T>,
) -> impl IntoView
where
T: Clone + PartialEq + Send + Sync + 'static,
{
let ctx = DropdownMenuRadioContext { value_signal: value };
view! {
<Provider value=ctx>
<ul data-name="DropdownMenuRadioGroup" role="group" class="group">
@@ -40,9 +48,11 @@ where
}
}
/// A radio item that shows a checkmark when selected.
#[component]
pub fn DropdownMenuRadioItem<T>(
children: Children,
/// The value this item represents
value: T,
#[prop(optional, into)] class: String,
) -> impl IntoView
@@ -50,13 +60,16 @@ where
T: Clone + PartialEq + Send + Sync + 'static,
{
let ctx = expect_context::<DropdownMenuRadioContext<T>>();
let value_for_check = value.clone();
let value_for_click = value.clone();
let is_selected = move || ctx.value_signal.get() == value_for_check;
let merged_class = tw_merge!(
"group inline-flex gap-2 items-center w-full rounded-sm pl-2 pr-2 py-1.5 text-sm cursor-pointer no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
class
);
view! {
<li
data-name="DropdownMenuRadioItem"
@@ -69,53 +82,138 @@ where
}
>
{children()}
<icons::Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-checked:opacity-100" />
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-checked:opacity-100" />
</li>
}
}
/// An action item in a dropdown menu (no checkmark, just triggers an action).
#[component]
pub fn DropdownMenuAction(
children: Children,
#[prop(optional, into)] class: String,
#[prop(optional, into)] href: Option<String>,
) -> impl IntoView {
let _ctx = expect_context::<DropdownMenuContext>();
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 hover:bg-accent hover:text-accent-foreground",
class
);
if let Some(href) = href {
// Render as <a> tag when href is provided
view! {
<a data-name="DropdownMenuAction" class=class href=href data-dropdown-close="true">
{children()}
</a>
}.into_any()
<script>
{r#"
(function() {
const link = document.currentScript.previousElementSibling;
if (!link) return;
link.addEventListener('click', function() {
// Close dropdown on route change after navigation
let currentPath = window.location.pathname;
const checkRouteChange = () => {
if (window.location.pathname !== currentPath) {
currentPath = window.location.pathname;
// Find and close the dropdown
const dropdown = link.closest('[data-target="target__dropdown"]');
if (dropdown) {
dropdown.setAttribute('data-state', 'closed');
dropdown.style.pointerEvents = 'none';
// Unlock scroll
if (window.ScrollLock) {
window.ScrollLock.unlock(200);
}
}
clearInterval(routeCheckInterval);
}
};
const routeCheckInterval = setInterval(checkRouteChange, 50);
// Clear interval after 2 seconds to prevent memory leaks
setTimeout(() => clearInterval(routeCheckInterval), 2000);
});
})();
"#}
</script>
}
.into_any()
} else {
// Render as <button> tag when no href
view! {
<button type="button" data-name="DropdownMenuAction" class=class data-dropdown-close="true">
{children()}
</button>
}.into_any()
}
.into_any()
}
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
#[derive(Clone, Copy, PartialEq, Eq, Default)]
#[allow(dead_code)]
pub enum DropdownMenuAlign {
#[default] Start, StartOuter, End, EndOuter, Center,
#[default]
Start,
StartOuter,
End,
EndOuter,
Center,
}
#[derive(Clone)]
struct DropdownMenuContext {
target_id: String,
align: DropdownMenuAlign,
}
#[component]
pub fn DropdownMenu(children: Children) -> impl IntoView {
pub fn DropdownMenu(
children: Children,
#[prop(default = DropdownMenuAlign::default())] align: DropdownMenuAlign,
) -> impl IntoView {
let dropdown_target_id = use_random_id_for("dropdown");
let ctx = DropdownMenuContext { target_id: dropdown_target_id.clone() };
let ctx = DropdownMenuContext { target_id: dropdown_target_id.clone(), align };
view! {
<Provider value=ctx>
<style>
"
/* Submenu Styles */
.dropdown__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;
}
.dropdown__menu_sub_trigger:hover .dropdown__menu_sub_content {
opacity: 1;
visibility: visible;
transform: translateX(0);
pointer-events: auto;
}
"
</style>
<div data-name="DropdownMenu">{children()}</div>
</Provider>
}
@@ -125,105 +223,314 @@ pub fn DropdownMenu(children: Children) -> impl IntoView {
pub fn DropdownMenuTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let ctx = expect_context::<DropdownMenuContext>();
let button_class = tw_merge!(
"px-4 py-2 h-9 inline-flex justify-center items-center text-sm font-medium whitespace-nowrap rounded-md transition-colors w-fit focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
"px-4 py-2 h-9 inline-flex justify-center items-center text-sm font-medium whitespace-nowrap rounded-md transition-colors w-fit focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
class
);
view! {
<button type="button" class=button_class data-name="DropdownMenuTrigger" data-dropdown-trigger=ctx.target_id tabindex="0">
<button
type="button"
class=button_class
data-name="DropdownMenuTrigger"
data-dropdown-trigger=ctx.target_id
tabindex="0"
>
{children()}
</button>
}
}
#[derive(Clone, Copy, PartialEq, Eq, Default)]
#[allow(dead_code)]
pub enum DropdownMenuPosition {
#[default] Auto, Top, Bottom,
#[default]
Auto,
Top,
Bottom,
}
#[component]
pub fn DropdownMenuContent(
children: Children,
#[prop(optional, into)] class: String,
#[prop(default = DropdownMenuAlign::default())] align: DropdownMenuAlign,
#[prop(default = DropdownMenuPosition::default())] position: DropdownMenuPosition,
) -> impl IntoView {
let ctx = expect_context::<DropdownMenuContext>();
let base_classes = "z-50 p-1 rounded-md border bg-card shadow-md h-fit fixed transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100";
let width_class = match align {
let width_class = match ctx.align {
DropdownMenuAlign::Center => "min-w-full",
_ => "w-[180px]",
};
let class = tw_merge!(width_class, base_classes, class);
let target_id_for_script = ctx.target_id.clone();
let align_for_script = match align {
DropdownMenuAlign::Start => "start", DropdownMenuAlign::StartOuter => "start-outer",
DropdownMenuAlign::End => "end", DropdownMenuAlign::EndOuter => "end-outer",
let align_for_script = match ctx.align {
DropdownMenuAlign::Start => "start",
DropdownMenuAlign::StartOuter => "start-outer",
DropdownMenuAlign::End => "end",
DropdownMenuAlign::EndOuter => "end-outer",
DropdownMenuAlign::Center => "center",
};
let position_for_script = match position {
DropdownMenuPosition::Auto => "auto", DropdownMenuPosition::Top => "top", DropdownMenuPosition::Bottom => "bottom",
DropdownMenuPosition::Auto => "auto",
DropdownMenuPosition::Top => "top",
DropdownMenuPosition::Bottom => "bottom",
};
view! {
<div data-name="DropdownMenuContent" class=class id=ctx.target_id data-target="target__dropdown" data-state="closed" data-align=align_for_script data-position=position_for_script style="pointer-events: none;">
<div
data-name="DropdownMenuContent"
class=class
id=ctx.target_id
data-target="target__dropdown"
data-state="closed"
data-align=align_for_script
data-position=position_for_script
style="pointer-events: none;"
>
{children()}
</div>
<script>
{format!(r#"
{format!(
r#"
(function() {{
const setupDropdown = () => {{
const dropdown = document.querySelector('#{}');
const trigger = document.querySelector('[data-dropdown-trigger="{}"]');
if (!dropdown || !trigger || dropdown.hasAttribute('data-initialized')) return;
if (!dropdown || !trigger) {{
setTimeout(setupDropdown, 50);
return;
}}
if (dropdown.hasAttribute('data-initialized')) {{
return;
}}
dropdown.setAttribute('data-initialized', 'true');
let isOpen = false;
const updatePosition = () => {{
const triggerRect = trigger.getBoundingClientRect();
const dropdownRect = dropdown.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const spaceBelow = viewportHeight - triggerRect.bottom;
const spaceAbove = triggerRect.top;
const align = dropdown.getAttribute('data-align') || 'start';
const position = dropdown.getAttribute('data-position') || 'auto';
// Determine if we should position above
let shouldPositionAbove = false;
if (position === 'top') {{
shouldPositionAbove = true;
}} else if (position === 'bottom') {{
shouldPositionAbove = false;
}} else {{
// Auto: position above if there's space above AND not enough space below
shouldPositionAbove = spaceAbove >= dropdownRect.height && spaceBelow < dropdownRect.height;
}}
switch (align) {{
case 'start':
if (shouldPositionAbove) {{
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
dropdown.style.transformOrigin = 'left bottom';
}} else {{
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
dropdown.style.transformOrigin = 'left top';
}}
dropdown.style.left = `${{triggerRect.left}}px`;
break;
case 'end':
if (shouldPositionAbove) {{
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
dropdown.style.transformOrigin = 'right bottom';
}} else {{
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
dropdown.style.transformOrigin = 'right top';
}}
dropdown.style.left = `${{triggerRect.right - dropdownRect.width}}px`;
break;
case 'start-outer':
if (shouldPositionAbove) {{
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
dropdown.style.transformOrigin = 'right bottom';
}} else {{
dropdown.style.top = `${{triggerRect.top}}px`;
dropdown.style.transformOrigin = 'right top';
}}
dropdown.style.left = `${{triggerRect.left - dropdownRect.width - 16}}px`;
break;
case 'end-outer':
if (shouldPositionAbove) {{
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
dropdown.style.transformOrigin = 'left bottom';
}} else {{
dropdown.style.top = `${{triggerRect.top}}px`;
dropdown.style.transformOrigin = 'left top';
}}
dropdown.style.left = `${{triggerRect.right + 8}}px`;
break;
case 'center':
if (shouldPositionAbove) {{
dropdown.style.top = `${{triggerRect.top - dropdownRect.height - 6}}px`;
dropdown.style.transformOrigin = 'center bottom';
}} else {{
dropdown.style.top = `${{triggerRect.bottom + 6}}px`;
dropdown.style.transformOrigin = 'center top';
}}
dropdown.style.left = `${{triggerRect.left}}px`;
dropdown.style.minWidth = `${{triggerRect.width}}px`;
break;
}}
}};
const openDropdown = () => {{
isOpen = true;
// Set state to open first to remove scale transform for accurate measurements
dropdown.setAttribute('data-state', 'open');
// Make dropdown invisible but rendered to measure true height
dropdown.style.visibility = 'hidden';
dropdown.style.pointerEvents = 'auto';
if (window.ScrollLock) window.ScrollLock.lock();
setTimeout(() => {{ document.addEventListener('click', handleClickOutside); }}, 0);
// Force reflow to ensure height is calculated
dropdown.offsetHeight;
// Calculate position with accurate height
updatePosition();
// Now make it visible
dropdown.style.visibility = 'visible';
// Lock all scrollable elements
window.ScrollLock.lock();
// Close on click outside
setTimeout(() => {{
document.addEventListener('click', handleClickOutside);
}}, 0);
}};
const closeDropdown = () => {{
isOpen = false;
dropdown.setAttribute('data-state', 'closed');
dropdown.style.pointerEvents = 'none';
document.removeEventListener('click', handleClickOutside);
if (window.ScrollLock) window.ScrollLock.unlock(200);
// Unlock scroll after animation (200ms delay)
window.ScrollLock.unlock(200);
}};
const handleClickOutside = (e) => {{ if (!dropdown.contains(e.target) && !trigger.contains(e.target)) closeDropdown(); }};
const handleClickOutside = (e) => {{
if (!dropdown.contains(e.target) && !trigger.contains(e.target)) {{
closeDropdown();
}}
}};
// Toggle dropdown when trigger is clicked
trigger.addEventListener('click', (e) => {{
e.stopPropagation();
if (isOpen) closeDropdown(); else openDropdown();
// Check if any other dropdown is open
const allDropdowns = document.querySelectorAll('[data-target=\"target__dropdown\"]');
let otherDropdownOpen = false;
allDropdowns.forEach(dd => {{
if (dd !== dropdown && dd.getAttribute('data-state') === 'open') {{
otherDropdownOpen = true;
dd.setAttribute('data-state', 'closed');
dd.style.pointerEvents = 'none';
// Unlock scroll
if (window.ScrollLock) {{
window.ScrollLock.unlock(200);
}}
}}
}});
// If another dropdown was open, just close it and don't open this one
if (otherDropdownOpen) {{
return;
}}
// Normal toggle behavior
if (isOpen) {{
closeDropdown();
}} else {{
openDropdown();
}}
}});
// Close when action is clicked
const actions = dropdown.querySelectorAll('[data-dropdown-close]');
actions.forEach(action => {{
action.addEventListener('click', () => {{
closeDropdown();
}});
}});
// Handle ESC key to close
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape' && isOpen) {{
e.preventDefault();
closeDropdown();
}}
}});
dropdown.querySelectorAll('[data-dropdown-close]').forEach(action => action.addEventListener('click', closeDropdown));
}};
setupDropdown();
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', setupDropdown);
}} else {{
setupDropdown();
}}
}})();
"#, target_id_for_script, target_id_for_script)}
"#,
target_id_for_script,
target_id_for_script,
)}
</script>
}
}
#[component]
pub fn DropdownMenuSub(children: Children) -> impl IntoView {
clx! {DropdownMenuSubRoot, li, "dropdown__menu_sub_trigger", "relative inline-flex 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 hover:bg-accent hover:text-accent-foreground"}
// TODO. Find a better way for dropdown__menu_sub_trigger.
clx! {DropdownMenuSubRoot, li, "dropdown__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! { <DropdownMenuSubRoot>{children()}</DropdownMenuSubRoot> }
}
#[component]
pub fn DropdownMenuSubTrigger(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("flex items-center justify-between w-full", class);
view! {
<span attr:data-name="DropdownMenuSubTrigger" class=class>
<span class="flex gap-2 items-center">{children()}</span>
<icons::ChevronRight class="opacity-70 size-4" />
<ChevronRight class="opacity-70 size-4" />
</span>
}
}
#[component]
pub fn DropdownMenuSubItem(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="DropdownMenuSubItem" class=class data-dropdown-close="true">{children()}</li> }
}
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="DropdownMenuSubItem" class=class data-dropdown-close="true">
{children()}
</li>
}
}

View File

@@ -2,6 +2,7 @@ pub mod accordion;
pub mod alert_dialog;
pub mod badge;
pub mod button;
pub mod button_action;
pub mod card;
pub mod checkbox;
pub mod context_menu;
@@ -13,10 +14,14 @@ pub mod input;
pub mod multi_select;
pub mod select;
pub mod separator;
pub mod scroll_area;
pub mod sheet;
pub mod sidenav;
pub mod skeleton;
pub mod shimmer;
pub mod svg_icon;
pub mod switch;
pub mod table;
pub mod tabs;
pub mod theme_toggle;
pub mod toast;

View File

@@ -1,29 +1,43 @@
use std::collections::HashSet;
use icons::{Check, ChevronDown, ChevronUp};
use leptos::context::Provider;
use leptos::prelude::*;
use tw_merge::*;
use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
use crate::components::hooks::use_random::use_random_id_for;
// * Reuse @select.rs
pub use crate::components::ui::select::{
SelectGroup as MultiSelectGroup, SelectItem as MultiSelectItem,
SelectGroup as MultiSelectGroup, SelectItem as MultiSelectItem, SelectLabel as MultiSelectLabel,
};
#[derive(Clone, Copy, PartialEq, Eq, Default)]
#[allow(dead_code)]
pub enum MultiSelectAlign {
Start, #[default] Center, End,
Start,
#[default]
Center,
End,
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
#[component]
pub fn MultiSelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView {
let multi_select_ctx = expect_context::<MultiSelectContext>();
view! {
<span data-name="MultiSelectValue" class="text-sm text-muted-foreground truncate">
{move || {
let values = multi_select_ctx.values_signal.get();
if values.is_empty() { placeholder.clone() }
else { let count = values.len(); if count == 1 { "1 selected".to_string() } else { format!("{} selected", count) } }
if values.is_empty() {
placeholder.clone()
} else {
let count = values.len();
if count == 1 { "1 selected".to_string() } else { format!("{} selected", count) }
}
}}
</span>
}
@@ -36,17 +50,41 @@ pub fn MultiSelectOption(
#[prop(optional, into)] value: Option<String>,
) -> impl IntoView {
let multi_select_ctx = expect_context::<MultiSelectContext>();
let value_clone = value.clone();
let is_selected = Signal::derive(move || {
if let Some(ref val) = value_clone { multi_select_ctx.values_signal.with(|values| values.contains(val)) } else { false }
if let Some(ref val) = value_clone {
multi_select_ctx.values_signal.with(|values| values.contains(val))
} else {
false
}
});
let class = tw_merge!("group 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 hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50", class);
let class = tw_merge!(
"group 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 hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50",
class
);
view! {
<button type="button" data-name="MultiSelectOption" class=class role="option" aria-selected=move || is_selected.get().to_string()
<button
type="button"
data-name="MultiSelectOption"
class=class
role="option"
aria-selected=move || is_selected.get().to_string()
on:click=move |ev: web_sys::MouseEvent| {
ev.prevent_default(); ev.stop_propagation();
ev.prevent_default();
ev.stop_propagation();
if let Some(val) = value.clone() {
multi_select_ctx.values_signal.update(|values| { if values.contains(&val) { values.remove(&val); } else { values.insert(val); } });
multi_select_ctx
.values_signal
.update(|values| {
if values.contains(&val) {
values.remove(&val);
} else {
values.insert(val);
}
});
}
}
>
@@ -56,6 +94,10 @@ pub fn MultiSelectOption(
}
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
#[derive(Clone)]
struct MultiSelectContext {
target_id: String,
@@ -71,7 +113,9 @@ pub fn MultiSelect(
) -> impl IntoView {
let multi_select_target_id = use_random_id_for("multi_select");
let values_signal = values.unwrap_or_else(|| RwSignal::new(HashSet::<String>::new()));
let multi_select_ctx = MultiSelectContext { target_id: multi_select_target_id.clone(), values_signal, align };
view! {
<Provider value=multi_select_ctx>
<div data-name="MultiSelect" class="relative w-fit">
@@ -82,12 +126,32 @@ pub fn MultiSelect(
}
#[component]
pub fn MultiSelectTrigger(children: Children, #[prop(optional, into)] class: String, #[prop(optional, into)] id: String) -> impl IntoView {
let ctx = expect_context::<MultiSelectContext>();
let button_class = tw_merge!("w-full p-2 h-9 inline-flex items-center justify-between text-sm font-medium whitespace-nowrap rounded-md transition-colors focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 border bg-background border-input hover:bg-accent hover:text-accent-foreground", class);
let button_id = if !id.is_empty() { id } else { format!("trigger_{}", ctx.target_id) };
pub fn MultiSelectTrigger(
children: Children,
#[prop(optional, into)] class: String,
#[prop(optional, into)] id: String,
) -> impl IntoView {
let multi_select_ctx = expect_context::<MultiSelectContext>();
let peer_class = if !id.is_empty() { format!("peer/{}", id) } else { String::new() };
let button_class = tw_merge!(
"w-full p-2 h-9 inline-flex items-center justify-between text-sm font-medium whitespace-nowrap rounded-md transition-colors focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not(:last-child)]:mr-2 [&_svg:not(:first-child)]:ml-2 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
&peer_class,
class
);
let button_id = if !id.is_empty() { id } else { format!("trigger_{}", multi_select_ctx.target_id) };
view! {
<button type="button" data-name="MultiSelectTrigger" class=button_class id=button_id tabindex="0" data-multi-select-trigger=ctx.target_id>
<button
type="button"
data-name="MultiSelectTrigger"
class=button_class
id=button_id
tabindex="0"
data-multi-select-trigger=multi_select_ctx.target_id
>
{children()}
<ChevronDown class="text-muted-foreground" />
</button>
@@ -97,33 +161,134 @@ pub fn MultiSelectTrigger(children: Children, #[prop(optional, into)] class: Str
#[component]
pub fn MultiSelectContent(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let multi_select_ctx = expect_context::<MultiSelectContext>();
let align_str = match multi_select_ctx.align { MultiSelectAlign::Start => "start", MultiSelectAlign::Center => "center", MultiSelectAlign::End => "end" };
let class = tw_merge!("w-[150px] overflow-auto z-50 p-1 rounded-md border bg-card shadow-md h-fit max-h-[300px] absolute top-[calc(100%+4px)] transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[align=start]:left-0 data-[align=center]:left-1/2 data-[align=center]:-translate-x-1/2 data-[align=end]:right-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", class);
let target_id = multi_select_ctx.target_id.clone();
let (on_scroll, can_scroll_up, can_scroll_down) = use_can_scroll_vertical();
let align_str = match multi_select_ctx.align {
MultiSelectAlign::Start => "start",
MultiSelectAlign::Center => "center",
MultiSelectAlign::End => "end",
};
let class = tw_merge!(
"w-[150px] overflow-auto z-50 p-1 rounded-md border bg-card shadow-md h-fit max-h-[300px] absolute top-[calc(100%+4px)] transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[align=start]:left-0 data-[align=center]:left-1/2 data-[align=center]:-translate-x-1/2 data-[align=end]:right-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
class
);
let target_id_for_script = multi_select_ctx.target_id.clone();
let target_id_for_script_2 = multi_select_ctx.target_id.clone();
// Scroll indicator signals
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
view! {
<div data-name="MultiSelectContent" class=class id=target_id.clone() data-target="target__multi_select" data-state="closed" data-align=align_str style="pointer-events: none;" on:scroll=move |ev| on_scroll.run(ev)>
<div class=move || if can_scroll_up.get() { "sticky -top-1 z-10 flex items-center justify-center py-1 bg-card" } else { "hidden" }><ChevronUp class="size-4 text-muted-foreground" /></div>
<div
data-name="MultiSelectContent"
class=class
id=multi_select_ctx.target_id
data-target="target__multi_select"
data-state="closed"
data-align=align_str
style="pointer-events: none;"
on:scroll=move |ev| on_scroll.run(ev)
>
<div
data-scroll-up="true"
class=move || {
let is_up: bool = can_scroll_up_signal.get();
if is_up {
"sticky -top-1 z-10 flex items-center justify-center py-1 bg-card"
} else {
"hidden"
}
}
>
<ChevronUp class="size-4 text-muted-foreground" />
</div>
{children()}
<div class=move || if can_scroll_down.get() { "sticky -bottom-1 z-10 flex items-center justify-center py-1 bg-card" } else { "hidden" }><ChevronDown class="size-4 text-muted-foreground" /></div>
<div
data-scroll-down="true"
class=move || {
let is_down: bool = can_scroll_down_signal.get();
if is_down {
"sticky -bottom-1 z-10 flex items-center justify-center py-1 bg-card"
} else {
"hidden"
}
}
>
<ChevronDown class="size-4 text-muted-foreground" />
</div>
</div>
<script>
{format!(r#"
{format!(
r#"
(function() {{
const setup = () => {{
const ms = document.querySelector('#{}');
const tr = document.querySelector('[data-multi-select-trigger="{}"]');
if (!ms || !tr || ms.hasAttribute('data-initialized')) return;
ms.setAttribute('data-initialized', 'true');
const setupMultiSelect = () => {{
const multiSelect = document.querySelector('#{}');
const trigger = document.querySelector('[data-multi-select-trigger="{}"]');
if (!multiSelect || !trigger) {{
setTimeout(setupMultiSelect, 50);
return;
}}
if (multiSelect.hasAttribute('data-initialized')) {{
return;
}}
multiSelect.setAttribute('data-initialized', 'true');
let isOpen = false;
const open = () => {{ isOpen = true; ms.setAttribute('data-state', 'open'); ms.style.pointerEvents = 'auto'; if (window.ScrollLock) window.ScrollLock.lock(); setTimeout(() => document.addEventListener('click', clickOut), 0); }};
const close = () => {{ isOpen = false; ms.setAttribute('data-state', 'closed'); ms.style.pointerEvents = 'none'; document.removeEventListener('click', clickOut); if (window.ScrollLock) window.ScrollLock.unlock(200); }};
const clickOut = (e) => {{ if (!ms.contains(e.target) && !tr.contains(e.target)) close(); }};
tr.addEventListener('click', (e) => {{ e.stopPropagation(); if (isOpen) close(); else open(); }});
const openMultiSelect = () => {{
isOpen = true;
if (window.ScrollLock) window.ScrollLock.lock();
multiSelect.setAttribute('data-state', 'open');
multiSelect.style.pointerEvents = 'auto';
const triggerRect = trigger.getBoundingClientRect();
multiSelect.style.minWidth = `${{triggerRect.width}}px`;
multiSelect.dispatchEvent(new Event('scroll'));
setTimeout(() => {{
document.addEventListener('click', handleClickOutside);
}}, 0);
}};
const closeMultiSelect = () => {{
isOpen = false;
multiSelect.setAttribute('data-state', 'closed');
multiSelect.style.pointerEvents = 'none';
document.removeEventListener('click', handleClickOutside);
if (window.ScrollLock) window.ScrollLock.unlock(200);
}};
const handleClickOutside = (e) => {{
if (!multiSelect.contains(e.target) && !trigger.contains(e.target)) {{
closeMultiSelect();
}}
}};
trigger.addEventListener('click', (e) => {{
e.stopPropagation();
if (isOpen) closeMultiSelect(); else openMultiSelect();
}});
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape' && isOpen) {{
e.preventDefault();
closeMultiSelect();
}}
}});
}};
setup();
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', setupMultiSelect);
}} else {{
setupMultiSelect();
}}
}})();
"#, target_id, target_id)}
"#,
target_id_for_script,
target_id_for_script_2,
)}
</script>
}
}.into_any()
}

View File

@@ -0,0 +1,102 @@
use leptos::prelude::*;
use leptos_ui::void;
use tw_merge::*;
// Removed unused fake components
/* ========================================================== */
/* ✨ COMPONENTS ✨ */
/* ========================================================== */
#[component]
pub fn ScrollArea(children: Children, #[prop(into, optional)] class: String) -> impl IntoView {
let merged_class = tw_merge!("relative overflow-hidden", class);
view! {
<div data-name="ScrollArea" class=merged_class>
<ScrollAreaViewport class="pr-3 pb-3 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-border/60 [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-border/80">{children()}</ScrollAreaViewport>
</div>
}
}
#[component]
pub fn ScrollAreaViewport(children: Children, #[prop(into, optional)] class: String) -> impl IntoView {
let merged_class = tw_merge!(
"focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 overflow-auto",
class
);
view! {
<div data-name="ScrollAreaViewport" class=merged_class>
{children()}
</div>
}
}
/* ========================================================== */
/* 🧬 ENUMS 🧬 */
/* ========================================================== */
// Real scrollbars are now utilized in the viewport directly.
/* ========================================================== */
/* 🧬 STRUCT 🧬 */
/* ========================================================== */
#[component]
pub fn SnapScrollArea(
#[prop(into, default = SnapAreaVariant::default())] variant: SnapAreaVariant,
#[prop(into, optional)] class: String,
children: Children,
) -> impl IntoView {
let snap_item = SnapAreaClass { variant };
let merged_class = snap_item.with_class(class);
view! {
<div data-name="SnapScrollArea" class=merged_class>
{children()}
</div>
}
}
#[derive(TwClass, Default)]
#[tw(class = "")]
pub struct SnapAreaClass {
variant: SnapAreaVariant,
}
#[derive(TwVariant)]
pub enum SnapAreaVariant {
// * snap-x by default
#[tw(default, class = "overflow-x-auto snap-x")]
Center,
}
/* ========================================================== */
/* 🧬 STRUCT 🧬 */
/* ========================================================== */
#[component]
pub fn SnapItem(
#[prop(into, default = SnapVariant::default())] variant: SnapVariant,
#[prop(into, optional)] class: String,
children: Children,
) -> impl IntoView {
let snap_item = SnapItemClass { variant };
let merged_class = snap_item.with_class(class);
view! {
<div data-name="SnapItem" class=merged_class>
{children()}
</div>
}
}
#[derive(TwClass, Default)]
#[tw(class = "shrink-0")]
pub struct SnapItemClass {
variant: SnapVariant,
}
#[derive(TwVariant)]
pub enum SnapVariant {
// * snap-center by default
#[tw(default, class = "snap-center")]
Center,
}

View File

@@ -1,34 +1,311 @@
use icons::{Check, ChevronDown, ChevronUp};
use leptos::context::Provider;
use leptos::prelude::*;
use leptos_ui::clx;
use strum::{AsRefStr, Display};
use tw_merge::*;
#[component] pub fn SelectGroup(children: Children) -> impl IntoView { view! { <div class="px-1 py-1">{children()}</div> } }
#[component] pub fn SelectItem(children: Children) -> impl IntoView { view! { <div class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground">{children()}</div> } }
use crate::components::hooks::use_can_scroll_vertical::use_can_scroll_vertical;
use crate::components::hooks::use_random::use_random_id_for;
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
#[allow(dead_code)]
pub enum SelectPosition { #[default] Below, Above }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, AsRefStr)]
pub enum SelectPosition {
#[default]
Below,
Above,
}
mod components {
use super::*;
clx! {SelectLabel, span, "px-2 py-1.5 text-sm font-medium data-inset:pl-8", "mb-1"}
clx! {SelectItem, 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"}
}
pub use components::*;
#[component]
pub fn SelectGroup(
children: Children,
#[prop(optional, into)] class: String,
#[prop(default = "Select options".into(), into)] aria_label: String,
) -> impl IntoView {
let merged_class = tw_merge!("group", class);
view! {
<ul data-name="SelectGroup" role="listbox" aria-label=aria_label class=merged_class>
{children()}
</ul>
}
}
#[component]
pub fn SelectValue(#[prop(optional, into)] placeholder: String) -> impl IntoView {
view! { <span class="text-sm text-muted-foreground">{placeholder}</span> }
let select_ctx = expect_context::<SelectContext>();
view! {
<span data-name="SelectValue" class="text-sm text-muted-foreground truncate">
{move || { select_ctx.value_signal.get().unwrap_or_else(|| placeholder.clone()) }}
</span>
}
}
#[component]
pub fn SelectOption(children: Children, #[prop(optional, into)] value: Option<String>) -> impl IntoView {
let _ = value;
view! { <div role="option">{children()}</div> }
pub fn SelectOption(
children: Children,
#[prop(optional, into)] class: String,
#[prop(default = false.into(), into)] aria_selected: Signal<bool>,
#[prop(optional, into)] value: Option<String>,
) -> impl IntoView {
let ctx = expect_context::<SelectContext>();
let merged_class = tw_merge!(
"group inline-flex gap-2 items-center w-full rounded-sm px-2 py-1.5 text-sm cursor-pointer no-underline transition-colors duration-200 text-popover-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
class
);
let value_for_check = value.clone();
let is_selected = move || aria_selected.get() || ctx.value_signal.get() == value_for_check;
view! {
<li
data-name="SelectOption"
class=merged_class
role="option"
tabindex="0"
aria-selected=move || is_selected().to_string()
data-select-option="true"
on:click=move |_| {
let val = value.clone();
ctx.value_signal.set(val.clone());
if let Some(on_change) = ctx.on_change {
on_change.run(val);
}
}
>
{children()}
<Check class="ml-auto opacity-0 size-4 text-muted-foreground group-aria-selected:opacity-100" />
</li>
}
}
#[derive(Clone)]
struct SelectContext {
target_id: String,
value_signal: RwSignal<Option<String>>,
on_change: Option<Callback<Option<String>>>,
}
#[component]
pub fn Select(children: Children) -> impl IntoView {
view! { <div>{children()}</div> }
pub fn Select(
children: Children,
#[prop(optional, into)] class: String,
#[prop(optional, into)] default_value: Option<String>,
#[prop(optional)] on_change: Option<Callback<Option<String>>>,
) -> impl IntoView {
let select_target_id = use_random_id_for("select");
let value_signal = RwSignal::new(default_value);
let ctx = SelectContext { target_id: select_target_id.clone(), value_signal, on_change };
let merged_class = tw_merge!("relative w-fit", class);
view! {
<Provider value=ctx>
<div data-name="Select" class=merged_class>
{children()}
</div>
</Provider>
}
}
#[component]
pub fn SelectTrigger(children: Children) -> impl IntoView {
view! { <button type="button">{children()}</button> }
pub fn SelectTrigger(
children: Children,
#[prop(optional, into)] class: String,
#[prop(optional, into)] id: String,
) -> impl IntoView {
let ctx = expect_context::<SelectContext>();
let peer_class = if !id.is_empty() { format!("peer/{}", id) } else { String::new() };
let button_class = tw_merge!(
"w-full p-2 h-9 inline-flex items-center justify-between text-sm font-medium whitespace-nowrap rounded-md transition-colors focus:outline-none focus:ring-1 focus:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&_svg:not(:last-child)]:mr-2 [&_svg:not(:first-child)]:ml-2 [&_svg:not([class*='size-'])]:size-4 border bg-background border-input hover:bg-accent hover:text-accent-foreground",
&peer_class,
class
);
let button_id = if !id.is_empty() { id } else { format!("trigger_{}", ctx.target_id) };
view! {
<button
type="button"
data-name="SelectTrigger"
class=button_class
id=button_id
tabindex="0"
data-select-trigger=ctx.target_id
>
{children()}
<ChevronDown class="text-muted-foreground" />
</button>
}
}
#[component]
pub fn SelectContent(children: Children) -> impl IntoView {
view! { <div>{children()}</div> }
}
pub fn SelectContent(
children: Children,
#[prop(optional, into)] class: String,
#[prop(default = SelectPosition::default())] position: SelectPosition,
#[prop(optional)] on_close: Option<Callback<()>>,
) -> impl IntoView {
let ctx = expect_context::<SelectContext>();
let merged_class = tw_merge!(
"w-[150px] overflow-auto z-50 p-1 rounded-md border bg-card shadow-md h-fit max-h-[300px] absolute top-[calc(100%+4px)] left-0 data-[position=Above]:top-auto data-[position=Above]:bottom-[calc(100%+4px)] transition-all duration-200 data-[state=closed]:opacity-0 data-[state=closed]:scale-95 data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[state=closed]:data-[position=Below]:origin-top data-[state=open]:data-[position=Below]:origin-top data-[state=closed]:data-[position=Above]:origin-bottom data-[state=open]:data-[position=Above]:origin-bottom [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
class
);
let target_id_for_script = ctx.target_id.clone();
let target_id_for_script_2 = ctx.target_id.clone();
// Scroll indicator signals
let (on_scroll, can_scroll_up_signal, can_scroll_down_signal) = use_can_scroll_vertical();
view! {
<div
data-name="SelectContent"
class=merged_class
on:selectclose=move |_: web_sys::CustomEvent| {
if let Some(cb) = on_close {
cb.run(());
}
}
id=ctx.target_id
data-target="target__select"
data-state="closed"
data-position=position.to_string()
style="pointer-events: none;"
on:scroll=move |ev| on_scroll.run(ev)
>
<div
data-scroll-up="true"
class=move || {
let is_up: bool = can_scroll_up_signal.get();
if is_up {
"sticky -top-1 z-10 flex items-center justify-center py-1 bg-card"
} else {
"hidden"
}
}
>
<ChevronUp class="size-4 text-muted-foreground" />
</div>
{children()}
<div
data-scroll-down="true"
class=move || {
let is_down: bool = can_scroll_down_signal.get();
if is_down {
"sticky -bottom-1 z-10 flex items-center justify-center py-1 bg-card"
} else {
"hidden"
}
}
>
<ChevronDown class="size-4 text-muted-foreground" />
</div>
</div>
<script>
{format!(
r#"
(function() {{
const setupSelect = () => {{
const select = document.querySelector('#{}');
const trigger = document.querySelector('[data-select-trigger="{}"]');
if (!select || !trigger) {{
setTimeout(setupSelect, 50);
return;
}}
if (select.hasAttribute('data-initialized')) {{
return;
}}
select.setAttribute('data-initialized', 'true');
let isOpen = false;
const updatePosition = () => {{
const triggerRect = trigger.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - triggerRect.bottom;
const spaceAbove = triggerRect.top;
if (spaceBelow < 200 && spaceAbove > spaceBelow) {{
select.setAttribute('data-position', 'Above');
}} else {{
select.setAttribute('data-position', 'Below');
}}
select.style.minWidth = `${{triggerRect.width}}px`;
}};
const openSelect = () => {{
isOpen = true;
if (window.ScrollLock) window.ScrollLock.lock();
updatePosition();
select.setAttribute('data-state', 'open');
select.style.pointerEvents = 'auto';
select.dispatchEvent(new Event('scroll'));
setTimeout(() => {{
document.addEventListener('click', handleClickOutside);
}}, 0);
}};
const closeSelect = () => {{
isOpen = false;
select.setAttribute('data-state', 'closed');
select.style.pointerEvents = 'none';
document.removeEventListener('click', handleClickOutside);
select.dispatchEvent(new CustomEvent('selectclose', {{ bubbles: false }}));
if (window.ScrollLock) window.ScrollLock.unlock(200);
}};
const handleClickOutside = (e) => {{
if (!select.contains(e.target) && !trigger.contains(e.target)) {{
closeSelect();
}}
}};
trigger.addEventListener('click', (e) => {{
e.stopPropagation();
if (isOpen) closeSelect(); else openSelect();
}});
const options = select.querySelectorAll('[data-select-option]');
options.forEach(option => {{
option.addEventListener('click', () => closeSelect());
}});
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape' && isOpen) {{
e.preventDefault();
closeSelect();
}}
}});
}};
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', setupSelect);
}} else {{
setupSelect();
}}
}})();
"#,
target_id_for_script,
target_id_for_script_2,
)}
</script>
}.into_any()
}

View File

@@ -1,21 +1,35 @@
use leptos::prelude::*;
use tailwind_fuse::tw_merge;
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
pub enum SeparatorOrientation { #[default] Horizontal, Vertical }
use tw_merge::*;
#[component]
pub fn Separator(
#[prop(into, optional)] orientation: Signal<SeparatorOrientation>,
#[prop(into, optional)] class: String,
// children: Children,
) -> impl IntoView {
let class_signal = move || tw_merge!(
"shrink-0 bg-border",
match orientation.get() {
SeparatorOrientation::Horizontal => "h-[1px] w-full",
SeparatorOrientation::Vertical => "h-full w-[1px]",
},
class.clone()
);
view! { <div class=class_signal role="none" /> }
let merged_class = Memo::new(move |_| {
let orientation = orientation.get();
let separator = SeparatorClass { orientation };
separator.with_class(class.clone())
});
view! { <div class=merged_class role="separator" /> }
}
/* ========================================================== */
/* 🧬 STRUCT 🧬 */
/* ========================================================== */
#[derive(TwClass, Default)]
#[tw(class = "shrink-0 bg-border")]
pub struct SeparatorClass {
orientation: SeparatorOrientation,
}
#[derive(TwVariant)]
pub enum SeparatorOrientation {
#[tw(default, class = "w-full h-[1px]")]
Default,
#[tw(class = "h-full w-[1px]")]
Vertical,
}

View File

@@ -16,21 +16,32 @@ mod components {
clx! {SheetFooter, footer, "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"}
}
pub use components::*;
/* ========================================================== */
/* ✨ CONTEXT ✨ */
/* ========================================================== */
#[derive(Clone)]
pub struct SheetContext {
pub target_id: String,
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
pub type SheetVariant = ButtonVariant;
pub type SheetSize = ButtonSize;
#[component]
pub fn Sheet(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let _ = class;
let sheet_target_id = use_random_id_for("sheet");
let ctx = SheetContext { target_id: sheet_target_id };
let merged_class = tw_merge!("", class);
view! {
<Provider value=ctx>
<div data-name="Sheet">
<div data-name="Sheet" class=merged_class>
{children()}
</div>
</Provider>
@@ -46,6 +57,7 @@ pub fn SheetTrigger(
) -> impl IntoView {
let ctx = expect_context::<SheetContext>();
let trigger_id = format!("trigger_{}", ctx.target_id);
view! {
<Button class=class attr:id=trigger_id attr:data-sheet-trigger=ctx.target_id variant=variant size=size>
{children()}
@@ -61,6 +73,7 @@ pub fn SheetClose(
#[prop(default = ButtonSize::Default)] size: ButtonSize,
) -> impl IntoView {
let ctx = expect_context::<SheetContext>();
view! {
<Button class=class attr:data-sheet-close=ctx.target_id attr:aria-label="Close sheet" variant=variant size=size>
{children()}
@@ -76,68 +89,131 @@ pub fn SheetContent(
#[prop(into, optional)] hide_close_button: Option<bool>,
) -> impl IntoView {
let ctx = expect_context::<SheetContext>();
let backdrop_id = format!("{}_backdrop", ctx.target_id);
let target_id_for_script = ctx.target_id.clone();
let backdrop_id_for_script = backdrop_id.clone();
let merged_class = tw_merge!(
"fixed z-100 bg-card shadow-lg p-6 transition-transform duration-300 overflow-y-auto overscroll-y-contain",
direction.initial_position(),
direction.closed_class(),
class
);
view! {
<div data-name="SheetBackdrop" id=backdrop_id class="fixed inset-0 transition-opacity duration-200 pointer-events-none z-60 bg-black/50 data-[state=closed]:opacity-0 data-[state=open]:opacity-100" data-state="closed" />
<div data-name="SheetContent" class=merged_class id=ctx.target_id data-direction=direction.to_string() data-state="closed" style="pointer-events: none;">
<button type="button" class=format!("absolute top-4 right-4 p-1 rounded-sm focus:ring-2 focus:ring-offset-2 focus:outline-none [&_svg:not([class*='size-'])]:size-4 focus:ring-ring{}", if hide_close_button.unwrap_or(false) { " hidden" } else { "" }) data-sheet-close=ctx.target_id.clone() aria-label="Close sheet">
<div
data-name="SheetBackdrop"
id=backdrop_id
class="fixed inset-0 transition-opacity duration-200 pointer-events-none z-60 bg-black/50 data-[state=closed]:opacity-0 data-[state=open]:opacity-100"
data-state="closed"
/>
<div
data-name="SheetContent"
class=merged_class
id=ctx.target_id
data-direction=direction.to_string()
data-state="closed"
style="pointer-events: none;"
>
<button
type="button"
class=format!(
"absolute top-4 right-4 p-1 rounded-sm focus:ring-2 focus:ring-offset-2 focus:outline-none [&_svg:not([class*='size-'])]:size-4 focus:ring-ring{}",
if hide_close_button.unwrap_or(false) { " hidden" } else { "" },
)
data-sheet-close=ctx.target_id.clone()
aria-label="Close sheet"
>
<span class="hidden">"Close Sheet"</span>
<X />
</button>
{children()}
</div>
<script>
{format!(r#"
{format!(
r#"
(function() {{
const setupSheet = () => {{
const sheet = document.querySelector('#{}');
const backdrop = document.querySelector('#{}');
const trigger = document.querySelector('[data-sheet-trigger="{}"]');
if (!sheet || !backdrop || !trigger || sheet.hasAttribute('data-initialized')) return;
if (!sheet || !backdrop || !trigger) {{
setTimeout(setupSheet, 50);
return;
}}
if (sheet.hasAttribute('data-initialized')) {{
return;
}}
sheet.setAttribute('data-initialized', 'true');
const openSheet = () => {{
if (window.ScrollLock) window.ScrollLock.lock();
sheet.setAttribute('data-state', 'open');
backdrop.setAttribute('data-state', 'open');
sheet.style.pointerEvents = 'auto';
backdrop.style.pointerEvents = 'auto';
const direction = sheet.getAttribute('data-direction');
sheet.classList.remove('translate-x-full', '-translate-x-full', 'translate-y-full', '-translate-y-full');
sheet.classList.add('translate-x-0', 'translate-y-0');
}};
const closeSheet = () => {{
sheet.setAttribute('data-state', 'closed');
backdrop.setAttribute('data-state', 'closed');
sheet.style.pointerEvents = 'none';
backdrop.style.pointerEvents = 'none';
sheet.classList.remove('translate-x-0', 'translate-y-0');
const direction = sheet.getAttribute('data-direction');
sheet.classList.remove('translate-x-0', 'translate-y-0');
if (direction === 'Right') sheet.classList.add('translate-x-full');
else if (direction === 'Left') sheet.classList.add('-translate-x-full');
else if (direction === 'Top') sheet.classList.add('-translate-y-full');
else if (direction === 'Bottom') sheet.classList.add('translate-y-full');
if (window.ScrollLock) window.ScrollLock.unlock(300);
}};
trigger.addEventListener('click', openSheet);
sheet.querySelectorAll('[data-sheet-close]').forEach(btn => btn.addEventListener('click', closeSheet));
const closeButtons = sheet.querySelectorAll('[data-sheet-close]');
closeButtons.forEach(btn => btn.addEventListener('click', closeSheet));
backdrop.addEventListener('click', closeSheet);
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape' && sheet.getAttribute('data-state') === 'open') {{
e.preventDefault();
closeSheet();
}}
}});
}};
setupSheet();
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', setupSheet);
}} else {{
setupSheet();
}}
}})();
"#, target_id_for_script, backdrop_id_for_script, target_id_for_script)}
"#,
target_id_for_script,
backdrop_id_for_script,
target_id_for_script,
)}
</script>
}
}.into_any()
}
/* ========================================================== */
/* ✨ ENUM ✨ */
/* ========================================================== */
#[derive(Clone, Copy, strum::AsRefStr, strum::Display)]
#[allow(dead_code)]
pub enum SheetDirection {
Right, Left, Top, Bottom,
Right,
Left,
Top,
Bottom,
}
impl SheetDirection {
@@ -149,6 +225,7 @@ impl SheetDirection {
SheetDirection::Bottom => "translate-y-full",
}
}
fn initial_position(self) -> &'static str {
match self {
SheetDirection::Right => "top-0 right-0 h-full w-[400px]",

View File

@@ -0,0 +1,52 @@
use leptos::prelude::*;
use tw_merge::*;
use crate::components::hooks::use_random::use_random_id_for;
#[component]
pub fn Shimmer(
/// Controls shimmer visibility (works with any bool signal)
#[prop(into)]
loading: Signal<bool>,
/// Color of the shimmer wave (default: "rgba(255,255,255,0.15)")
#[prop(into, optional)]
shimmer_color: Option<String>,
/// Background color of shimmer blocks (default: "rgba(255,255,255,0.08)")
#[prop(into, optional)]
background_color: Option<String>,
/// Animation duration in seconds (default: 1.5)
#[prop(optional)]
duration: Option<f64>,
/// Fallback border-radius for text elements in px (default: 4)
#[prop(optional)]
fallback_border_radius: Option<f64>,
/// Additional classes
#[prop(into, optional)]
class: String,
/// Children to wrap
children: Children,
) -> impl IntoView {
let shimmer_id = use_random_id_for("Shimmer");
let merged_class = tw_merge!("relative", class);
view! {
<div
id=shimmer_id
class=merged_class
data-name="Shimmer"
data-shimmer-loading=move || loading.get().to_string()
data-shimmer-color=shimmer_color
data-shimmer-bg-color=background_color
data-shimmer-duration=duration.map(|d| d.to_string())
data-shimmer-fallback-radius=fallback_border_radius.map(|r| r.to_string())
>
{children()}
</div>
}
}

View File

@@ -1,69 +1,232 @@
use leptos::prelude::*;
use tw_merge::tw_merge;
use leptos_router::hooks::use_location;
use leptos_ui::{clx, variants, void};
#[derive(Clone, Copy, PartialEq, Eq, Default)]
#[allow(dead_code)]
pub enum SidenavState { #[default] Expanded, Collapsed }
mod components {
use super::*;
clx! {SidenavWrapper, div, "group/sidenav-wrapper has-data-[variant=Inset]:bg-sidenav flex h-full w-full"}
// clx! {SidenavInset, div, "bg-background relative flex w-full flex-1 flex-col md:peer-data-[variant=Inset]:m-2 md:peer-data-[variant=Inset]:ml-0 md:peer-data-[variant=Inset]:rounded-xl md:peer-data-[variant=Inset]:shadow-sm md:peer-data-[variant=Inset]:peer-data-[state=Collapsed]:ml-2"}
clx! {SidenavInset, div, "bg-background relative flex w-full flex-1 flex-col data-[variant=Inset]:rounded-lg data-[variant=Inset]:border data-[variant=Inset]:border-sidenav-border data-[variant=Inset]:shadow-sm data-[variant=Inset]:m-2"}
// * data-[], not group-data-[]
clx! {SidenavInner, div, "flex flex-col w-full h-full bg-sidenav data-[variant=Floating]:rounded-lg data-[variant=Floating]:border data-[variant=Floating]:border-sidenav-border data-[variant=Floating]:shadow-sm"}
clx! {SidenavHeader, div, "flex flex-col gap-2 p-2"}
clx! {SidenavMenu, ul, "flex flex-col gap-1 w-full min-w-0"}
clx! {SidenavMenuSub, ul, "border-sidenav-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5 group-data-[collapsible=Icon]:hidden"}
clx! {SidenavMenuItem, li, "relative group/menu-item"}
clx! {SidenavContent, div, "scrollbar__on_hover", "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=Icon]:overflow-hidden"}
clx! {SidenavGroup, div, "flex relative flex-col p-2 w-full min-w-0"}
clx! {SidenavGroupContent, div, "w-full text-sm"}
clx! {SidenavGroupLabel, div, "text-sidenav-foreground/70 ring-sidenav-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 group-data-[collapsible=Icon]:-mt-8 group-data-[collapsible=Icon]:opacity-0"}
clx! {SidenavFooter, footer, "flex flex-col gap-2 p-2"}
// Button "More"
clx! {DropdownMenuTriggerEllipsis, button, "text-sidenav-foreground ring-sidenav-ring hover:bg-sidenav-accent hover:text-sidenav-accent-foreground peer-hover/menu-button:text-sidenav-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 after:absolute after:-inset-2 md:after:hidden peer-data-[size=sm]/menu-button:top-1 peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 group-data-[collapsible=Icon]:hidden peer-data-[active=true]/menu-button:text-sidenav-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0"}
#[derive(Clone)]
pub struct SidenavContext {
pub state: RwSignal<SidenavState>,
void! {SidenavInput, input,
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50",
"focus-visible:ring-2", // TODO. Port tw_merge to Tailwind V4.
// "focus-visible:ring-[3px]", // TODO. Port tw_merge to Tailwind V4.
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"read-only:bg-muted",
// Specific to Sidenav
"w-full h-8 shadow-none bg-background"
}
}
#[component]
pub fn SidenavWrapper(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let state = RwSignal::new(SidenavState::Expanded);
provide_context(SidenavContext { state });
let class = tw_merge!("flex min-h-screen w-full bg-background", class);
view! { <div class=class>{children()}</div> }
}
pub use components::*;
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
#[component]
pub fn Sidenav(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let ctx = expect_context::<SidenavContext>();
let class_signal = move || tw_merge!(
"hidden md:flex flex-col border-r bg-card transition-all duration-300",
match ctx.state.get() {
SidenavState::Expanded => "w-[var(--sidenav-width)]",
SidenavState::Collapsed => "w-[var(--sidenav-width-icon)]",
},
class.clone()
);
view! { <aside class=class_signal>{children()}</aside> }
}
#[component]
pub fn SidenavInset(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let class = tw_merge!("flex flex-col flex-1 min-w-0", class);
view! { <main class=class>{children()}</main> }
}
#[component] pub fn SidenavHeader(children: Children) -> impl IntoView { view! { <div class="flex flex-col">{children()}</div> } }
#[component] pub fn SidenavContent(children: Children) -> impl IntoView { view! { <div class="flex-1 overflow-auto">{children()}</div> } }
#[component] pub fn SidenavFooter(children: Children) -> impl IntoView { view! { <div class="mt-auto">{children()}</div> } }
#[component] pub fn SidenavGroup(children: Children) -> impl IntoView { view! { <div class="px-2 py-2">{children()}</div> } }
#[component] pub fn SidenavGroupLabel(children: Children) -> impl IntoView { view! { <div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">{children()}</div> } }
#[component] pub fn SidenavGroupContent(children: Children) -> impl IntoView { view! { <div class="space-y-1">{children()}</div> } }
#[component] pub fn SidenavMenu(children: Children) -> impl IntoView { view! { <nav class="grid gap-1">{children()}</nav> } }
#[component] pub fn SidenavMenuItem(children: Children) -> impl IntoView { view! { <div>{children()}</div> } }
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum SidenavMenuButtonVariant { #[default] Default, Outline }
#[component]
pub fn SidenavMenuButton(
pub fn SidenavLink(
children: Children,
#[prop(into, optional)] variant: Signal<SidenavMenuButtonVariant>,
#[prop(into, optional)] class: Signal<String>,
#[prop(into)] href: String,
#[prop(optional, into)] class: String,
) -> impl IntoView {
let class_signal = move || tw_merge!(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
if variant.get() == SidenavMenuButtonVariant::Outline { "border border-input bg-background shadow-xs" } else { "" },
class.get()
let merged_class = tw_merge!(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left outline-hidden ring-sidenav-ring transition-[width,height,padding] focus-visible:ring-2 active:bg-sidenav-accent active:text-sidenav-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidenav=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-[current=page]:bg-sidenav-accent aria-[current=page]:font-semibold aria-[current=page]:text-sidenav-accent-foreground data-[state=open]:hover:bg-sidenav-accent data-[state=open]:hover:text-sidenav-accent-foreground group-data-[collapsible=Icon]:size-8! group-data-[collapsible=Icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 hover:bg-sidenav-accent hover:text-sidenav-accent-foreground h-8 text-sm",
class
);
view! { <button class=class_signal>{children()}</button> }
let location = use_location();
// Check if the link is active based on current path
let href_clone = href.clone();
let is_active = move || {
let path = location.pathname.get();
path == href_clone || path.starts_with(&format!("{}/", href_clone))
};
let aria_current = move || if is_active() { "page" } else { "false" };
view! {
<a data-name="SidenavLink" class=merged_class href=href aria-current=aria_current>
{children()}
</a>
}
}
#[component] pub fn SidenavLink(children: Children, #[prop(into)] href: String) -> impl IntoView {
view! { <a href=href class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-accent">{children()}</a> }
variants! {
SidenavMenuButton {
base: "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidenav-ring transition-[width,height,padding] hover:bg-sidenav-accent hover:text-sidenav-accent-foreground focus-visible:ring-2 active:bg-sidenav-accent active:text-sidenav-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidenav=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-[current=page]:bg-sidenav-accent aria-[current=page]:font-medium aria-[current=page]:text-sidenav-accent-foreground data-[state=open]:hover:bg-sidenav-accent data-[state=open]:hover:text-sidenav-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 group-data-[collapsible=Icon]:size-8! group-data-[collapsible=Icon]:p-0! [&>svg]:stroke-2 aria-[current=page]:[&>svg]:stroke-[2.7]",
variants: {
variant: {
Default: "hover:bg-sidenav-accent hover:text-sidenav-accent-foreground", // Already in base
Outline: "bg-background shadow-[0_0_0_1px_hsl(var(--sidenav-border))] hover:bg-sidenav-accent hover:text-sidenav-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidenav-accent))]",
},
size: {
Default: "h-8 text-sm",
Sm: "h-7 text-xs",
Lg: "h-12",
}
},
component: {
element: button,
support_href: true,
support_aria_current: true
}
}
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, strum::IntoStaticStr)]
pub enum SidenavVariant {
#[default]
Sidenav,
Floating,
Inset,
}
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
pub enum SidenavSide {
#[default]
Left,
Right,
}
#[derive(Default, Clone, Copy, PartialEq, Eq, strum::Display)]
pub enum SidenavCollapsible {
#[default]
Offcanvas,
None,
Icon,
}
#[component]
pub fn Sidenav(
#[prop(into, optional)] class: String,
#[prop(default = SidenavVariant::default())] variant: SidenavVariant,
#[prop(default = SidenavState::default())] data_state: SidenavState,
#[prop(default = SidenavSide::default())] data_side: SidenavSide,
#[prop(default = SidenavCollapsible::default())] data_collapsible: SidenavCollapsible,
children: Children,
) -> impl IntoView {
view! {
{if data_collapsible == SidenavCollapsible::None {
view! {
<aside
data-name="Sidenav"
class=tw_merge!(
"flex flex-col h-full bg-sidenav text-sidenav-foreground w-(--sidenav-width)", class.clone()
)
>
{children()}
</aside>
}
.into_any()
} else {
view! {
<aside
data-name="Sidenav"
data-sidenav=data_state.to_string()
data-side=data_side.to_string()
class="hidden md:block group peer text-sidenav-foreground data-[state=Collapsed]:hidden"
>
// * SidenavGap: This is what handles the sidenav gap on desktop
<div
data-name="SidenavGap"
class=tw_merge!(
"relative w-(--sidenav-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=Offcanvas]:w-0",
"group-data-[side=Right]:rotate-180",
match variant {
SidenavVariant::Sidenav => "group-data-[collapsible=Icon]:w-(--sidenav-width-icon)",
SidenavVariant::Floating | SidenavVariant::Inset =>
"group-data-[collapsible=Icon]:w-[calc(var(--sidenav-width-icon)+(--spacing(4)))]",
}
)
/>
<div
data-name="SidenavContainer"
class=tw_merge!(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidenav-width) transition-[left,right,width] duration-200 ease-linear md:flex",
class,
match data_side {
SidenavSide::Left => "left-0 group-data-[collapsible=Offcanvas]:left-[calc(var(--sidenav-width)*-1)]",
SidenavSide::Right => "right-0 group-data-[collapsible=Offcanvas]:right-[calc(var(--sidenav-width)*-1)]"
},
match variant {
SidenavVariant::Sidenav => "group-data-[collapsible=Icon]:w-(--sidenav-width-icon) group-data-[side=Left]:border-r group-data-[side=Right]:border-l",
SidenavVariant::Floating | SidenavVariant::Inset =>
"p-2 group-data-[collapsible=Icon]:w-[calc(var(--sidenav-width-icon)+(--spacing(4))+2px)]",
},
)
>
// * Act as a Sidenav for the onclick trigger to work with nested Sidenavs.
<SidenavInner attr:data-sidenav="Sidenav" attr:data-variant=variant.to_string()>
{children()}
<SidenavToggleRail />
</SidenavInner>
</div>
</aside>
}
.into_any()
}}
}
}
/* ========================================================== */
/* ✨ FUNCTIONS ✨ */
/* ========================================================== */
#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)]
pub enum SidenavState {
#[default]
Expanded,
Collapsed,
}
const ONCLICK_TRIGGER: &str = "document.querySelector('[data-name=\"Sidenav\"]').setAttribute('data-state', document.querySelector('[data-name=\"Sidenav\"]').getAttribute('data-state') === 'Collapsed' ? 'Expanded' : 'Collapsed')";
#[component]
pub fn SidenavTrigger(children: Children) -> impl IntoView {
view! {
// TODO. Use Button.
<button
onclick=ONCLICK_TRIGGER
data-name="SidenavTrigger"
class="inline-flex gap-2 justify-center items-center -ml-1 text-sm font-medium whitespace-nowrap rounded-md transition-all outline-none disabled:opacity-50 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 aria-invalid:ring-destructive/20 aria-invalid:border-destructive size-7 dark:aria-invalid:ring-destructive/40 dark:hover:bg-accent/50 hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
>
{children()}
</button>
}
}
#[component]
fn SidenavToggleRail() -> impl IntoView {
view! {
<button
data-name="SidenavToggleRail"
aria-label="Toggle Sidenav"
tabindex="-1"
onclick=ONCLICK_TRIGGER
class="hidden absolute inset-y-0 z-20 w-4 transition-all ease-linear -translate-x-1/2 sm:flex group-data-[side=Left]:-right-4 group-data-[side=Right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] in-data-[side=Left]:cursor-w-resize in-data-[side=Right]:cursor-e-resize [[data-side=Left][data-state=Collapsed]_&]:cursor-e-resize [[data-side=Right][data-state=Collapsed]_&]:cursor-w-resize group-data-[collapsible=Offcanvas]:translate-x-0 group-data-[collapsible=Offcanvas]:after:left-full [[data-side=Left][data-collapsible=Offcanvas]_&]:-right-2 [[data-side=Right][data-collapsible=Offcanvas]_&]:-left-0eft-2 hover:after:bg-sidenav-border hover:group-data-[collapsible=Offcanvas]:bg-sidenav"
/>
}
}

View File

@@ -0,0 +1,42 @@
use leptos::prelude::*;
use tailwind_fuse::tw_merge;
#[component]
pub fn Switch(
#[prop(into)] checked: Signal<bool>,
#[prop(into, optional)] on_checked_change: Option<Callback<bool>>,
#[prop(into, optional)] class: String,
#[prop(into, optional)] disabled: Signal<bool>,
) -> impl IntoView {
let checked_val = move || checked.get();
let disabled_val = move || disabled.get();
let track_class = move || tw_merge!(
"inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
if checked_val() { "bg-primary" } else { "bg-input" },
class.clone()
);
let thumb_class = move || tw_merge!(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform",
if checked_val() { "translate-x-4" } else { "translate-x-0" }
);
view! {
<button
type="button"
role="switch"
aria-checked=move || checked_val().to_string()
disabled=disabled_val
class=track_class
on:click=move |e| {
e.prevent_default();
if let Some(cb) = on_checked_change {
cb.run(!checked_val());
}
}
>
<span class=thumb_class />
</button>
}
}

View File

@@ -15,7 +15,6 @@ pub fn Table(children: Children, #[prop(optional, into)] class: String) -> impl
#[component]
pub fn TableCaption(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let _ = class;
let class = tw_merge!("mt-4 text-sm text-muted-foreground", class);
view! { <caption class=class>{children()}</caption> }
}
@@ -56,7 +55,6 @@ pub fn TableCell(children: Children, #[prop(optional, into)] class: String) -> i
#[component]
pub fn TableFooter(children: Children, #[prop(optional, into)] class: String) -> impl IntoView {
let _ = class;
let class = tw_merge!("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", class);
view! { <tfoot class=class>{children()}</tfoot> }
}

View File

@@ -0,0 +1,108 @@
use leptos::context::Provider;
use leptos::prelude::*;
use tw_merge::tw_merge;
#[derive(Clone)]
pub struct TabsContext {
pub active_tab: RwSignal<String>,
}
#[component]
pub fn Tabs(
#[prop(into)] default_value: String,
children: Children,
#[prop(optional, into)] class: String,
) -> impl IntoView {
let active_tab = RwSignal::new(default_value);
let ctx = TabsContext { active_tab };
let merged_class = tw_merge!("w-full", &class);
view! {
<Provider value=ctx>
<div data-name="Tabs" class=merged_class>
{children()}
</div>
</Provider>
}
}
#[component]
pub fn TabsList(
children: Children,
#[prop(optional, into)] class: String,
) -> impl IntoView {
let merged_class = tw_merge!(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
&class
);
view! {
<div data-name="TabsList" class=merged_class>
{children()}
</div>
}
}
#[component]
pub fn TabsTrigger(
#[prop(into)] value: String,
children: Children,
#[prop(optional, into)] class: String,
) -> impl IntoView {
let ctx = expect_context::<TabsContext>();
let v_clone = value.clone();
let is_active = Memo::new(move |_| ctx.active_tab.get() == v_clone);
let merged_class = move || tw_merge!(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer select-none",
if is_active.get() {
"bg-background text-foreground shadow-sm"
} else {
"hover:bg-background/50 hover:text-foreground"
},
&class
);
view! {
<button
data-name="TabsTrigger"
type="button"
class=merged_class
data-state=move || if is_active.get() { "active" } else { "inactive" }
on:click=move |_| ctx.active_tab.set(value.clone())
>
{children()}
</button>
}
}
#[component]
pub fn TabsContent(
#[prop(into)] value: String,
children: Children,
#[prop(optional, into)] class: String,
) -> impl IntoView {
let ctx = expect_context::<TabsContext>();
let v_clone = value.clone();
let is_active = Memo::new(move |_| ctx.active_tab.get() == v_clone);
let merged_class = move || tw_merge!(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
if !is_active.get() { "hidden" } else { "" },
&class
);
view! {
<div
data-name="TabsContent"
class=merged_class
data-state=move || if is_active.get() { "active" } else { "inactive" }
tabindex=move || if is_active.get() { "0" } else { "-1" }
>
{children()}
</div>
}
}

View File

@@ -170,9 +170,10 @@ pub fn Toaster(#[prop(default = SonnerPosition::default())] position: SonnerPosi
<div
class=tw_merge!(
"fixed z-[100] flex gap-3 pointer-events-none w-full sm:w-[400px]",
"left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 px-4 sm:px-0", // Mobile centering fix
if is_bottom { "flex-col-reverse" } else { "flex-col" },
container_class,
"pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)] px-4 sm:px-0"
"pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)]"
)
>
<For

View File

@@ -5,7 +5,8 @@ use leptos::task::spawn_local;
use shared::{AppEvent, GlobalStats, NotificationLevel, Torrent};
use std::collections::HashMap;
use struct_patch::traits::Patch;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use base64::{Engine as _, engine::general_purpose::{URL_SAFE_NO_PAD as BASE64_URL, STANDARD as BASE64}};
use wasm_bindgen::JsCast;
use crate::components::ui::toast::{ToastType, toast};
@@ -24,8 +25,6 @@ pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
toast(msg, variant);
}
pub fn toast_success(message: impl Into<String>) { show_toast(NotificationLevel::Success, message); }
pub fn toast_error(message: impl Into<String>) { show_toast(NotificationLevel::Error, message); }
@@ -54,6 +53,7 @@ pub struct TorrentStore {
pub global_stats: RwSignal<GlobalStats>,
pub user: RwSignal<Option<String>>,
pub selected_torrent: RwSignal<Option<String>>,
pub push_enabled: RwSignal<bool>,
}
pub fn provide_torrent_store() {
@@ -63,12 +63,20 @@ pub fn provide_torrent_store() {
let global_stats = RwSignal::new(GlobalStats::default());
let user = RwSignal::new(Option::<String>::None);
let selected_torrent = RwSignal::new(Option::<String>::None);
let push_enabled = RwSignal::new(false);
let show_browser_notification = crate::utils::notification::use_app_notification();
let store = TorrentStore { torrents, filter, search_query, global_stats, user, selected_torrent };
let store = TorrentStore { torrents, filter, search_query, global_stats, user, selected_torrent, push_enabled };
provide_context(store);
// Initial check for push status
spawn_local(async move {
if let Ok(enabled) = is_push_subscribed().await {
push_enabled.set(enabled);
}
});
let global_stats_for_sse = global_stats;
let torrents_for_sse = torrents;
let show_browser_notification = show_browser_notification.clone();
@@ -79,17 +87,12 @@ pub fn provide_torrent_store() {
let mut disconnect_notified = false;
loop {
log::debug!("SSE: Creating EventSource...");
let es_result = EventSource::new("/api/events");
match es_result {
Ok(mut es) => {
log::debug!("SSE: EventSource created, subscribing...");
if let Ok(mut stream) = es.subscribe("message") {
log::debug!("SSE: Subscribed to message channel");
let mut got_first_message = false;
while let Some(Ok((_, msg))) = stream.next().await {
log::debug!("SSE: Received message");
if !got_first_message {
got_first_message = true;
backoff_ms = 1000;
@@ -101,47 +104,30 @@ pub fn provide_torrent_store() {
}
if let Some(data_str) = msg.data().as_string() {
// Decode Base64
match BASE64.decode(&data_str) {
Ok(bytes) => {
// Deserialize MessagePack
match rmp_serde::from_slice::<AppEvent>(&bytes) {
Ok(event) => {
match event {
AppEvent::FullList(list, _) => {
log::info!("SSE: Received FullList with {} torrents", list.len());
torrents_for_sse.update(|map| {
let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
map.retain(|hash, _| new_hashes.contains(hash));
for new_torrent in list {
map.insert(new_torrent.hash.clone(), new_torrent);
}
});
log::debug!("SSE: torrents map now has {} entries", torrents_for_sse.with(|m| m.len()));
}
AppEvent::Update(patch) => {
let hash_opt = patch.hash.clone();
if let Some(hash) = hash_opt {
torrents_for_sse.update(|map| {
if let Some(t) = map.get_mut(&hash) {
t.apply(patch);
}
});
}
}
AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); }
AppEvent::Notification(n) => {
show_toast(n.level.clone(), n.message.clone());
if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
show_browser_notification("VibeTorrent", &n.message);
}
}
if let Ok(bytes) = BASE64.decode(&data_str) {
if let Ok(event) = rmp_serde::from_slice::<AppEvent>(&bytes) {
match event {
AppEvent::FullList(list, _) => {
torrents_for_sse.update(|map| {
let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
map.retain(|hash, _| new_hashes.contains(hash));
for new_torrent in list { map.insert(new_torrent.hash.clone(), new_torrent); }
});
}
AppEvent::Update(patch) => {
if let Some(hash) = patch.hash.clone() {
torrents_for_sse.update(|map| { if let Some(t) = map.get_mut(&hash) { t.apply(patch); } });
}
}
AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); }
AppEvent::Notification(n) => {
show_toast(n.level.clone(), n.message.clone());
if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
show_browser_notification("VibeTorrent", &n.message);
}
}
Err(e) => log::error!("SSE: Failed to deserialize MessagePack: {}", e),
}
}
Err(e) => log::error!("SSE: Failed to decode Base64: {}", e),
}
}
}
@@ -158,13 +144,106 @@ pub fn provide_torrent_store() {
}
}
}
log::debug!("SSE: Reconnecting in {}ms...", backoff_ms);
gloo_timers::future::TimeoutFuture::new(backoff_ms).await;
backoff_ms = std::cmp::min(backoff_ms * 2, 30000);
}
});
}
pub async fn subscribe_to_push_notifications() {
// ...
pub async fn is_push_subscribed() -> Result<bool, String> {
let window = web_sys::window().ok_or("no window")?;
let navigator = window.navigator();
let sw_container = navigator.service_worker();
let registration = wasm_bindgen_futures::JsFuture::from(sw_container.ready().map_err(|e| format!("{:?}", e))?)
.await
.map_err(|e| format!("{:?}", e))?
.dyn_into::<web_sys::ServiceWorkerRegistration>()
.map_err(|_| "not a registration")?;
let push_manager = registration.push_manager().map_err(|e| format!("{:?}", e))?;
let subscription = wasm_bindgen_futures::JsFuture::from(push_manager.get_subscription().map_err(|e| format!("{:?}", e))?)
.await
.map_err(|e| format!("{:?}", e))?;
Ok(!subscription.is_null())
}
pub async fn subscribe_to_push_notifications() {
let window = web_sys::window().expect("no window");
let navigator = window.navigator();
let sw_container = navigator.service_worker();
let registration = match wasm_bindgen_futures::JsFuture::from(sw_container.ready().expect("sw not ready")).await {
Ok(reg) => reg.dyn_into::<web_sys::ServiceWorkerRegistration>().expect("not a reg"),
Err(e) => { log::error!("SW Ready Error: {:?}", e); return; }
};
// 1. Get Public Key from Backend
let public_key_res: Result<String, _> = shared::server_fns::push::get_public_key().await;
let public_key = match public_key_res {
Ok(key) => key,
Err(e) => { log::error!("Failed to get public key: {:?}", e); return; }
};
// 2. Convert base64 key to Uint8Array
let decoded_key = BASE64_URL.decode(public_key.trim()).expect("invalid public key");
let key_array = js_sys::Uint8Array::from(&decoded_key[..]);
// 3. Prepare Options
let options = web_sys::PushSubscriptionOptionsInit::new();
options.set_user_visible_only(true);
options.set_application_server_key(&key_array.into());
// 4. Subscribe
let push_manager = registration.push_manager().expect("no push manager");
match wasm_bindgen_futures::JsFuture::from(push_manager.subscribe_with_options(&options).expect("subscribe failed")).await {
Ok(subscription) => {
let sub_js = subscription.clone();
// Use JS to extract JSON string representation
let json_str = js_sys::JSON::stringify(&sub_js).expect("stringify failed").as_string().expect("not a string");
let sub_obj: serde_json::Value = serde_json::from_str(&json_str).expect("serde from str failed");
let endpoint = sub_obj["endpoint"].as_str().expect("no endpoint").to_string();
let p256dh = sub_obj["keys"]["p256dh"].as_str().expect("no p256dh").to_string();
let auth = sub_obj["keys"]["auth"].as_str().expect("no auth").to_string();
// 5. Save to Backend
match shared::server_fns::push::subscribe_push(endpoint, p256dh, auth).await {
Ok(_) => {
log::info!("Push subscription saved successfully");
toast_success("Bildirimler aktif edildi");
}
Err(e) => log::error!("Failed to save subscription: {:?}", e),
}
}
Err(e) => log::error!("Subscription Error: {:?}", e),
}
}
pub async fn unsubscribe_from_push_notifications() {
let window = web_sys::window().expect("no window");
let sw_container = window.navigator().service_worker();
let registration = wasm_bindgen_futures::JsFuture::from(sw_container.ready().expect("sw not ready")).await
.unwrap().dyn_into::<web_sys::ServiceWorkerRegistration>().unwrap();
let push_manager = registration.push_manager().unwrap();
if let Ok(sub_future) = push_manager.get_subscription() {
if let Ok(subscription) = wasm_bindgen_futures::JsFuture::from(sub_future).await {
if !subscription.is_null() {
let sub = subscription.dyn_into::<web_sys::PushSubscription>().unwrap();
let endpoint = sub.endpoint();
// 1. Unsubscribe in Browser
let _ = wasm_bindgen_futures::JsFuture::from(sub.unsubscribe().unwrap()).await;
// 2. Remove from Backend
let _ = shared::server_fns::push::unsubscribe_push(endpoint).await;
log::info!("Push subscription removed");
show_toast(NotificationLevel::Info, "Bildirimler kapatıldı");
}
}
}
}

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = "vibetorrent-v2";
const CACHE_NAME = "vibetorrent-v3";
const ASSETS_TO_CACHE = [
"/",
"/index.html",
@@ -51,6 +51,11 @@ self.addEventListener("activate", (event) => {
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
// Skip unsupported schemes (like chrome-extension://)
if (!url.protocol.startsWith("http")) {
return;
}
// Network-first strategy for API calls
if (url.pathname.startsWith("/api/")) {
event.respondWith(
@@ -75,10 +80,12 @@ self.addEventListener("fetch", (event) => {
fetch(event.request)
.then((response) => {
// Cache the latest version of the HTML
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
if (response && response.status === 200) {
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
}
return response;
})
.catch(() => {

372
package-lock.json generated Normal file
View File

@@ -0,0 +1,372 @@
{
"name": "vibetorrent-v3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@tailwindcss/cli": "^4.1.18",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
"node-addon-api": "^7.0.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.6",
"@parcel/watcher-darwin-arm64": "2.5.6",
"@parcel/watcher-darwin-x64": "2.5.6",
"@parcel/watcher-freebsd-x64": "2.5.6",
"@parcel/watcher-linux-arm-glibc": "2.5.6",
"@parcel/watcher-linux-arm-musl": "2.5.6",
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
"@parcel/watcher-linux-arm64-musl": "2.5.6",
"@parcel/watcher-linux-x64-glibc": "2.5.6",
"@parcel/watcher-linux-x64-musl": "2.5.6",
"@parcel/watcher-win32-arm64": "2.5.6",
"@parcel/watcher-win32-ia32": "2.5.6",
"@parcel/watcher-win32-x64": "2.5.6"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/cli": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.18.tgz",
"integrity": "sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==",
"license": "MIT",
"dependencies": {
"@parcel/watcher": "^2.5.1",
"@tailwindcss/node": "4.1.18",
"@tailwindcss/oxide": "4.1.18",
"enhanced-resolve": "^5.18.3",
"mri": "^1.2.0",
"picocolors": "^1.1.1",
"tailwindcss": "4.1.18"
},
"bin": {
"tailwindcss": "dist/index.mjs"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
"integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"enhanced-resolve": "^5.18.3",
"jiti": "^2.6.1",
"lightningcss": "1.30.2",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.1.18"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
"integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.18",
"@tailwindcss/oxide-darwin-arm64": "4.1.18",
"@tailwindcss/oxide-darwin-x64": "4.1.18",
"@tailwindcss/oxide-freebsd-x64": "4.1.18",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
"@tailwindcss/oxide-linux-x64-musl": "4.1.18",
"@tailwindcss/oxide-wasm32-wasi": "4.1.18",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
"integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/enhanced-resolve": {
"version": "5.19.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
"integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.0"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.30.2",
"lightningcss-darwin-arm64": "1.30.2",
"lightningcss-darwin-x64": "1.30.2",
"lightningcss-freebsd-x64": "1.30.2",
"lightningcss-linux-arm-gnueabihf": "1.30.2",
"lightningcss-linux-arm64-gnu": "1.30.2",
"lightningcss-linux-arm64-musl": "1.30.2",
"lightningcss-linux-x64-gnu": "1.30.2",
"lightningcss-linux-x64-musl": "1.30.2",
"lightningcss-win32-arm64-msvc": "1.30.2",
"lightningcss-win32-x64-msvc": "1.30.2"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tailwindcss": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"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"
}
}
}
}

8
package.json Normal file
View File

@@ -0,0 +1,8 @@
{
"type": "module",
"dependencies": {
"@tailwindcss/cli": "^4.1.18",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0"
}
}

View File

@@ -42,6 +42,11 @@ pub struct Torrent {
pub error_message: String,
pub added_date: i64,
pub label: Option<String>,
pub ratio: f64,
pub uploaded: i64,
pub wasted: i64,
pub save_path: String,
pub free_disk_space: i64,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
@@ -121,6 +126,13 @@ pub struct TorrentTracker {
pub url: String,
pub status: String,
pub message: String,
pub is_enabled: bool,
pub group: i64,
pub seeders: i64,
pub peers: i64,
pub downloaded: i64,
pub last_updated: i64,
pub interval: i64,
}
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]

View File

@@ -83,12 +83,19 @@ impl ScgiRequest {
pub async fn send_request(socket_path: &str, request: ScgiRequest) -> Result<Bytes, ScgiError> {
let perform_request = async {
let mut stream = UnixStream::connect(socket_path).await?;
let data = request.encode();
stream.write_all(&data).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
if socket_path.contains(':') {
let mut stream = tokio::net::TcpStream::connect(socket_path).await?;
stream.write_all(&data).await?;
stream.read_to_end(&mut response).await?;
} else {
let mut stream = tokio::net::UnixStream::connect(socket_path).await?;
stream.write_all(&data).await?;
stream.read_to_end(&mut response).await?;
}
Ok::<Vec<u8>, std::io::Error>(response)
};

View File

@@ -20,3 +20,13 @@ pub async fn subscribe_push(
.await
.map_err(|e| ServerFnError::new(format!("Failed to save subscription: {}", e)))
}
#[server(UnsubscribePush, "/api/server_fns")]
pub async fn unsubscribe_push(endpoint: String) -> Result<(), ServerFnError> {
let db_ctx = expect_context::<crate::DbContext>();
db_ctx
.db
.remove_push_subscription(&endpoint)
.await
.map_err(|e| ServerFnError::new(format!("Failed to remove subscription: {}", e)))
}

View File

@@ -193,8 +193,12 @@ pub async fn get_trackers(hash: String) -> Result<Vec<TorrentTracker>, ServerFnE
RpcParam::from(hash.as_str()),
RpcParam::from(""),
RpcParam::from("t.url="),
RpcParam::from("t.activity_date_last="),
RpcParam::from("t.message="),
RpcParam::from("t.is_enabled="),
RpcParam::from("t.group="),
RpcParam::from("t.scrape_complete="),
RpcParam::from("t.scrape_incomplete="),
RpcParam::from("t.scrape_downloaded="),
RpcParam::from("t.normal_interval="),
];
let xml = client
@@ -205,14 +209,23 @@ pub async fn get_trackers(hash: String) -> Result<Vec<TorrentTracker>, ServerFnE
let rows = parse_multicall_response(&xml)
.map_err(|e| ServerFnError::new(format!("Parse error: {}", e)))?;
Ok(rows
let result: Vec<TorrentTracker> = rows
.into_iter()
.map(|row| TorrentTracker {
url: row.get(0).cloned().unwrap_or_default(),
status: "Unknown".to_string(),
message: row.get(2).cloned().unwrap_or_default(),
is_enabled: row.get(1).and_then(|s| s.parse::<i64>().ok()).unwrap_or(0) != 0,
group: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0),
seeders: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0),
peers: row.get(4).and_then(|s| s.parse().ok()).unwrap_or(0),
downloaded: row.get(5).and_then(|s| s.parse().ok()).unwrap_or(0),
interval: row.get(6).and_then(|s| s.parse().ok()).unwrap_or(0),
last_updated: 0,
status: "Unknown".to_string(), // Can derive from activity later, or keep unknown
message: "".to_string(),
})
.collect())
.collect();
Ok(result)
}
#[server(SetFilePriority, "/api/server_fns")]
@@ -225,6 +238,7 @@ pub async fn set_file_priority(
let ctx = expect_context::<crate::ServerContext>();
let client = RtorrentClient::new(&ctx.scgi_socket_path);
// rTorrent f.set_priority takes: target = "HASH:fINDEX", value = priority
let target = format!("{}:f{}", hash, file_index);
let params = vec![
RpcParam::from(target.as_str()),
@@ -232,10 +246,11 @@ pub async fn set_file_priority(
];
client
.call("f.set_priority", &params)
.call("f.priority.set", &params)
.await
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
.map_err(|e| ServerFnError::new(format!("RPC error setting priority: {}", e)))?;
// Notify rTorrent to update its internal priority state
let _ = client
.call("d.update_priorities", &[RpcParam::from(hash.as_str())])
.await;

26
test_rpc.rs Normal file
View File

@@ -0,0 +1,26 @@
use shared::xmlrpc::{RtorrentClient, RpcParam, parse_multicall_response};
#[tokio::main]
async fn main() {
let client = RtorrentClient::new("/tmp/rtorrent.sock");
// Hardcode a known hash from the UI, e.g. "C3315ABFAD70C54505813D1303C1457900C5B795" (from first image)
let hash = "C3315ABFAD70C54505813D1303C1457900C5B795";
let params = vec![
RpcParam::from(hash),
RpcParam::from(""),
RpcParam::from("t.url="),
];
match client.call("t.multicall", &params).await {
Ok(xml) => {
println!("Response XML:\n{}", xml);
match parse_multicall_response(&xml) {
Ok(rows) => println!("Rows ({})", rows.len()),
Err(e) => println!("Parse error: {:?}", e),
}
},
Err(e) => println!("Error: {:?}", e),
}
}

2
ui_config.toml Normal file
View File

@@ -0,0 +1,2 @@
base_color = "neutral"
base_path_components = "backend/src/components"