feat(desktop): modernize download management

This commit is contained in:
NimBold
2026-06-12 20:33:17 +03:30
parent 3074438147
commit a5022fbf8a
9 changed files with 492 additions and 383 deletions
+18 -2
View File
@@ -493,6 +493,12 @@ async fn start_media_download(
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")
@@ -543,6 +549,9 @@ async fn start_media_download(
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() => {
@@ -554,6 +563,13 @@ async fn start_media_download(
.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())
@@ -566,7 +582,7 @@ async fn start_media_download(
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() };
+8 -4
View File
@@ -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>
); );
+124 -51
View File
@@ -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';
@@ -214,23 +237,24 @@ 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);
@@ -256,7 +280,7 @@ export const AddDownloadsModal = () => {
setSaveLocation(defaultDownloadPath); setSaveLocation(defaultDownloadPath);
setUrls(''); setUrls('');
setParsedItems([]); setParsedItems([]);
setExtractMedia(false); setSelectedItemIndex(null);
} }
}, [isAddModalOpen, defaultDownloadPath]); }, [isAddModalOpen, defaultDownloadPath]);
@@ -272,22 +296,29 @@ export const AddDownloadsModal = () => {
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;
@@ -311,16 +342,21 @@ 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;
@@ -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,
@@ -430,16 +466,6 @@ export const AddDownloadsModal = () => {
<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"
@@ -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>
)) ))
)} )}
@@ -528,6 +542,65 @@ export const AddDownloadsModal = () => {
<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">
@@ -558,7 +631,7 @@ 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>
+71 -62
View File
@@ -106,7 +106,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
{/* 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();
@@ -115,62 +115,60 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
> >
<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>
@@ -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>
@@ -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">
@@ -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);
@@ -138,8 +136,7 @@ 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 && (
@@ -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" />
@@ -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>
); );
}; };
+56 -43
View File
@@ -1,5 +1,4 @@
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,
@@ -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) {
@@ -31,17 +33,21 @@ 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,7 +55,7 @@ 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
@@ -57,48 +63,55 @@ export const Sidebar: React.FC<SidebarProps> = ({ selectedFilter, onSelectFilter
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 className="shrink-0 pb-2">
<div className="text-[11px] font-semibold text-text-muted/80 uppercase tracking-wider px-2 mb-1">Tools</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">
<CalendarClock className="w-4 h-4 mr-2 opacity-80" /><span>Scheduler</span>
</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">
<Gauge className="w-4 h-4 mr-2 opacity-80" /><span>Speed Limiter</span>
</div>
<div <div
onClick={() => useSettingsStore.getState().toggleSettingsModal(true)} onClick={() => useSettingsStore.getState().setActiveView('settings')}
className="flex items-center px-2 py-1.5 rounded-md text-[13px] text-text-secondary hover:bg-item-hover cursor-pointer transition-colors" className={`flex items-center px-3 py-2 rounded-md text-[13px] font-medium cursor-pointer transition-colors ${
activeView === 'settings'
? 'bg-[#3B66DE] text-white shadow-sm'
: 'text-text-secondary hover:bg-[#2A2C2F] hover:text-text-primary'
}`}
> >
<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>
+2 -1
View File
@@ -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;
+17 -9
View File
@@ -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 }),