mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-13 04:57:29 +00:00
feat(desktop): modernize download management
This commit is contained in:
@@ -26,7 +26,7 @@ async fn fetch_metadata(url: String, user_agent: Option<String>) -> Result<Metad
|
|||||||
} else {
|
} else {
|
||||||
builder = builder.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
|
builder = builder.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = builder.build().map_err(|e| e.to_string())?;
|
let client = builder.build().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let mut res = client.head(&url).send().await.map_err(|e| e.to_string())?;
|
let mut res = client.head(&url).send().await.map_err(|e| e.to_string())?;
|
||||||
@@ -44,7 +44,7 @@ async fn fetch_metadata(url: String, user_agent: Option<String>) -> Result<Metad
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if filename.is_empty() {
|
if filename.is_empty() {
|
||||||
if let Ok(parsed) = reqwest::Url::parse(&url) {
|
if let Ok(parsed) = reqwest::Url::parse(&url) {
|
||||||
if let Some(segments) = parsed.path_segments() {
|
if let Some(segments) = parsed.path_segments() {
|
||||||
@@ -85,7 +85,7 @@ async fn fetch_media_metadata(app_handle: tauri::AppHandle, url: String, cookie_
|
|||||||
println!("fetch_media_metadata called for: {}", url);
|
println!("fetch_media_metadata called for: {}", url);
|
||||||
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?;
|
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?;
|
||||||
let ytdlp_path = resource_dir.join("binaries").join("yt-dlp");
|
let ytdlp_path = resource_dir.join("binaries").join("yt-dlp");
|
||||||
|
|
||||||
let mut cmd = AsyncCommand::new(&ytdlp_path);
|
let mut cmd = AsyncCommand::new(&ytdlp_path);
|
||||||
cmd.arg("-J")
|
cmd.arg("-J")
|
||||||
.arg("--no-warnings")
|
.arg("--no-warnings")
|
||||||
@@ -102,14 +102,14 @@ async fn fetch_media_metadata(app_handle: tauri::AppHandle, url: String, cookie_
|
|||||||
cmd.arg("--cookies-from-browser").arg(&browser);
|
cmd.arg("--cookies-from-browser").arg(&browser);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.arg(&url);
|
cmd.arg(&url);
|
||||||
|
|
||||||
// We use tokio AsyncCommand so it doesn't block the async thread
|
// We use tokio AsyncCommand so it doesn't block the async thread
|
||||||
let output = cmd.output()
|
let output = cmd.output()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to execute yt-dlp: {}", e))?;
|
.map_err(|e| format!("Failed to execute yt-dlp: {}", e))?;
|
||||||
|
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
let text = String::from_utf8_lossy(&output.stdout).to_string();
|
let text = String::from_utf8_lossy(&output.stdout).to_string();
|
||||||
Ok(text)
|
Ok(text)
|
||||||
@@ -131,7 +131,7 @@ async fn test_ytdlp(app_handle: tauri::AppHandle) -> Result<String, String> {
|
|||||||
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?;
|
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?;
|
||||||
let ytdlp_path = resource_dir.join("binaries").join("yt-dlp");
|
let ytdlp_path = resource_dir.join("binaries").join("yt-dlp");
|
||||||
println!("Resolved yt-dlp path: {:?}", ytdlp_path);
|
println!("Resolved yt-dlp path: {:?}", ytdlp_path);
|
||||||
|
|
||||||
let output = Command::new(&ytdlp_path)
|
let output = Command::new(&ytdlp_path)
|
||||||
.arg("--version")
|
.arg("--version")
|
||||||
.output()
|
.output()
|
||||||
@@ -139,7 +139,7 @@ async fn test_ytdlp(app_handle: tauri::AppHandle) -> Result<String, String> {
|
|||||||
println!("Failed to execute: {}", e);
|
println!("Failed to execute: {}", e);
|
||||||
format!("Failed to execute yt-dlp: {}", e)
|
format!("Failed to execute yt-dlp: {}", e)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
println!("yt-dlp execution finished with status: {}", output.status);
|
println!("yt-dlp execution finished with status: {}", output.status);
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
@@ -158,7 +158,7 @@ async fn test_aria2c(app_handle: tauri::AppHandle) -> Result<String, String> {
|
|||||||
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?;
|
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?;
|
||||||
let aria2c_path = resource_dir.join("binaries").join("aria2c");
|
let aria2c_path = resource_dir.join("binaries").join("aria2c");
|
||||||
println!("Resolved aria2c path: {:?}", aria2c_path);
|
println!("Resolved aria2c path: {:?}", aria2c_path);
|
||||||
|
|
||||||
let output = Command::new(&aria2c_path)
|
let output = Command::new(&aria2c_path)
|
||||||
.arg("--version")
|
.arg("--version")
|
||||||
.output()
|
.output()
|
||||||
@@ -166,7 +166,7 @@ async fn test_aria2c(app_handle: tauri::AppHandle) -> Result<String, String> {
|
|||||||
println!("Failed to execute: {}", e);
|
println!("Failed to execute: {}", e);
|
||||||
format!("Failed to execute aria2c: {}", e)
|
format!("Failed to execute aria2c: {}", e)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
println!("aria2c execution finished with status: {}", output.status);
|
println!("aria2c execution finished with status: {}", output.status);
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
@@ -187,7 +187,7 @@ async fn test_ffmpeg(app_handle: tauri::AppHandle) -> Result<String, String> {
|
|||||||
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?;
|
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?;
|
||||||
let ffmpeg_path = resource_dir.join("binaries").join("ffmpeg");
|
let ffmpeg_path = resource_dir.join("binaries").join("ffmpeg");
|
||||||
println!("Resolved ffmpeg path: {:?}", ffmpeg_path);
|
println!("Resolved ffmpeg path: {:?}", ffmpeg_path);
|
||||||
|
|
||||||
let output = Command::new(&ffmpeg_path)
|
let output = Command::new(&ffmpeg_path)
|
||||||
.arg("-version")
|
.arg("-version")
|
||||||
.output()
|
.output()
|
||||||
@@ -195,7 +195,7 @@ async fn test_ffmpeg(app_handle: tauri::AppHandle) -> Result<String, String> {
|
|||||||
println!("Failed to execute: {}", e);
|
println!("Failed to execute: {}", e);
|
||||||
format!("Failed to execute ffmpeg: {}", e)
|
format!("Failed to execute ffmpeg: {}", e)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
println!("ffmpeg execution finished with status: {}", output.status);
|
println!("ffmpeg execution finished with status: {}", output.status);
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
@@ -350,7 +350,7 @@ async fn start_download(
|
|||||||
.arg("--check-certificate=false")
|
.arg("--check-certificate=false")
|
||||||
.arg(format!("--dir={}", resolved_dest.to_string_lossy()))
|
.arg(format!("--dir={}", resolved_dest.to_string_lossy()))
|
||||||
.arg(format!("--out={}", filename));
|
.arg(format!("--out={}", filename));
|
||||||
|
|
||||||
if let Some(conn) = connections {
|
if let Some(conn) = connections {
|
||||||
cmd.arg(format!("--split={}", conn));
|
cmd.arg(format!("--split={}", conn));
|
||||||
cmd.arg(format!("--max-connection-per-server={}", conn));
|
cmd.arg(format!("--max-connection-per-server={}", conn));
|
||||||
@@ -388,7 +388,7 @@ async fn start_download(
|
|||||||
cmd.arg(format!("--all-proxy={}", p));
|
cmd.arg(format!("--all-proxy={}", p));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.arg(&url);
|
cmd.arg(&url);
|
||||||
cmd.stdout(Stdio::piped());
|
cmd.stdout(Stdio::piped());
|
||||||
cmd.stderr(Stdio::null());
|
cmd.stderr(Stdio::null());
|
||||||
@@ -396,14 +396,14 @@ async fn start_download(
|
|||||||
// cmd.kill_on_drop(true);
|
// cmd.kill_on_drop(true);
|
||||||
|
|
||||||
let mut child = cmd.spawn().map_err(|e| format!("Failed to spawn aria2c: {}", e))?;
|
let mut child = cmd.spawn().map_err(|e| format!("Failed to spawn aria2c: {}", e))?;
|
||||||
|
|
||||||
let pid = child.id().unwrap_or(0);
|
let pid = child.id().unwrap_or(0);
|
||||||
state.tasks.lock().unwrap().insert(id.clone(), pid);
|
state.tasks.lock().unwrap().insert(id.clone(), pid);
|
||||||
|
|
||||||
let stdout = child.stdout.take().unwrap();
|
let stdout = child.stdout.take().unwrap();
|
||||||
let app_handle_clone = app_handle.clone();
|
let app_handle_clone = app_handle.clone();
|
||||||
let id_clone = id.clone();
|
let id_clone = id.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut reader = BufReader::new(stdout).lines();
|
let mut reader = BufReader::new(stdout).lines();
|
||||||
let percentage_re = Regex::new(r"\((\d+)%\)").unwrap();
|
let percentage_re = Regex::new(r"\((\d+)%\)").unwrap();
|
||||||
@@ -420,17 +420,17 @@ async fn start_download(
|
|||||||
.and_then(|cap| cap.get(1))
|
.and_then(|cap| cap.get(1))
|
||||||
.and_then(|m| m.as_str().parse::<f64>().ok())
|
.and_then(|m| m.as_str().parse::<f64>().ok())
|
||||||
.unwrap_or(0.0) / 100.0;
|
.unwrap_or(0.0) / 100.0;
|
||||||
|
|
||||||
let speed = speed_re.captures(&line)
|
let speed = speed_re.captures(&line)
|
||||||
.and_then(|cap| cap.get(1))
|
.and_then(|cap| cap.get(1))
|
||||||
.map(|m| m.as_str().to_string())
|
.map(|m| m.as_str().to_string())
|
||||||
.unwrap_or_else(|| "-".to_string());
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
|
||||||
let eta = eta_re.captures(&line)
|
let eta = eta_re.captures(&line)
|
||||||
.and_then(|cap| cap.get(1))
|
.and_then(|cap| cap.get(1))
|
||||||
.map(|m| m.as_str().to_string())
|
.map(|m| m.as_str().to_string())
|
||||||
.unwrap_or_else(|| "-".to_string());
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
|
||||||
let _ = app_handle_clone.emit("download-progress", DownloadProgressEvent {
|
let _ = app_handle_clone.emit("download-progress", DownloadProgressEvent {
|
||||||
id: id_clone.clone(),
|
id: id_clone.clone(),
|
||||||
fraction,
|
fraction,
|
||||||
@@ -490,9 +490,15 @@ async fn start_media_download(
|
|||||||
if !resolved_dest.exists() {
|
if !resolved_dest.exists() {
|
||||||
let _ = std::fs::create_dir_all(&resolved_dest);
|
let _ = std::fs::create_dir_all(&resolved_dest);
|
||||||
}
|
}
|
||||||
|
|
||||||
let out_path = resolved_dest.join(&filename);
|
let out_path = resolved_dest.join(&filename);
|
||||||
|
|
||||||
|
let total_tracks: f64 = if let Some(ref format) = format_selector {
|
||||||
|
if format.contains('+') { 2.0 } else { 1.0 }
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
|
||||||
let mut cmd = AsyncCommand::new(&ytdlp_path);
|
let mut cmd = AsyncCommand::new(&ytdlp_path);
|
||||||
cmd.arg("--newline")
|
cmd.arg("--newline")
|
||||||
.arg("--ffmpeg-location")
|
.arg("--ffmpeg-location")
|
||||||
@@ -502,7 +508,7 @@ async fn start_media_download(
|
|||||||
.arg("--retries").arg("3")
|
.arg("--retries").arg("3")
|
||||||
.arg("--extractor-retries").arg("3")
|
.arg("--extractor-retries").arg("3")
|
||||||
.arg("-o").arg(out_path.to_string_lossy().to_string());
|
.arg("-o").arg(out_path.to_string_lossy().to_string());
|
||||||
|
|
||||||
if let Some(format) = format_selector {
|
if let Some(format) = format_selector {
|
||||||
cmd.arg("-f").arg(format);
|
cmd.arg("-f").arg(format);
|
||||||
// If the filename implies an audio format, use it as audio output
|
// If the filename implies an audio format, use it as audio output
|
||||||
@@ -523,7 +529,7 @@ async fn start_media_download(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.arg(&url);
|
cmd.arg(&url);
|
||||||
cmd.stdout(Stdio::piped());
|
cmd.stdout(Stdio::piped());
|
||||||
cmd.stderr(Stdio::piped()); // Also pipe stderr for better error reporting
|
cmd.stderr(Stdio::piped()); // Also pipe stderr for better error reporting
|
||||||
@@ -535,14 +541,17 @@ async fn start_media_download(
|
|||||||
let stdout = child.stdout.take().unwrap();
|
let stdout = child.stdout.take().unwrap();
|
||||||
let app_handle_clone = app_handle.clone();
|
let app_handle_clone = app_handle.clone();
|
||||||
let id_clone = id.clone();
|
let id_clone = id.clone();
|
||||||
|
|
||||||
// yt-dlp parsing regex
|
// yt-dlp parsing regex
|
||||||
let pct_re = Regex::new(r"\[download\]\s+(\d+(?:\.\d+)?)%").unwrap();
|
let pct_re = Regex::new(r"\[download\]\s+(\d+(?:\.\d+)?)%").unwrap();
|
||||||
let spd_re = Regex::new(r"at\s+([^\s]+)").unwrap();
|
let spd_re = Regex::new(r"at\s+([^\s]+)").unwrap();
|
||||||
let eta_re = Regex::new(r"ETA\s+([^\s]+)").unwrap();
|
let eta_re = Regex::new(r"ETA\s+([^\s]+)").unwrap();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut reader = BufReader::new(stdout).lines();
|
let mut reader = BufReader::new(stdout).lines();
|
||||||
|
let mut current_track: f64 = 0.0;
|
||||||
|
let mut last_fraction: f64 = 0.0;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
line_result = reader.next_line() => {
|
line_result = reader.next_line() => {
|
||||||
@@ -553,20 +562,27 @@ async fn start_media_download(
|
|||||||
.and_then(|cap| cap.get(1))
|
.and_then(|cap| cap.get(1))
|
||||||
.and_then(|m| m.as_str().parse::<f64>().ok())
|
.and_then(|m| m.as_str().parse::<f64>().ok())
|
||||||
.unwrap_or(0.0) / 100.0;
|
.unwrap_or(0.0) / 100.0;
|
||||||
|
|
||||||
|
if fraction < last_fraction && (last_fraction - fraction) > 0.5 {
|
||||||
|
current_track += 1.0;
|
||||||
|
}
|
||||||
|
last_fraction = fraction;
|
||||||
|
|
||||||
|
let overall_fraction = ((current_track + fraction) / total_tracks).min(1.0);
|
||||||
|
|
||||||
let speed = spd_re.captures(&line)
|
let speed = spd_re.captures(&line)
|
||||||
.and_then(|cap| cap.get(1))
|
.and_then(|cap| cap.get(1))
|
||||||
.map(|m| m.as_str().to_string())
|
.map(|m| m.as_str().to_string())
|
||||||
.unwrap_or_else(|| "-".to_string());
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
|
||||||
let eta = eta_re.captures(&line)
|
let eta = eta_re.captures(&line)
|
||||||
.and_then(|cap| cap.get(1))
|
.and_then(|cap| cap.get(1))
|
||||||
.map(|m| m.as_str().to_string())
|
.map(|m| m.as_str().to_string())
|
||||||
.unwrap_or_else(|| "-".to_string());
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
|
||||||
let _ = app_handle_clone.emit("download-progress", DownloadProgressEvent {
|
let _ = app_handle_clone.emit("download-progress", DownloadProgressEvent {
|
||||||
id: id_clone.clone(),
|
id: id_clone.clone(),
|
||||||
fraction,
|
fraction: overall_fraction,
|
||||||
speed,
|
speed,
|
||||||
eta,
|
eta,
|
||||||
});
|
});
|
||||||
@@ -619,7 +635,7 @@ async fn pause_download(state: tauri::State<'_, AppState>, id: String) -> Result
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn update_dock_badge(app_handle: tauri::AppHandle, count: i32) {
|
fn update_dock_badge(_app_handle: tauri::AppHandle, count: i32) {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
let label = if count > 0 { count.to_string() } else { "".to_string() };
|
let label = if count > 0 { count.to_string() } else { "".to_string() };
|
||||||
@@ -661,7 +677,7 @@ fn get_free_space(app_handle: tauri::AppHandle, path: String) -> Result<String,
|
|||||||
use sysinfo::Disks;
|
use sysinfo::Disks;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
let disks = Disks::new_with_refreshed_list();
|
let disks = Disks::new_with_refreshed_list();
|
||||||
|
|
||||||
let mut resolved_dest = std::path::PathBuf::from(&path);
|
let mut resolved_dest = std::path::PathBuf::from(&path);
|
||||||
if path.starts_with("~/") {
|
if path.starts_with("~/") {
|
||||||
if let Ok(home) = app_handle.path().home_dir() {
|
if let Ok(home) = app_handle.path().home_dir() {
|
||||||
@@ -672,11 +688,11 @@ fn get_free_space(app_handle: tauri::AppHandle, path: String) -> Result<String,
|
|||||||
resolved_dest = home;
|
resolved_dest = home;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the disk that the path is mounted on
|
// Find the disk that the path is mounted on
|
||||||
let mut best_match: Option<&sysinfo::Disk> = None;
|
let mut best_match: Option<&sysinfo::Disk> = None;
|
||||||
let mut max_match_len = 0;
|
let mut max_match_len = 0;
|
||||||
|
|
||||||
for disk in disks.list() {
|
for disk in disks.list() {
|
||||||
let mount_point = disk.mount_point();
|
let mount_point = disk.mount_point();
|
||||||
if resolved_dest.starts_with(mount_point) {
|
if resolved_dest.starts_with(mount_point) {
|
||||||
@@ -687,7 +703,7 @@ fn get_free_space(app_handle: tauri::AppHandle, path: String) -> Result<String,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(disk) = best_match {
|
if let Some(disk) = best_match {
|
||||||
let bytes = disk.available_space();
|
let bytes = disk.available_space();
|
||||||
let size_str = if bytes < 1024 * 1024 {
|
let size_str = if bytes < 1024 * 1024 {
|
||||||
@@ -713,7 +729,7 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
greet, test_ytdlp, test_aria2c, test_ffmpeg, open_file, show_in_folder,
|
greet, test_ytdlp, test_aria2c, test_ffmpeg, open_file, show_in_folder,
|
||||||
start_download, start_media_download, pause_download, fetch_metadata, fetch_media_metadata, update_dock_badge, set_prevent_sleep, get_free_space
|
start_download, start_media_download, pause_download, fetch_metadata, fetch_media_metadata, update_dock_badge, set_prevent_sleep, get_free_space
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { Sidebar, SidebarFilter } from "./components/Sidebar";
|
import { Sidebar, SidebarFilter } from "./components/Sidebar";
|
||||||
import { DownloadTable } from "./components/DownloadTable";
|
import { DownloadTable } from "./components/DownloadTable";
|
||||||
import { AddDownloadsModal } from "./components/AddDownloadsModal";
|
import { AddDownloadsModal } from "./components/AddDownloadsModal";
|
||||||
import { SettingsModal } from "./components/SettingsModal";
|
import SettingsView from "./components/SettingsView";
|
||||||
import { PropertiesModal } from "./components/PropertiesModal";
|
import { PropertiesModal } from "./components/PropertiesModal";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { useDownloadStore } from "./store/useDownloadStore";
|
import { useDownloadStore } from "./store/useDownloadStore";
|
||||||
@@ -14,6 +14,7 @@ function App() {
|
|||||||
const updateDownload = useDownloadStore(state => state.updateDownload);
|
const updateDownload = useDownloadStore(state => state.updateDownload);
|
||||||
const theme = useSettingsStore(state => state.theme);
|
const theme = useSettingsStore(state => state.theme);
|
||||||
const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible);
|
const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible);
|
||||||
|
const activeView = useSettingsStore(state => state.activeView);
|
||||||
const appFontSize = useSettingsStore(state => state.appFontSize);
|
const appFontSize = useSettingsStore(state => state.appFontSize);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -95,10 +96,13 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen bg-main-bg text-text-primary overflow-hidden">
|
<div className="flex h-screen w-screen bg-main-bg text-text-primary overflow-hidden">
|
||||||
{isSidebarVisible && <Sidebar selectedFilter={filter} onSelectFilter={setFilter} />}
|
{isSidebarVisible && <Sidebar selectedFilter={filter} onSelectFilter={(f) => { setFilter(f); useSettingsStore.getState().setActiveView('downloads'); }} />}
|
||||||
<DownloadTable filter={filter} />
|
{activeView === 'downloads' ? (
|
||||||
|
<DownloadTable filter={filter} />
|
||||||
|
) : (
|
||||||
|
<SettingsView />
|
||||||
|
)}
|
||||||
<AddDownloadsModal />
|
<AddDownloadsModal />
|
||||||
<SettingsModal />
|
|
||||||
<PropertiesModal />
|
<PropertiesModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useDownloadStore } from '../store/useDownloadStore';
|
import { useDownloadStore } from '../store/useDownloadStore';
|
||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
import { X, FolderPlus, Settings, Shield, Globe, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, CheckCircle2, Play, ChevronDown, ChevronRight, Video } from 'lucide-react';
|
import { DownloadCategory } from '../store/useDownloadStore';
|
||||||
|
import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music } from 'lucide-react';
|
||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
function determineCategory(fileName: string): DownloadCategory {
|
||||||
|
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||||
|
if (['mp4', 'mkv', 'webm', 'avi', 'mov', 'flv'].includes(ext)) return 'Video';
|
||||||
|
if (['mp3', 'm4a', 'wav', 'flac', 'ogg', 'aac'].includes(ext)) return 'Audio';
|
||||||
|
if (['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf'].includes(ext)) return 'Documents';
|
||||||
|
if (['exe', 'dmg', 'pkg', 'app', 'apk', 'deb', 'rpm'].includes(ext)) return 'Apps';
|
||||||
|
if (['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'tiff'].includes(ext)) return 'Images';
|
||||||
|
if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz'].includes(ext)) return 'Archives';
|
||||||
|
return 'Other';
|
||||||
|
}
|
||||||
|
|
||||||
interface RawMediaFormat {
|
interface RawMediaFormat {
|
||||||
format_id?: string;
|
format_id?: string;
|
||||||
ext?: string;
|
ext?: string;
|
||||||
@@ -17,6 +29,17 @@ interface RawMediaFormat {
|
|||||||
filesize_approx?: number;
|
filesize_approx?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ParsedDownloadItem {
|
||||||
|
url: string;
|
||||||
|
file: string;
|
||||||
|
size?: string;
|
||||||
|
sizeBytes?: number;
|
||||||
|
status?: string;
|
||||||
|
isMedia?: boolean;
|
||||||
|
formats?: { name: string; selector: string; ext: string; detail: string; type: string; bytes: number }[];
|
||||||
|
selectedFormat?: number;
|
||||||
|
}
|
||||||
|
|
||||||
const isVideo = (f: RawMediaFormat) => {
|
const isVideo = (f: RawMediaFormat) => {
|
||||||
const vcodec = f.vcodec?.toLowerCase();
|
const vcodec = f.vcodec?.toLowerCase();
|
||||||
return vcodec && vcodec !== 'none';
|
return vcodec && vcodec !== 'none';
|
||||||
@@ -32,7 +55,7 @@ const formatSize = (f: RawMediaFormat) => f.filesize ?? f.filesize_approx ?? 0;
|
|||||||
|
|
||||||
const matchesHeight = (f: RawMediaFormat, height: number | null) => {
|
const matchesHeight = (f: RawMediaFormat, height: number | null) => {
|
||||||
if (height === null) return true;
|
if (height === null) return true;
|
||||||
|
|
||||||
const note = f.format_note || "";
|
const note = f.format_note || "";
|
||||||
if (height === 2160 && (note.includes("2160p") || note.toLowerCase().includes("4k"))) return true;
|
if (height === 2160 && (note.includes("2160p") || note.toLowerCase().includes("4k"))) return true;
|
||||||
if (height === 1440 && note.includes("1440p")) return true;
|
if (height === 1440 && note.includes("1440p")) return true;
|
||||||
@@ -137,7 +160,7 @@ const parseMediaFormats = (jsonStr: string) => {
|
|||||||
{ h: 360, name: "360p" }
|
{ h: 360, name: "360p" }
|
||||||
];
|
];
|
||||||
|
|
||||||
const availableResolutions = standardResolutions.filter(res =>
|
const availableResolutions = standardResolutions.filter(res =>
|
||||||
rawFormats.some(f => isVideo(f) && matchesHeight(f, res.h))
|
rawFormats.some(f => isVideo(f) && matchesHeight(f, res.h))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -153,7 +176,7 @@ const parseMediaFormats = (jsonStr: string) => {
|
|||||||
if (!hasVideoFormat(rawFormats, q.h, c.ext)) continue;
|
if (!hasVideoFormat(rawFormats, q.h, c.ext)) continue;
|
||||||
const est = estimatedVideoBytes(rawFormats, q.h, c.ext);
|
const est = estimatedVideoBytes(rawFormats, q.h, c.ext);
|
||||||
const filter = q.h ? `[height<=${q.h}]` : '';
|
const filter = q.h ? `[height<=${q.h}]` : '';
|
||||||
|
|
||||||
let selector = `bestvideo${filter}+bestaudio/best${filter}`;
|
let selector = `bestvideo${filter}+bestaudio/best${filter}`;
|
||||||
if (c.ext === 'mp4') {
|
if (c.ext === 'mp4') {
|
||||||
selector = `bestvideo${filter}[ext=mp4]+bestaudio[ext=m4a]/best${filter}[ext=mp4]/bestvideo${filter}+bestaudio/best${filter}`;
|
selector = `bestvideo${filter}[ext=mp4]+bestaudio[ext=m4a]/best${filter}[ext=mp4]/bestvideo${filter}+bestaudio/best${filter}`;
|
||||||
@@ -214,35 +237,36 @@ const parseMediaFormats = (jsonStr: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MEDIA_DOMAINS = ['youtube.com', 'youtu.be', 'twitter.com', 'x.com', 'twitch.tv', 'vimeo.com', 'instagram.com', 'tiktok.com', 'reddit.com', 'soundcloud.com', 'facebook.com'];
|
||||||
|
const isMediaUrl = (url: string) => {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return MEDIA_DOMAINS.some(d => u.hostname.includes(d));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export const AddDownloadsModal = () => {
|
export const AddDownloadsModal = () => {
|
||||||
const { isAddModalOpen, toggleAddModal, addDownload } = useDownloadStore();
|
const { isAddModalOpen, toggleAddModal, addDownload } = useDownloadStore();
|
||||||
const { defaultDownloadPath } = useSettingsStore();
|
const { defaultDownloadPath } = useSettingsStore();
|
||||||
|
|
||||||
const [urls, setUrls] = useState('');
|
const [urls, setUrls] = useState('');
|
||||||
const [extractMedia, setExtractMedia] = useState(false);
|
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
|
||||||
const [parsedItems, setParsedItems] = useState<{
|
const [parsedItems, setParsedItems] = useState<ParsedDownloadItem[]>([]);
|
||||||
url: string,
|
|
||||||
file: string,
|
|
||||||
size?: string,
|
|
||||||
sizeBytes?: number,
|
|
||||||
status?: string,
|
|
||||||
isMedia?: boolean,
|
|
||||||
formats?: { name: string, selector: string, ext: string, detail: string, type: string, bytes: number }[],
|
|
||||||
selectedFormat?: number
|
|
||||||
}[]>([]);
|
|
||||||
|
|
||||||
// Right Form
|
// Right Form
|
||||||
const [saveLocation, setSaveLocation] = useState(defaultDownloadPath);
|
const [saveLocation, setSaveLocation] = useState(defaultDownloadPath);
|
||||||
const [connections, setConnections] = useState(16);
|
const [connections, setConnections] = useState(16);
|
||||||
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
|
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
|
||||||
const [speedLimit, setSpeedLimit] = useState('1024');
|
const [speedLimit, setSpeedLimit] = useState('1024');
|
||||||
const [freeSpace, setFreeSpace] = useState('Unknown');
|
const [freeSpace, setFreeSpace] = useState('Unknown');
|
||||||
|
|
||||||
const [useAuth, setUseAuth] = useState(false);
|
const [useAuth, setUseAuth] = useState(false);
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
const [advancedExpanded, setAdvancedExpanded] = useState(false);
|
const [advancedExpanded, setAdvancedExpanded] = useState(false);
|
||||||
const [checksumEnabled, setChecksumEnabled] = useState(false);
|
const [checksumEnabled, setChecksumEnabled] = useState(false);
|
||||||
const [checksumAlgo, setChecksumAlgo] = useState('SHA-256');
|
const [checksumAlgo, setChecksumAlgo] = useState('SHA-256');
|
||||||
@@ -256,7 +280,7 @@ export const AddDownloadsModal = () => {
|
|||||||
setSaveLocation(defaultDownloadPath);
|
setSaveLocation(defaultDownloadPath);
|
||||||
setUrls('');
|
setUrls('');
|
||||||
setParsedItems([]);
|
setParsedItems([]);
|
||||||
setExtractMedia(false);
|
setSelectedItemIndex(null);
|
||||||
}
|
}
|
||||||
}, [isAddModalOpen, defaultDownloadPath]);
|
}, [isAddModalOpen, defaultDownloadPath]);
|
||||||
|
|
||||||
@@ -270,35 +294,42 @@ export const AddDownloadsModal = () => {
|
|||||||
// Metadata parser
|
// Metadata parser
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const lines = urls.split('\n').map(u => u.trim()).filter(u => u.length > 0);
|
const lines = urls.split('\n').map(u => u.trim()).filter(u => u.length > 0);
|
||||||
|
|
||||||
// Immediately display items in loading state
|
// Immediately display items in loading state
|
||||||
const initialItems = lines.map(url => {
|
const initialItems: ParsedDownloadItem[] = lines.map(url => {
|
||||||
let fallbackFile = 'URL';
|
let fallbackFile = 'URL';
|
||||||
try { fallbackFile = new URL(url).pathname.split('/').pop() || 'download'; } catch {}
|
try { fallbackFile = new URL(url).pathname.split('/').pop() || 'download'; } catch {}
|
||||||
return { url, file: fallbackFile, size: '-', status: 'Loading' };
|
return { url, file: fallbackFile, size: '-', status: 'Loading', isMedia: isMediaUrl(url) };
|
||||||
});
|
});
|
||||||
setParsedItems(initialItems);
|
setParsedItems(initialItems);
|
||||||
|
|
||||||
if (lines.length === 0) return;
|
if (lines.length === 0) {
|
||||||
|
setSelectedItemIndex(null);
|
||||||
|
return;
|
||||||
|
} else if (selectedItemIndex === null || selectedItemIndex >= lines.length) {
|
||||||
|
setSelectedItemIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
const updatedItems = [...initialItems];
|
const updatedItems = [...initialItems];
|
||||||
|
let firstReadyIndex: number | null = null;
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const url = lines[i];
|
const url = lines[i];
|
||||||
try {
|
try {
|
||||||
new URL(url);
|
new URL(url);
|
||||||
if (extractMedia) {
|
if (isMediaUrl(url)) {
|
||||||
const { mediaCookieSource } = useSettingsStore.getState();
|
const { mediaCookieSource } = useSettingsStore.getState();
|
||||||
const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null;
|
const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null;
|
||||||
|
|
||||||
const jsonStr = await invoke<string>('fetch_media_metadata', { url, cookieBrowser: browserArg });
|
const jsonStr = await invoke<string>('fetch_media_metadata', { url, cookieBrowser: browserArg });
|
||||||
const mediaData = parseMediaFormats(jsonStr);
|
const mediaData = parseMediaFormats(jsonStr);
|
||||||
if (mediaData && mediaData.formats.length > 0) {
|
if (mediaData && mediaData.formats.length > 0) {
|
||||||
updatedItems[i] = {
|
updatedItems[i] = {
|
||||||
url,
|
url,
|
||||||
file: `${mediaData.title}.${mediaData.formats[0].ext}`,
|
file: `${mediaData.title}.${mediaData.formats[0].ext}`,
|
||||||
size: mediaData.formats[0].detail || 'Unknown (Media)',
|
size: mediaData.formats[0].detail || 'Unknown (Media)',
|
||||||
sizeBytes: mediaData.formats[0].bytes,
|
sizeBytes: mediaData.formats[0].bytes,
|
||||||
status: 'Ready',
|
status: 'Ready',
|
||||||
isMedia: true,
|
isMedia: true,
|
||||||
formats: mediaData.formats,
|
formats: mediaData.formats,
|
||||||
@@ -311,17 +342,22 @@ export const AddDownloadsModal = () => {
|
|||||||
const meta = await invoke<{filename: string, size: string, size_bytes: number}>('fetch_metadata', { url });
|
const meta = await invoke<{filename: string, size: string, size_bytes: number}>('fetch_metadata', { url });
|
||||||
updatedItems[i] = { url, file: meta.filename, size: meta.size, sizeBytes: meta.size_bytes, status: 'Ready' };
|
updatedItems[i] = { url, file: meta.filename, size: meta.size, sizeBytes: meta.size_bytes, status: 'Ready' };
|
||||||
}
|
}
|
||||||
|
if (firstReadyIndex === null) firstReadyIndex = i;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Meta fetch failed", e);
|
console.error("Meta fetch failed", e);
|
||||||
updatedItems[i] = { ...updatedItems[i], size: 'Unknown', sizeBytes: 0, status: 'Error' };
|
updatedItems[i] = { ...updatedItems[i], size: 'Unknown', sizeBytes: 0, status: 'Error' };
|
||||||
}
|
}
|
||||||
setParsedItems([...updatedItems]);
|
setParsedItems([...updatedItems]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (firstReadyIndex !== null) {
|
||||||
|
setSelectedItemIndex(firstReadyIndex);
|
||||||
|
}
|
||||||
}, 400);
|
}, 400);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [urls, extractMedia]); // Re-fetch if extractMedia toggles
|
}, [urls]); // Re-fetch only on urls change
|
||||||
|
|
||||||
if (!isAddModalOpen) return null;
|
if (!isAddModalOpen) return null;
|
||||||
|
|
||||||
const handleBrowse = async () => {
|
const handleBrowse = async () => {
|
||||||
@@ -364,7 +400,7 @@ export const AddDownloadsModal = () => {
|
|||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
let finalFile = item.file;
|
let finalFile = item.file;
|
||||||
let formatSelector = undefined;
|
let formatSelector = undefined;
|
||||||
|
|
||||||
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
|
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
|
||||||
const selectedFormat = item.formats[item.selectedFormat];
|
const selectedFormat = item.formats[item.selectedFormat];
|
||||||
formatSelector = selectedFormat.selector;
|
formatSelector = selectedFormat.selector;
|
||||||
@@ -378,7 +414,7 @@ export const AddDownloadsModal = () => {
|
|||||||
url: item.url,
|
url: item.url,
|
||||||
fileName: finalFile,
|
fileName: finalFile,
|
||||||
status: startImmediately ? 'queued' : 'paused',
|
status: startImmediately ? 'queued' : 'paused',
|
||||||
category: item.isMedia ? 'Video' : 'Other',
|
category: determineCategory(finalFile),
|
||||||
dateAdded: new Date().toISOString(),
|
dateAdded: new Date().toISOString(),
|
||||||
connections: Number(connections),
|
connections: Number(connections),
|
||||||
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
|
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
|
||||||
@@ -407,8 +443,8 @@ export const AddDownloadsModal = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const requiredBytes = parsedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
|
const requiredBytes = parsedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
|
||||||
const requiredStr = requiredBytes > 0
|
const requiredStr = requiredBytes > 0
|
||||||
? (requiredBytes < 1024 * 1024 ? `${(requiredBytes / 1024).toFixed(1)} KB`
|
? (requiredBytes < 1024 * 1024 ? `${(requiredBytes / 1024).toFixed(1)} KB`
|
||||||
: requiredBytes < 1024 * 1024 * 1024 ? `${(requiredBytes / 1024 / 1024).toFixed(1)} MB`
|
: requiredBytes < 1024 * 1024 * 1024 ? `${(requiredBytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
: `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`)
|
: `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`)
|
||||||
: 'Unknown';
|
: 'Unknown';
|
||||||
@@ -416,32 +452,22 @@ export const AddDownloadsModal = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md">
|
||||||
<div className="w-[900px] h-[650px] bg-bg-modal border border-border-modal rounded-xl shadow-2xl flex flex-col overflow-hidden text-sm">
|
<div className="w-[900px] h-[650px] bg-bg-modal border border-border-modal rounded-xl shadow-2xl flex flex-col overflow-hidden text-sm">
|
||||||
|
|
||||||
{/* Main Content Split */}
|
{/* Main Content Split */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
|
||||||
{/* Left Column: URLs and Preview */}
|
{/* Left Column: URLs and Preview */}
|
||||||
<div className="w-[55%] border-r border-border-modal flex flex-col bg-main-bg/50">
|
<div className="w-[55%] border-r border-border-modal flex flex-col bg-main-bg/50">
|
||||||
<div className="p-5 flex-1 flex flex-col gap-5">
|
<div className="p-5 flex-1 flex flex-col gap-5">
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 text-text-primary font-semibold">
|
<div className="flex items-center gap-2 text-text-primary font-semibold">
|
||||||
<Link size={16} className="text-blue-500" />
|
<Link size={16} className="text-blue-500" />
|
||||||
Download Links
|
Download Links
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-xs text-text-primary font-medium bg-item-hover px-2 py-1 rounded-md border border-border-modal cursor-pointer hover:bg-item-hover/80 transition-colors">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={extractMedia}
|
|
||||||
onChange={e => setExtractMedia(e.target.checked)}
|
|
||||||
className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20"
|
|
||||||
/>
|
|
||||||
<Video size={14} className="text-purple-500" />
|
|
||||||
Extract Media
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full h-32 bg-bg-input/80 border border-border-modal rounded-lg p-3 text-[13px] text-text-primary focus:outline-none focus:border-blue-500 resize-none font-mono shadow-inner transition-colors"
|
className="w-full h-32 bg-bg-input/80 border border-border-modal rounded-lg p-3 text-[13px] text-text-primary focus:outline-none focus:border-blue-500 resize-none font-mono shadow-inner transition-colors"
|
||||||
placeholder="Paste HTTP, HTTPS, FTP, or SFTP URLs here..."
|
placeholder="Paste HTTP, HTTPS, FTP, or SFTP URLs here..."
|
||||||
value={urls}
|
value={urls}
|
||||||
@@ -480,7 +506,15 @@ export const AddDownloadsModal = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
parsedItems.map((item, i) => (
|
parsedItems.map((item, i) => (
|
||||||
<div key={i} className="flex flex-col text-xs px-2 py-1.5 hover:bg-item-hover rounded-md transition-colors group">
|
<div
|
||||||
|
key={i}
|
||||||
|
onClick={() => setSelectedItemIndex(i)}
|
||||||
|
className={`flex flex-col text-xs px-2 py-2 cursor-pointer rounded-md transition-all group ${
|
||||||
|
selectedItemIndex === i
|
||||||
|
? 'bg-blue-500/10 border border-blue-500/30 shadow-sm'
|
||||||
|
: 'hover:bg-item-hover border border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="flex items-center w-full">
|
<div className="flex items-center w-full">
|
||||||
<div className="flex-[2] text-text-primary font-medium truncate pr-2" title={item.file}>{item.file}</div>
|
<div className="flex-[2] text-text-primary font-medium truncate pr-2" title={item.file}>{item.file}</div>
|
||||||
<div className={`flex-1 font-mono ${item.status === 'Loading' ? 'text-text-muted/50' : 'text-text-muted'}`}>{item.size || 'Unknown'}</div>
|
<div className={`flex-1 font-mono ${item.status === 'Loading' ? 'text-text-muted/50' : 'text-text-muted'}`}>{item.size || 'Unknown'}</div>
|
||||||
@@ -494,26 +528,6 @@ export const AddDownloadsModal = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{item.isMedia && item.formats && (
|
|
||||||
<div className="mt-2 pl-2">
|
|
||||||
<select
|
|
||||||
className="w-full bg-bg-input border border-border-modal rounded px-2 py-1 text-xs text-text-primary focus:outline-none focus:border-purple-500"
|
|
||||||
value={item.selectedFormat}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newItems = [...parsedItems];
|
|
||||||
const selIdx = parseInt(e.target.value, 10);
|
|
||||||
newItems[i].selectedFormat = selIdx;
|
|
||||||
newItems[i].size = newItems[i].formats?.[selIdx].detail || 'Unknown';
|
|
||||||
newItems[i].sizeBytes = newItems[i].formats?.[selIdx].bytes || 0;
|
|
||||||
setParsedItems(newItems);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.formats.map((f, idx) => (
|
|
||||||
<option key={idx} value={idx}>{f.name} {f.detail ? `(${f.detail})` : ''}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -527,20 +541,79 @@ export const AddDownloadsModal = () => {
|
|||||||
{/* Right Column: Settings */}
|
{/* Right Column: Settings */}
|
||||||
<div className="w-[45%] flex flex-col overflow-y-auto bg-bg-modal">
|
<div className="w-[45%] flex flex-col overflow-y-auto bg-bg-modal">
|
||||||
<div className="p-6 space-y-7">
|
<div className="p-6 space-y-7">
|
||||||
|
|
||||||
|
{/* Media Format (Dynamic) */}
|
||||||
|
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
|
||||||
|
<section className="bg-gradient-to-br from-purple-500/5 to-blue-500/5 border border-purple-500/20 rounded-xl p-4 shadow-sm relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 right-0 p-2 opacity-10">
|
||||||
|
<Video size={48} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-text-primary mb-3 relative z-10">
|
||||||
|
<Video size={16} className="text-purple-500" /> Media Format
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{parsedItems[selectedItemIndex].status === 'Loading' ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-6 gap-3 relative z-10">
|
||||||
|
<RefreshCw size={24} className="animate-spin text-purple-500" />
|
||||||
|
<span className="text-xs text-text-muted font-medium animate-pulse">Fetching media streams...</span>
|
||||||
|
</div>
|
||||||
|
) : parsedItems[selectedItemIndex].formats ? (
|
||||||
|
<div className="space-y-3 relative z-10">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-wider text-text-muted">Available Streams</label>
|
||||||
|
<div className="flex flex-col gap-1 max-h-48 overflow-y-auto pr-1">
|
||||||
|
{parsedItems[selectedItemIndex].formats!.map((f: any, idx: number) => {
|
||||||
|
const isSelected = parsedItems[selectedItemIndex].selectedFormat === idx;
|
||||||
|
const Icon = f.type === 'Audio' ? Music : Film;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
onClick={() => {
|
||||||
|
const newItems = [...parsedItems];
|
||||||
|
newItems[selectedItemIndex].selectedFormat = idx;
|
||||||
|
newItems[selectedItemIndex].size = f.detail || 'Unknown';
|
||||||
|
newItems[selectedItemIndex].sizeBytes = f.bytes || 0;
|
||||||
|
// Update filename extension
|
||||||
|
const baseName = newItems[selectedItemIndex].file.substring(0, newItems[selectedItemIndex].file.lastIndexOf('.')) || newItems[selectedItemIndex].file;
|
||||||
|
newItems[selectedItemIndex].file = `${baseName}.${f.ext}`;
|
||||||
|
setParsedItems(newItems);
|
||||||
|
}}
|
||||||
|
className={`flex items-center justify-between px-3 py-2 rounded-lg cursor-pointer text-xs border transition-all ${
|
||||||
|
isSelected ? 'bg-purple-500/10 border-purple-500/30 text-purple-600 dark:text-purple-400 font-semibold shadow-sm' : 'bg-bg-input border-border-modal text-text-secondary hover:border-border-modal/80 hover:bg-item-hover/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon size={14} className={isSelected ? 'text-purple-500' : 'text-text-muted'} />
|
||||||
|
<span>{f.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[11px] opacity-80">{f.detail}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-4 relative z-10">
|
||||||
|
<span className="text-xs text-red-400 font-medium">Failed to load media streams.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Save Location */}
|
{/* Save Location */}
|
||||||
<section>
|
<section>
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold text-text-primary mb-3">
|
<div className="flex items-center gap-2 text-sm font-semibold text-text-primary mb-3">
|
||||||
<FolderPlus size={16} className="text-blue-500" /> Save Location
|
<FolderPlus size={16} className="text-blue-500" /> Save Location
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
readOnly
|
readOnly
|
||||||
value={saveLocation}
|
value={saveLocation}
|
||||||
className="flex-1 bg-bg-input border border-border-modal rounded-md px-3 py-1.5 text-xs text-text-muted font-mono"
|
className="flex-1 bg-bg-input border border-border-modal rounded-md px-3 py-1.5 text-xs text-text-muted font-mono"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleBrowse}
|
onClick={handleBrowse}
|
||||||
className="bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
|
className="bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||||
>
|
>
|
||||||
@@ -558,11 +631,11 @@ export const AddDownloadsModal = () => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-xs text-text-secondary font-medium">Connections per File</label>
|
<label className="text-xs text-text-secondary font-medium">Connections per File</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input type="range" min="1" max="16" value={connections} onChange={e=>setConnections(Number(e.target.value))} className="w-24 accent-blue-500" disabled={extractMedia} />
|
<input type="range" min="1" max="16" value={connections} onChange={e=>setConnections(Number(e.target.value))} className="w-24 accent-blue-500" disabled={parsedItems.some(i => i.isMedia)} />
|
||||||
<span className="text-xs text-text-primary font-mono w-4 text-right">{connections}</span>
|
<span className="text-xs text-text-primary font-mono w-4 text-right">{connections}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
|
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
|
||||||
<input type="checkbox" checked={speedLimitEnabled} onChange={e=>setSpeedLimitEnabled(e.target.checked)} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20" />
|
<input type="checkbox" checked={speedLimitEnabled} onChange={e=>setSpeedLimitEnabled(e.target.checked)} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20" />
|
||||||
@@ -587,7 +660,7 @@ export const AddDownloadsModal = () => {
|
|||||||
<input type="checkbox" checked={useAuth} onChange={e=>setUseAuth(e.target.checked)} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20" />
|
<input type="checkbox" checked={useAuth} onChange={e=>setUseAuth(e.target.checked)} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20" />
|
||||||
Use authorization
|
Use authorization
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{useAuth && (
|
{useAuth && (
|
||||||
<div className="space-y-2.5 pl-5 border-l-2 border-border-modal/50">
|
<div className="space-y-2.5 pl-5 border-l-2 border-border-modal/50">
|
||||||
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} placeholder="Username" className="w-full bg-bg-input border border-border-modal rounded-md px-3 py-1.5 text-xs text-text-primary focus:border-blue-500 focus:outline-none" />
|
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} placeholder="Username" className="w-full bg-bg-input border border-border-modal rounded-md px-3 py-1.5 text-xs text-text-primary focus:border-blue-500 focus:outline-none" />
|
||||||
@@ -598,21 +671,21 @@ export const AddDownloadsModal = () => {
|
|||||||
|
|
||||||
{/* Advanced */}
|
{/* Advanced */}
|
||||||
<section className="pt-2 border-t border-border-modal/50">
|
<section className="pt-2 border-t border-border-modal/50">
|
||||||
<button
|
<button
|
||||||
onClick={() => setAdvancedExpanded(!advancedExpanded)}
|
onClick={() => setAdvancedExpanded(!advancedExpanded)}
|
||||||
className="flex items-center gap-2 text-sm font-semibold text-text-primary w-full hover:text-blue-500 transition-colors"
|
className="flex items-center gap-2 text-sm font-semibold text-text-primary w-full hover:text-blue-500 transition-colors"
|
||||||
>
|
>
|
||||||
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
Advanced Transfer
|
Advanced Transfer
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{advancedExpanded && (
|
{advancedExpanded && (
|
||||||
<div className="mt-4 space-y-4 pl-6">
|
<div className="mt-4 space-y-4 pl-6">
|
||||||
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
|
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
|
||||||
<input type="checkbox" checked={checksumEnabled} onChange={e=>setChecksumEnabled(e.target.checked)} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20" />
|
<input type="checkbox" checked={checksumEnabled} onChange={e=>setChecksumEnabled(e.target.checked)} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20" />
|
||||||
Verify Checksum
|
Verify Checksum
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{checksumEnabled && (
|
{checksumEnabled && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select value={checksumAlgo} onChange={e=>setChecksumAlgo(e.target.value)} className="w-24 bg-bg-input border border-border-modal rounded-md px-2 text-xs text-text-primary focus:border-blue-500 focus:outline-none">
|
<select value={checksumAlgo} onChange={e=>setChecksumAlgo(e.target.value)} className="w-24 bg-bg-input border border-border-modal rounded-md px-2 text-xs text-text-primary focus:border-blue-500 focus:outline-none">
|
||||||
@@ -651,15 +724,15 @@ export const AddDownloadsModal = () => {
|
|||||||
<button onClick={() => toggleAddModal(false)} className="px-4 py-1.5 rounded-lg text-xs font-medium text-text-secondary hover:text-text-primary hover:bg-item-hover transition-colors">
|
<button onClick={() => toggleAddModal(false)} className="px-4 py-1.5 rounded-lg text-xs font-medium text-text-secondary hover:text-text-primary hover:bg-item-hover transition-colors">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleStart(false)}
|
onClick={() => handleStart(false)}
|
||||||
disabled={parsedItems.length === 0}
|
disabled={parsedItems.length === 0}
|
||||||
className="px-4 py-1.5 rounded-lg text-xs font-medium bg-item-hover text-text-primary border border-border-modal hover:bg-border-modal/40 transition-colors disabled:opacity-50"
|
className="px-4 py-1.5 rounded-lg text-xs font-medium bg-item-hover text-text-primary border border-border-modal hover:bg-border-modal/40 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Add to Queue
|
Add to Queue
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleStart(true)}
|
onClick={() => handleStart(true)}
|
||||||
disabled={parsedItems.length === 0}
|
disabled={parsedItems.length === 0}
|
||||||
className="px-5 py-1.5 rounded-lg text-xs font-medium bg-blue-600 hover:bg-blue-500 text-white shadow-md shadow-blue-500/20 transition-all active:scale-95 disabled:opacity-50 flex items-center gap-1.5"
|
className="px-5 py-1.5 rounded-lg text-xs font-medium bg-blue-600 hover:bg-blue-500 text-white shadow-md shadow-blue-500/20 transition-all active:scale-95 disabled:opacity-50 flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -103,74 +103,72 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col bg-main-bg h-full relative">
|
<div className="flex-1 flex flex-col bg-main-bg h-full relative">
|
||||||
|
|
||||||
{/* Modern Toolbar */}
|
{/* Modern Toolbar */}
|
||||||
<div
|
<div
|
||||||
className={`flex px-6 py-4 border-b border-border-color items-center glass-panel z-10 sticky top-0 ${!isSidebarVisible ? 'pl-24' : ''}`}
|
className={`flex px-6 py-4 items-center glass-panel z-10 sticky top-0 ${!isSidebarVisible ? 'pl-24' : ''}`}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (e.button === 0 && (e.target as HTMLElement).closest('.no-drag') === null) {
|
if (e.button === 0 && (e.target as HTMLElement).closest('.no-drag') === null) {
|
||||||
getCurrentWindow().startDragging();
|
getCurrentWindow().startDragging();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className="no-drag mr-3 p-1.5 rounded-lg text-text-secondary hover:text-text-primary hover:bg-item-hover transition-colors"
|
className="no-drag mr-4 p-1.5 rounded-md text-text-secondary hover:text-text-primary hover:bg-item-hover transition-colors"
|
||||||
title="Toggle Sidebar"
|
title="Toggle Sidebar"
|
||||||
>
|
>
|
||||||
<PanelLeft size={18} strokeWidth={2} />
|
<PanelLeft size={18} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
<h2 className="text-lg font-bold mr-auto text-text-primary tracking-tight cursor-default">{getFilterTitle()}</h2>
|
<h2 className="text-[15px] font-bold mr-auto text-text-primary tracking-tight cursor-default">{getFilterTitle()}</h2>
|
||||||
|
|
||||||
<div className="flex gap-1.5 items-center bg-bg-input/50 p-1 rounded-xl border border-border-modal/50 shadow-sm no-drag">
|
<div className="flex gap-1.5 items-center no-drag">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleAddModal(true)}
|
onClick={() => toggleAddModal(true)}
|
||||||
className="p-2 rounded-lg text-text-secondary hover:text-blue-500 hover:bg-blue-500/10 transition-all duration-200 group relative"
|
className="p-1.5 rounded-md text-text-secondary hover:text-white hover:bg-item-hover transition-all duration-200"
|
||||||
title="Add Download"
|
title="Add Download"
|
||||||
>
|
>
|
||||||
<Plus size={18} strokeWidth={2.5} />
|
<Plus size={16} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
<div className="w-[1px] h-4 bg-border-color/60 mx-1"></div>
|
<button
|
||||||
<button
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
filteredDownloads.filter(d => d.status === 'paused').forEach(d => handleResume(d));
|
filteredDownloads.filter(d => d.status === 'paused').forEach(d => handleResume(d));
|
||||||
}}
|
}}
|
||||||
className="p-2 rounded-lg text-text-secondary hover:text-green-500 hover:bg-green-500/10 transition-all duration-200"
|
className="p-1.5 rounded-md text-text-secondary hover:text-white hover:bg-item-hover transition-all duration-200"
|
||||||
title="Resume All"
|
title="Resume All"
|
||||||
>
|
>
|
||||||
<Play size={18} fill="currentColor" className="opacity-80" />
|
<Play size={16} fill="currentColor" className="opacity-90" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
filteredDownloads.filter(d => d.status === 'downloading').forEach(d => handlePause(d.id));
|
filteredDownloads.filter(d => d.status === 'downloading').forEach(d => handlePause(d.id));
|
||||||
}}
|
}}
|
||||||
className="p-2 rounded-lg text-text-secondary hover:text-orange-500 hover:bg-orange-500/10 transition-all duration-200"
|
className="p-1.5 rounded-md text-text-secondary hover:text-white hover:bg-item-hover transition-all duration-200"
|
||||||
title="Pause All"
|
title="Pause All"
|
||||||
>
|
>
|
||||||
<Pause size={18} fill="currentColor" className="opacity-80" />
|
<Pause size={16} fill="currentColor" className="opacity-90" />
|
||||||
</button>
|
</button>
|
||||||
<div className="w-[1px] h-4 bg-border-color/60 mx-1"></div>
|
<button
|
||||||
<button
|
|
||||||
onClick={clearFinished}
|
onClick={clearFinished}
|
||||||
className="p-2 rounded-lg text-text-secondary hover:text-red-500 hover:bg-red-500/10 transition-all duration-200"
|
className="p-1.5 rounded-md text-text-secondary hover:text-red-400 hover:bg-item-hover transition-all duration-200"
|
||||||
title="Clear Finished"
|
title="Clear Finished"
|
||||||
>
|
>
|
||||||
<Trash2 size={18} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto bg-main-bg">
|
||||||
<table className="w-full border-collapse text-left">
|
<table className="w-full border-collapse text-left">
|
||||||
<thead className="sticky top-0 bg-main-bg/95 backdrop-blur-md z-0 shadow-sm border-b border-border-color">
|
<thead className="sticky top-0 bg-main-bg z-0 border-b border-border-color">
|
||||||
<tr className="text-text-muted text-xs uppercase tracking-wider font-semibold">
|
<tr className="text-text-muted/60 text-[10px] font-bold tracking-widest uppercase">
|
||||||
<th className={`${py} px-3 pl-6`}>File</th>
|
<th className={`${py} px-3 pl-8`}>FILE</th>
|
||||||
<th className={`${py} px-3`}>Size</th>
|
<th className={`${py} px-3 w-40`}>SIZE</th>
|
||||||
<th className={`${py} px-3`}>Status</th>
|
<th className={`${py} px-3 w-36`}>STATUS</th>
|
||||||
<th className={`${py} px-3`}>Speed</th>
|
<th className={`${py} px-3 w-28`}>SPEED</th>
|
||||||
<th className={`${py} px-3 pr-6`}>ETA</th>
|
<th className={`${py} px-3 w-28`}>ETA</th>
|
||||||
<th className={`${py} px-3 w-16`}></th>
|
<th className={`${py} px-3 pr-8 w-36`}>DATE ADDED</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -185,8 +183,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredDownloads.map(d => (
|
filteredDownloads.map(d => (
|
||||||
<tr
|
<tr
|
||||||
key={d.id}
|
key={d.id}
|
||||||
className="border-b border-border-color/30 hover:bg-item-hover transition-colors duration-200 group cursor-default"
|
className="border-b border-border-color/30 hover:bg-item-hover transition-colors duration-200 group cursor-default"
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -197,56 +195,67 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td className={`${py} px-3 pl-6 text-sm text-text-primary`}>
|
<td className={`${py} px-3 pl-8 text-[13px] text-text-primary`}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-bg-input/50 rounded-lg shadow-sm border border-border-modal/20">
|
<div className="p-2 bg-item-hover rounded-md border border-border-color/50 text-text-muted">
|
||||||
{getCategoryIcon(d.category)}
|
{getCategoryIcon(d.category)}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium truncate max-w-[250px]">{d.fileName}</span>
|
<span className="font-medium truncate max-w-[280px]">{d.fileName}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={`${py} px-3 text-[13px] text-text-secondary w-32`}>
|
<td className={`${py} px-3`}>
|
||||||
<div className="w-full bg-border-color rounded-full h-1.5 mb-1.5 mt-0.5 overflow-hidden shadow-inner">
|
{d.status === 'downloading' || d.status === 'paused' ? (
|
||||||
<div className={`h-1.5 rounded-full transition-all duration-300 ${d.status === 'completed' ? 'bg-green-500' : d.status === 'paused' ? 'bg-orange-500' : d.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${(d.fraction || 0) * 100}%` }}></div>
|
<div className="w-full pr-4">
|
||||||
</div>
|
<div className="w-full bg-[#3f3f3f] rounded-full h-1.5 mb-1.5 overflow-hidden">
|
||||||
<div className="text-[11px] font-mono text-text-muted font-medium">
|
<div className={`h-1.5 rounded-full transition-all duration-300 ${d.status === 'paused' ? 'bg-orange-500' : 'bg-[#3B66DE]'}`} style={{ width: `${(d.fraction || 0) * 100}%` }}></div>
|
||||||
{((d.fraction || 0) * 100).toFixed(1)}%
|
</div>
|
||||||
</div>
|
<div className="text-[11px] text-text-muted font-medium">
|
||||||
|
{((d.fraction || 0) * 100).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[12px] text-text-secondary font-medium">{d.size || '-'}</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className={`${py} px-3 text-[13px]`}>
|
<td className={`${py} px-3`}>
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-bold uppercase tracking-wider ${
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold tracking-widest uppercase ${
|
||||||
d.status === 'completed' ? 'bg-green-500/10 text-green-500' :
|
d.status === 'completed' ? 'text-[#4CAF50]' :
|
||||||
d.status === 'downloading' ? 'bg-blue-500/10 text-blue-500' :
|
d.status === 'downloading' ? 'bg-[#1E3A8A] text-[#60A5FA]' :
|
||||||
d.status === 'failed' ? 'bg-red-500/10 text-red-500' :
|
d.status === 'failed' ? 'text-red-400' :
|
||||||
d.status === 'paused' ? 'bg-orange-500/10 text-orange-500' :
|
d.status === 'paused' ? 'text-orange-400' :
|
||||||
'bg-zinc-500/10 text-text-secondary'
|
'text-text-muted'
|
||||||
}`}>
|
}`}>
|
||||||
{d.status}
|
{d.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className={`${py} px-3 text-[12px] text-text-secondary font-mono`}>{d.speed}</td>
|
<td className={`${py} px-3 text-[12px] text-text-secondary font-medium`}>{d.speed}</td>
|
||||||
<td className={`${py} px-3 pr-6 text-[12px] text-text-secondary font-mono`}>{d.eta}</td>
|
<td className={`${py} px-3 text-[12px] text-text-secondary font-medium`}>{d.eta}</td>
|
||||||
<td className={`${py} px-3 pr-6 text-right opacity-0 group-hover:opacity-100 transition-opacity duration-200`}>
|
<td className={`${py} px-3 pr-8 relative`}>
|
||||||
<div className="flex justify-end gap-1.5">
|
<div className="flex items-center justify-between">
|
||||||
{d.status === 'downloading' && (
|
<span className="text-[12px] text-text-secondary font-medium">
|
||||||
<button onClick={() => handlePause(d.id)} className="p-1.5 bg-bg-input/80 shadow-sm border border-border-modal/50 hover:bg-item-hover rounded-md text-text-muted hover:text-orange-500 transition-colors" title="Pause">
|
{d.dateAdded ? new Date(d.dateAdded).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '-'}
|
||||||
<Pause size={14} fill="currentColor" />
|
</span>
|
||||||
|
<div className="flex justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity duration-200 absolute right-8 top-1/2 -translate-y-1/2">
|
||||||
|
{d.status === 'downloading' && (
|
||||||
|
<button onClick={() => handlePause(d.id)} className="p-1 bg-[#2A2B2D] border border-[#3f3f3f] hover:bg-[#3f3f3f] rounded text-orange-400 transition-colors" title="Pause">
|
||||||
|
<Pause size={14} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{d.status === 'paused' && (
|
||||||
|
<button onClick={() => handleResume(d)} className="p-1 bg-[#2A2B2D] border border-[#3f3f3f] hover:bg-[#3f3f3f] rounded text-green-400 transition-colors" title="Resume">
|
||||||
|
<Play size={14} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setContextMenu({ x: e.clientX, y: e.clientY, id: d.id });
|
||||||
|
}}
|
||||||
|
className="p-1 bg-[#2A2B2D] border border-[#3f3f3f] hover:bg-[#3f3f3f] rounded text-text-muted hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<MoreVertical size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
{d.status === 'paused' && (
|
|
||||||
<button onClick={() => handleResume(d)} className="p-1.5 bg-bg-input/80 shadow-sm border border-border-modal/50 hover:bg-item-hover rounded-md text-text-muted hover:text-green-500 transition-colors" title="Resume">
|
|
||||||
<Play size={14} fill="currentColor" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, id: d.id });
|
|
||||||
}}
|
|
||||||
className="p-1.5 bg-bg-input/80 shadow-sm border border-border-modal/50 hover:bg-item-hover rounded-md text-text-muted hover:text-text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<MoreVertical size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -255,7 +264,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Bar */}
|
{/* Status Bar */}
|
||||||
<div className="px-6 py-2 border-t border-border-color text-[11px] font-medium text-text-muted bg-sidebar-bg/50 backdrop-blur-md">
|
<div className="px-6 py-2 border-t border-border-color text-[11px] font-medium text-text-muted bg-sidebar-bg/50 backdrop-blur-md">
|
||||||
{downloads.length} Item{downloads.length !== 1 ? 's' : ''}
|
{downloads.length} Item{downloads.length !== 1 ? 's' : ''}
|
||||||
@@ -263,16 +272,16 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
|
|
||||||
{/* Floating Context Menu */}
|
{/* Floating Context Menu */}
|
||||||
{contextMenu && contextItem && (
|
{contextMenu && contextItem && (
|
||||||
<div
|
<div
|
||||||
className="fixed z-50 bg-bg-modal/95 backdrop-blur-xl border border-border-modal rounded-xl shadow-2xl py-1.5 min-w-[180px] text-[13px] font-medium text-text-primary overflow-hidden"
|
className="fixed z-50 bg-bg-modal/95 backdrop-blur-xl border border-border-modal rounded-xl shadow-2xl py-1.5 min-w-[180px] text-[13px] font-medium text-text-primary overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
top: Math.min(contextMenu.y, window.innerHeight - 300),
|
top: Math.min(contextMenu.y, window.innerHeight - 300),
|
||||||
left: Math.min(contextMenu.x, window.innerWidth - 200)
|
left: Math.min(contextMenu.x, window.innerWidth - 200)
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{contextItem.status === 'completed' && (
|
{contextItem.status === 'completed' && (
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
try {
|
try {
|
||||||
@@ -281,14 +290,14 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to open file:", e);
|
console.error("Failed to open file:", e);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors"
|
className="w-full text-left px-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
Open File
|
Open File
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
try {
|
try {
|
||||||
@@ -297,7 +306,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to show in folder:", e);
|
console.error("Failed to show in folder:", e);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors"
|
className="w-full text-left px-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
Show in Finder
|
Show in Finder
|
||||||
@@ -306,11 +315,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
||||||
|
|
||||||
{(contextItem.status === 'downloading' || contextItem.status === 'queued') && (
|
{(contextItem.status === 'downloading' || contextItem.status === 'queued') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
handlePause(contextItem.id);
|
handlePause(contextItem.id);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors"
|
className="w-full text-left px-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
Pause
|
Pause
|
||||||
@@ -318,11 +327,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{(contextItem.status === 'paused' || contextItem.status === 'failed') && (
|
{(contextItem.status === 'paused' || contextItem.status === 'failed') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
handleResume(contextItem);
|
handleResume(contextItem);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors"
|
className="w-full text-left px-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
Resume
|
Resume
|
||||||
@@ -330,11 +339,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{['completed', 'failed', 'paused'].includes(contextItem.status) && (
|
{['completed', 'failed', 'paused'].includes(contextItem.status) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
redownload(contextItem.id);
|
redownload(contextItem.id);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors"
|
className="w-full text-left px-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
Redownload
|
Redownload
|
||||||
@@ -343,23 +352,23 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
|
|
||||||
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
navigator.clipboard.writeText(contextItem.url);
|
navigator.clipboard.writeText(contextItem.url);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors"
|
className="w-full text-left px-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
Copy Address
|
Copy Address
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{contextItem.status === 'completed' && (
|
{contextItem.status === 'completed' && (
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
const fullPath = await resolvePath(contextItem.destination || '~/Downloads', contextItem.fileName);
|
const fullPath = await resolvePath(contextItem.destination || '~/Downloads', contextItem.fileName);
|
||||||
navigator.clipboard.writeText(fullPath);
|
navigator.clipboard.writeText(fullPath);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors"
|
className="w-full text-left px-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
Copy File Path
|
Copy File Path
|
||||||
@@ -368,11 +377,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
|
|
||||||
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
handleDelete(contextItem.id);
|
handleDelete(contextItem.id);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-1.5 hover:bg-red-500 hover:text-white text-red-500 transition-colors"
|
className="w-full text-left px-4 py-1.5 hover:bg-red-500 hover:text-white text-red-500 transition-colors"
|
||||||
>
|
>
|
||||||
Remove from List
|
Remove from List
|
||||||
@@ -380,11 +389,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
|||||||
|
|
||||||
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
useDownloadStore.getState().setSelectedPropertiesDownloadId(contextItem.id);
|
useDownloadStore.getState().setSelectedPropertiesDownloadId(contextItem.id);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors"
|
className="w-full text-left px-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
Properties
|
Properties
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause, FileBox, File, Image as ImageIcon, Music, Video, Box, Archive } from 'lucide-react';
|
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
|
||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
|
|
||||||
type LoginMode = 'matching' | 'custom' | 'none';
|
type LoginMode = 'matching' | 'custom' | 'none';
|
||||||
@@ -131,13 +131,6 @@ export const PropertiesModal = () => {
|
|||||||
else if (item.status === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; }
|
else if (item.status === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; }
|
||||||
else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; }
|
else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; }
|
||||||
|
|
||||||
let CategoryIcon = File;
|
|
||||||
if (item.category === 'Images') CategoryIcon = ImageIcon;
|
|
||||||
if (item.category === 'Audio') CategoryIcon = Music;
|
|
||||||
if (item.category === 'Video') CategoryIcon = Video;
|
|
||||||
if (item.category === 'Apps') CategoryIcon = Box;
|
|
||||||
if (item.category === 'Archives') CategoryIcon = Archive;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
<div className="w-[720px] h-[580px] bg-bg-modal border border-border-modal rounded-xl shadow-2xl flex flex-col overflow-hidden text-sm">
|
<div className="w-[720px] h-[580px] bg-bg-modal border border-border-modal rounded-xl shadow-2xl flex flex-col overflow-hidden text-sm">
|
||||||
|
|||||||
+86
-94
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
import {
|
import {
|
||||||
X, Download, Palette, Globe, Folder, Key,
|
Download, Palette, Globe, Folder, Key,
|
||||||
Moon, Terminal, Puzzle, Info, Plus, Trash2, Copy, RefreshCw
|
Moon, Terminal, Puzzle, Info, Plus, Trash2, Copy, RefreshCw
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
@@ -9,7 +9,7 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
|
|
||||||
type TabType = 'downloads' | 'lookandfeel' | 'network' | 'locations' | 'sitelogins' | 'power' | 'engine' | 'integrations' | 'about';
|
type TabType = 'downloads' | 'lookandfeel' | 'network' | 'locations' | 'sitelogins' | 'power' | 'engine' | 'integrations' | 'about';
|
||||||
|
|
||||||
export const SettingsModal = () => {
|
export default function SettingsView() {
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('downloads');
|
const [activeTab, setActiveTab] = useState<TabType>('downloads');
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ export const SettingsModal = () => {
|
|||||||
|
|
||||||
// Fetch engine versions when Engine tab is opened
|
// Fetch engine versions when Engine tab is opened
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings.isSettingsModalOpen && activeTab === 'engine') {
|
if (settings.activeView === 'settings' && activeTab === 'engine') {
|
||||||
invoke<string>('test_aria2c')
|
invoke<string>('test_aria2c')
|
||||||
.then(v => setAria2Version(v))
|
.then(v => setAria2Version(v))
|
||||||
.catch(e => setAria2Version('Error: ' + e));
|
.catch(e => setAria2Version('Error: ' + e));
|
||||||
@@ -49,9 +49,7 @@ export const SettingsModal = () => {
|
|||||||
.then(v => setFfmpegVersion(v))
|
.then(v => setFfmpegVersion(v))
|
||||||
.catch(e => setFfmpegVersion('Error: ' + e));
|
.catch(e => setFfmpegVersion('Error: ' + e));
|
||||||
}
|
}
|
||||||
}, [settings.isSettingsModalOpen, activeTab]);
|
}, [settings.activeView, activeTab]);
|
||||||
|
|
||||||
if (!settings.isSettingsModalOpen) return null;
|
|
||||||
|
|
||||||
const showToast = (msg: string) => {
|
const showToast = (msg: string) => {
|
||||||
setToastMessage(msg);
|
setToastMessage(msg);
|
||||||
@@ -126,8 +124,8 @@ export const SettingsModal = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab(type)}
|
onClick={() => setActiveTab(type)}
|
||||||
className={`flex flex-col items-center justify-center p-2 rounded-lg transition-all text-center min-w-[76px] cursor-default ${
|
className={`flex flex-col items-center justify-center p-2 rounded-lg transition-all text-center min-w-[76px] cursor-default ${
|
||||||
active
|
active
|
||||||
? 'bg-blue-600/15 text-blue-500 font-semibold'
|
? 'bg-blue-600/15 text-blue-500 font-semibold'
|
||||||
: 'text-text-secondary hover:bg-item-hover hover:text-text-primary'
|
: 'text-text-secondary hover:bg-item-hover hover:text-text-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -138,9 +136,8 @@ export const SettingsModal = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
<div className="flex-1 flex flex-col bg-main-bg relative h-full overflow-hidden">
|
||||||
<div className="w-[840px] h-[640px] bg-bg-modal border border-border-modal rounded-xl shadow-2xl flex flex-col overflow-hidden relative">
|
|
||||||
|
|
||||||
{/* Toast Notification */}
|
{/* Toast Notification */}
|
||||||
{toastMessage && (
|
{toastMessage && (
|
||||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-blue-600 text-white text-[13px] font-medium py-2 px-4 rounded-full shadow-lg z-50 animate-bounce">
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-blue-600 text-white text-[13px] font-medium py-2 px-4 rounded-full shadow-lg z-50 animate-bounce">
|
||||||
@@ -149,14 +146,11 @@ export const SettingsModal = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header (Horizontal Tab Bar) */}
|
{/* Header (Horizontal Tab Bar) */}
|
||||||
<div className="flex flex-col border-b border-border-modal bg-sidebar-bg/50">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center justify-between p-3 pl-4 border-b border-border-modal/50">
|
<div className="flex items-center justify-between p-4 px-6 border-b border-border-color">
|
||||||
<h2 className="text-sm font-semibold tracking-wide text-text-primary">Preferences</h2>
|
<h2 className="text-[15px] font-bold tracking-tight text-text-primary">Preferences</h2>
|
||||||
<button onClick={() => settings.toggleSettingsModal(false)} className="text-text-muted hover:text-text-primary transition-colors">
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 p-2 overflow-x-auto justify-center">
|
<div className="flex items-center gap-2 p-3 overflow-x-auto justify-center bg-bg-input/30 border-b border-border-color shadow-sm">
|
||||||
<TabButton type="downloads" icon={Download} label="Downloads" />
|
<TabButton type="downloads" icon={Download} label="Downloads" />
|
||||||
<TabButton type="lookandfeel" icon={Palette} label="Look & Feel" />
|
<TabButton type="lookandfeel" icon={Palette} label="Look & Feel" />
|
||||||
<TabButton type="network" icon={Globe} label="Network" />
|
<TabButton type="network" icon={Globe} label="Network" />
|
||||||
@@ -171,17 +165,17 @@ export const SettingsModal = () => {
|
|||||||
|
|
||||||
{/* Content Area */}
|
{/* Content Area */}
|
||||||
<div className="flex-1 overflow-y-auto p-6 bg-main-bg/10">
|
<div className="flex-1 overflow-y-auto p-6 bg-main-bg/10">
|
||||||
|
|
||||||
{/* Downloads Pane */}
|
{/* Downloads Pane */}
|
||||||
{activeTab === 'downloads' && (
|
{activeTab === 'downloads' && (
|
||||||
<div className="space-y-6 max-w-xl mx-auto">
|
<div className="space-y-6 max-w-xl mx-auto">
|
||||||
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Download Options</h3>
|
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Download Options</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px]">
|
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px]">
|
||||||
<label className="text-text-secondary font-medium">Parallel downloads:</label>
|
<label className="text-text-secondary font-medium">Parallel downloads:</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<input
|
<input
|
||||||
type="range" min="1" max="12"
|
type="range" min="1" max="12"
|
||||||
value={settings.maxConcurrentDownloads}
|
value={settings.maxConcurrentDownloads}
|
||||||
onChange={(e) => settings.setMaxConcurrentDownloads(Number(e.target.value))}
|
onChange={(e) => settings.setMaxConcurrentDownloads(Number(e.target.value))}
|
||||||
className="flex-1 accent-blue-500"
|
className="flex-1 accent-blue-500"
|
||||||
@@ -195,8 +189,8 @@ export const SettingsModal = () => {
|
|||||||
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px]">
|
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px]">
|
||||||
<label className="text-text-secondary font-medium">Default connections:</label>
|
<label className="text-text-secondary font-medium">Default connections:</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<input
|
<input
|
||||||
type="number" min="1" max="16"
|
type="number" min="1" max="16"
|
||||||
value={settings.perServerConnections}
|
value={settings.perServerConnections}
|
||||||
onChange={(e) => settings.setPerServerConnections(Number(e.target.value))}
|
onChange={(e) => settings.setPerServerConnections(Number(e.target.value))}
|
||||||
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-24 text-text-primary focus:outline-none focus:border-blue-500"
|
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-24 text-text-primary focus:outline-none focus:border-blue-500"
|
||||||
@@ -208,8 +202,8 @@ export const SettingsModal = () => {
|
|||||||
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px]">
|
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px]">
|
||||||
<label className="text-text-secondary font-medium">Global speed limit:</label>
|
<label className="text-text-secondary font-medium">Global speed limit:</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={settings.globalSpeedLimit}
|
value={settings.globalSpeedLimit}
|
||||||
onChange={(e) => settings.setGlobalSpeedLimit(e.target.value)}
|
onChange={(e) => settings.setGlobalSpeedLimit(e.target.value)}
|
||||||
placeholder="Unlimited"
|
placeholder="Unlimited"
|
||||||
@@ -222,8 +216,8 @@ export const SettingsModal = () => {
|
|||||||
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px]">
|
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px]">
|
||||||
<label className="text-text-secondary font-medium">Automatic retries:</label>
|
<label className="text-text-secondary font-medium">Automatic retries:</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<input
|
<input
|
||||||
type="number" min="0" max="10"
|
type="number" min="0" max="10"
|
||||||
value={settings.maxAutomaticRetries}
|
value={settings.maxAutomaticRetries}
|
||||||
onChange={(e) => settings.setMaxAutomaticRetries(Number(e.target.value))}
|
onChange={(e) => settings.setMaxAutomaticRetries(Number(e.target.value))}
|
||||||
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-24 text-text-primary focus:outline-none focus:border-blue-500"
|
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-24 text-text-primary focus:outline-none focus:border-blue-500"
|
||||||
@@ -234,8 +228,8 @@ export const SettingsModal = () => {
|
|||||||
|
|
||||||
<div className="border-t border-border-color/30 pt-4 space-y-3">
|
<div className="border-t border-border-color/30 pt-4 space-y-3">
|
||||||
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary">
|
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings.showNotifications}
|
checked={settings.showNotifications}
|
||||||
onChange={(e) => settings.setShowNotifications(e.target.checked)}
|
onChange={(e) => settings.setShowNotifications(e.target.checked)}
|
||||||
className="mt-0.5 rounded accent-blue-500"
|
className="mt-0.5 rounded accent-blue-500"
|
||||||
@@ -247,8 +241,8 @@ export const SettingsModal = () => {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary pl-6">
|
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary pl-6">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings.playCompletionSound && settings.showNotifications}
|
checked={settings.playCompletionSound && settings.showNotifications}
|
||||||
disabled={!settings.showNotifications}
|
disabled={!settings.showNotifications}
|
||||||
onChange={(e) => settings.setPlayCompletionSound(e.target.checked)}
|
onChange={(e) => settings.setPlayCompletionSound(e.target.checked)}
|
||||||
@@ -266,15 +260,15 @@ export const SettingsModal = () => {
|
|||||||
{activeTab === 'lookandfeel' && (
|
{activeTab === 'lookandfeel' && (
|
||||||
<div className="space-y-6 max-w-xl mx-auto">
|
<div className="space-y-6 max-w-xl mx-auto">
|
||||||
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Appearance Settings</h3>
|
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Appearance Settings</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-[180px_1fr] items-start gap-4 text-[13px]">
|
<div className="grid grid-cols-[180px_1fr] items-start gap-4 text-[13px]">
|
||||||
<label className="text-text-secondary font-medium pt-1">App Theme:</label>
|
<label className="text-text-secondary font-medium pt-1">App Theme:</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{['system', 'dark', 'light', 'dracula', 'nord'].map((t) => (
|
{['system', 'dark', 'light', 'dracula', 'nord'].map((t) => (
|
||||||
<label key={t} className="flex items-center gap-2 cursor-default select-none text-text-secondary capitalize">
|
<label key={t} className="flex items-center gap-2 cursor-default select-none text-text-secondary capitalize">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="themeRadio"
|
name="themeRadio"
|
||||||
value={t}
|
value={t}
|
||||||
checked={settings.theme === t}
|
checked={settings.theme === t}
|
||||||
onChange={() => settings.setTheme(t as any)}
|
onChange={() => settings.setTheme(t as any)}
|
||||||
@@ -289,8 +283,8 @@ export const SettingsModal = () => {
|
|||||||
|
|
||||||
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px]">
|
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px]">
|
||||||
<label className="text-text-secondary font-medium">Font Size:</label>
|
<label className="text-text-secondary font-medium">Font Size:</label>
|
||||||
<select
|
<select
|
||||||
value={settings.appFontSize}
|
value={settings.appFontSize}
|
||||||
onChange={(e) => settings.setAppFontSize(e.target.value as any)}
|
onChange={(e) => settings.setAppFontSize(e.target.value as any)}
|
||||||
className="bg-bg-input border border-border-modal rounded-lg p-2 text-[13px] text-text-primary focus:outline-none focus:border-blue-500 max-w-[200px]"
|
className="bg-bg-input border border-border-modal rounded-lg p-2 text-[13px] text-text-primary focus:outline-none focus:border-blue-500 max-w-[200px]"
|
||||||
>
|
>
|
||||||
@@ -302,8 +296,8 @@ export const SettingsModal = () => {
|
|||||||
|
|
||||||
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px]">
|
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px]">
|
||||||
<label className="text-text-secondary font-medium">List Row Density:</label>
|
<label className="text-text-secondary font-medium">List Row Density:</label>
|
||||||
<select
|
<select
|
||||||
value={settings.listRowDensity}
|
value={settings.listRowDensity}
|
||||||
onChange={(e) => settings.setListRowDensity(e.target.value as any)}
|
onChange={(e) => settings.setListRowDensity(e.target.value as any)}
|
||||||
className="bg-bg-input border border-border-modal rounded-lg p-2 text-[13px] text-text-primary focus:outline-none focus:border-blue-500 max-w-[200px]"
|
className="bg-bg-input border border-border-modal rounded-lg p-2 text-[13px] text-text-primary focus:outline-none focus:border-blue-500 max-w-[200px]"
|
||||||
>
|
>
|
||||||
@@ -315,8 +309,8 @@ export const SettingsModal = () => {
|
|||||||
|
|
||||||
<div className="border-t border-border-color/30 pt-4 space-y-3">
|
<div className="border-t border-border-color/30 pt-4 space-y-3">
|
||||||
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary">
|
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings.showNotifications} // mapped to dock badge placeholder
|
checked={settings.showNotifications} // mapped to dock badge placeholder
|
||||||
className="mt-0.5 rounded accent-blue-500"
|
className="mt-0.5 rounded accent-blue-500"
|
||||||
/>
|
/>
|
||||||
@@ -333,12 +327,12 @@ export const SettingsModal = () => {
|
|||||||
{activeTab === 'network' && (
|
{activeTab === 'network' && (
|
||||||
<div className="space-y-6 max-w-xl mx-auto">
|
<div className="space-y-6 max-w-xl mx-auto">
|
||||||
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Proxy & User Agent</h3>
|
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Proxy & User Agent</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-[180px_1fr] items-start gap-4 text-[13px]">
|
<div className="grid grid-cols-[180px_1fr] items-start gap-4 text-[13px]">
|
||||||
<label className="text-text-secondary font-medium pt-1">Proxy Mode:</label>
|
<label className="text-text-secondary font-medium pt-1">Proxy Mode:</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="flex items-center gap-2 cursor-default select-none text-text-secondary">
|
<label className="flex items-center gap-2 cursor-default select-none text-text-secondary">
|
||||||
<input
|
<input
|
||||||
type="radio" name="proxyMode" value="none"
|
type="radio" name="proxyMode" value="none"
|
||||||
checked={settings.proxyMode === 'none'}
|
checked={settings.proxyMode === 'none'}
|
||||||
onChange={() => settings.setProxyMode('none')}
|
onChange={() => settings.setProxyMode('none')}
|
||||||
@@ -347,7 +341,7 @@ export const SettingsModal = () => {
|
|||||||
No proxy
|
No proxy
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 cursor-default select-none text-text-secondary">
|
<label className="flex items-center gap-2 cursor-default select-none text-text-secondary">
|
||||||
<input
|
<input
|
||||||
type="radio" name="proxyMode" value="system"
|
type="radio" name="proxyMode" value="system"
|
||||||
checked={settings.proxyMode === 'system'}
|
checked={settings.proxyMode === 'system'}
|
||||||
onChange={() => settings.setProxyMode('system')}
|
onChange={() => settings.setProxyMode('system')}
|
||||||
@@ -356,7 +350,7 @@ export const SettingsModal = () => {
|
|||||||
Use system proxy
|
Use system proxy
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 cursor-default select-none text-text-secondary">
|
<label className="flex items-center gap-2 cursor-default select-none text-text-secondary">
|
||||||
<input
|
<input
|
||||||
type="radio" name="proxyMode" value="custom"
|
type="radio" name="proxyMode" value="custom"
|
||||||
checked={settings.proxyMode === 'custom'}
|
checked={settings.proxyMode === 'custom'}
|
||||||
onChange={() => settings.setProxyMode('custom')}
|
onChange={() => settings.setProxyMode('custom')}
|
||||||
@@ -371,20 +365,20 @@ export const SettingsModal = () => {
|
|||||||
<div className="bg-item-hover/30 border border-border-modal rounded-lg p-4 pl-6 space-y-4 max-w-[420px] ml-[180px]">
|
<div className="bg-item-hover/30 border border-border-modal rounded-lg p-4 pl-6 space-y-4 max-w-[420px] ml-[180px]">
|
||||||
<div className="grid grid-cols-[80px_1fr] items-center gap-2 text-[13px]">
|
<div className="grid grid-cols-[80px_1fr] items-center gap-2 text-[13px]">
|
||||||
<label className="text-text-secondary">Host:</label>
|
<label className="text-text-secondary">Host:</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={settings.proxyHost}
|
value={settings.proxyHost}
|
||||||
onChange={(e) => settings.setProxyHost(e.target.value)}
|
onChange={(e) => settings.setProxyHost(e.target.value)}
|
||||||
placeholder="127.0.0.1"
|
placeholder="127.0.0.1"
|
||||||
className="bg-bg-input border border-border-modal rounded-md px-3 py-1 text-text-primary font-mono text-xs focus:outline-none"
|
className="bg-bg-input border border-border-modal rounded-md px-3 py-1 text-text-primary font-mono text-xs focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[80px_1fr] items-center gap-2 text-[13px]">
|
<div className="grid grid-cols-[80px_1fr] items-center gap-2 text-[13px]">
|
||||||
<label className="text-text-secondary">Port:</label>
|
<label className="text-text-secondary">Port:</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={settings.proxyPort}
|
value={settings.proxyPort}
|
||||||
onChange={(e) => settings.setProxyPort(Number(e.target.value))}
|
onChange={(e) => settings.setProxyPort(Number(e.target.value))}
|
||||||
className="bg-bg-input border border-border-modal rounded-md px-3 py-1 text-text-primary font-mono text-xs w-[100px] focus:outline-none"
|
className="bg-bg-input border border-border-modal rounded-md px-3 py-1 text-text-primary font-mono text-xs w-[100px] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -394,8 +388,8 @@ export const SettingsModal = () => {
|
|||||||
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px] border-t border-border-color/30 pt-4">
|
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px] border-t border-border-color/30 pt-4">
|
||||||
<label className="text-text-secondary font-medium">User Agent:</label>
|
<label className="text-text-secondary font-medium">User Agent:</label>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={settings.customUserAgent}
|
value={settings.customUserAgent}
|
||||||
onChange={(e) => settings.setCustomUserAgent(e.target.value)}
|
onChange={(e) => settings.setCustomUserAgent(e.target.value)}
|
||||||
placeholder="e.g. Mozilla/5.0..."
|
placeholder="e.g. Mozilla/5.0..."
|
||||||
@@ -413,8 +407,8 @@ export const SettingsModal = () => {
|
|||||||
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Download Directories</h3>
|
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Download Directories</h3>
|
||||||
|
|
||||||
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary">
|
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings.askWhereToSaveEachFile}
|
checked={settings.askWhereToSaveEachFile}
|
||||||
onChange={(e) => settings.setAskWhereToSaveEachFile(e.target.checked)}
|
onChange={(e) => settings.setAskWhereToSaveEachFile(e.target.checked)}
|
||||||
className="mt-0.5 rounded accent-blue-500"
|
className="mt-0.5 rounded accent-blue-500"
|
||||||
@@ -432,11 +426,11 @@ export const SettingsModal = () => {
|
|||||||
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px] bg-item-hover/35 p-3 rounded-lg border border-border-modal/40">
|
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px] bg-item-hover/35 p-3 rounded-lg border border-border-modal/40">
|
||||||
<label className="font-semibold text-text-primary">All Categories Base:</label>
|
<label className="font-semibold text-text-primary">All Categories Base:</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text" readOnly placeholder="Choose base folder to sub-categorize..."
|
type="text" readOnly placeholder="Choose base folder to sub-categorize..."
|
||||||
className="flex-1 bg-bg-input border border-border-modal rounded-md px-3 py-1 text-xs text-text-muted"
|
className="flex-1 bg-bg-input border border-border-modal rounded-md px-3 py-1 text-xs text-text-muted"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleBrowseBulk}
|
onClick={handleBrowseBulk}
|
||||||
className="bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded-md text-xs font-semibold shadow transition-colors"
|
className="bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded-md text-xs font-semibold shadow transition-colors"
|
||||||
>
|
>
|
||||||
@@ -449,13 +443,13 @@ export const SettingsModal = () => {
|
|||||||
<div key={category} className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
<div key={category} className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
||||||
<label className="text-text-secondary capitalize">{category} folder:</label>
|
<label className="text-text-secondary capitalize">{category} folder:</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={(settings.downloadDirectories || {})[category]}
|
value={(settings.downloadDirectories || {})[category]}
|
||||||
onChange={(e) => settings.setCategoryDirectory(category, e.target.value)}
|
onChange={(e) => settings.setCategoryDirectory(category, e.target.value)}
|
||||||
className="flex-1 bg-bg-input border border-border-modal rounded-md px-3 py-1 text-xs text-text-primary font-mono"
|
className="flex-1 bg-bg-input border border-border-modal rounded-md px-3 py-1 text-xs text-text-primary font-mono"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBrowseCategory(category)}
|
onClick={() => handleBrowseCategory(category)}
|
||||||
className="bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal px-2.5 py-1 rounded-md text-xs"
|
className="bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal px-2.5 py-1 rounded-md text-xs"
|
||||||
>
|
>
|
||||||
@@ -466,7 +460,7 @@ export const SettingsModal = () => {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2 border-t border-border-color/30">
|
<div className="flex justify-end gap-2 pt-2 border-t border-border-color/30">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
settings.resetCategoryDirectories();
|
settings.resetCategoryDirectories();
|
||||||
showToast("Reset directories to default");
|
showToast("Reset directories to default");
|
||||||
@@ -484,7 +478,7 @@ export const SettingsModal = () => {
|
|||||||
{activeTab === 'sitelogins' && (
|
{activeTab === 'sitelogins' && (
|
||||||
<div className="space-y-6 max-w-xl mx-auto">
|
<div className="space-y-6 max-w-xl mx-auto">
|
||||||
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Site Credentials</h3>
|
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Site Credentials</h3>
|
||||||
|
|
||||||
{/* Site Logins List */}
|
{/* Site Logins List */}
|
||||||
<div className="space-y-2 max-h-[200px] overflow-y-auto border border-border-modal rounded-lg p-2 bg-item-hover/10">
|
<div className="space-y-2 max-h-[200px] overflow-y-auto border border-border-modal rounded-lg p-2 bg-item-hover/10">
|
||||||
{(settings.siteLogins || []).length === 0 ? (
|
{(settings.siteLogins || []).length === 0 ? (
|
||||||
@@ -496,7 +490,7 @@ export const SettingsModal = () => {
|
|||||||
<p className="font-bold text-text-primary font-mono text-[11px]">{login.urlPattern}</p>
|
<p className="font-bold text-text-primary font-mono text-[11px]">{login.urlPattern}</p>
|
||||||
<p className="text-text-secondary text-xs">User: {login.username}</p>
|
<p className="text-text-secondary text-xs">User: {login.username}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
settings.removeSiteLogin(login.id);
|
settings.removeSiteLogin(login.id);
|
||||||
showToast("Deleted credential");
|
showToast("Deleted credential");
|
||||||
@@ -514,15 +508,15 @@ export const SettingsModal = () => {
|
|||||||
{/* Add Site Login Form */}
|
{/* Add Site Login Form */}
|
||||||
<div className="border-t border-border-color/30 pt-4 space-y-4">
|
<div className="border-t border-border-color/30 pt-4 space-y-4">
|
||||||
<h4 className="text-[13px] font-bold text-text-primary">Add Site Credentials</h4>
|
<h4 className="text-[13px] font-bold text-text-primary">Add Site Credentials</h4>
|
||||||
|
|
||||||
{loginError && (
|
{loginError && (
|
||||||
<p className="text-red-500 text-xs">{loginError}</p>
|
<p className="text-red-500 text-xs">{loginError}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
||||||
<label className="text-text-secondary">URL Pattern:</label>
|
<label className="text-text-secondary">URL Pattern:</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={loginPattern}
|
value={loginPattern}
|
||||||
onChange={(e) => setLoginPattern(e.target.value)}
|
onChange={(e) => setLoginPattern(e.target.value)}
|
||||||
placeholder="e.g. *.example.com or example.com/downloads"
|
placeholder="e.g. *.example.com or example.com/downloads"
|
||||||
@@ -532,8 +526,8 @@ export const SettingsModal = () => {
|
|||||||
|
|
||||||
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
||||||
<label className="text-text-secondary">Username:</label>
|
<label className="text-text-secondary">Username:</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={loginUser}
|
value={loginUser}
|
||||||
onChange={(e) => setLoginUser(e.target.value)}
|
onChange={(e) => setLoginUser(e.target.value)}
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
@@ -543,8 +537,8 @@ export const SettingsModal = () => {
|
|||||||
|
|
||||||
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
||||||
<label className="text-text-secondary">Password:</label>
|
<label className="text-text-secondary">Password:</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={loginPass}
|
value={loginPass}
|
||||||
onChange={(e) => setLoginPass(e.target.value)}
|
onChange={(e) => setLoginPass(e.target.value)}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
@@ -553,7 +547,7 @@ export const SettingsModal = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end pt-2">
|
<div className="flex justify-end pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleAddLogin}
|
onClick={handleAddLogin}
|
||||||
className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-1.5 rounded-lg text-xs font-semibold shadow flex items-center gap-1.5"
|
className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-1.5 rounded-lg text-xs font-semibold shadow flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
@@ -568,10 +562,10 @@ export const SettingsModal = () => {
|
|||||||
{activeTab === 'power' && (
|
{activeTab === 'power' && (
|
||||||
<div className="space-y-6 max-w-xl mx-auto">
|
<div className="space-y-6 max-w-xl mx-auto">
|
||||||
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Power Management</h3>
|
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Power Management</h3>
|
||||||
|
|
||||||
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary">
|
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings.preventsSleepWhileDownloading}
|
checked={settings.preventsSleepWhileDownloading}
|
||||||
onChange={(e) => settings.setPreventsSleepWhileDownloading(e.target.checked)}
|
onChange={(e) => settings.setPreventsSleepWhileDownloading(e.target.checked)}
|
||||||
className="mt-0.5 rounded accent-blue-500"
|
className="mt-0.5 rounded accent-blue-500"
|
||||||
@@ -588,7 +582,7 @@ export const SettingsModal = () => {
|
|||||||
{activeTab === 'engine' && (
|
{activeTab === 'engine' && (
|
||||||
<div className="space-y-6 max-w-xl mx-auto">
|
<div className="space-y-6 max-w-xl mx-auto">
|
||||||
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Media Downloader & Engines</h3>
|
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Media Downloader & Engines</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="border border-border-modal rounded-lg p-4 space-y-3 bg-item-hover/5">
|
<div className="border border-border-modal rounded-lg p-4 space-y-3 bg-item-hover/5">
|
||||||
<h4 className="text-[13px] font-bold text-text-primary flex items-center gap-2 border-b border-border-modal pb-1">
|
<h4 className="text-[13px] font-bold text-text-primary flex items-center gap-2 border-b border-border-modal pb-1">
|
||||||
@@ -623,8 +617,8 @@ export const SettingsModal = () => {
|
|||||||
|
|
||||||
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px] border-t border-border-modal/50 pt-3 mt-2">
|
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px] border-t border-border-modal/50 pt-3 mt-2">
|
||||||
<label className="text-text-secondary font-semibold">Browser Cookies Source:</label>
|
<label className="text-text-secondary font-semibold">Browser Cookies Source:</label>
|
||||||
<select
|
<select
|
||||||
value={settings.mediaCookieSource}
|
value={settings.mediaCookieSource}
|
||||||
onChange={(e) => settings.setMediaCookieSource(e.target.value as any)}
|
onChange={(e) => settings.setMediaCookieSource(e.target.value as any)}
|
||||||
className="bg-bg-input border border-border-modal rounded-lg p-1.5 text-[13px] text-text-primary focus:outline-none focus:border-blue-500"
|
className="bg-bg-input border border-border-modal rounded-lg p-1.5 text-[13px] text-text-primary focus:outline-none focus:border-blue-500"
|
||||||
>
|
>
|
||||||
@@ -655,7 +649,7 @@ export const SettingsModal = () => {
|
|||||||
|
|
||||||
{/* Step Guide Cards */}
|
{/* Step Guide Cards */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
|
||||||
{/* Step 1 */}
|
{/* Step 1 */}
|
||||||
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col justify-between h-[190px]">
|
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col justify-between h-[190px]">
|
||||||
<div>
|
<div>
|
||||||
@@ -667,13 +661,13 @@ export const SettingsModal = () => {
|
|||||||
<p className="text-text-muted text-[11px] leading-relaxed">This secure token authorizes your browser extension.</p>
|
<p className="text-text-muted text-[11px] leading-relaxed">This secure token authorizes your browser extension.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<button
|
<button
|
||||||
onClick={copyToken}
|
onClick={copyToken}
|
||||||
className="w-full bg-blue-600 hover:bg-blue-500 text-white font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 shadow transition-colors"
|
className="w-full bg-blue-600 hover:bg-blue-500 text-white font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 shadow transition-colors"
|
||||||
>
|
>
|
||||||
<Copy size={11} /> Copy Token
|
<Copy size={11} /> Copy Token
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
settings.regeneratePairingToken();
|
settings.regeneratePairingToken();
|
||||||
showToast("Pairing token regenerated");
|
showToast("Pairing token regenerated");
|
||||||
@@ -696,15 +690,15 @@ export const SettingsModal = () => {
|
|||||||
<p className="text-text-muted text-[11px] leading-relaxed">Install the Firelink Companion extension on your browser.</p>
|
<p className="text-text-muted text-[11px] leading-relaxed">Install the Firelink Companion extension on your browser.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<a
|
<a
|
||||||
href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"
|
href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"
|
||||||
target="_blank" rel="noreferrer"
|
target="_blank" rel="noreferrer"
|
||||||
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] block text-center transition-colors"
|
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] block text-center transition-colors"
|
||||||
>
|
>
|
||||||
Firefox Add-ons
|
Firefox Add-ons
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/nimbold/Firelink-Extension/releases"
|
href="https://github.com/nimbold/Firelink-Extension/releases"
|
||||||
target="_blank" rel="noreferrer"
|
target="_blank" rel="noreferrer"
|
||||||
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] block text-center transition-colors"
|
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] block text-center transition-colors"
|
||||||
>
|
>
|
||||||
@@ -759,17 +753,15 @@ export const SettingsModal = () => {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="p-4 border-t border-border-modal bg-sidebar-bg/50 flex justify-end gap-3">
|
<div className="p-4 border-t border-border-modal bg-sidebar-bg/50 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => settings.toggleSettingsModal(false)}
|
onClick={() => settings.setActiveView('downloads')}
|
||||||
className="px-5 py-2 rounded-lg text-sm font-medium bg-blue-600 hover:bg-blue-500 text-white shadow-lg shadow-blue-500/20 transition-all active:scale-95"
|
className="px-5 py-2 rounded-lg text-sm font-medium bg-blue-600 hover:bg-blue-500 text-white shadow-lg shadow-blue-500/20 transition-all active:scale-95"
|
||||||
>
|
>
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
// Force Vite HMR rebuild
|
import {
|
||||||
import {
|
Inbox, Zap, CheckCircle2, CircleDashed,
|
||||||
Inbox, Zap, CheckCircle2, CircleDashed,
|
|
||||||
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion,
|
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion,
|
||||||
List, CalendarClock, Gauge, Settings
|
List, CalendarClock, Gauge, Settings
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -16,8 +15,11 @@ interface SidebarProps {
|
|||||||
onSelectFilter: (filter: SidebarFilter) => void;
|
onSelectFilter: (filter: SidebarFilter) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = ({ selectedFilter, onSelectFilter }) => {
|
export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||||
|
const selectedFilter = props.selectedFilter;
|
||||||
|
const onSelectFilter = props.onSelectFilter;
|
||||||
const downloads = useDownloadStore(state => state.downloads);
|
const downloads = useDownloadStore(state => state.downloads);
|
||||||
|
const activeView = useSettingsStore(state => state.activeView);
|
||||||
|
|
||||||
const getCount = (filter: SidebarFilter) => {
|
const getCount = (filter: SidebarFilter) => {
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
@@ -30,18 +32,22 @@ export const Sidebar: React.FC<SidebarProps> = ({ selectedFilter, onSelectFilter
|
|||||||
};
|
};
|
||||||
|
|
||||||
const NavItem = ({ icon: Icon, label, filter }: { icon: any, label: string, filter: SidebarFilter }) => (
|
const NavItem = ({ icon: Icon, label, filter }: { icon: any, label: string, filter: SidebarFilter }) => (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center px-2 py-1.5 rounded-md text-[13px] cursor-default transition-colors mb-0.5 ${
|
className={`flex items-center px-3 py-1.5 rounded-md text-[13px] cursor-default transition-colors mb-[2px] ${
|
||||||
selectedFilter === filter
|
selectedFilter === filter
|
||||||
? 'bg-blue-500/20 text-blue-500'
|
? 'bg-[#3B66DE] text-white shadow-sm font-medium'
|
||||||
: 'text-text-secondary hover:bg-item-hover'
|
: 'text-text-secondary hover:bg-item-hover hover:text-text-primary font-medium'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onSelectFilter(filter)}
|
onClick={() => onSelectFilter(filter)}
|
||||||
>
|
>
|
||||||
<Icon className={`w-4 h-4 mr-2 ${selectedFilter === filter ? 'opacity-100' : 'opacity-80'}`} />
|
<Icon className={`w-4 h-4 mr-2.5 ${selectedFilter === filter ? 'opacity-100 text-white' : 'opacity-70'}`} strokeWidth={selectedFilter === filter ? 2.5 : 2} />
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{getCount(filter) > 0 && (
|
{getCount(filter) > 0 && (
|
||||||
<span className="ml-auto text-[11px] text-text-muted bg-item-hover px-1.5 py-0.5 rounded-full">
|
<span className={`ml-auto text-[11px] font-bold px-1.5 py-0.5 rounded-full ${
|
||||||
|
selectedFilter === filter
|
||||||
|
? 'bg-black/20 text-white'
|
||||||
|
: 'bg-item-hover text-text-muted group-hover:bg-black/10'
|
||||||
|
}`}>
|
||||||
{getCount(filter)}
|
{getCount(filter)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -49,56 +55,63 @@ export const Sidebar: React.FC<SidebarProps> = ({ selectedFilter, onSelectFilter
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[220px] min-w-[190px] max-w-[260px] bg-sidebar-bg/80 backdrop-blur-xl border-r border-border-color flex flex-col p-3 pt-8 pb-4 overflow-y-auto relative shrink-0">
|
<div className="w-[220px] min-w-[190px] max-w-[260px] bg-[#1E1E20] border-r border-border-color flex flex-col p-2.5 pt-8 pb-4 relative shrink-0">
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 left-0 right-0 h-10 z-50"
|
className="absolute top-0 left-0 right-0 h-10 z-50"
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (e.button === 0) getCurrentWindow().startDragging();
|
if (e.button === 0) getCurrentWindow().startDragging();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="mb-4 shrink-0 mt-2">
|
<div className="overflow-y-auto flex-1 flex flex-col hide-scrollbar">
|
||||||
<div className="text-[11px] font-semibold text-text-muted/80 uppercase tracking-wider px-2 mb-1">Library</div>
|
<div className="mb-5 shrink-0 mt-2">
|
||||||
<NavItem icon={Inbox} label="All" filter="all" />
|
<div className="text-[10px] font-bold text-text-muted/60 tracking-widest px-3 mb-2">LIBRARY</div>
|
||||||
<NavItem icon={Zap} label="Active" filter="active" />
|
<NavItem icon={Inbox} label="All" filter="all" />
|
||||||
<NavItem icon={CheckCircle2} label="Completed" filter="completed" />
|
<NavItem icon={Zap} label="Active" filter="active" />
|
||||||
<NavItem icon={CircleDashed} label="Unfinished" filter="unfinished" />
|
<NavItem icon={CheckCircle2} label="Completed" filter="completed" />
|
||||||
</div>
|
<NavItem icon={CircleDashed} label="Unfinished" filter="unfinished" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 shrink-0">
|
<div className="mb-5 shrink-0">
|
||||||
<div className="text-[11px] font-semibold text-text-muted/80 uppercase tracking-wider px-2 mb-1">Folders</div>
|
<div className="text-[10px] font-bold text-text-muted/60 tracking-widest px-3 mb-2">FOLDERS</div>
|
||||||
<NavItem icon={Film} label="Video" filter="Video" />
|
<NavItem icon={Film} label="Video" filter="Video" />
|
||||||
<NavItem icon={Music} label="Audio" filter="Audio" />
|
<NavItem icon={Music} label="Audio" filter="Audio" />
|
||||||
<NavItem icon={FileText} label="Documents" filter="Documents" />
|
<NavItem icon={FileText} label="Documents" filter="Documents" />
|
||||||
<NavItem icon={Box} label="Apps" filter="Apps" />
|
<NavItem icon={Box} label="Apps" filter="Apps" />
|
||||||
<NavItem icon={ImageIcon} label="Images" filter="Images" />
|
<NavItem icon={ImageIcon} label="Images" filter="Images" />
|
||||||
<NavItem icon={Archive} label="Archives" filter="Archives" />
|
<NavItem icon={Archive} label="Archives" filter="Archives" />
|
||||||
<NavItem icon={FileQuestion} label="Other" filter="Other" />
|
<NavItem icon={FileQuestion} label="Other" filter="Other" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 shrink-0">
|
<div className="mb-5 shrink-0">
|
||||||
<div className="text-[11px] font-semibold text-text-muted/80 uppercase tracking-wider px-2 mb-1">Queues</div>
|
<div className="text-[10px] font-bold text-text-muted/60 tracking-widest px-3 mb-2">QUEUES</div>
|
||||||
<div className="flex items-center px-2 py-1.5 rounded-md text-[13px] text-text-secondary hover:bg-item-hover cursor-default transition-colors mb-0.5">
|
<div className="flex items-center px-3 py-1.5 rounded-md text-[13px] font-medium text-text-secondary hover:bg-item-hover hover:text-text-primary cursor-default transition-colors mb-[2px]">
|
||||||
<List className="w-4 h-4 mr-2 opacity-80" />
|
<List className="w-4 h-4 mr-2.5 opacity-70" strokeWidth={2} />
|
||||||
<span>Main Queue</span>
|
<span>Main Queue</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 pb-2">
|
||||||
|
<div className="text-[10px] font-bold text-text-muted/60 tracking-widest px-3 mb-2">TOOLS</div>
|
||||||
|
<div className="flex items-center px-3 py-1.5 rounded-md text-[13px] font-medium text-text-secondary hover:bg-item-hover hover:text-text-primary cursor-default transition-colors mb-[2px]">
|
||||||
|
<CalendarClock className="w-4 h-4 mr-2.5 opacity-70" strokeWidth={2} /><span>Scheduler</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-3 py-1.5 rounded-md text-[13px] font-medium text-text-secondary hover:bg-item-hover hover:text-text-primary cursor-default transition-colors mb-[2px]">
|
||||||
|
<Gauge className="w-4 h-4 mr-2.5 opacity-70" strokeWidth={2} /><span>Speed Limiter</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-[16px]"></div>
|
<div className="shrink-0 pt-4 mt-auto">
|
||||||
|
<div
|
||||||
<div className="shrink-0 pb-2">
|
onClick={() => useSettingsStore.getState().setActiveView('settings')}
|
||||||
<div className="text-[11px] font-semibold text-text-muted/80 uppercase tracking-wider px-2 mb-1">Tools</div>
|
className={`flex items-center px-3 py-2 rounded-md text-[13px] font-medium cursor-pointer transition-colors ${
|
||||||
<div className="flex items-center px-2 py-1.5 rounded-md text-[13px] text-text-secondary hover:bg-item-hover cursor-default transition-colors mb-0.5">
|
activeView === 'settings'
|
||||||
<CalendarClock className="w-4 h-4 mr-2 opacity-80" /><span>Scheduler</span>
|
? 'bg-[#3B66DE] text-white shadow-sm'
|
||||||
</div>
|
: 'text-text-secondary hover:bg-[#2A2C2F] hover:text-text-primary'
|
||||||
<div className="flex items-center px-2 py-1.5 rounded-md text-[13px] text-text-secondary hover:bg-item-hover cursor-default transition-colors mb-0.5">
|
}`}
|
||||||
<Gauge className="w-4 h-4 mr-2 opacity-80" /><span>Speed Limiter</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
onClick={() => useSettingsStore.getState().toggleSettingsModal(true)}
|
|
||||||
className="flex items-center px-2 py-1.5 rounded-md text-[13px] text-text-secondary hover:bg-item-hover cursor-pointer transition-colors"
|
|
||||||
>
|
>
|
||||||
<Settings className="w-4 h-4 mr-2 opacity-80" /><span>Settings</span>
|
<Settings className={`w-4 h-4 mr-2.5 ${activeView === 'settings' ? 'opacity-100' : 'opacity-70'}`} strokeWidth={activeView === 'settings' ? 2.5 : 2} /><span>Settings</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface DownloadItem {
|
|||||||
fraction?: number;
|
fraction?: number;
|
||||||
speed?: string;
|
speed?: string;
|
||||||
eta?: string;
|
eta?: string;
|
||||||
|
size?: string;
|
||||||
category: DownloadCategory;
|
category: DownloadCategory;
|
||||||
dateAdded: string;
|
dateAdded: string;
|
||||||
// Advanced Settings
|
// Advanced Settings
|
||||||
@@ -146,7 +147,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
processQueue: async () => {
|
processQueue: async () => {
|
||||||
const { downloads, updateDownload } = get();
|
const { downloads, updateDownload } = get();
|
||||||
const { maxConcurrentDownloads, globalSpeedLimit, defaultDownloadPath } = useSettingsStore.getState();
|
const { maxConcurrentDownloads } = useSettingsStore.getState();
|
||||||
|
|
||||||
const activeCount = downloads.filter(d => d.status === 'downloading').length;
|
const activeCount = downloads.filter(d => d.status === 'downloading').length;
|
||||||
if (activeCount >= maxConcurrentDownloads) return;
|
if (activeCount >= maxConcurrentDownloads) return;
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export interface SettingsState {
|
|||||||
defaultDownloadPath: string;
|
defaultDownloadPath: string;
|
||||||
maxConcurrentDownloads: number;
|
maxConcurrentDownloads: number;
|
||||||
globalSpeedLimit: string;
|
globalSpeedLimit: string;
|
||||||
isSettingsModalOpen: boolean;
|
|
||||||
isSidebarVisible: boolean;
|
isSidebarVisible: boolean;
|
||||||
|
activeView: 'downloads' | 'settings';
|
||||||
|
|
||||||
// Replicated SwiftUI App Settings
|
// Replicated SwiftUI App Settings
|
||||||
perServerConnections: number;
|
perServerConnections: number;
|
||||||
@@ -38,7 +38,7 @@ export interface SettingsState {
|
|||||||
setDefaultDownloadPath: (path: string) => void;
|
setDefaultDownloadPath: (path: string) => void;
|
||||||
setMaxConcurrentDownloads: (count: number) => void;
|
setMaxConcurrentDownloads: (count: number) => void;
|
||||||
setGlobalSpeedLimit: (limit: string) => void;
|
setGlobalSpeedLimit: (limit: string) => void;
|
||||||
toggleSettingsModal: (isOpen: boolean) => void;
|
setActiveView: (view: 'downloads' | 'settings') => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
|
|
||||||
setPerServerConnections: (count: number) => void;
|
setPerServerConnections: (count: number) => void;
|
||||||
@@ -67,7 +67,7 @@ const defaultDirectories = {
|
|||||||
Documents: '~/Downloads/Documents',
|
Documents: '~/Downloads/Documents',
|
||||||
Apps: '~/Downloads/Apps',
|
Apps: '~/Downloads/Apps',
|
||||||
Images: '~/Downloads/Images',
|
Images: '~/Downloads/Images',
|
||||||
Archives: '~/Downloads/Archives',
|
Archives: '~/Downloads/Compressed',
|
||||||
Other: '~/Downloads/Other'
|
Other: '~/Downloads/Other'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
defaultDownloadPath: '~/Downloads',
|
defaultDownloadPath: '~/Downloads',
|
||||||
maxConcurrentDownloads: 3,
|
maxConcurrentDownloads: 3,
|
||||||
globalSpeedLimit: '',
|
globalSpeedLimit: '',
|
||||||
isSettingsModalOpen: false,
|
activeView: 'downloads',
|
||||||
isSidebarVisible: true,
|
isSidebarVisible: true,
|
||||||
|
|
||||||
// Replicated SwiftUI defaults
|
// Replicated SwiftUI defaults
|
||||||
@@ -118,15 +118,23 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
askWhereToSaveEachFile: false,
|
askWhereToSaveEachFile: false,
|
||||||
preventsSleepWhileDownloading: true,
|
preventsSleepWhileDownloading: true,
|
||||||
mediaCookieSource: 'none',
|
mediaCookieSource: 'none',
|
||||||
downloadDirectories: { ...defaultDirectories },
|
downloadDirectories: {
|
||||||
|
'Video': '~/Downloads/Video',
|
||||||
|
'Audio': '~/Downloads/Audio',
|
||||||
|
'Documents': '~/Downloads/Documents',
|
||||||
|
'Apps': '~/Downloads/Apps',
|
||||||
|
'Images': '~/Downloads/Images',
|
||||||
|
'Archives': '~/Downloads/Compressed',
|
||||||
|
'Other': '~/Downloads/Other'
|
||||||
|
},
|
||||||
siteLogins: [],
|
siteLogins: [],
|
||||||
extensionPairingToken: generateSecureToken(),
|
extensionPairingToken: generateSecureToken(),
|
||||||
|
|
||||||
setTheme: (theme) => set({ theme }),
|
setTheme: (theme) => set({ theme }),
|
||||||
setDefaultDownloadPath: (defaultDownloadPath) => set({ defaultDownloadPath }),
|
setDefaultDownloadPath: (path) => set({ defaultDownloadPath: path }),
|
||||||
setMaxConcurrentDownloads: (maxConcurrentDownloads) => set({ maxConcurrentDownloads }),
|
setMaxConcurrentDownloads: (max) => set({ maxConcurrentDownloads: max }),
|
||||||
setGlobalSpeedLimit: (globalSpeedLimit) => set({ globalSpeedLimit }),
|
setGlobalSpeedLimit: (limit) => set({ globalSpeedLimit: limit }),
|
||||||
toggleSettingsModal: (isSettingsModalOpen) => set({ isSettingsModalOpen }),
|
setActiveView: (view) => set({ activeView: view }),
|
||||||
toggleSidebar: () => set((state) => ({ isSidebarVisible: !state.isSidebarVisible })),
|
toggleSidebar: () => set((state) => ({ isSidebarVisible: !state.isSidebarVisible })),
|
||||||
|
|
||||||
setPerServerConnections: (perServerConnections) => set({ perServerConnections }),
|
setPerServerConnections: (perServerConnections) => set({ perServerConnections }),
|
||||||
|
|||||||
Reference in New Issue
Block a user