mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 20:18:37 +00:00
feat: implement site logins, queues parity, and engine settings updates
- Implemented Site Logins parity by integrating auth credentials to Rust backend (Aria2 and yt-dlp) and fetching metadata. - Implemented Queues functionality including context menu actions (start, pause, rename, delete). - Updated Engine settings UI to include Deno and render clean version output without extra trademark info.
This commit is contained in:
@@ -15,7 +15,7 @@ struct MetadataResponse {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn fetch_metadata(url: String, user_agent: Option<String>) -> Result<MetadataResponse, String> {
|
||||
async fn fetch_metadata(url: String, user_agent: Option<String>, username: Option<String>, password: Option<String>) -> Result<MetadataResponse, String> {
|
||||
let mut builder = reqwest::Client::builder();
|
||||
if let Some(ua) = user_agent {
|
||||
if !ua.is_empty() {
|
||||
@@ -29,10 +29,22 @@ async fn fetch_metadata(url: String, user_agent: Option<String>) -> Result<Metad
|
||||
|
||||
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 head_req = client.head(&url);
|
||||
if let Some(ref user) = username {
|
||||
if !user.is_empty() {
|
||||
head_req = head_req.basic_auth(user, password.as_deref());
|
||||
}
|
||||
}
|
||||
let mut res = head_req.send().await.map_err(|e| e.to_string())?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
res = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
let mut get_req = client.get(&url);
|
||||
if let Some(ref user) = username {
|
||||
if !user.is_empty() {
|
||||
get_req = get_req.basic_auth(user, password.as_deref());
|
||||
}
|
||||
}
|
||||
res = get_req.send().await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let mut filename = String::new();
|
||||
@@ -81,7 +93,7 @@ async fn fetch_metadata(url: String, user_agent: Option<String>) -> Result<Metad
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn fetch_media_metadata(app_handle: tauri::AppHandle, url: String, cookie_browser: Option<String>) -> Result<String, String> {
|
||||
async fn fetch_media_metadata(app_handle: tauri::AppHandle, url: String, cookie_browser: Option<String>, username: Option<String>, password: Option<String>) -> Result<String, String> {
|
||||
println!("fetch_media_metadata called for: {}", url);
|
||||
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?;
|
||||
let ytdlp_path = resource_dir.join("binaries").join("yt-dlp");
|
||||
@@ -102,6 +114,16 @@ async fn fetch_media_metadata(app_handle: tauri::AppHandle, url: String, cookie_
|
||||
cmd.arg("--cookies-from-browser").arg(&browser);
|
||||
}
|
||||
}
|
||||
if let Some(user) = username {
|
||||
if !user.is_empty() {
|
||||
cmd.arg("--username").arg(&user);
|
||||
}
|
||||
}
|
||||
if let Some(pass) = password {
|
||||
if !pass.is_empty() {
|
||||
cmd.arg("--password").arg(&pass);
|
||||
}
|
||||
}
|
||||
|
||||
cmd.arg(&url);
|
||||
|
||||
@@ -172,8 +194,9 @@ async fn test_aria2c(app_handle: tauri::AppHandle) -> Result<String, String> {
|
||||
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
// aria2c prints a lot, just get the first line for the version
|
||||
let first_line = text.lines().next().unwrap_or("").to_string();
|
||||
println!("aria2c output: {}", first_line);
|
||||
Ok(first_line)
|
||||
let clean = first_line.replace("aria2 version ", "");
|
||||
println!("aria2c output: {}", clean);
|
||||
Ok(clean)
|
||||
} else {
|
||||
let err = String::from_utf8_lossy(&output.stderr);
|
||||
println!("aria2c error output: {}", err);
|
||||
@@ -200,8 +223,10 @@ async fn test_ffmpeg(app_handle: tauri::AppHandle) -> Result<String, String> {
|
||||
if output.status.success() {
|
||||
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let first_line = text.lines().next().unwrap_or("").to_string();
|
||||
println!("ffmpeg output: {}", first_line);
|
||||
Ok(first_line)
|
||||
let parts: Vec<&str> = first_line.split_whitespace().collect();
|
||||
let clean = parts.get(2).unwrap_or(&first_line.as_str()).split('-').next().unwrap_or("").to_string();
|
||||
println!("ffmpeg output: {}", clean);
|
||||
Ok(clean)
|
||||
} else {
|
||||
let err = String::from_utf8_lossy(&output.stderr);
|
||||
println!("ffmpeg error output: {}", err);
|
||||
@@ -209,6 +234,36 @@ async fn test_ffmpeg(app_handle: tauri::AppHandle) -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn test_deno(app_handle: tauri::AppHandle) -> Result<String, String> {
|
||||
println!("test_deno called!");
|
||||
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?;
|
||||
let deno_path = resource_dir.join("binaries").join("deno");
|
||||
println!("Resolved deno path: {:?}", deno_path);
|
||||
|
||||
let output = Command::new(&deno_path)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
println!("Failed to execute: {}", e);
|
||||
format!("Failed to execute deno: {}", e)
|
||||
})?;
|
||||
|
||||
println!("deno execution finished with status: {}", output.status);
|
||||
if output.status.success() {
|
||||
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let first_line = text.lines().next().unwrap_or("").to_string();
|
||||
let parts: Vec<&str> = first_line.split_whitespace().collect();
|
||||
let clean = parts.get(1).unwrap_or(&first_line.as_str()).to_string();
|
||||
println!("deno output: {}", clean);
|
||||
Ok(clean)
|
||||
} else {
|
||||
let err = String::from_utf8_lossy(&output.stderr);
|
||||
println!("deno error output: {}", err);
|
||||
Err(format!("deno error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_file(path: String) -> Result<(), String> {
|
||||
println!("open_file called for path: {}", path);
|
||||
@@ -363,11 +418,13 @@ async fn start_download(
|
||||
if let Some(user) = username {
|
||||
if !user.is_empty() {
|
||||
cmd.arg(format!("--http-user={}", user));
|
||||
cmd.arg(format!("--ftp-user={}", user));
|
||||
}
|
||||
}
|
||||
if let Some(pass) = password {
|
||||
if !pass.is_empty() {
|
||||
cmd.arg(format!("--http-passwd={}", pass));
|
||||
cmd.arg(format!("--ftp-passwd={}", pass));
|
||||
}
|
||||
}
|
||||
if let Some(hdr) = headers {
|
||||
@@ -471,6 +528,8 @@ async fn start_media_download(
|
||||
filename: String,
|
||||
format_selector: Option<String>,
|
||||
speed_limit: Option<String>,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
println!("start_media_download called for id: {}", id);
|
||||
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?;
|
||||
@@ -537,6 +596,17 @@ async fn start_media_download(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(user) = username {
|
||||
if !user.is_empty() {
|
||||
cmd.arg("--username").arg(&user);
|
||||
}
|
||||
}
|
||||
if let Some(pass) = password {
|
||||
if !pass.is_empty() {
|
||||
cmd.arg("--password").arg(&pass);
|
||||
}
|
||||
}
|
||||
|
||||
cmd.arg(&url);
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped()); // Also pipe stderr for better error reporting
|
||||
@@ -842,7 +912,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet, test_ytdlp, test_aria2c, test_ffmpeg, open_file, show_in_folder,
|
||||
greet, test_ytdlp, test_aria2c, test_ffmpeg, test_deno, 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, perform_system_action,
|
||||
request_automation_permission, open_automation_settings
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AddDownloadsModal } from "./components/AddDownloadsModal";
|
||||
import SettingsView from "./components/SettingsView";
|
||||
import { PropertiesModal } from "./components/PropertiesModal";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useDownloadStore } from "./store/useDownloadStore";
|
||||
import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
|
||||
import { useSettingsStore } from "./store/useSettingsStore";
|
||||
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
@@ -68,7 +68,7 @@ function App() {
|
||||
const triggerKey = `${dateKey}-${currentTime}`;
|
||||
if (state.schedulerLastStartKey !== triggerKey) {
|
||||
state.setSchedulerLastStartKey(triggerKey);
|
||||
const started = await useDownloadStore.getState().startMainQueue();
|
||||
const started = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID);
|
||||
state.setSchedulerRunning(started > 0);
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ function App() {
|
||||
const triggerKey = `${dateKey}-${currentTime}`;
|
||||
if (state.schedulerLastStopKey !== triggerKey) {
|
||||
state.setSchedulerLastStopKey(triggerKey);
|
||||
await useDownloadStore.getState().pauseMainQueue();
|
||||
await useDownloadStore.getState().pauseQueue(MAIN_QUEUE_ID);
|
||||
state.setSchedulerRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useDownloadStore } from '../store/useDownloadStore';
|
||||
import { useDownloadStore, MAIN_QUEUE_ID, getSiteLogin } from '../store/useDownloadStore';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { DownloadCategory } from '../store/useDownloadStore';
|
||||
import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music } from 'lucide-react';
|
||||
@@ -249,9 +249,11 @@ const isMediaUrl = (url: string) => {
|
||||
|
||||
|
||||
export const AddDownloadsModal = () => {
|
||||
const { isAddModalOpen, toggleAddModal, addDownload } = useDownloadStore();
|
||||
const { isAddModalOpen, toggleAddModal, addDownload, queues } = useDownloadStore();
|
||||
const { defaultDownloadPath } = useSettingsStore();
|
||||
|
||||
const [selectedQueueId, setSelectedQueueId] = useState<string>(MAIN_QUEUE_ID);
|
||||
|
||||
const [urls, setUrls] = useState('');
|
||||
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
|
||||
const [parsedItems, setParsedItems] = useState<ParsedDownloadItem[]>([]);
|
||||
@@ -281,6 +283,7 @@ export const AddDownloadsModal = () => {
|
||||
setUrls('');
|
||||
setParsedItems([]);
|
||||
setSelectedItemIndex(null);
|
||||
setSelectedQueueId(queues.find(q => q.isMain)?.id || MAIN_QUEUE_ID);
|
||||
}
|
||||
}, [isAddModalOpen, defaultDownloadPath]);
|
||||
|
||||
@@ -319,10 +322,17 @@ export const AddDownloadsModal = () => {
|
||||
try {
|
||||
new URL(url);
|
||||
if (isMediaUrl(url)) {
|
||||
const { mediaCookieSource } = useSettingsStore.getState();
|
||||
const settingsStore = useSettingsStore.getState();
|
||||
const { mediaCookieSource } = settingsStore;
|
||||
const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null;
|
||||
const login = getSiteLogin(url, settingsStore);
|
||||
|
||||
const jsonStr = await invoke<string>('fetch_media_metadata', { url, cookieBrowser: browserArg });
|
||||
const jsonStr = await invoke<string>('fetch_media_metadata', {
|
||||
url,
|
||||
cookieBrowser: browserArg,
|
||||
username: login?.username || null,
|
||||
password: login?.password || null
|
||||
});
|
||||
const mediaData = parseMediaFormats(jsonStr);
|
||||
if (mediaData && mediaData.formats.length > 0) {
|
||||
updatedItems[i] = {
|
||||
@@ -339,7 +349,13 @@ export const AddDownloadsModal = () => {
|
||||
throw new Error("Invalid media metadata or no formats found");
|
||||
}
|
||||
} else {
|
||||
const meta = await invoke<{filename: string, size: string, size_bytes: number}>('fetch_metadata', { url });
|
||||
const settingsStore = useSettingsStore.getState();
|
||||
const login = getSiteLogin(url, settingsStore);
|
||||
const meta = await invoke<{filename: string, size: string, size_bytes: number}>('fetch_metadata', {
|
||||
url,
|
||||
username: login?.username || null,
|
||||
password: login?.password || null
|
||||
});
|
||||
updatedItems[i] = { url, file: meta.filename, size: meta.size, sizeBytes: meta.size_bytes, status: 'Ready' };
|
||||
}
|
||||
if (firstReadyIndex === null) firstReadyIndex = i;
|
||||
@@ -423,7 +439,8 @@ export const AddDownloadsModal = () => {
|
||||
headers: headers.trim() || undefined,
|
||||
destination: finalLocation,
|
||||
isMedia: item.isMedia,
|
||||
mediaFormatSelector: formatSelector
|
||||
mediaFormatSelector: formatSelector,
|
||||
queueId: selectedQueueId
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Invalid URL or failed to add:", e);
|
||||
@@ -627,9 +644,18 @@ export const AddDownloadsModal = () => {
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-text-primary mb-3">
|
||||
<Settings size={16} className="text-blue-500" /> Transfer Settings
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-text-secondary font-medium">Connections per File</label>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-text-secondary font-medium">Target Queue</label>
|
||||
<select value={selectedQueueId} onChange={e=>setSelectedQueueId(e.target.value)} className="w-32 bg-bg-input border border-border-modal rounded-md px-2 py-1 text-xs text-text-primary focus:border-blue-500 focus:outline-none">
|
||||
{queues.map(q => (
|
||||
<option key={q.id} value={q.id}>{q.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-text-secondary font-medium">Connections per File</label>
|
||||
<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={parsedItems.some(i => i.isMedia)} />
|
||||
<span className="text-xs text-text-primary font-mono w-4 text-right">{connections}</span>
|
||||
|
||||
@@ -44,6 +44,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
};
|
||||
|
||||
const filteredDownloads = downloads.filter((d: DownloadItem) => {
|
||||
if (filter.startsWith('queue:')) {
|
||||
return d.queueId === filter.replace('queue:', '');
|
||||
}
|
||||
switch (filter) {
|
||||
case 'all': return true;
|
||||
case 'active': return d.status === 'downloading';
|
||||
@@ -54,6 +57,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
});
|
||||
|
||||
const getFilterTitle = () => {
|
||||
if (filter.startsWith('queue:')) {
|
||||
const qid = filter.replace('queue:', '');
|
||||
const queue = useDownloadStore.getState().queues.find(q => q.id === qid);
|
||||
return queue ? queue.name : 'Unknown Queue';
|
||||
}
|
||||
switch (filter) {
|
||||
case 'all': return 'All Downloads';
|
||||
case 'active': return 'Active';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
CheckCircle2, Clock3, List, LockKeyhole, Moon,
|
||||
CheckCircle2, Clock3, List, Moon, LockKeyhole,
|
||||
Pause, Play, Power, RotateCcw, Save
|
||||
} from 'lucide-react';
|
||||
import { PostQueueAction, SchedulerSettings, useSettingsStore } from '../store/useSettingsStore';
|
||||
import { useDownloadStore } from '../store/useDownloadStore';
|
||||
import { useDownloadStore, MAIN_QUEUE_ID } from '../store/useDownloadStore';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
|
||||
const days = [
|
||||
@@ -97,7 +97,7 @@ export default function SchedulerView() {
|
||||
};
|
||||
|
||||
const runNow = async () => {
|
||||
const count = await useDownloadStore.getState().startMainQueue();
|
||||
const count = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID);
|
||||
if (count > 0) {
|
||||
useSettingsStore.getState().setSchedulerRunning(true);
|
||||
setToast(`Started ${count} download${count === 1 ? '' : 's'}`);
|
||||
@@ -107,7 +107,7 @@ export default function SchedulerView() {
|
||||
};
|
||||
|
||||
const pauseNow = async () => {
|
||||
const count = await useDownloadStore.getState().pauseMainQueue();
|
||||
const count = await useDownloadStore.getState().pauseQueue(MAIN_QUEUE_ID);
|
||||
useSettingsStore.getState().setSchedulerRunning(false);
|
||||
setToast(count > 0 ? `Paused ${count} active download${count === 1 ? '' : 's'}` : 'No active downloads');
|
||||
};
|
||||
|
||||
@@ -25,9 +25,16 @@ export default function SettingsView() {
|
||||
const activeTab = settings.activeSettingsTab;
|
||||
|
||||
// Local state for versions
|
||||
const [aria2Version, setAria2Version] = useState('Checking...');
|
||||
const [ytdlpVersion, setYtdlpVersion] = useState('Checking...');
|
||||
const [ffmpegVersion, setFfmpegVersion] = useState('Checking...');
|
||||
const [aria2Version, setAria2Version] = useState<string>('Checking...');
|
||||
const [ytdlpVersion, setYtdlpVersion] = useState<string>('Checking...');
|
||||
const [ffmpegVersion, setFfmpegVersion] = useState<string>('Checking...');
|
||||
const [denoVersion, setDenoVersion] = useState<string>('Checking...');
|
||||
|
||||
const getEngineStatus = (v: string) => {
|
||||
if (v === 'Checking...') return <span className="text-text-muted font-medium">Checking...</span>;
|
||||
if (v.startsWith('Error')) return <span className="text-red-500 font-medium">Error / Missing</span>;
|
||||
return <span className="text-green-500 font-medium">Ready</span>;
|
||||
};
|
||||
|
||||
// Local state for adding site login
|
||||
const [loginPattern, setLoginPattern] = useState('');
|
||||
@@ -59,6 +66,10 @@ export default function SettingsView() {
|
||||
invoke<string>('test_ffmpeg')
|
||||
.then(v => setFfmpegVersion(v))
|
||||
.catch(e => setFfmpegVersion('Error: ' + e));
|
||||
|
||||
invoke<string>('test_deno')
|
||||
.then(v => setDenoVersion(v))
|
||||
.catch(e => setDenoVersion('Error: ' + e));
|
||||
}
|
||||
}, [settings.activeView, activeTab]);
|
||||
|
||||
@@ -626,7 +637,7 @@ export default function SettingsView() {
|
||||
</div>
|
||||
<div className="grid grid-cols-[120px_1fr] text-[13px] items-center">
|
||||
<span className="text-text-secondary">Status:</span>
|
||||
<span className="text-green-500 font-medium">Ready</span>
|
||||
{getEngineStatus(aria2Version)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -634,17 +645,23 @@ export default function SettingsView() {
|
||||
<h4 className="text-[13px] font-bold text-text-primary flex items-center gap-2 border-b border-border-modal pb-1">
|
||||
<Terminal size={14} className="text-orange-500" /> Media Extractors
|
||||
</h4>
|
||||
<div className="grid grid-cols-[120px_1fr] text-[13px] pb-1">
|
||||
|
||||
<div className="grid grid-cols-[120px_1fr_80px] text-[13px] pb-1 items-center">
|
||||
<span className="text-text-secondary font-semibold">yt-dlp:</span>
|
||||
<span className="font-mono text-xs text-text-muted select-all">{ytdlpVersion}</span>
|
||||
<span className="font-mono text-xs text-text-muted select-all truncate pr-4">{ytdlpVersion}</span>
|
||||
{getEngineStatus(ytdlpVersion)}
|
||||
</div>
|
||||
<div className="grid grid-cols-[120px_1fr] text-[13px] pb-1">
|
||||
|
||||
<div className="grid grid-cols-[120px_1fr_80px] text-[13px] pb-1 items-center">
|
||||
<span className="text-text-secondary font-semibold">FFmpeg:</span>
|
||||
<span className="font-mono text-xs text-text-muted select-all">{ffmpegVersion}</span>
|
||||
<span className="font-mono text-xs text-text-muted select-all truncate pr-4">{ffmpegVersion}</span>
|
||||
{getEngineStatus(ffmpegVersion)}
|
||||
</div>
|
||||
<div className="grid grid-cols-[120px_1fr] text-[13px] pb-1">
|
||||
|
||||
<div className="grid grid-cols-[120px_1fr_80px] text-[13px] pb-1 items-center">
|
||||
<span className="text-text-secondary font-semibold">Deno:</span>
|
||||
<span className="text-red-500 text-xs font-semibold">Not Installed (Local Extension Only)</span>
|
||||
<span className="font-mono text-xs text-text-muted select-all truncate pr-4">{denoVersion}</span>
|
||||
{getEngineStatus(denoVersion)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px] border-t border-border-modal/50 pt-3 mt-2">
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Inbox, Zap, CheckCircle2, CircleDashed,
|
||||
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion,
|
||||
List, CalendarClock, Gauge, Settings, Plus
|
||||
List, CalendarClock, Gauge, Settings, Plus, Play, Pause, Edit2, Trash2
|
||||
} from 'lucide-react';
|
||||
import { useDownloadStore, DownloadCategory } from '../store/useDownloadStore';
|
||||
import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore';
|
||||
import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
|
||||
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings';
|
||||
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
|
||||
|
||||
interface SidebarProps {
|
||||
selectedFilter: SidebarFilter;
|
||||
@@ -16,18 +16,44 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
const selectedFilter = props.selectedFilter;
|
||||
const onSelectFilter = props.onSelectFilter;
|
||||
const downloads = useDownloadStore(state => state.downloads);
|
||||
const activeView = useSettingsStore(state => state.activeView);
|
||||
const { selectedFilter, onSelectFilter } = props;
|
||||
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue } = useDownloadStore();
|
||||
const { activeView, setActiveView } = useSettingsStore();
|
||||
|
||||
const [isAddingQueue, setIsAddingQueue] = useState(false);
|
||||
const [newQueueName, setNewQueueName] = useState('');
|
||||
const [renamingQueueId, setRenamingQueueId] = useState<string | null>(null);
|
||||
const [editingQueueName, setEditingQueueName] = useState('');
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
|
||||
|
||||
const addInputRef = useRef<HTMLInputElement>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCloseMenu = () => setContextMenu(null);
|
||||
window.addEventListener('click', handleCloseMenu);
|
||||
return () => window.removeEventListener('click', handleCloseMenu);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAddingQueue) addInputRef.current?.focus();
|
||||
}, [isAddingQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (renamingQueueId) renameInputRef.current?.focus();
|
||||
}, [renamingQueueId]);
|
||||
|
||||
const getCount = (filter: SidebarFilter) => {
|
||||
if (filter.startsWith('queue:')) {
|
||||
const qid = filter.replace('queue:', '');
|
||||
return downloads.filter(d => d.queueId === qid).length;
|
||||
}
|
||||
switch (filter) {
|
||||
case 'all': return downloads.length;
|
||||
case 'active': return downloads.filter(d => d.status === 'downloading').length;
|
||||
case 'completed': return downloads.filter(d => d.status === 'completed').length;
|
||||
case 'unfinished': return downloads.filter(d => d.status !== 'completed').length;
|
||||
default: return downloads.filter(d => d.category === filter).length;
|
||||
default: return downloads.filter(d => d.category === filter as DownloadCategory).length;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -57,12 +83,80 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const handleQueueContextMenu = (e: React.MouseEvent, id: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, id });
|
||||
};
|
||||
|
||||
const handleAddQueueSubmit = () => {
|
||||
if (newQueueName.trim()) addQueue(newQueueName.trim());
|
||||
setNewQueueName('');
|
||||
setIsAddingQueue(false);
|
||||
};
|
||||
|
||||
const handleRenameQueueSubmit = () => {
|
||||
if (renamingQueueId && editingQueueName.trim()) {
|
||||
renameQueue(renamingQueueId, editingQueueName.trim());
|
||||
}
|
||||
setRenamingQueueId(null);
|
||||
};
|
||||
|
||||
const QueueItem = ({ queue }: { queue: Queue }) => {
|
||||
const filterId = `queue:${queue.id}`;
|
||||
const isSelected = activeView === 'downloads' && selectedFilter === filterId;
|
||||
const isRenaming = renamingQueueId === queue.id;
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<div className="flex items-center px-2.5 py-1 rounded-lg mb-0.5 bg-item-hover">
|
||||
<List className="w-4 h-4 mr-2 text-text-secondary" strokeWidth={2} />
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
type="text"
|
||||
className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0"
|
||||
value={editingQueueName}
|
||||
onChange={e => setEditingQueueName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleRenameQueueSubmit();
|
||||
if (e.key === 'Escape') setRenamingQueueId(null);
|
||||
}}
|
||||
onBlur={handleRenameQueueSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onContextMenu={e => handleQueueContextMenu(e, queue.id)}
|
||||
onClick={() => onSelectFilter(filterId)}
|
||||
className={`group flex w-full items-center px-2.5 py-1.5 rounded-lg text-[13px] text-left cursor-default transition-colors mb-0.5 ${
|
||||
isSelected
|
||||
? 'bg-accent text-white font-medium'
|
||||
: 'text-text-primary hover:bg-item-hover'
|
||||
}`}
|
||||
>
|
||||
<List className={`w-4 h-4 mr-2 shrink-0 ${isSelected ? 'text-white' : 'text-text-secondary'}`} strokeWidth={isSelected ? 2.25 : 2} />
|
||||
<span className="truncate">{queue.name}</span>
|
||||
{getCount(filterId) > 0 && (
|
||||
<span className={`ml-auto min-w-5 px-1.5 py-0.5 rounded-full text-center text-[10px] leading-none font-semibold shrink-0 ${
|
||||
isSelected ? 'bg-black/20 text-white' : 'bg-item-hover text-text-secondary'
|
||||
}`}>
|
||||
{getCount(filterId)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolItem = ({ icon: Icon, label, view }: { icon: any; label: string; view: ActiveView }) => {
|
||||
const isSelected = activeView === view;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => useSettingsStore.getState().setActiveView(view)}
|
||||
onClick={() => setActiveView(view)}
|
||||
className={`flex w-full items-center px-2.5 py-1.5 rounded-lg text-[13px] text-left cursor-default transition-colors mb-0.5 ${
|
||||
isSelected ? 'bg-accent text-white font-medium' : 'text-text-primary hover:bg-item-hover'
|
||||
}`}
|
||||
@@ -98,14 +192,36 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
|
||||
<section className="mb-4">
|
||||
<div className="text-[11px] font-semibold text-text-muted px-2.5 mb-1.5">Queues</div>
|
||||
<div className="flex items-center px-2.5 py-1.5 rounded-lg text-[13px] text-text-primary hover:bg-item-hover cursor-default transition-colors mb-0.5">
|
||||
<List className="w-4 h-4 mr-2 text-text-secondary" strokeWidth={2} />
|
||||
<span>Main Queue</span>
|
||||
</div>
|
||||
<div className="flex items-center px-2.5 py-1.5 rounded-lg text-[13px] text-text-secondary hover:bg-item-hover cursor-default transition-colors">
|
||||
<Plus className="w-4 h-4 mr-2" strokeWidth={2} />
|
||||
<span>Add new queue</span>
|
||||
</div>
|
||||
{queues.map(queue => (
|
||||
<QueueItem key={queue.id} queue={queue} />
|
||||
))}
|
||||
{isAddingQueue ? (
|
||||
<div className="flex items-center px-2.5 py-1 rounded-lg bg-item-hover">
|
||||
<Plus className="w-4 h-4 mr-2 text-text-secondary shrink-0" strokeWidth={2} />
|
||||
<input
|
||||
ref={addInputRef}
|
||||
type="text"
|
||||
placeholder="Queue name"
|
||||
className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0"
|
||||
value={newQueueName}
|
||||
onChange={e => setNewQueueName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleAddQueueSubmit();
|
||||
if (e.key === 'Escape') setIsAddingQueue(false);
|
||||
}}
|
||||
onBlur={handleAddQueueSubmit}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setIsAddingQueue(true); setNewQueueName(''); }}
|
||||
className="flex w-full items-center px-2.5 py-1.5 rounded-lg text-[13px] text-text-secondary hover:bg-item-hover cursor-default transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2 shrink-0" strokeWidth={2} />
|
||||
<span className="truncate">Add new queue</span>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -118,7 +234,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
<div className="shrink-0 border-t border-border-color bg-sidebar-glass px-2 py-2 backdrop-blur-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => useSettingsStore.getState().setActiveView('settings')}
|
||||
onClick={() => setActiveView('settings')}
|
||||
className={`flex w-full items-center px-2.5 py-2 rounded-lg text-[13px] text-left cursor-default transition-colors ${
|
||||
activeView === 'settings'
|
||||
? 'bg-accent text-white font-medium'
|
||||
@@ -129,6 +245,53 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="fixed z-50 w-48 py-1 rounded-xl shadow-lg border border-border-modal bg-bg-context-menu backdrop-blur-xl animate-fade-in text-[13px] text-text-primary overflow-hidden"
|
||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
|
||||
onClick={() => { startQueue(contextMenu.id); setContextMenu(null); }}
|
||||
>
|
||||
<Play size={14} className="mr-2 text-text-secondary" />
|
||||
Start Queue
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
|
||||
onClick={() => { pauseQueue(contextMenu.id); setContextMenu(null); }}
|
||||
>
|
||||
<Pause size={14} className="mr-2 text-text-secondary" />
|
||||
Pause Queue
|
||||
</button>
|
||||
<div className="h-px bg-border-color my-1 mx-2" />
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
|
||||
onClick={() => {
|
||||
const q = queues.find(q => q.id === contextMenu.id);
|
||||
if (q) {
|
||||
setEditingQueueName(q.name);
|
||||
setRenamingQueueId(q.id);
|
||||
}
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={14} className="mr-2 text-text-secondary" />
|
||||
Rename Queue
|
||||
</button>
|
||||
{!queues.find(q => q.id === contextMenu.id)?.isMain && (
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-red-500/20 text-red-400"
|
||||
onClick={() => { removeQueue(contextMenu.id); setContextMenu(null); }}
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
Delete Queue
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ const getProxyArgs = (settings: ReturnType<typeof useSettingsStore.getState>) =>
|
||||
return null;
|
||||
};
|
||||
|
||||
const getSiteLogin = (url: string, settings: ReturnType<typeof useSettingsStore.getState>) => {
|
||||
export const getSiteLogin = (url: string, settings: ReturnType<typeof useSettingsStore.getState>) => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const host = urlObj.hostname.toLowerCase();
|
||||
@@ -79,6 +79,14 @@ const effectiveSpeedLimit = (
|
||||
export type DownloadStatus = 'downloading' | 'paused' | 'completed' | 'failed' | 'queued';
|
||||
export type DownloadCategory = 'Documents' | 'Images' | 'Audio' | 'Video' | 'Apps' | 'Archives' | 'Other';
|
||||
|
||||
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
export interface Queue {
|
||||
id: string;
|
||||
name: string;
|
||||
isMain: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadItem {
|
||||
id: string;
|
||||
url: string;
|
||||
@@ -99,10 +107,12 @@ export interface DownloadItem {
|
||||
destination?: string;
|
||||
isMedia?: boolean;
|
||||
mediaFormatSelector?: string;
|
||||
queueId: string;
|
||||
}
|
||||
|
||||
interface DownloadState {
|
||||
downloads: DownloadItem[];
|
||||
queues: Queue[];
|
||||
isAddModalOpen: boolean;
|
||||
selectedPropertiesDownloadId: string | null;
|
||||
toggleAddModal: (isOpen: boolean) => void;
|
||||
@@ -113,13 +123,17 @@ interface DownloadState {
|
||||
clearFinished: () => void;
|
||||
redownload: (id: string) => void;
|
||||
processQueue: () => Promise<void>;
|
||||
startMainQueue: () => Promise<number>;
|
||||
pauseMainQueue: () => Promise<number>;
|
||||
startQueue: (queueId: string) => Promise<number>;
|
||||
pauseQueue: (queueId: string) => Promise<number>;
|
||||
addQueue: (name: string) => void;
|
||||
renameQueue: (id: string, name: string) => void;
|
||||
removeQueue: (id: string) => void;
|
||||
restartActiveDownloads: () => Promise<number>;
|
||||
}
|
||||
|
||||
export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
downloads: [],
|
||||
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
|
||||
isAddModalOpen: false,
|
||||
selectedPropertiesDownloadId: null,
|
||||
toggleAddModal: (isOpen) => set({ isAddModalOpen: isOpen }),
|
||||
@@ -184,9 +198,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
}));
|
||||
get().processQueue();
|
||||
},
|
||||
startMainQueue: async () => {
|
||||
startQueue: async (queueId) => {
|
||||
const runnableIds = get().downloads
|
||||
.filter(item => item.status === 'queued' || item.status === 'paused' || item.status === 'failed')
|
||||
.filter(item => item.queueId === queueId && (item.status === 'queued' || item.status === 'paused' || item.status === 'failed'))
|
||||
.map(item => item.id);
|
||||
|
||||
if (runnableIds.length === 0) return 0;
|
||||
@@ -201,9 +215,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
await get().processQueue();
|
||||
return runnableIds.length;
|
||||
},
|
||||
pauseMainQueue: async () => {
|
||||
pauseQueue: async (queueId) => {
|
||||
const activeIds = get().downloads
|
||||
.filter(item => item.status === 'downloading')
|
||||
.filter(item => item.queueId === queueId && item.status === 'downloading')
|
||||
.map(item => item.id);
|
||||
|
||||
if (activeIds.length === 0) return 0;
|
||||
@@ -219,6 +233,20 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
syncSystemIntegrations();
|
||||
return activeIds.length;
|
||||
},
|
||||
addQueue: (name) => {
|
||||
set((state) => ({ queues: [...state.queues, { id: crypto.randomUUID(), name, isMain: false }] }));
|
||||
},
|
||||
renameQueue: (id, name) => {
|
||||
set((state) => ({
|
||||
queues: state.queues.map(q => q.id === id ? { ...q, name } : q)
|
||||
}));
|
||||
},
|
||||
removeQueue: (id) => {
|
||||
set((state) => ({
|
||||
queues: state.queues.filter(q => q.id !== id || q.isMain),
|
||||
downloads: state.downloads.map(d => d.queueId === id ? { ...d, queueId: MAIN_QUEUE_ID } : d)
|
||||
}));
|
||||
},
|
||||
restartActiveDownloads: async () => {
|
||||
const activeIds = get().downloads
|
||||
.filter(item => item.status === 'downloading')
|
||||
@@ -281,7 +309,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
destination: destPath,
|
||||
filename: item.fileName,
|
||||
formatSelector: item.mediaFormatSelector || null,
|
||||
speedLimit
|
||||
speedLimit,
|
||||
username: item.username || (login ? login.username : null),
|
||||
password: item.password || (login ? login.password : null)
|
||||
});
|
||||
} else {
|
||||
const speedLimit = effectiveSpeedLimit(
|
||||
|
||||
Reference in New Issue
Block a user