mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-05 08:59:05 +00:00
refactor(repo): promote tauri app to repository root
This commit is contained in:
+113
@@ -0,0 +1,113 @@
|
||||
.logo.vite:hover {
|
||||
filter: drop-shadow(0 0 2em #747bff);
|
||||
}
|
||||
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafb);
|
||||
}
|
||||
:root {
|
||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
color: #0f0f0f;
|
||||
background-color: #f6f6f6;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 0;
|
||||
padding-top: 10vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: 0.75s;
|
||||
}
|
||||
|
||||
.logo.tauri:hover {
|
||||
filter: drop-shadow(0 0 2em #24c8db);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: #0f0f0f;
|
||||
background-color: #ffffff;
|
||||
transition: border-color 0.25s;
|
||||
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #396cd8;
|
||||
}
|
||||
button:active {
|
||||
border-color: #396cd8;
|
||||
background-color: #e8e8e8;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color: #f6f6f6;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #24c8db;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
color: #ffffff;
|
||||
background-color: #0f0f0f98;
|
||||
}
|
||||
button:active {
|
||||
background-color: #0f0f0f69;
|
||||
}
|
||||
}
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Sidebar, SidebarFilter } from "./components/Sidebar";
|
||||
import { DownloadTable } from "./components/DownloadTable";
|
||||
import { AddDownloadsModal } from "./components/AddDownloadsModal";
|
||||
import SettingsView from "./components/SettingsView";
|
||||
import { PropertiesModal } from "./components/PropertiesModal";
|
||||
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
|
||||
import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
|
||||
import { useSettingsStore } from "./store/useSettingsStore";
|
||||
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
|
||||
import SchedulerView from "./components/SchedulerView";
|
||||
import SpeedLimiterView from "./components/SpeedLimiterView";
|
||||
|
||||
function App() {
|
||||
const [filter, setFilter] = useState<SidebarFilter>('all');
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
|
||||
return Number.isFinite(stored) && stored >= 190 && stored <= 260 ? stored : 220;
|
||||
});
|
||||
const updateDownload = useDownloadStore(state => state.updateDownload);
|
||||
const theme = useSettingsStore(state => state.theme);
|
||||
const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible);
|
||||
const activeView = useSettingsStore(state => state.activeView);
|
||||
const appFontSize = useSettingsStore(state => state.appFontSize);
|
||||
const showDockBadge = useSettingsStore(state => state.showDockBadge);
|
||||
const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon);
|
||||
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
|
||||
const downloads = useDownloadStore(state => state.downloads);
|
||||
const activeDownloadCount = downloads.filter(download => download.status === 'downloading').length;
|
||||
const queuedCount = downloads.filter(download => download.status === 'queued').length;
|
||||
const doneCount = downloads.filter(download => download.status === 'completed').length;
|
||||
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
||||
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
|
||||
const previousSpeedLimit = useRef(globalSpeedLimit);
|
||||
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
|
||||
|
||||
const startSidebarResize = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
const startWidth = sidebarWidth;
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const nextWidth = Math.min(260, Math.max(190, startWidth + moveEvent.clientX - startX));
|
||||
setSidebarWidth(nextWidth);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
document.body.classList.remove('is-resizing');
|
||||
};
|
||||
|
||||
document.body.classList.add('is-resizing');
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem('firelink-sidebar-width', String(sidebarWidth));
|
||||
}, [sidebarWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
useDownloadStore.getState().initDB();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.document.documentElement.setAttribute('data-font-size', appFontSize);
|
||||
}, [appFontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke('set_concurrent_limit', { limit: maxConcurrentDownloads }).catch(console.error);
|
||||
}, [maxConcurrentDownloads]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {});
|
||||
}, [showDockBadge, activeDownloadCount]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error);
|
||||
}, [showMenuBarIcon]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke('set_extension_pairing_token', { token: extensionPairingToken }).catch(error => {
|
||||
console.error('Failed to configure browser extension pairing token:', error);
|
||||
});
|
||||
}, [extensionPairingToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousSpeedLimit.current === globalSpeedLimit) return;
|
||||
previousSpeedLimit.current = globalSpeedLimit;
|
||||
|
||||
// Convert to aria2 format (e.g. "1M", "500K")
|
||||
let formattedLimit = null;
|
||||
if (globalSpeedLimit) {
|
||||
const match = globalSpeedLimit.trim().match(/^(\d+(?:\.\d+)?)\s*([kmgt]?)b?(?:\/s)?$/i);
|
||||
if (match) {
|
||||
const amount = Number(match[1]);
|
||||
if (Number.isFinite(amount) && amount > 0) {
|
||||
const multipliers: Record<string, number> = { '': 1, k: 1024, m: 1048576, g: 1073741824 };
|
||||
const bytes = Math.round(amount * multipliers[match[2].toLowerCase()]);
|
||||
formattedLimit = `${bytes}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invoke('set_global_speed_limit', { limit: formattedLimit }).catch(error => {
|
||||
console.error('Failed to apply global speed limit:', error);
|
||||
});
|
||||
}, [globalSpeedLimit]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listen('schedule-trigger', async (event) => {
|
||||
const state = useSettingsStore.getState();
|
||||
if (event.payload === 'start') {
|
||||
const started = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID);
|
||||
state.setSchedulerRunning(started > 0);
|
||||
} else if (event.payload === 'stop') {
|
||||
await useDownloadStore.getState().pauseQueue(MAIN_QUEUE_ID);
|
||||
state.setSchedulerRunning(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then(f => f()).catch(console.error);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!schedulerRunning) return;
|
||||
const hasPendingScheduledWork = downloads.some(download =>
|
||||
download.status === 'queued' || download.status === 'downloading'
|
||||
);
|
||||
if (hasPendingScheduledWork) return;
|
||||
|
||||
const settings = useSettingsStore.getState();
|
||||
settings.setSchedulerRunning(false);
|
||||
if (settings.scheduler.postQueueAction !== 'none') {
|
||||
invoke('perform_system_action', { action: settings.scheduler.postQueueAction }).catch(error => {
|
||||
console.error('Scheduled post action failed:', error);
|
||||
});
|
||||
}
|
||||
}, [downloads, schedulerRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
// Request notification permissions
|
||||
const initNotifications = async () => {
|
||||
let permissionGranted = await isPermissionGranted();
|
||||
if (!permissionGranted) {
|
||||
await requestPermission();
|
||||
}
|
||||
};
|
||||
initNotifications();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
const applyTheme = () => {
|
||||
// Remove all theme classes first
|
||||
root.classList.remove('theme-dark', 'theme-light', 'theme-dracula', 'theme-nord', 'dark');
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
root.classList.add(systemDark ? 'theme-dark' : 'theme-light');
|
||||
} else {
|
||||
root.classList.add(`theme-${theme}`);
|
||||
}
|
||||
};
|
||||
|
||||
applyTheme();
|
||||
|
||||
if (theme === 'system') {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const listener = () => applyTheme();
|
||||
mediaQuery.addEventListener('change', listener);
|
||||
return () => mediaQuery.removeEventListener('change', listener);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlistenProgress = listen('download-progress', (event) => {
|
||||
const { id, fraction, speed, eta } = event.payload;
|
||||
const state = useDownloadStore.getState();
|
||||
const current = state.downloads.find(d => d.id === id);
|
||||
if (current && current.status === 'queued') {
|
||||
updateDownload(id, { status: 'downloading', fraction, speed, eta });
|
||||
} else {
|
||||
updateDownload(id, { fraction, speed, eta });
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenComplete = listen('download-complete', (event) => {
|
||||
updateDownload(event.payload, { status: 'completed', fraction: 1.0, speed: '-', eta: '-' });
|
||||
|
||||
const settings = useSettingsStore.getState();
|
||||
if (settings.showNotifications) {
|
||||
const item = useDownloadStore.getState().downloads.find(d => d.id === event.payload);
|
||||
const fileName = item?.fileName || 'A file';
|
||||
|
||||
sendNotification({
|
||||
title: 'Download Complete',
|
||||
body: `${fileName} has finished downloading.`,
|
||||
sound: settings.playCompletionSound ? 'default' : undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenFailed = listen('download-failed', (event) => {
|
||||
// If it's already paused, don't mark as failed (since we aborted it)
|
||||
const current = useDownloadStore.getState().downloads.find(d => d.id === event.payload);
|
||||
if (current && current.status !== 'paused') {
|
||||
updateDownload(event.payload, { status: 'failed', speed: '-', eta: '-' });
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenExtension = listen('extension-add-download', (event) => {
|
||||
useDownloadStore.getState().handleExtensionDownload(event.payload);
|
||||
});
|
||||
const unlistenDeepLink = listen('deep-link-add-download', (event) => {
|
||||
useDownloadStore.getState().openAddModalWithUrls(event.payload);
|
||||
});
|
||||
Promise.all([unlistenExtension, unlistenDeepLink])
|
||||
.then(() => invoke('set_extension_frontend_ready', { ready: true }))
|
||||
.catch(error => console.error('Failed to activate browser extension integration:', error));
|
||||
|
||||
return () => {
|
||||
invoke('set_extension_frontend_ready', { ready: false }).catch(() => {});
|
||||
unlistenProgress.then(f => f());
|
||||
unlistenComplete.then(f => f());
|
||||
unlistenFailed.then(f => f());
|
||||
unlistenExtension.then(f => f());
|
||||
unlistenDeepLink.then(f => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="app-shell flex h-screen w-screen overflow-hidden text-text-primary">
|
||||
<div
|
||||
className={`app-sidebar-shell relative z-20 shrink-0 transition-all duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)] ${
|
||||
isSidebarVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
style={{
|
||||
width: sidebarWidth,
|
||||
marginLeft: isSidebarVisible ? 0 : -sidebarWidth
|
||||
}}
|
||||
>
|
||||
<div className="app-sidebar-panel h-full w-full">
|
||||
<Sidebar
|
||||
selectedFilter={filter}
|
||||
onSelectFilter={(f) => {
|
||||
setFilter(f);
|
||||
useSettingsStore.getState().setActiveView('downloads');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="sidebar-resize-handle"
|
||||
onPointerDown={startSidebarResize}
|
||||
title="Resize Sidebar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="app-workspace relative z-0 flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||
{activeView === 'downloads' && <DownloadTable filter={filter} />}
|
||||
{activeView === 'settings' && <SettingsView />}
|
||||
{activeView === 'scheduler' && <SchedulerView />}
|
||||
{activeView === 'speedLimiter' && <SpeedLimiterView />}
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="app-statusbar px-[14px] flex items-center justify-between text-text-muted shrink-0">
|
||||
<span>Ready</span>
|
||||
<div className="flex gap-3 tabular-nums">
|
||||
<span>{activeDownloadCount} active</span>
|
||||
<span>{queuedCount} queued</span>
|
||||
<span>{doneCount} done</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddDownloadsModal />
|
||||
<PropertiesModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Component, ErrorInfo, ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error, errorInfo: null };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("Uncaught error:", error, errorInfo);
|
||||
this.setState({ errorInfo });
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', color: 'red', backgroundColor: '#222', height: '100vh', width: '100vw', whiteSpace: 'pre-wrap', overflow: 'auto' }}>
|
||||
<h1>Something went wrong.</h1>
|
||||
<p>{this.state.error?.toString()}</p>
|
||||
<hr />
|
||||
<p>{this.state.errorInfo?.componentStack}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 211 KiB |
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ActiveView = "downloads" | "settings" | "scheduler" | "speedLimiter";
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type AppFontSize = "small" | "standard" | "large";
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type AvailableReleaseUpdate = { version: string, tag_name: string, title: string, release_notes: string, release_url: string, published_at: string | null, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DownloadCategory = "Musics" | "Movies" | "Compressed" | "Documents" | "Pictures" | "Applications" | "Other";
|
||||
@@ -0,0 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DownloadCategory } from "./DownloadCategory";
|
||||
import type { DownloadStatus } from "./DownloadStatus";
|
||||
|
||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId: string, _dispatched?: boolean, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DownloadStatus = "downloading" | "paused" | "completed" | "failed" | "queued";
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ExtensionDownload = { urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ListRowDensity = "compact" | "standard" | "relaxed";
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type MediaCookieSource = "none" | "safari" | "chrome" | "firefox" | "edge" | "brave";
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type MetadataResponse = { filename: string, size: string, size_bytes: number, };
|
||||
@@ -0,0 +1,11 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AppFontSize } from "./AppFontSize";
|
||||
import type { ListRowDensity } from "./ListRowDensity";
|
||||
import type { MediaCookieSource } from "./MediaCookieSource";
|
||||
import type { ProxyMode } from "./ProxyMode";
|
||||
import type { SchedulerSettings } from "./SchedulerSettings";
|
||||
import type { SettingsTab } from "./SettingsTab";
|
||||
import type { SiteLogin } from "./SiteLogin";
|
||||
import type { Theme } from "./Theme";
|
||||
|
||||
export type PersistedSettings = { theme: Theme, defaultDownloadPath: string, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, downloadDirectories: { [key in string]: string }, siteLogins: Array<SiteLogin>, extensionPairingToken: string, autoCheckUpdates: boolean, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PostQueueAction = "none" | "sleep" | "restart" | "shutdown";
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ProxyMode = "none" | "system" | "custom";
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type Queue = { id: string, name: string, isMain: boolean, };
|
||||
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AvailableReleaseUpdate } from "./AvailableReleaseUpdate";
|
||||
|
||||
export type ReleaseCheckOutcome = { "type": "UpdateAvailable", update: AvailableReleaseUpdate, } | { "type": "UpToDate", latest_version: string, local_version: string, };
|
||||
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { PostQueueAction } from "./PostQueueAction";
|
||||
|
||||
export type SchedulerSettings = { enabled: boolean, startTime: string, stopTimeEnabled: boolean, stopTime: string, everyday: boolean, selectedDays: Array<number>, postQueueAction: PostQueueAction, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type SettingsTab = "downloads" | "lookandfeel" | "network" | "locations" | "sitelogins" | "power" | "engine" | "integrations" | "about";
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type SiteLogin = { id: string, urlPattern: string, username: string, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type Theme = "dark" | "light" | "system" | "dracula" | "nord";
|
||||
@@ -0,0 +1,951 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useDownloadStore, MAIN_QUEUE_ID, getSiteLogin } from '../store/useDownloadStore';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, type LucideIcon } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
|
||||
import { categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads';
|
||||
|
||||
interface RawMediaFormat {
|
||||
format_id?: string;
|
||||
ext?: string;
|
||||
resolution?: string;
|
||||
format_note?: string;
|
||||
vcodec?: string;
|
||||
acodec?: string;
|
||||
height?: number;
|
||||
filesize?: number;
|
||||
filesize_approx?: number;
|
||||
}
|
||||
|
||||
interface MediaFormat {
|
||||
name: string;
|
||||
selector: string;
|
||||
ext: string;
|
||||
detail: string;
|
||||
type: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
interface ParsedDownloadItem {
|
||||
url: string;
|
||||
file: string;
|
||||
size?: string;
|
||||
sizeBytes?: number;
|
||||
status?: string;
|
||||
isMedia?: boolean;
|
||||
formats?: MediaFormat[];
|
||||
selectedFormat?: number;
|
||||
}
|
||||
|
||||
const isVideo = (f: RawMediaFormat) => {
|
||||
const vcodec = f.vcodec?.toLowerCase();
|
||||
return vcodec && vcodec !== 'none';
|
||||
};
|
||||
|
||||
const isAudio = (f: RawMediaFormat) => {
|
||||
const acodec = f.acodec?.toLowerCase();
|
||||
const vcodec = f.vcodec?.toLowerCase();
|
||||
return acodec && acodec !== 'none' && (!vcodec || vcodec === 'none');
|
||||
};
|
||||
|
||||
const formatSize = (f: RawMediaFormat) => f.filesize ?? f.filesize_approx ?? 0;
|
||||
|
||||
const matchesHeight = (f: RawMediaFormat, height: number | null) => {
|
||||
if (height === null) return true;
|
||||
|
||||
const note = f.format_note || "";
|
||||
if (height === 2160 && (note.includes("2160p") || note.toLowerCase().includes("4k"))) return true;
|
||||
if (height === 1440 && note.includes("1440p")) return true;
|
||||
if (height === 1080 && note.includes("1080p")) return true;
|
||||
if (height === 720 && note.includes("720p")) return true;
|
||||
if (height === 480 && note.includes("480p")) return true;
|
||||
if (height === 360 && note.includes("360p")) return true;
|
||||
|
||||
if (f.resolution) {
|
||||
const parts = f.resolution.split('x').map(n => parseInt(n, 10));
|
||||
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
||||
const maxDim = Math.max(parts[0], parts[1]);
|
||||
switch (height) {
|
||||
case 2160: if (maxDim >= 3800) return true; break;
|
||||
case 1440: if (maxDim >= 2500 && maxDim < 3800) return true; break;
|
||||
case 1080: if (maxDim >= 1900 && maxDim < 2500) return true; break;
|
||||
case 720: if (maxDim >= 1200 && maxDim < 1900) return true; break;
|
||||
case 480: if (maxDim >= 800 && maxDim < 1200) return true; break;
|
||||
case 360: if (maxDim >= 600 && maxDim < 800) return true; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const formatHeight = f.height;
|
||||
if (!formatHeight) return false;
|
||||
|
||||
let tolerance = 100;
|
||||
if (height >= 2160) tolerance = 600;
|
||||
else if (height >= 1440) tolerance = 400;
|
||||
else if (height >= 1080) tolerance = 300;
|
||||
else if (height >= 720) tolerance = 200;
|
||||
|
||||
return formatHeight <= height && formatHeight >= height - tolerance;
|
||||
};
|
||||
|
||||
const hasVideoFormat = (formats: RawMediaFormat[], height: number | null, container: string) => {
|
||||
return formats.some(f => {
|
||||
if (!isVideo(f) || !matchesHeight(f, height)) return false;
|
||||
return container === 'mkv' || f.ext?.toLowerCase() === container.toLowerCase();
|
||||
});
|
||||
};
|
||||
|
||||
const hasAudioFormat = (formats: RawMediaFormat[], ext: string | null) => {
|
||||
return formats.some(f => {
|
||||
if (!isAudio(f)) return false;
|
||||
if (!ext) return true;
|
||||
return f.ext?.toLowerCase() === ext.toLowerCase();
|
||||
});
|
||||
};
|
||||
|
||||
const estimatedVideoBytes = (formats: RawMediaFormat[], height: number | null, container: string) => {
|
||||
let maxVideo = 0;
|
||||
for (const f of formats) {
|
||||
if (isVideo(f) && matchesHeight(f, height) && (container === 'mkv' || f.ext?.toLowerCase() === container.toLowerCase())) {
|
||||
const size = formatSize(f);
|
||||
if (size > maxVideo) maxVideo = size;
|
||||
}
|
||||
}
|
||||
if (maxVideo === 0) return null;
|
||||
|
||||
let maxAudio = estimatedAudioBytes(formats, container === 'webm' ? 'webm' : 'm4a') || estimatedAudioBytes(formats, null) || 0;
|
||||
return maxVideo + maxAudio;
|
||||
};
|
||||
|
||||
const estimatedAudioBytes = (formats: RawMediaFormat[], ext: string | null): number | null => {
|
||||
let maxPreferred = 0;
|
||||
for (const f of formats) {
|
||||
if (isAudio(f)) {
|
||||
if (!ext || f.ext?.toLowerCase() === ext.toLowerCase()) {
|
||||
const size = formatSize(f);
|
||||
if (size > maxPreferred) maxPreferred = size;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxPreferred > 0 || !ext) return maxPreferred > 0 ? maxPreferred : null;
|
||||
return estimatedAudioBytes(formats, null);
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return 'Unknown size';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const parseMediaFormats = (jsonStr: string) => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(jsonStr);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const data = parsed as { title?: unknown; formats?: unknown };
|
||||
let title = typeof data.title === 'string' ? data.title : 'Media';
|
||||
title = title.replace(/[\/\\?%*:|"<>]/g, '-');
|
||||
const rawFormats = Array.isArray(data.formats)
|
||||
? data.formats.filter((format): format is RawMediaFormat => Boolean(format) && typeof format === 'object')
|
||||
: [];
|
||||
|
||||
const options = [];
|
||||
|
||||
const standardResolutions = [
|
||||
{ h: 2160, name: "4K" },
|
||||
{ h: 1440, name: "1440p" },
|
||||
{ h: 1080, name: "1080p" },
|
||||
{ h: 720, name: "720p" },
|
||||
{ h: 480, name: "480p" },
|
||||
{ h: 360, name: "360p" }
|
||||
];
|
||||
|
||||
const availableResolutions = standardResolutions.filter(res =>
|
||||
rawFormats.some(f => isVideo(f) && matchesHeight(f, res.h))
|
||||
);
|
||||
|
||||
const videoQualities: { h: number | null, name: string }[] = [{ h: null, name: "Best" }, ...availableResolutions];
|
||||
const videoContainers = [
|
||||
{ ext: "mp4", name: "MP4" },
|
||||
{ ext: "mkv", name: "MKV" },
|
||||
{ ext: "webm", name: "WebM" }
|
||||
];
|
||||
|
||||
for (const q of videoQualities) {
|
||||
for (const c of videoContainers) {
|
||||
if (!hasVideoFormat(rawFormats, q.h, c.ext)) continue;
|
||||
const est = estimatedVideoBytes(rawFormats, q.h, c.ext);
|
||||
const filter = q.h ? `[height<=${q.h}]` : '';
|
||||
|
||||
let selector = `bestvideo${filter}+bestaudio/best${filter}`;
|
||||
if (c.ext === 'mp4') {
|
||||
selector = `bestvideo${filter}[ext=mp4]+bestaudio[ext=m4a]/best${filter}[ext=mp4]/bestvideo${filter}+bestaudio/best${filter}`;
|
||||
} else if (c.ext === 'webm') {
|
||||
selector = `bestvideo${filter}[ext=webm]+bestaudio[ext=webm]/best${filter}[ext=webm]/bestvideo${filter}+bestaudio/best${filter}`;
|
||||
}
|
||||
|
||||
options.push({
|
||||
name: `${q.name} ${c.name}`,
|
||||
selector,
|
||||
ext: c.ext,
|
||||
detail: est ? `~${formatBytes(est)}` : '',
|
||||
type: 'Video',
|
||||
bytes: est || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAudioFormat(rawFormats, null)) {
|
||||
const est = estimatedAudioBytes(rawFormats, null);
|
||||
options.push({
|
||||
name: "Audio MP3",
|
||||
selector: "bestaudio/best",
|
||||
ext: "mp3",
|
||||
detail: est ? `~${formatBytes(est)}` : '',
|
||||
type: 'Audio',
|
||||
bytes: est || 0
|
||||
});
|
||||
}
|
||||
|
||||
if (hasAudioFormat(rawFormats, "m4a")) {
|
||||
const est = estimatedAudioBytes(rawFormats, "m4a");
|
||||
options.push({
|
||||
name: "Audio M4A",
|
||||
selector: "bestaudio[ext=m4a]/bestaudio/best",
|
||||
ext: "m4a",
|
||||
detail: est ? `~${formatBytes(est)}` : '',
|
||||
type: 'Audio',
|
||||
bytes: est || 0
|
||||
});
|
||||
}
|
||||
|
||||
if (hasAudioFormat(rawFormats, "webm") || hasAudioFormat(rawFormats, "opus")) {
|
||||
const est = estimatedAudioBytes(rawFormats, "webm") || estimatedAudioBytes(rawFormats, "opus");
|
||||
options.push({
|
||||
name: "Audio Opus",
|
||||
selector: "bestaudio[ext=webm]/bestaudio/best",
|
||||
ext: "opus",
|
||||
detail: est ? `~${formatBytes(est)}` : '',
|
||||
type: 'Audio',
|
||||
bytes: est || 0
|
||||
});
|
||||
}
|
||||
|
||||
return { title, formats: options };
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const AddDownloadsModal = () => {
|
||||
const {
|
||||
isAddModalOpen,
|
||||
pendingAddUrls,
|
||||
pendingAddReferer,
|
||||
pendingAddFilename,
|
||||
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[]>([]);
|
||||
|
||||
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
|
||||
const [showingDuplicates, setShowingDuplicates] = useState(false);
|
||||
const [pendingStartFlag, setPendingStartFlag] = useState(false);
|
||||
const [resolvedLocation, setResolvedLocation] = useState('');
|
||||
|
||||
// Right Form
|
||||
const [saveLocation, setSaveLocation] = useState(defaultDownloadPath);
|
||||
const [connections, setConnections] = useState(16);
|
||||
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
|
||||
const [speedLimit, setSpeedLimit] = useState('1024');
|
||||
const [freeSpace, setFreeSpace] = useState('Unknown');
|
||||
|
||||
const [useAuth, setUseAuth] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const [advancedExpanded, setAdvancedExpanded] = useState(false);
|
||||
const [checksumEnabled, setChecksumEnabled] = useState(false);
|
||||
const [checksumAlgo, setChecksumAlgo] = useState('SHA-256');
|
||||
const [checksumValue, setChecksumValue] = useState('');
|
||||
const [headers, setHeaders] = useState('');
|
||||
const [cookies, setCookies] = useState('');
|
||||
const [mirrors, setMirrors] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isAddModalOpen) {
|
||||
setSaveLocation(defaultDownloadPath);
|
||||
setUrls(pendingAddUrls || '');
|
||||
setParsedItems([]);
|
||||
setSelectedItemIndex(null);
|
||||
setSelectedQueueId(queues.find(q => q.isMain)?.id || MAIN_QUEUE_ID);
|
||||
setUseAuth(false);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setAdvancedExpanded(false);
|
||||
setChecksumEnabled(false);
|
||||
setChecksumAlgo('SHA-256');
|
||||
setChecksumValue('');
|
||||
setHeaders(pendingAddReferer ? `Referer: ${pendingAddReferer}` : '');
|
||||
setCookies('');
|
||||
setMirrors('');
|
||||
}
|
||||
}, [
|
||||
isAddModalOpen,
|
||||
pendingAddUrls,
|
||||
pendingAddReferer,
|
||||
defaultDownloadPath,
|
||||
queues
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!saveLocation) return;
|
||||
invoke('get_free_space', { path: saveLocation })
|
||||
.then(space => setFreeSpace(space))
|
||||
.catch(() => setFreeSpace('Unknown'));
|
||||
}, [saveLocation, isAddModalOpen]);
|
||||
|
||||
// Metadata parser
|
||||
useEffect(() => {
|
||||
const lines = urls.split('\n').map(u => u.trim()).filter(u => u.length > 0);
|
||||
|
||||
// Immediately display items in loading state
|
||||
const initialItems: ParsedDownloadItem[] = lines.map(url => {
|
||||
const fallbackFile = lines.length === 1 && pendingAddFilename
|
||||
? pendingAddFilename
|
||||
: fileNameFromUrl(url);
|
||||
return { url, file: fallbackFile, size: '-', status: 'Loading', isMedia: isMediaUrl(url) };
|
||||
});
|
||||
setParsedItems(initialItems);
|
||||
|
||||
if (lines.length === 0) {
|
||||
setSelectedItemIndex(null);
|
||||
return;
|
||||
} else if (selectedItemIndex === null || selectedItemIndex >= lines.length) {
|
||||
setSelectedItemIndex(0);
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
const updatedItems = [...initialItems];
|
||||
let firstReadyIndex: number | null = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const url = lines[i];
|
||||
try {
|
||||
new URL(url);
|
||||
if (isMediaUrl(url)) {
|
||||
const settingsStore = useSettingsStore.getState();
|
||||
const { mediaCookieSource } = settingsStore;
|
||||
const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null;
|
||||
const login = getSiteLogin(url, settingsStore);
|
||||
let keychainPassword = null;
|
||||
if (login) {
|
||||
try {
|
||||
keychainPassword = await invoke('get_keychain_password', { id: login.id });
|
||||
} catch (e) {
|
||||
console.warn("Could not fetch keychain password:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const jsonStr = await invoke('fetch_media_metadata', {
|
||||
url,
|
||||
cookieBrowser: browserArg,
|
||||
username: login?.username || null,
|
||||
password: keychainPassword
|
||||
});
|
||||
const mediaData = parseMediaFormats(jsonStr);
|
||||
if (mediaData && mediaData.formats.length > 0) {
|
||||
updatedItems[i] = {
|
||||
url,
|
||||
file: `${mediaData.title}.${mediaData.formats[0].ext}`,
|
||||
size: mediaData.formats[0].detail || 'Unknown (Media)',
|
||||
sizeBytes: mediaData.formats[0].bytes,
|
||||
status: 'Ready',
|
||||
isMedia: true,
|
||||
formats: mediaData.formats,
|
||||
selectedFormat: 0
|
||||
};
|
||||
} else {
|
||||
throw new Error("Invalid media metadata or no formats found");
|
||||
}
|
||||
} else {
|
||||
const settingsStore = useSettingsStore.getState();
|
||||
const login = getSiteLogin(url, settingsStore);
|
||||
let keychainPassword = null;
|
||||
if (login) {
|
||||
try {
|
||||
keychainPassword = await invoke('get_keychain_password', { id: login.id });
|
||||
} catch (e) {
|
||||
console.warn("Could not fetch keychain password:", e);
|
||||
}
|
||||
}
|
||||
const meta = await invoke('fetch_metadata', {
|
||||
url,
|
||||
userAgent: settingsStore.customUserAgent || null,
|
||||
username: login?.username || null,
|
||||
password: keychainPassword
|
||||
});
|
||||
updatedItems[i] = {
|
||||
url,
|
||||
file: lines.length === 1 && pendingAddFilename ? pendingAddFilename : meta.filename,
|
||||
size: meta.size,
|
||||
sizeBytes: meta.size_bytes,
|
||||
status: 'Ready'
|
||||
};
|
||||
}
|
||||
if (firstReadyIndex === null) firstReadyIndex = i;
|
||||
} catch (e) {
|
||||
console.error("Meta fetch failed", e);
|
||||
updatedItems[i] = { ...updatedItems[i], size: 'Unknown', sizeBytes: 0, status: 'Error' };
|
||||
}
|
||||
setParsedItems([...updatedItems]);
|
||||
}
|
||||
|
||||
if (firstReadyIndex !== null) {
|
||||
setSelectedItemIndex(firstReadyIndex);
|
||||
}
|
||||
}, 400);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [urls, pendingAddFilename]);
|
||||
|
||||
if (!isAddModalOpen) return null;
|
||||
|
||||
const handleBrowse = async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation
|
||||
});
|
||||
if (selected && typeof selected === 'string') {
|
||||
setSaveLocation(selected);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to select folder:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = async (startImmediately: boolean) => {
|
||||
let finalLocation = saveLocation;
|
||||
const settings = useSettingsStore.getState();
|
||||
if (settings.askWhereToSaveEachFile && parsedItems.length > 0) {
|
||||
try {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
defaultPath: finalLocation.startsWith('~') ? undefined : finalLocation
|
||||
});
|
||||
if (selected && typeof selected === 'string') {
|
||||
finalLocation = selected;
|
||||
} else {
|
||||
return; // Cancelled
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to select folder:", e);
|
||||
}
|
||||
}
|
||||
|
||||
setResolvedLocation(finalLocation);
|
||||
const store = useDownloadStore.getState();
|
||||
const newConflicts: DuplicateConflict[] = [];
|
||||
|
||||
for (let i = 0; i < parsedItems.length; i++) {
|
||||
const item = parsedItems[i];
|
||||
let finalFile = item.file;
|
||||
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
|
||||
const selectedFormat = item.formats[item.selectedFormat];
|
||||
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
||||
finalFile = `${baseName}.${selectedFormat.ext}`;
|
||||
}
|
||||
|
||||
const isUrlDupe = store.downloads.some(d => d.url === item.url && d.status !== 'failed' && d.status !== 'completed');
|
||||
if (isUrlDupe) {
|
||||
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: 'URL already in queue' }, resolution: 'rename' });
|
||||
} else {
|
||||
const fileExistsInStore = store.downloads.some(d => {
|
||||
const dest = d.destination || settings.defaultDownloadPath || '~/Downloads';
|
||||
return dest === finalLocation && d.fileName === finalFile && d.status !== 'failed';
|
||||
});
|
||||
|
||||
let fileExistsOnDisk = false;
|
||||
try {
|
||||
const cleanLocation = finalLocation.endsWith('/') ? finalLocation.slice(0, -1) : finalLocation;
|
||||
fileExistsOnDisk = await invoke('check_file_exists', { path: `${cleanLocation}/${finalFile}` });
|
||||
} catch (e) {}
|
||||
|
||||
if (fileExistsInStore || fileExistsOnDisk) {
|
||||
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'file', msg: 'File exists at destination' }, resolution: 'rename' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newConflicts.length > 0) {
|
||||
setConflicts(newConflicts);
|
||||
setPendingStartFlag(startImmediately);
|
||||
setShowingDuplicates(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await executeAddDownloads(startImmediately, finalLocation);
|
||||
};
|
||||
|
||||
const executeAddDownloads = async (startImmediately: boolean, finalLocation: string, resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => {
|
||||
let itemsToAdd: Array<ParsedDownloadItem | null> = [...parsedItems];
|
||||
|
||||
if (resolutions) {
|
||||
for (const res of resolutions) {
|
||||
const idx = parseInt(res.id);
|
||||
const item = itemsToAdd[idx];
|
||||
if (!item) continue;
|
||||
|
||||
if (res.resolution === 'skip') {
|
||||
itemsToAdd[idx] = null;
|
||||
} else if (res.resolution === 'rename') {
|
||||
let finalFile = item.file;
|
||||
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
|
||||
const selectedFormat = item.formats[item.selectedFormat];
|
||||
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
||||
finalFile = `${baseName}.${selectedFormat.ext}`;
|
||||
}
|
||||
const cleanLocation = finalLocation.endsWith('/') ? finalLocation.slice(0, -1) : finalLocation;
|
||||
|
||||
let count = 1;
|
||||
const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
||||
const ext = finalFile.includes('.') ? finalFile.substring(finalFile.lastIndexOf('.')) : '';
|
||||
let newName = finalFile;
|
||||
let exists = true;
|
||||
|
||||
while (exists) {
|
||||
newName = `${base} (${count})${ext}`;
|
||||
const storeHas = useDownloadStore.getState().downloads.some(d => {
|
||||
const dest = d.destination || useSettingsStore.getState().defaultDownloadPath || '~/Downloads';
|
||||
return dest === finalLocation && d.fileName === newName && d.status !== 'failed';
|
||||
});
|
||||
let diskHas = false;
|
||||
try { diskHas = await invoke('check_file_exists', { path: `${cleanLocation}/${newName}` }); } catch(e) {}
|
||||
exists = storeHas || diskHas;
|
||||
count++;
|
||||
}
|
||||
|
||||
itemsToAdd[idx] = { ...item, file: newName };
|
||||
} else if (res.resolution === 'replace') {
|
||||
let finalFile = item.file;
|
||||
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
|
||||
const selectedFormat = item.formats[item.selectedFormat];
|
||||
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
||||
finalFile = `${baseName}.${selectedFormat.ext}`;
|
||||
}
|
||||
const cleanLocation = finalLocation.endsWith('/') ? finalLocation.slice(0, -1) : finalLocation;
|
||||
const fullPath = `${cleanLocation}/${finalFile}`;
|
||||
|
||||
const store = useDownloadStore.getState();
|
||||
const existingItem = store.downloads.find(d => {
|
||||
const dest = d.destination || useSettingsStore.getState().defaultDownloadPath || '~/Downloads';
|
||||
return (d.url === item.url || (dest === finalLocation && d.fileName === finalFile)) && d.status !== 'failed';
|
||||
});
|
||||
|
||||
if (existingItem) {
|
||||
await store.removeDownload(existingItem.id);
|
||||
}
|
||||
|
||||
try { await invoke('delete_file', { path: fullPath }); } catch(e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedItems = itemsToAdd.filter((item): item is ParsedDownloadItem => item !== null);
|
||||
|
||||
for (const item of resolvedItems) {
|
||||
try {
|
||||
const id = crypto.randomUUID();
|
||||
let finalFile = item.file;
|
||||
let formatSelector = undefined;
|
||||
|
||||
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
|
||||
const selectedFormat = item.formats[item.selectedFormat];
|
||||
formatSelector = selectedFormat.selector;
|
||||
if (!finalFile.endsWith(`.${selectedFormat.ext}`)) {
|
||||
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
||||
finalFile = `${baseName}.${selectedFormat.ext}`;
|
||||
}
|
||||
}
|
||||
|
||||
addDownload({
|
||||
id,
|
||||
url: item.url,
|
||||
fileName: finalFile,
|
||||
status: startImmediately ? 'queued' : 'paused',
|
||||
category: categoryForFileName(finalFile),
|
||||
dateAdded: new Date().toISOString(),
|
||||
connections: Number(connections),
|
||||
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
|
||||
username: useAuth ? username.trim() : undefined,
|
||||
password: useAuth ? password.trim() : undefined,
|
||||
headers: headers.trim() || undefined,
|
||||
checksum: checksumEnabled && checksumValue.trim()
|
||||
? `${checksumAlgo}=${checksumValue.trim()}`
|
||||
: undefined,
|
||||
cookies: cookies.trim() || undefined,
|
||||
mirrors: mirrors.trim() || undefined,
|
||||
destination: finalLocation,
|
||||
isMedia: item.isMedia,
|
||||
mediaFormatSelector: formatSelector,
|
||||
queueId: selectedQueueId
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Invalid URL or failed to add:", e);
|
||||
}
|
||||
}
|
||||
toggleAddModal(false);
|
||||
};
|
||||
|
||||
const SummaryBox = ({ title, value, icon: Icon, color }: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
color: string;
|
||||
}) => (
|
||||
<div className="flex flex-col bg-bg-input/50 border border-border-modal/40 rounded-lg p-2.5 shadow-sm">
|
||||
<div className="flex items-center gap-1.5 text-text-muted mb-1">
|
||||
<Icon size={12} className={color} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">{title}</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-text-primary truncate">{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const requiredBytes = parsedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
|
||||
const requiredStr = requiredBytes > 0
|
||||
? (requiredBytes < 1024 * 1024 ? `${(requiredBytes / 1024).toFixed(1)} KB`
|
||||
: requiredBytes < 1024 * 1024 * 1024 ? `${(requiredBytes / 1024 / 1024).toFixed(1)} MB`
|
||||
: `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`)
|
||||
: 'Unknown';
|
||||
|
||||
return (
|
||||
<>
|
||||
{showingDuplicates && (
|
||||
<DuplicateResolutionModal
|
||||
conflicts={conflicts}
|
||||
onConfirm={(resolutions) => {
|
||||
setShowingDuplicates(false);
|
||||
executeAddDownloads(pendingStartFlag, resolvedLocation, resolutions);
|
||||
}}
|
||||
onCancel={() => setShowingDuplicates(false)}
|
||||
/>
|
||||
)}
|
||||
<div className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="app-modal w-[900px] h-[650px] flex flex-col overflow-hidden text-sm">
|
||||
|
||||
{/* Main Content Split */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
|
||||
{/* Left Column: URLs and Preview */}
|
||||
<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="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-text-primary font-semibold">
|
||||
<Link size={16} className="text-blue-500" />
|
||||
Download Links
|
||||
</div>
|
||||
</div>
|
||||
<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-accent resize-none font-mono shadow-inner transition-colors"
|
||||
placeholder="Paste HTTP, HTTPS, FTP, or SFTP URLs here..."
|
||||
value={urls}
|
||||
onChange={(e) => setUrls(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-between items-center px-1">
|
||||
<span className="text-[11px] text-text-muted font-medium">{parsedItems.length} valid link(s) detected</span>
|
||||
<button className="flex items-center gap-1.5 text-[11px] text-blue-500 hover:text-blue-400 font-medium">
|
||||
<RefreshCw size={12} /> Refresh Metadata
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<SummaryBox title="Files" value={parsedItems.length} icon={FileText} color="text-blue-500" />
|
||||
<SummaryBox title="Required" value={requiredStr} icon={Database} color="text-orange-500" />
|
||||
<SummaryBox title="Free" value={freeSpace} icon={HardDrive} color="text-green-500" />
|
||||
<SummaryBox title="Unknown" value={parsedItems.filter(i => !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 flex-1 overflow-hidden">
|
||||
<div className="flex items-center gap-2 text-text-primary font-semibold">
|
||||
<ArrowRight size={16} className="text-blue-500" />
|
||||
Preview
|
||||
</div>
|
||||
<div className="flex-1 border border-border-modal rounded-lg overflow-hidden bg-bg-input/30 flex flex-col">
|
||||
<div className="bg-sidebar-bg/50 border-b border-border-modal px-3 py-2 flex text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
<div className="flex-[2]">File</div>
|
||||
<div className="flex-1">Size</div>
|
||||
<div className="flex-[1.5]">Status</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{parsedItems.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-text-muted text-xs italic">
|
||||
No links added yet.
|
||||
</div>
|
||||
) : (
|
||||
parsedItems.map((item, i) => (
|
||||
<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-[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.5] font-medium ${item.status === 'Error' ? 'text-red-500' : item.status === 'Loading' ? 'text-orange-400' : 'text-blue-500'}`}>
|
||||
{item.status === 'Loading' ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RefreshCw size={12} className="animate-spin" /> Fetching...
|
||||
</div>
|
||||
) : (
|
||||
item.status || 'Ready'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Settings */}
|
||||
<div className="w-[45%] flex flex-col overflow-y-auto bg-bg-modal">
|
||||
<div className="p-6 space-y-7">
|
||||
|
||||
{/* Media Format (Dynamic) */}
|
||||
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
|
||||
<section className="app-card relative overflow-hidden p-4">
|
||||
<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, idx) => {
|
||||
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 */}
|
||||
<section>
|
||||
<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
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Transfer Settings */}
|
||||
<section>
|
||||
<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="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-lg px-2 py-1 text-xs text-text-primary focus:border-accent 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<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" />
|
||||
Limit speed per file
|
||||
</label>
|
||||
{speedLimitEnabled && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input type="number" value={speedLimit} onChange={e=>setSpeedLimit(e.target.value)} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2 py-1 text-xs font-mono text-text-primary focus:border-accent focus:outline-none" />
|
||||
<span className="text-[10px] text-text-muted">KiB/s</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Authorization */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-text-primary mb-3">
|
||||
<Shield size={16} className="text-blue-500" /> Authorization
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer mb-3">
|
||||
<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
|
||||
</label>
|
||||
|
||||
{useAuth && (
|
||||
<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-lg px-3 py-1.5 text-xs text-text-primary focus:border-accent focus:outline-none" />
|
||||
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} placeholder="Password" className="w-full bg-bg-input border border-border-modal rounded-lg px-3 py-1.5 text-xs text-text-primary focus:border-accent focus:outline-none" />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Advanced */}
|
||||
<section className="pt-2 border-t border-border-modal/50">
|
||||
<button
|
||||
onClick={() => setAdvancedExpanded(!advancedExpanded)}
|
||||
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} />}
|
||||
Advanced Transfer
|
||||
</button>
|
||||
|
||||
{advancedExpanded && (
|
||||
<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">
|
||||
<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
|
||||
</label>
|
||||
|
||||
{checksumEnabled && (
|
||||
<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-lg px-2 text-xs text-text-primary focus:border-accent focus:outline-none">
|
||||
<option>MD5</option><option>SHA-1</option><option>SHA-256</option>
|
||||
</select>
|
||||
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} placeholder="Expected digest" className="flex-1 bg-bg-input border border-border-modal rounded-lg px-3 py-1.5 text-xs font-mono text-text-primary focus:border-accent focus:outline-none" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">Headers</label>
|
||||
<textarea value={headers} onChange={e=>setHeaders(e.target.value)} className="w-full h-12 bg-bg-input border border-border-modal rounded-lg px-3 py-1.5 text-xs font-mono text-text-primary focus:border-accent focus:outline-none resize-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">Cookies</label>
|
||||
<input type="text" value={cookies} onChange={e=>setCookies(e.target.value)} placeholder="name=value; other=value" className="w-full bg-bg-input border border-border-modal rounded-lg px-3 py-1.5 text-xs font-mono text-text-primary focus:border-accent focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">Mirrors</label>
|
||||
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} className="w-full h-12 bg-bg-input border border-border-modal rounded-lg px-3 py-1.5 text-xs font-mono text-text-primary focus:border-accent focus:outline-none resize-none" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 bg-sidebar-bg/50 border-t border-border-modal flex items-center shrink-0">
|
||||
<div className="text-[11px] text-text-muted font-medium flex-1">
|
||||
{parsedItems.length === 0 ? "Paste one or more links." : `Ready to add ${parsedItems.length} download(s).`}
|
||||
</div>
|
||||
<div className="flex gap-2.5">
|
||||
<button onClick={() => toggleAddModal(false)} className="app-button border-transparent bg-transparent px-4 text-xs text-text-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStart(false)}
|
||||
disabled={parsedItems.length === 0}
|
||||
className="app-button px-4 text-xs disabled:opacity-50"
|
||||
>
|
||||
Add to Queue
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStart(true)}
|
||||
disabled={parsedItems.length === 0}
|
||||
className="app-button app-button-primary px-5 text-xs disabled:opacity-50"
|
||||
>
|
||||
<Play size={12} fill="currentColor" /> Start Downloads
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,460 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { SidebarFilter } from './Sidebar';
|
||||
import { Play, Pause, Plus, Trash2, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, MoreVertical, PanelLeft, ArrowDownCircle, Command } from 'lucide-react';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { homeDir } from '@tauri-apps/api/path';
|
||||
|
||||
interface DownloadTableProps {
|
||||
filter: SidebarFilter;
|
||||
}
|
||||
|
||||
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
const { downloads, toggleAddModal, updateDownload, removeDownload, clearFinished, redownload } = useDownloadStore();
|
||||
const { isSidebarVisible, toggleSidebar } = useSettingsStore();
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac');
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
|
||||
const [columnWidths, setColumnWidths] = useState([340, 100, 220, 100, 80, 170]);
|
||||
const columnMinimums = [200, 80, 170, 80, 70, 120];
|
||||
const tableGridTemplate = columnWidths.map(width => `${width}px`).join(' ');
|
||||
|
||||
const startColumnResize = (index: number, event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const startX = event.clientX;
|
||||
const startWidth = columnWidths[index];
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const nextWidth = Math.max(columnMinimums[index], startWidth + moveEvent.clientX - startX);
|
||||
setColumnWidths(widths => widths.map((width, columnIndex) => columnIndex === index ? nextWidth : width));
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
document.body.classList.remove('is-resizing');
|
||||
};
|
||||
|
||||
document.body.classList.add('is-resizing');
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleCloseMenu = () => setContextMenu(null);
|
||||
window.addEventListener('click', handleCloseMenu);
|
||||
return () => window.removeEventListener('click', handleCloseMenu);
|
||||
}, []);
|
||||
|
||||
const resolvePath = async (dir: string, file: string) => {
|
||||
let resolvedDir = dir;
|
||||
if (dir.startsWith('~/')) {
|
||||
const home = await homeDir();
|
||||
resolvedDir = home + '/' + dir.slice(2);
|
||||
} else if (dir === '~') {
|
||||
resolvedDir = await homeDir();
|
||||
}
|
||||
return resolvedDir + '/' + file;
|
||||
};
|
||||
|
||||
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';
|
||||
case 'completed': return d.status === 'completed';
|
||||
case 'unfinished': return d.status !== 'completed';
|
||||
default: return d.category === 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';
|
||||
case 'completed': return 'Completed';
|
||||
case 'unfinished': return 'Unfinished';
|
||||
default: return filter;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async (id: string) => {
|
||||
try {
|
||||
await invoke('pause_download', { id });
|
||||
updateDownload(id, { status: 'paused', speed: '-', eta: '-' });
|
||||
} catch (e) {
|
||||
console.error("Failed to pause:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResume = (item: DownloadItem) => {
|
||||
useDownloadStore.setState((state) => ({
|
||||
downloads: state.downloads.map(d => d.id === item.id ? { ...d, status: 'queued', speed: '-', eta: '-' } : d)
|
||||
}));
|
||||
useDownloadStore.getState().processQueue();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await removeDownload(id);
|
||||
} catch (e) {
|
||||
console.error("Failed to delete download:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const contextItem = contextMenu ? downloads.find(d => d.id === contextMenu.id) : null;
|
||||
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch(category) {
|
||||
case 'Musics': return <Music size={16} className="text-pink-400" />;
|
||||
case 'Movies': return <Film size={16} className="text-red-400" />;
|
||||
case 'Documents': return <FileText size={16} className="text-blue-400" />;
|
||||
case 'Applications': return <Box size={16} className="text-indigo-400" />;
|
||||
case 'Pictures': return <ImageIcon size={16} className="text-purple-400" />;
|
||||
case 'Compressed': return <Archive size={16} className="text-amber-600" />;
|
||||
case 'Other': return <FileQuestion size={16} className="text-gray-400" />;
|
||||
default: return <FileQuestion size={16} className="text-gray-400" />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="downloads-view flex-1 flex flex-col h-full min-w-0">
|
||||
<div className={`main-titlebar ${!isSidebarVisible ? 'pl-[80px]' : ''}`} data-tauri-drag-region>
|
||||
{!isSidebarVisible && (
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="app-icon-button relative z-50 h-7 w-7 mr-2"
|
||||
title="Show Sidebar"
|
||||
>
|
||||
<PanelLeft size={16} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
<div className="main-titlebar-title cursor-default" data-tauri-drag-region>Firelink</div>
|
||||
|
||||
<div className="main-control-group">
|
||||
<button className="main-control-button primary" onClick={() => toggleAddModal(true)} title="Add Download">
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="main-control-button"
|
||||
disabled={filteredDownloads.length === 0}
|
||||
onClick={() => {
|
||||
filteredDownloads.filter(d => d.status === 'paused').forEach(d => handleResume(d));
|
||||
}}
|
||||
title="Resume All"
|
||||
>
|
||||
<Play size={15} fill="currentColor" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="main-control-button"
|
||||
disabled={filteredDownloads.length === 0}
|
||||
onClick={() => {
|
||||
filteredDownloads.filter(d => d.status === 'downloading').forEach(d => handlePause(d.id));
|
||||
}}
|
||||
title="Pause All"
|
||||
>
|
||||
<Pause size={15} fill="currentColor" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="main-control-button hover:!text-red-400"
|
||||
disabled={filteredDownloads.length === 0}
|
||||
onClick={clearFinished}
|
||||
title="Clear Finished"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="downloads-content-header">
|
||||
<div className="downloads-title">
|
||||
{getFilterTitle()}
|
||||
<span className="downloads-count">{filteredDownloads.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="downloads-table flex-1 flex flex-col">
|
||||
{filteredDownloads.length === 0 ? (
|
||||
<div className="downloads-empty-state">
|
||||
<ArrowDownCircle aria-hidden="true" />
|
||||
<div className="downloads-empty-title">No Downloads</div>
|
||||
<div className="downloads-empty-description flex items-center justify-center mt-2.5 text-[13px] text-text-muted">
|
||||
Click <Plus size={15} className="text-accent stroke-[3] mx-1.5" /> button or
|
||||
<span className="flex items-center mx-1.5">
|
||||
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
|
||||
{isMac ? <Command size={12} strokeWidth={2.5} className="text-text-primary" /> : <span className="text-[10px] font-bold text-text-primary">Ctrl</span>}
|
||||
</span>
|
||||
<span className="text-accent font-bold mx-1.5 text-[14px]">+</span>
|
||||
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
|
||||
<span className="text-[11px] font-bold text-text-primary">V</span>
|
||||
</span>
|
||||
</span>
|
||||
to add downloads
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="download-table-scroll">
|
||||
<div className="download-table-header" style={{ gridTemplateColumns: tableGridTemplate }}>
|
||||
{['File Name', 'Size', 'Status', 'Speed', 'ETA', 'Date Added'].map((label, index) => (
|
||||
<div key={label} className={index === 5 ? 'download-cell-right' : undefined}>
|
||||
<span>{label}</span>
|
||||
<div
|
||||
className="column-resize-handle"
|
||||
onPointerDown={(event) => startColumnResize(index, event)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="download-table-body">
|
||||
<div className="h-full overflow-auto flex flex-col">
|
||||
{filteredDownloads.map(d => (
|
||||
<div
|
||||
key={d.id}
|
||||
className="download-row group cursor-default relative"
|
||||
style={{ gridTemplateColumns: tableGridTemplate }}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, id: d.id });
|
||||
}}
|
||||
>
|
||||
<div className="download-file-cell">
|
||||
<span className="shrink-0 text-text-muted">
|
||||
{getCategoryIcon(d.category)}
|
||||
</span>
|
||||
<span className="download-file-name">
|
||||
{d.fileName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="tabular-nums">
|
||||
{d.size && d.size !== '-' ? d.size : 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="download-status-cell">
|
||||
{d.status === 'completed' ? (
|
||||
<span className="download-status download-status-completed">Completed</span>
|
||||
) : (
|
||||
<>
|
||||
<div className="download-progress-track">
|
||||
<div
|
||||
className={`download-progress-fill ${d.status === 'paused' ? 'paused' : ''}`}
|
||||
style={{ width: `${(d.fraction || 0) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`download-status ${d.status === 'paused' ? 'download-status-paused' : d.status === 'failed' ? 'download-status-failed' : d.status === 'downloading' ? 'download-status-downloading' : ''}`}>
|
||||
{d.status === 'downloading'
|
||||
? `${((d.fraction || 0) * 100).toFixed(0)}%`
|
||||
: d.status.charAt(0).toUpperCase() + d.status.slice(1)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="tabular-nums">{d.status === 'downloading' ? d.speed : '-'}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="tabular-nums">{d.status === 'downloading' ? d.eta : '-'}</span>
|
||||
</div>
|
||||
|
||||
<div className="download-cell-right">
|
||||
<span className="truncate group-hover:hidden tabular-nums ml-auto">
|
||||
{d.dateAdded ? new Date(d.dateAdded).toLocaleDateString() : '-'}
|
||||
</span>
|
||||
|
||||
<div className="hidden group-hover:flex items-center justify-end gap-0.5 w-full ml-auto">
|
||||
{d.status === 'downloading' && (
|
||||
<button onClick={() => handlePause(d.id)} className="app-icon-button h-7 w-7" title="Pause">
|
||||
<Pause size={14} fill="currentColor" />
|
||||
</button>
|
||||
)}
|
||||
{d.status === 'paused' && (
|
||||
<button onClick={() => handleResume(d)} className="app-icon-button h-7 w-7" title="Resume">
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, id: d.id });
|
||||
}}
|
||||
className="app-icon-button h-7 w-7"
|
||||
title="Options"
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex-1 overflow-hidden flex flex-col pointer-events-none">
|
||||
{Array.from({ length: 50 }).map((_, index) => {
|
||||
const isEven = (filteredDownloads.length + index) % 2 === 1;
|
||||
return (
|
||||
<div
|
||||
key={`ghost-${index}`}
|
||||
className={`download-ghost-row ${isEven ? 'striped' : ''}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating Context Menu */}
|
||||
{contextMenu && contextItem && (
|
||||
<div
|
||||
className="app-modal fixed z-50 min-w-[180px] overflow-hidden py-1.5 text-[12px] font-medium text-text-primary"
|
||||
style={{
|
||||
top: Math.min(contextMenu.y, window.innerHeight - 300),
|
||||
left: Math.min(contextMenu.x, window.innerWidth - 200)
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{contextItem.status === 'completed' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setContextMenu(null);
|
||||
try {
|
||||
const fullPath = await resolvePath(contextItem.destination || '~/Downloads', contextItem.fileName);
|
||||
await invoke('open_file', { path: fullPath });
|
||||
} catch (e) {
|
||||
console.error("Failed to open file:", e);
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||
>
|
||||
Open File
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
setContextMenu(null);
|
||||
try {
|
||||
const fullPath = await resolvePath(contextItem.destination || '~/Downloads', contextItem.fileName);
|
||||
await invoke('show_in_folder', { path: fullPath });
|
||||
} catch (e) {
|
||||
console.error("Failed to show in folder:", e);
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||
>
|
||||
Show in Finder
|
||||
</button>
|
||||
|
||||
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
||||
|
||||
{(contextItem.status === 'downloading' || contextItem.status === 'queued') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
handlePause(contextItem.id);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(contextItem.status === 'paused' || contextItem.status === 'failed') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
handleResume(contextItem);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
)}
|
||||
|
||||
{['completed', 'failed', 'paused'].includes(contextItem.status) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
redownload(contextItem.id);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||
>
|
||||
Redownload
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
navigator.clipboard.writeText(contextItem.url);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||
>
|
||||
Copy Address
|
||||
</button>
|
||||
|
||||
{contextItem.status === 'completed' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setContextMenu(null);
|
||||
const fullPath = await resolvePath(contextItem.destination || '~/Downloads', contextItem.fileName);
|
||||
navigator.clipboard.writeText(fullPath);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||
>
|
||||
Copy File Path
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
handleDelete(contextItem.id);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
Remove from List
|
||||
</button>
|
||||
|
||||
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
useDownloadStore.getState().setSelectedPropertiesDownloadId(contextItem.id);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
|
||||
>
|
||||
Properties
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export type DuplicateReason = { type: 'url', msg: string } | { type: 'file', msg: string };
|
||||
type DuplicateResolution = 'rename' | 'replace' | 'skip';
|
||||
|
||||
export interface DuplicateConflict {
|
||||
id: string; // id of the pending item
|
||||
fileName: string;
|
||||
reason: DuplicateReason;
|
||||
resolution: DuplicateResolution;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
conflicts: DuplicateConflict[];
|
||||
onConfirm: (resolutions: { id: string, resolution: DuplicateResolution }[]) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
|
||||
const [conflicts, setConflicts] = useState<DuplicateConflict[]>(initialConflicts);
|
||||
|
||||
const updateResolution = (id: string, resolution: DuplicateResolution) => {
|
||||
setConflicts(conflicts.map(c => c.id === id ? { ...c, resolution } : c));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-modal-backdrop fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div className="app-modal w-[500px] flex flex-col overflow-hidden text-sm">
|
||||
<div className="p-4 border-b border-border-modal flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Duplicate Downloads Detected</h2>
|
||||
<p className="text-xs text-text-muted">Some of the downloads you are adding already exist in the queue or on disk. Please choose how to resolve these conflicts.</p>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[300px] overflow-y-auto p-4 space-y-3">
|
||||
{conflicts.map(conflict => (
|
||||
<div key={conflict.id} className="flex items-center justify-between bg-bg-input/50 p-2.5 rounded-lg border border-border-modal/50 gap-4">
|
||||
<div className="flex flex-col overflow-hidden min-w-0">
|
||||
<span className="font-medium text-text-primary truncate" title={conflict.fileName}>{conflict.fileName}</span>
|
||||
<span className="text-[11px] text-orange-400 mt-0.5">{conflict.reason.msg}</span>
|
||||
</div>
|
||||
<select
|
||||
value={conflict.resolution}
|
||||
onChange={(e) => updateResolution(conflict.id, e.target.value as DuplicateResolution)}
|
||||
className="app-control w-24 shrink-0 px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="rename">Rename</option>
|
||||
<option value="replace">Replace</option>
|
||||
<option value="skip">Skip</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-border-modal flex items-center justify-between bg-sidebar-bg/50">
|
||||
<button onClick={onCancel} className="app-button border-transparent bg-transparent px-4 text-xs text-text-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onConfirm(conflicts.map(c => ({ id: c.id, resolution: c.resolution })))}
|
||||
className="app-button app-button-primary px-5 text-xs"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,355 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
|
||||
type LoginMode = 'matching' | 'custom' | 'none';
|
||||
|
||||
export const PropertiesModal = () => {
|
||||
const {
|
||||
selectedPropertiesDownloadId,
|
||||
setSelectedPropertiesDownloadId,
|
||||
downloads,
|
||||
updateDownload
|
||||
} = useDownloadStore();
|
||||
|
||||
const { defaultDownloadPath } = useSettingsStore();
|
||||
|
||||
const [item, setItem] = useState<DownloadItem | null>(null);
|
||||
|
||||
// Form states
|
||||
const [url, setUrl] = useState('');
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [saveLocation, setSaveLocation] = useState('');
|
||||
const [connections, setConnections] = useState(16);
|
||||
|
||||
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
|
||||
const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s
|
||||
|
||||
const [loginMode, setLoginMode] = useState<LoginMode>('matching');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const [advancedExpanded, setAdvancedExpanded] = useState(false);
|
||||
const [checksumEnabled, setChecksumEnabled] = useState(false);
|
||||
const [checksumAlgorithm, setChecksumAlgorithm] = useState('SHA-256');
|
||||
const [checksumValue, setChecksumValue] = useState('');
|
||||
const [cookies, setCookies] = useState('');
|
||||
const [headers, setHeaders] = useState('');
|
||||
const [mirrors, setMirrors] = useState('');
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPropertiesDownloadId) {
|
||||
const activeItem = downloads.find(d => d.id === selectedPropertiesDownloadId);
|
||||
if (activeItem) {
|
||||
setItem(activeItem);
|
||||
setUrl(activeItem.url);
|
||||
setFileName(activeItem.fileName);
|
||||
setSaveLocation(activeItem.destination || defaultDownloadPath || '~/Downloads');
|
||||
setConnections(activeItem.connections || 16);
|
||||
|
||||
if (activeItem.speedLimit) {
|
||||
setSpeedLimitEnabled(true);
|
||||
setSpeedLimitValue(activeItem.speedLimit.replace(/[^0-9]/g, ''));
|
||||
} else {
|
||||
setSpeedLimitEnabled(false);
|
||||
}
|
||||
|
||||
if (activeItem.username || activeItem.password) {
|
||||
setLoginMode('custom');
|
||||
setUsername(activeItem.username || '');
|
||||
setPassword(activeItem.password || '');
|
||||
} else {
|
||||
setLoginMode('matching');
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
}
|
||||
|
||||
setHeaders(activeItem.headers || '');
|
||||
setChecksumEnabled(!!activeItem.checksum);
|
||||
if (activeItem.checksum) {
|
||||
const [algo, val] = activeItem.checksum.split('=');
|
||||
if (val) {
|
||||
setChecksumAlgorithm(algo);
|
||||
setChecksumValue(val);
|
||||
}
|
||||
} else {
|
||||
setChecksumAlgorithm('SHA-256');
|
||||
setChecksumValue('');
|
||||
}
|
||||
setCookies(activeItem.cookies || '');
|
||||
setMirrors(activeItem.mirrors || '');
|
||||
setErrorMessage('');
|
||||
} else {
|
||||
setItem(null);
|
||||
}
|
||||
} else {
|
||||
setItem(null);
|
||||
}
|
||||
}, [selectedPropertiesDownloadId, downloads, defaultDownloadPath]);
|
||||
|
||||
if (!selectedPropertiesDownloadId || !item) return null;
|
||||
|
||||
const handleBrowse = async () => {
|
||||
if (isLocked) return;
|
||||
try {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation
|
||||
});
|
||||
if (selected && typeof selected === 'string') {
|
||||
setSaveLocation(selected);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to select folder:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!url.trim()) {
|
||||
setErrorMessage("Enter a valid URL.");
|
||||
return;
|
||||
}
|
||||
if (!fileName.trim()) {
|
||||
setErrorMessage("File name cannot be empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Partial<DownloadItem> = {
|
||||
url,
|
||||
fileName,
|
||||
destination: saveLocation,
|
||||
connections: Number(connections),
|
||||
speedLimit: speedLimitEnabled && speedLimitValue ? `${speedLimitValue}K` : undefined,
|
||||
username: loginMode === 'custom' ? username.trim() : undefined,
|
||||
password: loginMode === 'custom' ? password.trim() : undefined,
|
||||
headers: headers.trim() || undefined,
|
||||
checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : undefined,
|
||||
cookies: cookies.trim() || undefined,
|
||||
mirrors: mirrors.trim() || undefined,
|
||||
};
|
||||
|
||||
updateDownload(item.id, updates);
|
||||
setSelectedPropertiesDownloadId(null);
|
||||
};
|
||||
|
||||
const isLocked = ['downloading', 'completed'].includes(item.status);
|
||||
const isTransferLocked = item.status === 'downloading';
|
||||
|
||||
let statusColor = 'text-text-secondary';
|
||||
let StatusIcon = Info;
|
||||
if (item.status === 'completed') { statusColor = 'text-green-500'; StatusIcon = CheckCircle; }
|
||||
else if (item.status === 'downloading') { statusColor = 'text-blue-500'; StatusIcon = Play; }
|
||||
else if (item.status === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; }
|
||||
else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; }
|
||||
|
||||
return (
|
||||
<div className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="app-modal w-[720px] h-[580px] flex flex-col overflow-hidden text-sm">
|
||||
|
||||
{/* Header Summary */}
|
||||
<div className="p-4 px-5 bg-sidebar-bg/50">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-base font-semibold truncate text-text-primary pr-4">{item.fileName}</h2>
|
||||
<span className={`flex items-center gap-1.5 text-xs font-semibold tracking-wide uppercase ${statusColor}`}>
|
||||
<StatusIcon size={14} />
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-border-color rounded-full h-1.5 overflow-hidden mb-4">
|
||||
<div className={`h-1.5 rounded-full transition-all duration-300 ${item.status === 'completed' ? 'bg-green-500' : item.status === 'paused' ? 'bg-orange-500' : item.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${(item.status === 'completed' ? 1 : item.fraction || 0) * 100}%` }}></div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-y-2 gap-x-4 text-[11px] leading-tight">
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[90px]">Progress</span><span className="text-text-secondary truncate">{item.status === 'completed' ? '100%' : ((item.fraction || 0) * 100).toFixed(0) + '%'}</span></div>
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[40px]">Size</span><span className="text-text-secondary truncate">-</span></div>
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[40px]">Speed</span><span className="text-text-secondary truncate">{item.status === 'completed' ? '-' : item.speed || '-'}</span></div>
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[30px]">ETA</span><span className="text-text-secondary truncate">{item.status === 'completed' ? '-' : item.eta || '-'}</span></div>
|
||||
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[90px]">Live connections</span><span className="text-text-secondary truncate">-</span></div>
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[60px]">Speed cap</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[55px]">Category</span><span className="text-text-secondary truncate">{item.category}</span></div>
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">Last try</span><span className="text-text-secondary truncate">-</span></div>
|
||||
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">Date added</span><span className="text-text-secondary truncate">{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span></div>
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">Destination</span><span className="text-text-secondary truncate" title={item.destination}>{item.destination || defaultDownloadPath}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[1px] bg-border-modal w-full shrink-0"></div>
|
||||
|
||||
{/* Scrollable Form Content */}
|
||||
<div className="flex-1 overflow-y-auto bg-main-bg/30 p-5 space-y-7">
|
||||
|
||||
{isLocked && (
|
||||
<div className="flex gap-2.5 items-center text-xs text-text-secondary bg-border-color/30 p-3 rounded-md border border-border-modal">
|
||||
{item.status === 'completed' ? <CheckCircle size={16} className="text-green-500" /> : <AlertCircle size={16} className="text-blue-500" />}
|
||||
<span>
|
||||
{item.status === 'completed'
|
||||
? "File identity is read-only. Transfer settings are saved for redownload."
|
||||
: "Only the speed limit applies to the current transfer. Other settings can be changed after stopping or pausing."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Section */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">Download</h3>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
|
||||
<label className="text-xs text-text-muted text-right">URL</label>
|
||||
<input type="text" value={url} onChange={e => setUrl(e.target.value)} disabled={isLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
|
||||
<label className="text-xs text-text-muted text-right">File name</label>
|
||||
<input type="text" value={fileName} onChange={e => setFileName(e.target.value)} disabled={isLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
|
||||
<label className="text-xs text-text-muted text-right">Save location</label>
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={saveLocation} readOnly disabled={isLocked} className="flex-1 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<button onClick={handleBrowse} disabled={isLocked} className="bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal px-3 py-1.5 rounded text-xs transition-colors disabled:opacity-40 flex items-center gap-1.5">
|
||||
<FolderPlus size={14} /> Select
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="text-xs text-text-muted text-right">Connections</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="number" value={connections} min={1} max={16} onChange={e=>setConnections(Number(e.target.value))} disabled={isTransferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<span className="text-xs text-text-muted">per file</span>
|
||||
</div>
|
||||
|
||||
<label className="text-xs text-text-muted text-right">Speed</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-xs text-text-primary">
|
||||
<input type="checkbox" checked={speedLimitEnabled} onChange={e => setSpeedLimitEnabled(e.target.checked)} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input" />
|
||||
Limit
|
||||
</label>
|
||||
{speedLimitEnabled && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="number" value={speedLimitValue} min={1} step={128} onChange={e=>setSpeedLimitValue(e.target.value)} className="w-20 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent" />
|
||||
<span className="text-xs text-text-muted">KiB/s</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Site Login Section */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
|
||||
{item.status === 'completed' ? 'Site Login for Redownload' : 'Site Login'}
|
||||
</h3>
|
||||
|
||||
<div className="flex gap-1 p-1 bg-border-color rounded-lg mb-4 w-fit mx-auto md:mx-0">
|
||||
{(['matching', 'custom', 'none'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => !isTransferLocked && setLoginMode(mode)}
|
||||
disabled={isTransferLocked}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${loginMode === mode ? 'bg-bg-modal text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}
|
||||
>
|
||||
{mode === 'matching' ? 'Matching site login' : mode === 'custom' ? 'Custom credentials' : 'No login'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
|
||||
{loginMode === 'matching' && (
|
||||
<div className="col-start-2 text-xs text-text-secondary italic">
|
||||
Will use saved login if available.
|
||||
</div>
|
||||
)}
|
||||
{loginMode === 'custom' && (
|
||||
<>
|
||||
<label className="text-xs text-text-muted text-right">Username</label>
|
||||
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} disabled={isTransferLocked} placeholder="Username" className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
|
||||
<label className="text-xs text-text-muted text-right">Password</label>
|
||||
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} disabled={isTransferLocked} placeholder="Password" className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Advanced Transfer Section */}
|
||||
<section>
|
||||
<button
|
||||
onClick={() => setAdvancedExpanded(!advancedExpanded)}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-text-primary w-full pb-1 border-b border-border-modal/50 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
{item.status === 'completed' ? 'Advanced Transfer for Redownload' : 'Advanced Transfer'}
|
||||
</button>
|
||||
|
||||
{advancedExpanded && (
|
||||
<div className="mt-4 grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center pl-6">
|
||||
<label className="text-xs text-text-muted text-right">Checksum</label>
|
||||
<label className="flex items-center gap-2 text-xs text-text-primary">
|
||||
<input type="checkbox" checked={checksumEnabled} onChange={e => setChecksumEnabled(e.target.checked)} disabled={isTransferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input" />
|
||||
Verify
|
||||
</label>
|
||||
|
||||
{checksumEnabled && (
|
||||
<>
|
||||
<label className="text-xs text-text-muted text-right">Algorithm</label>
|
||||
<select value={checksumAlgorithm} onChange={e=>setChecksumAlgorithm(e.target.value)} disabled={isTransferLocked} className="max-w-[150px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50">
|
||||
<option value="MD5">MD5</option>
|
||||
<option value="SHA-1">SHA-1</option>
|
||||
<option value="SHA-256">SHA-256</option>
|
||||
<option value="SHA-512">SHA-512</option>
|
||||
</select>
|
||||
|
||||
<label className="text-xs text-text-muted text-right">Digest</label>
|
||||
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} disabled={isTransferLocked} placeholder="Expected digest" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="text-xs text-text-muted text-right">Cookies</label>
|
||||
<input type="text" value={cookies} onChange={e=>setCookies(e.target.value)} disabled={isTransferLocked} placeholder="Cookies" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
|
||||
<div className="col-span-2 mt-2">
|
||||
<label className="block text-xs text-text-muted mb-1.5">Headers</label>
|
||||
<textarea value={headers} onChange={e=>setHeaders(e.target.value)} disabled={isTransferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs text-text-muted mb-1.5">Mirrors</label>
|
||||
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} disabled={isTransferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="h-[1px] bg-border-modal w-full shrink-0"></div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 px-4 bg-sidebar-bg flex items-center justify-between shrink-0">
|
||||
<div className="text-red-500 text-xs truncate max-w-[400px]">
|
||||
{errorMessage}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedPropertiesDownloadId(null)}
|
||||
className="app-button border-transparent bg-transparent px-4 text-xs text-text-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="app-button app-button-primary px-4 text-xs"
|
||||
>
|
||||
<CheckCircle size={14} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import {
|
||||
CheckCircle2, Clock3, List, Moon, LockKeyhole,
|
||||
Pause, Play, Power, RotateCcw, Save
|
||||
} from 'lucide-react';
|
||||
import { PostQueueAction, SchedulerSettings, useSettingsStore } from '../store/useSettingsStore';
|
||||
import { useDownloadStore, MAIN_QUEUE_ID } from '../store/useDownloadStore';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
|
||||
const days = [
|
||||
{ value: 0, label: 'Su' },
|
||||
{ value: 1, label: 'Mo' },
|
||||
{ value: 2, label: 'Tu' },
|
||||
{ value: 3, label: 'We' },
|
||||
{ value: 4, label: 'Th' },
|
||||
{ value: 5, label: 'Fr' },
|
||||
{ value: 6, label: 'Sa' },
|
||||
];
|
||||
|
||||
const postActions: { value: PostQueueAction; label: string; icon: typeof Moon }[] = [
|
||||
{ value: 'none', label: 'Do nothing', icon: CheckCircle2 },
|
||||
{ value: 'sleep', label: 'Sleep', icon: Moon },
|
||||
{ value: 'restart', label: 'Restart', icon: RotateCcw },
|
||||
{ value: 'shutdown', label: 'Shut down', icon: Power },
|
||||
];
|
||||
|
||||
function nextScheduledRun(settings: SchedulerSettings): string {
|
||||
if (!settings.enabled) return 'Scheduler is disabled';
|
||||
|
||||
const [hour, minute] = settings.startTime.split(':').map(Number);
|
||||
const now = new Date();
|
||||
|
||||
for (let offset = 0; offset < 8; offset += 1) {
|
||||
const candidate = new Date(now);
|
||||
candidate.setDate(now.getDate() + offset);
|
||||
candidate.setHours(hour, minute, 0, 0);
|
||||
const allowedDay = settings.everyday || settings.selectedDays.includes(candidate.getDay());
|
||||
if (allowedDay && candidate > now) {
|
||||
return candidate.toLocaleString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return 'No scheduled day selected';
|
||||
}
|
||||
|
||||
export default function SchedulerView() {
|
||||
const savedSettings = useSettingsStore(state => state.scheduler);
|
||||
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
||||
const setScheduler = useSettingsStore(state => state.setScheduler);
|
||||
const [draft, setDraft] = useState<SchedulerSettings>(savedSettings);
|
||||
const [toast, setToast] = useState('');
|
||||
const [permissionMessage, setPermissionMessage] = useState('');
|
||||
const isMac = navigator.userAgent.includes('Mac');
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(savedSettings);
|
||||
}, [savedSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
const timeout = window.setTimeout(() => setToast(''), 2200);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [toast]);
|
||||
|
||||
const nextRun = useMemo(() => nextScheduledRun(draft), [draft]);
|
||||
|
||||
const updateDraft = <K extends keyof SchedulerSettings>(key: K, value: SchedulerSettings[K]) => {
|
||||
setDraft(current => ({ ...current, [key]: value }));
|
||||
};
|
||||
|
||||
const toggleDay = (day: number) => {
|
||||
setDraft(current => ({
|
||||
...current,
|
||||
selectedDays: current.selectedDays.includes(day)
|
||||
? current.selectedDays.filter(value => value !== day)
|
||||
: [...current.selectedDays, day].sort()
|
||||
}));
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const normalized = {
|
||||
...draft,
|
||||
selectedDays: draft.everyday || draft.selectedDays.length > 0
|
||||
? draft.selectedDays
|
||||
: savedSettings.selectedDays
|
||||
};
|
||||
setScheduler(normalized);
|
||||
setDraft(normalized);
|
||||
setToast('Scheduler settings saved');
|
||||
};
|
||||
|
||||
const runNow = async () => {
|
||||
const count = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID);
|
||||
if (count > 0) {
|
||||
useSettingsStore.getState().setSchedulerRunning(true);
|
||||
setToast(`Started ${count} download${count === 1 ? '' : 's'}`);
|
||||
} else {
|
||||
setToast('No paused or failed downloads to start');
|
||||
}
|
||||
};
|
||||
|
||||
const pauseNow = async () => {
|
||||
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');
|
||||
};
|
||||
|
||||
const requestPermission = async () => {
|
||||
setPermissionMessage('Requesting permission...');
|
||||
try {
|
||||
await invoke('request_automation_permission');
|
||||
setPermissionMessage('Automation permission is available.');
|
||||
} catch (error) {
|
||||
setPermissionMessage(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex h-full flex-col overflow-hidden bg-main-bg">
|
||||
<WindowDragRegion />
|
||||
|
||||
<div className="flex items-center gap-3 border-b border-border-color px-6 pb-4">
|
||||
<label className="flex items-center gap-3 text-[17px] font-semibold tracking-tight text-text-primary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.enabled}
|
||||
onChange={event => updateDraft('enabled', event.target.checked)}
|
||||
className="h-4 w-4 accent-accent"
|
||||
/>
|
||||
Scheduler
|
||||
</label>
|
||||
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
|
||||
schedulerRunning ? 'bg-green-500/15 text-green-500' : 'bg-item-hover text-text-muted'
|
||||
}`}>
|
||||
{schedulerRunning ? 'Running' : nextRun}
|
||||
</span>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<button onClick={runNow} className="app-button px-3 text-[11px]">
|
||||
<Play size={14} /> Run Now
|
||||
</button>
|
||||
<button onClick={pauseNow} className="app-button px-3 text-[11px]">
|
||||
<Pause size={14} /> Pause
|
||||
</button>
|
||||
<button onClick={save} className="app-button app-button-primary px-3 text-[11px]">
|
||||
<Save size={14} /> Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className={`max-w-[760px] space-y-4 ${draft.enabled ? '' : 'opacity-50'}`}>
|
||||
<section className="app-card p-5">
|
||||
<div className="mb-5 flex items-center gap-2 font-semibold text-text-primary">
|
||||
<Clock3 size={17} className="text-accent" /> Timing
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-8">
|
||||
<label className="space-y-2 text-[12px] text-text-secondary">
|
||||
<span className="block">Start Time</span>
|
||||
<input type="time" value={draft.startTime} onChange={event => updateDraft('startTime', event.target.value)} disabled={!draft.enabled} className="app-control px-3 py-2 text-text-primary" />
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-[12px] text-text-secondary">
|
||||
<input type="checkbox" checked={draft.stopTimeEnabled} onChange={event => updateDraft('stopTimeEnabled', event.target.checked)} disabled={!draft.enabled} className="accent-accent" />
|
||||
Stop Time
|
||||
</label>
|
||||
<input type="time" value={draft.stopTime} onChange={event => updateDraft('stopTime', event.target.value)} disabled={!draft.enabled || !draft.stopTimeEnabled} className="app-control px-3 py-2 text-text-primary disabled:opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-5 border-t border-border-color" />
|
||||
<label className="flex items-center gap-2 text-[13px] font-medium text-text-primary">
|
||||
<input type="checkbox" checked={draft.everyday} onChange={event => updateDraft('everyday', event.target.checked)} disabled={!draft.enabled} className="accent-accent" />
|
||||
Run Every Day
|
||||
</label>
|
||||
{!draft.everyday && (
|
||||
<div className="mt-4 flex gap-2">
|
||||
{days.map(day => {
|
||||
const selected = draft.selectedDays.includes(day.value);
|
||||
return (
|
||||
<button
|
||||
key={day.value}
|
||||
type="button"
|
||||
disabled={!draft.enabled}
|
||||
onClick={() => toggleDay(day.value)}
|
||||
className={`h-8 w-8 rounded-full text-[12px] font-semibold ${
|
||||
selected ? 'bg-accent text-white' : 'bg-bg-input text-text-primary hover:bg-item-hover'
|
||||
}`}
|
||||
>
|
||||
{day.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="app-card p-5">
|
||||
<div className="mb-4 flex items-center gap-2 font-semibold text-text-primary">
|
||||
<List size={17} className="text-accent" /> Queues to Schedule
|
||||
</div>
|
||||
<label className="flex items-center gap-3 text-[13px] text-text-primary">
|
||||
<input type="checkbox" checked readOnly disabled={!draft.enabled} className="accent-accent" />
|
||||
Main Queue
|
||||
<span className="text-[11px] text-text-muted">All paused and failed downloads</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="app-card p-5">
|
||||
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
||||
<Power size={17} className="text-accent" /> After Completion
|
||||
</div>
|
||||
<p className="mb-4 text-[12px] text-text-muted">Choose what happens after downloads started by the scheduler finish.</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{postActions.map(action => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<label key={action.value} className={`flex items-center gap-3 rounded-lg border p-3 text-[13px] ${
|
||||
draft.postQueueAction === action.value ? 'border-accent bg-item-selected text-text-primary' : 'border-border-modal text-text-secondary'
|
||||
}`}>
|
||||
<input type="radio" name="post-action" checked={draft.postQueueAction === action.value} onChange={() => updateDraft('postQueueAction', action.value)} disabled={!draft.enabled} className="accent-accent" />
|
||||
<Icon size={15} />
|
||||
{action.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{draft.postQueueAction !== 'none' && (
|
||||
<p className="mt-3 text-[11px] text-orange-400">This action can interrupt other work on the computer. Firelink invokes it immediately after the scheduled queue finishes.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{isMac && (
|
||||
<section className="app-card mt-4 max-w-[760px] p-5">
|
||||
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
||||
<LockKeyhole size={17} className="text-accent" /> System Permissions
|
||||
</div>
|
||||
<p className="mb-4 text-[12px] text-text-muted">Sleep, restart, and shut down require macOS Automation permission for Finder.</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={requestPermission} className="app-button app-button-primary px-3 text-[11px]">Grant Permission</button>
|
||||
<button onClick={() => invoke('open_automation_settings')} className="app-button px-3 text-[11px]">Open Settings</button>
|
||||
</div>
|
||||
{permissionMessage && <p className="mt-3 text-[11px] text-text-muted">{permissionMessage}</p>}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{toast && (
|
||||
<div className="app-toast pointer-events-none absolute bottom-7 left-1/2 -translate-x-1/2 px-4 py-2 text-[12px] font-medium">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,914 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
type AppFontSize,
|
||||
type ListRowDensity,
|
||||
SettingsTab,
|
||||
useSettingsStore
|
||||
} from '../store/useSettingsStore';
|
||||
import {
|
||||
Download, Palette, Globe, Folder, Key,
|
||||
Moon, Terminal, Puzzle, Info, Plus, Trash2, Copy, RefreshCw, Code
|
||||
} from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import appIcon from '../assets/app-icon.png';
|
||||
|
||||
const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [
|
||||
{ type: 'downloads', label: 'Downloads', icon: Download },
|
||||
{ type: 'lookandfeel', label: 'Look and feel', icon: Palette },
|
||||
{ type: 'network', label: 'Network', icon: Globe },
|
||||
{ type: 'locations', label: 'Locations', icon: Folder },
|
||||
{ type: 'sitelogins', label: 'Site Logins', icon: Key },
|
||||
{ type: 'power', label: 'Power', icon: Moon },
|
||||
{ type: 'engine', label: 'Engine', icon: Terminal },
|
||||
{ type: 'integrations', label: 'Integrations', icon: Puzzle },
|
||||
{ type: 'about', label: 'About', icon: Info },
|
||||
];
|
||||
|
||||
export default function SettingsView() {
|
||||
const settings = useSettingsStore();
|
||||
const activeTab = settings.activeSettingsTab;
|
||||
|
||||
// Local state for versions
|
||||
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 [appVersion, setAppVersion] = useState('0.7.3');
|
||||
|
||||
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('');
|
||||
const [loginUser, setLoginUser] = useState('');
|
||||
const [loginPass, setLoginPass] = useState('');
|
||||
const [loginError, setLoginError] = useState('');
|
||||
|
||||
// Toast notifications
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (toastMessage) {
|
||||
const t = setTimeout(() => setToastMessage(''), 2000);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [toastMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
getVersion().then(setAppVersion).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
// Fetch engine versions when Engine tab is opened
|
||||
useEffect(() => {
|
||||
if (settings.activeView === 'settings' && activeTab === 'engine') {
|
||||
invoke('test_aria2c')
|
||||
.then(v => setAria2Version(v))
|
||||
.catch(e => setAria2Version('Error: ' + e));
|
||||
|
||||
invoke('test_ytdlp')
|
||||
.then(v => setYtdlpVersion(v))
|
||||
.catch(e => setYtdlpVersion('Error: ' + e));
|
||||
|
||||
invoke('test_ffmpeg')
|
||||
.then(v => setFfmpegVersion(v))
|
||||
.catch(e => setFfmpegVersion('Error: ' + e));
|
||||
|
||||
invoke('test_deno')
|
||||
.then(v => setDenoVersion(v))
|
||||
.catch(e => setDenoVersion('Error: ' + e));
|
||||
}
|
||||
}, [settings.activeView, activeTab]);
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
setToastMessage(msg);
|
||||
};
|
||||
|
||||
const handleCheckForUpdates = async () => {
|
||||
if (isCheckingForUpdates) return;
|
||||
|
||||
setIsCheckingForUpdates(true);
|
||||
showToast('Checking for updates...');
|
||||
|
||||
try {
|
||||
const result = await invoke('check_for_updates');
|
||||
|
||||
if (result.type === 'UpToDate') {
|
||||
showToast(`Firelink ${result.latest_version} is up to date`);
|
||||
} else if (result.type === 'UpdateAvailable') {
|
||||
showToast(`Firelink ${result.update.version} is available`);
|
||||
} else {
|
||||
showToast('The update check returned an unexpected response');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(`Update check failed: ${String(error)}`);
|
||||
} finally {
|
||||
setIsCheckingForUpdates(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseCategory = async (category: string) => {
|
||||
const currentPath = (settings.downloadDirectories || {})[category] || '';
|
||||
try {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
defaultPath: currentPath.startsWith('~') ? undefined : currentPath
|
||||
});
|
||||
if (selected && typeof selected === 'string') {
|
||||
settings.setCategoryDirectory(category, selected);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to select folder for ${category}:`, e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseBulk = async () => {
|
||||
try {
|
||||
const base = await open({
|
||||
directory: true,
|
||||
multiple: false
|
||||
});
|
||||
if (base && typeof base === 'string') {
|
||||
const cleanBase = base.replace(/\/$/, '');
|
||||
settings.setCategoryDirectory('Musics', `${cleanBase}/Musics`);
|
||||
settings.setCategoryDirectory('Movies', `${cleanBase}/Movies`);
|
||||
settings.setCategoryDirectory('Compressed', `${cleanBase}/Compressed`);
|
||||
settings.setCategoryDirectory('Documents', `${cleanBase}/Documents`);
|
||||
settings.setCategoryDirectory('Pictures', `${cleanBase}/Pictures`);
|
||||
settings.setCategoryDirectory('Applications', `${cleanBase}/Applications`);
|
||||
settings.setCategoryDirectory('Other', `${cleanBase}/Other`);
|
||||
showToast("Updated all categories to use base folder");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to browse base path:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddLogin = async () => {
|
||||
if (!loginPattern.trim() || !loginUser.trim()) {
|
||||
setLoginError("Please enter a URL pattern and a username.");
|
||||
return;
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
if (loginPass) {
|
||||
try {
|
||||
await invoke('set_keychain_password', { id, password: loginPass });
|
||||
} catch (e) {
|
||||
console.error("Failed to save password to keychain:", e);
|
||||
setLoginError("Failed to save password securely.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
settings.addSiteLogin({
|
||||
id,
|
||||
urlPattern: loginPattern.trim(),
|
||||
username: loginUser.trim()
|
||||
});
|
||||
setLoginPattern('');
|
||||
setLoginUser('');
|
||||
setLoginPass('');
|
||||
setLoginError('');
|
||||
showToast("Added site credential");
|
||||
};
|
||||
|
||||
const copyToken = () => {
|
||||
navigator.clipboard.writeText(settings.extensionPairingToken);
|
||||
showToast("Token copied to clipboard!");
|
||||
};
|
||||
|
||||
const activeTabLabel = settingsTabs.find(tab => tab.type === activeTab)?.label ?? 'Downloads';
|
||||
|
||||
const TabButton = ({ type, icon: Icon, label }: { type: SettingsTab; icon: typeof Download; label: string }) => {
|
||||
const active = activeTab === type;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-active={active}
|
||||
onClick={() => settings.setActiveSettingsTab(type)}
|
||||
className={`settings-tab-button flex min-w-0 flex-1 flex-col items-center justify-center px-1 text-center cursor-default ${
|
||||
active
|
||||
? 'text-white'
|
||||
: 'text-text-primary hover:bg-item-hover'
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} strokeWidth={2} />
|
||||
<span className="settings-tab-label mt-1 w-full whitespace-nowrap font-medium">{label}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-view flex-1 flex flex-col relative h-full overflow-hidden">
|
||||
<WindowDragRegion />
|
||||
|
||||
{/* Toast Notification */}
|
||||
{toastMessage && (
|
||||
<div className="app-toast absolute top-4 left-1/2 -translate-x-1/2 z-50 px-4 py-2 text-[12px] font-medium">
|
||||
{toastMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SwiftUI SettingsPaneContainer-style horizontal tab strip */}
|
||||
<div className="settings-toolbar">
|
||||
<div className="settings-tab-strip flex items-stretch gap-1">
|
||||
{settingsTabs.map(tab => (
|
||||
<TabButton key={tab.type} {...tab} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="settings-scroll flex-1 overflow-y-auto">
|
||||
<div className="settings-content-shell w-full">
|
||||
<h1 className="settings-title text-text-primary">{activeTabLabel}</h1>
|
||||
<div className="settings-content max-w-[720px]">
|
||||
|
||||
{/* Downloads Pane */}
|
||||
{activeTab === 'downloads' && (
|
||||
<div className="settings-pane max-w-[720px]">
|
||||
<div className="mac-settings-group">
|
||||
<div className="mac-settings-row">
|
||||
<div className="settings-row-label">
|
||||
<span>Default connections:</span>
|
||||
<small>For new downloads</small>
|
||||
</div>
|
||||
<input
|
||||
type="number" min="1" max="16"
|
||||
value={settings.perServerConnections}
|
||||
onChange={(e) => settings.setPerServerConnections(Number(e.target.value))}
|
||||
className="app-control w-24 text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="mac-settings-row">
|
||||
<div className="settings-row-label">
|
||||
<span>Parallel downloads:</span>
|
||||
<small>Max simultaneous active files</small>
|
||||
</div>
|
||||
<input
|
||||
type="number" min="1" max="12"
|
||||
value={settings.maxConcurrentDownloads}
|
||||
onChange={(e) => settings.setMaxConcurrentDownloads(Number(e.target.value))}
|
||||
className="app-control w-24 text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="mac-settings-row">
|
||||
<div className="settings-row-label">
|
||||
<span>Global speed limit:</span>
|
||||
<small>0 = unlimited speed</small>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.globalSpeedLimit}
|
||||
onChange={(e) => settings.setGlobalSpeedLimit(e.target.value)}
|
||||
placeholder="0"
|
||||
className="app-control w-24 text-center font-mono pr-9"
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-text-muted pointer-events-none">KiB/s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mac-settings-row">
|
||||
<div className="settings-row-label">
|
||||
<span>Automatic retries:</span>
|
||||
<small>If a connection fails</small>
|
||||
</div>
|
||||
<input
|
||||
type="number" min="0" max="10"
|
||||
value={settings.maxAutomaticRetries}
|
||||
onChange={(e) => settings.setMaxAutomaticRetries(Number(e.target.value))}
|
||||
className="app-control w-24 text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mac-settings-group">
|
||||
<label className="mac-settings-row cursor-default">
|
||||
<div className="settings-row-label">
|
||||
<span>Show notification when download completes</span>
|
||||
<small>Alerts you in Notification Center</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showNotifications}
|
||||
onChange={(e) => settings.setShowNotifications(e.target.checked)}
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
<label className="mac-settings-row cursor-default" style={{ opacity: settings.showNotifications ? 1 : 0.5 }}>
|
||||
<span className="text-[13px] text-text-primary">Play sound when download completes</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.playCompletionSound}
|
||||
disabled={!settings.showNotifications}
|
||||
onChange={(e) => settings.setPlayCompletionSound(e.target.checked)}
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Look & Feel Pane */}
|
||||
{activeTab === 'lookandfeel' && (
|
||||
<div className="settings-pane max-w-[720px]">
|
||||
<h2 className="settings-section-title">App Theme</h2>
|
||||
<div className="mac-settings-group">
|
||||
<div className="mac-settings-row settings-choice-row">
|
||||
<span className="text-[13px] text-text-primary pt-0.5">Theme</span>
|
||||
<div className="theme-option-grid" role="radiogroup" aria-label="App theme">
|
||||
{[
|
||||
{ value: 'system', label: 'System', colors: ['#f4f4f5', '#252525'] },
|
||||
{ value: 'light', label: 'Light', colors: ['#ffffff', '#e9e9ec'] },
|
||||
{ value: 'dark', label: 'Dark', colors: ['#1a1a1a', '#292929'] },
|
||||
{ value: 'dracula', label: 'Dracula', colors: ['#282a36', '#ff79c6'] },
|
||||
{ value: 'nord', label: 'Nord', colors: ['#2e3440', '#88c0d0'] },
|
||||
].map(({ value, label, colors }) => (
|
||||
<label
|
||||
key={value}
|
||||
className="theme-option"
|
||||
data-active={settings.theme === value}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="app-theme"
|
||||
checked={settings.theme === value}
|
||||
onChange={() => settings.setTheme(value as typeof settings.theme)}
|
||||
/>
|
||||
<span className="theme-option-preview" aria-hidden="true">
|
||||
<span style={{ background: colors[0] }} />
|
||||
<span style={{ background: colors[1] }} />
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="settings-group-footer">Select a color palette for the app's user interface.</p>
|
||||
</div>
|
||||
|
||||
<h2 className="settings-section-title">Display</h2>
|
||||
<div className="mac-settings-group">
|
||||
<div className="mac-settings-row">
|
||||
<span className="text-[13px] text-text-primary">Font Size</span>
|
||||
<select
|
||||
value={settings.appFontSize}
|
||||
onChange={(e) => settings.setAppFontSize(e.target.value as AppFontSize)}
|
||||
className="app-control w-40"
|
||||
>
|
||||
<option value="small">Small</option>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="large">Large</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mac-settings-row">
|
||||
<span className="text-[13px] text-text-primary">List Row Density</span>
|
||||
<select
|
||||
value={settings.listRowDensity}
|
||||
onChange={(e) => settings.setListRowDensity(e.target.value as ListRowDensity)}
|
||||
className="app-control w-40"
|
||||
>
|
||||
<option value="compact">Compact</option>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="relaxed">Relaxed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="settings-section-title">macOS Integration</h2>
|
||||
<div className="mac-settings-group">
|
||||
<label className="mac-settings-row cursor-default">
|
||||
<div className="settings-row-label">
|
||||
<span>Show badge on Dock icon</span>
|
||||
<small>Displays the number of active downloads on the Firelink Dock icon.</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showDockBadge}
|
||||
onChange={(e) => settings.setShowDockBadge(e.target.checked)}
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
<label className="mac-settings-row cursor-default">
|
||||
<div className="settings-row-label">
|
||||
<span>Show menu bar icon</span>
|
||||
<small>Provides quick access to downloads and queues from the macOS menu bar.</small>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showMenuBarIcon}
|
||||
onChange={(e) => settings.setShowMenuBarIcon(e.target.checked)}
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Network Pane */}
|
||||
{activeTab === 'network' && (
|
||||
<div className="settings-pane max-w-[720px]">
|
||||
<h2 className="settings-section-title">Proxy</h2>
|
||||
<div className="mac-settings-group">
|
||||
<div className="mac-settings-row settings-choice-row">
|
||||
<span className="text-[13px] text-text-primary pt-0.5">Mode</span>
|
||||
<div className="settings-radio-group">
|
||||
{[
|
||||
['none', 'No Proxy'],
|
||||
['system', 'Use System Proxy'],
|
||||
['custom', 'Custom Proxy'],
|
||||
].map(([value, label]) => (
|
||||
<label key={value}>
|
||||
<input
|
||||
type="radio"
|
||||
name="proxy-mode"
|
||||
checked={settings.proxyMode === value}
|
||||
onChange={() => settings.setProxyMode(value as typeof settings.proxyMode)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{settings.proxyMode === 'custom' && (
|
||||
<>
|
||||
<div className="mac-settings-row">
|
||||
<span className="text-[13px] text-text-primary pl-4">Proxy Host</span>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.proxyHost}
|
||||
onChange={(e) => settings.setProxyHost(e.target.value)}
|
||||
placeholder="127.0.0.1"
|
||||
className="app-control w-40 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="mac-settings-row">
|
||||
<span className="text-[13px] text-text-primary pl-4">Proxy Port</span>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.proxyPort}
|
||||
onChange={(e) => settings.setProxyPort(Number(e.target.value))}
|
||||
className="app-control w-24 text-center"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="settings-group-footer">
|
||||
{settings.proxyMode === 'none' && 'Downloads ignore configured proxies.'}
|
||||
{settings.proxyMode === 'system' && 'Downloads use the matching macOS system proxy when one is configured.'}
|
||||
{settings.proxyMode === 'custom' && (settings.proxyHost
|
||||
? `Downloads use http://${settings.proxyHost}:${settings.proxyPort}.`
|
||||
: 'Enter a proxy host and port to enable the custom proxy.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 className="settings-section-title">Identity</h2>
|
||||
<div className="mac-settings-group">
|
||||
<div className="mac-settings-row">
|
||||
<span className="text-[13px] text-text-primary">Custom User Agent</span>
|
||||
<div className="flex-1 ml-4 relative">
|
||||
<input
|
||||
type="text"
|
||||
list="user-agents"
|
||||
value={settings.customUserAgent}
|
||||
onChange={(e) => settings.setCustomUserAgent(e.target.value)}
|
||||
placeholder="e.g. Mozilla/5.0..."
|
||||
className="app-control w-full font-mono text-[11px]"
|
||||
/>
|
||||
<datalist id="user-agents">
|
||||
<option value="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36">Chrome (Windows)</option>
|
||||
<option value="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36">Chrome (macOS)</option>
|
||||
<option value="Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0">Firefox (Windows)</option>
|
||||
<option value="Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0">Firefox (macOS)</option>
|
||||
<option value="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15">Safari (macOS)</option>
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
<p className="settings-group-footer">Spoofs the browser User-Agent to bypass download restrictions. Leave blank for default.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Locations Pane */}
|
||||
{activeTab === 'locations' && (
|
||||
<div className="settings-pane max-w-[760px]">
|
||||
<div className="mac-settings-group">
|
||||
<label className="mac-settings-row cursor-default">
|
||||
<span className="text-[13px] text-text-primary">Ask where to save each file</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.askWhereToSaveEachFile}
|
||||
onChange={(e) => settings.setAskWhereToSaveEachFile(e.target.checked)}
|
||||
className="mac-switch"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mac-settings-group">
|
||||
<div className="mac-settings-row bg-item-hover/20">
|
||||
<span className="text-[13px] font-semibold text-text-primary">All Categories Base</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text" readOnly placeholder="Choose base folder..."
|
||||
className="app-control w-64 text-text-muted text-[11px] px-2"
|
||||
/>
|
||||
<button
|
||||
onClick={handleBrowseBulk}
|
||||
className="app-button px-3 text-xs font-semibold text-accent border border-accent/20 bg-accent/10 hover:bg-accent/20"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{['Musics', 'Movies', 'Compressed', 'Documents', 'Pictures', 'Applications', 'Other'].map((category) => (
|
||||
<div key={category} className="mac-settings-row">
|
||||
<span className="text-[13px] text-text-primary pl-4">{category}</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={(settings.downloadDirectories || {})[category] || ''}
|
||||
onChange={(e) => settings.setCategoryDirectory(category, e.target.value)}
|
||||
className="app-control w-64 text-[11px] px-2"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleBrowseCategory(category)}
|
||||
className="app-button px-3 text-xs text-text-secondary hover:bg-item-hover"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mac-settings-row justify-end border-t-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
settings.resetCategoryDirectories();
|
||||
showToast("Reset directories to default");
|
||||
}}
|
||||
className="app-control hover:bg-item-hover text-text-secondary px-4 py-1"
|
||||
>
|
||||
Reset Defaults
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Site Logins Pane */}
|
||||
{activeTab === 'sitelogins' && (
|
||||
<div className="settings-pane space-y-6 max-w-[760px]">
|
||||
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Site Credentials</h3>
|
||||
|
||||
{/* 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">
|
||||
{(settings.siteLogins || []).length === 0 ? (
|
||||
<p className="text-center text-text-muted text-[13px] py-6">No saved logins.</p>
|
||||
) : (
|
||||
(settings.siteLogins || []).map((login) => (
|
||||
<div key={login.id} className="flex justify-between items-center p-2 rounded bg-bg-modal border border-border-modal/40">
|
||||
<div className="text-[13px] space-y-0.5">
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await invoke('delete_keychain_password', { id: login.id });
|
||||
} catch (e) {
|
||||
console.warn("Could not delete password from keychain:", e);
|
||||
}
|
||||
settings.removeSiteLogin(login.id);
|
||||
showToast("Deleted credential");
|
||||
}}
|
||||
className="p-1.5 hover:bg-item-hover rounded-md text-text-muted hover:text-red-500"
|
||||
title="Delete credential"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Site Login Form */}
|
||||
<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>
|
||||
|
||||
{loginError && (
|
||||
<p className="text-red-500 text-xs">{loginError}</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
||||
<label className="text-text-secondary">URL Pattern:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={loginPattern}
|
||||
onChange={(e) => setLoginPattern(e.target.value)}
|
||||
placeholder="e.g. *.example.com or example.com/downloads"
|
||||
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
||||
<label className="text-text-secondary">Username:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={loginUser}
|
||||
onChange={(e) => setLoginUser(e.target.value)}
|
||||
placeholder="Username"
|
||||
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
|
||||
<label className="text-text-secondary">Password:</label>
|
||||
<input
|
||||
type="password"
|
||||
value={loginPass}
|
||||
onChange={(e) => setLoginPass(e.target.value)}
|
||||
placeholder="Password"
|
||||
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
onClick={handleAddLogin}
|
||||
className="bg-accent hover:bg-accent text-white px-4 py-1.5 rounded-lg text-xs font-semibold shadow flex items-center gap-1.5"
|
||||
>
|
||||
<Plus size={14} /> Add Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Power Pane */}
|
||||
{activeTab === 'power' && (
|
||||
<div className="settings-pane space-y-6 max-w-[760px]">
|
||||
<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">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.preventsSleepWhileDownloading}
|
||||
onChange={(e) => settings.setPreventsSleepWhileDownloading(e.target.checked)}
|
||||
className="mt-0.5 rounded accent-accent"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold text-text-primary">Prevent system sleep while downloads are active</p>
|
||||
<p className="text-text-muted text-xs mt-0.5">The display may still turn off. Firelink only keeps the device awake enough to complete active transfers.</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Engine Pane */}
|
||||
{activeTab === 'engine' && (
|
||||
<div className="settings-pane space-y-6 max-w-[760px]">
|
||||
<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="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">
|
||||
<Terminal size={14} className="text-accent" /> Core Downloader (Aria2)
|
||||
</h4>
|
||||
<div className="grid grid-cols-[120px_1fr] text-[13px]">
|
||||
<span className="text-text-secondary">Version:</span>
|
||||
<span className="font-mono text-xs text-text-muted select-all">{aria2Version}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[120px_1fr] text-[13px] items-center">
|
||||
<span className="text-text-secondary">Status:</span>
|
||||
{getEngineStatus(aria2Version)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Terminal size={14} className="text-orange-500" /> Media Extractors
|
||||
</h4>
|
||||
|
||||
<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 truncate pr-4">{ytdlpVersion}</span>
|
||||
{getEngineStatus(ytdlpVersion)}
|
||||
</div>
|
||||
|
||||
<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 truncate pr-4">{ffmpegVersion}</span>
|
||||
{getEngineStatus(ffmpegVersion)}
|
||||
</div>
|
||||
|
||||
<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="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">
|
||||
<label className="text-text-secondary font-semibold">Browser Cookies Source:</label>
|
||||
<select
|
||||
value={settings.mediaCookieSource}
|
||||
onChange={(e) => settings.setMediaCookieSource(
|
||||
e.target.value as typeof settings.mediaCookieSource
|
||||
)}
|
||||
className="bg-bg-input border border-border-modal rounded-lg p-1.5 text-[13px] text-text-primary focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="safari">Safari</option>
|
||||
<option value="chrome">Chrome</option>
|
||||
<option value="firefox">Firefox</option>
|
||||
<option value="edge">Edge</option>
|
||||
<option value="brave">Brave</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-text-muted text-xs mt-1">yt-dlp reads browser cookies to bypass video download limits or access restricted media. Firelink does not save browser cookies.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Integrations Pane */}
|
||||
{activeTab === 'integrations' && (
|
||||
<div className="settings-pane space-y-6 max-w-[760px]">
|
||||
<div className="flex items-center gap-3 border-b border-border-color/30 pb-3">
|
||||
<Puzzle size={28} className="text-orange-500" />
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-text-primary">Connect Browser Extension</h3>
|
||||
<p className="text-text-secondary text-xs">Capture downloads directly from your browser in three easy steps.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Guide Cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
|
||||
{/* Step 1 */}
|
||||
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col justify-between h-[190px]">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="bg-accent/25 text-accent font-bold rounded-full w-5 h-5 flex items-center justify-center text-xs">1</span>
|
||||
<Copy size={16} className="text-accent" />
|
||||
</div>
|
||||
<h4 className="text-[13px] font-bold text-text-primary mb-1">Copy Token</h4>
|
||||
<p className="text-text-muted text-[11px] leading-relaxed">This secure token authorizes your browser extension.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={copyToken}
|
||||
className="w-full bg-accent hover:bg-accent 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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
settings.regeneratePairingToken();
|
||||
showToast("Pairing token regenerated");
|
||||
}}
|
||||
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] flex items-center justify-center gap-1 transition-colors"
|
||||
>
|
||||
<RefreshCw size={11} /> Regenerate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col justify-between h-[190px]">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="bg-orange-600/25 text-orange-500 font-bold rounded-full w-5 h-5 flex items-center justify-center text-xs">2</span>
|
||||
<Globe size={16} className="text-orange-500" />
|
||||
</div>
|
||||
<h4 className="text-[13px] font-bold text-text-primary mb-1">Get Extension</h4>
|
||||
<p className="text-text-muted text-[11px] leading-relaxed">Install the Firelink Companion extension on your browser.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<a
|
||||
href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"
|
||||
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"
|
||||
>
|
||||
Firefox Add-ons
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/nimbold/Firelink-Extension/releases"
|
||||
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"
|
||||
>
|
||||
GitHub Releases
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 3 */}
|
||||
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col h-[190px]">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="bg-green-600/25 text-green-500 font-bold rounded-full w-5 h-5 flex items-center justify-center text-xs">3</span>
|
||||
<Puzzle size={16} className="text-green-500" />
|
||||
</div>
|
||||
<h4 className="text-[13px] font-bold text-text-primary mb-1">Paste & Connect</h4>
|
||||
<p className="text-text-muted text-[11px] leading-relaxed">Click the Firelink icon in your browser's toolbar and paste the copied token.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Info */}
|
||||
<div className="border border-border-modal/70 rounded-lg p-3 bg-item-hover/10 flex justify-between items-center text-[12px]">
|
||||
<span className="text-text-secondary font-medium">Extension Server Status:</span>
|
||||
<span className="text-green-500 font-semibold flex items-center gap-1">
|
||||
● Listening on 127.0.0.1:23522 (Active)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* About Pane */}
|
||||
{activeTab === 'about' && (
|
||||
<div className="settings-pane space-y-6 max-w-[760px]">
|
||||
{/* Header Box */}
|
||||
<div className="bg-bg-modal border border-border-modal/40 rounded-xl p-6 flex items-center gap-4">
|
||||
<img src={appIcon} alt="Firelink Icon" className="w-[72px] h-[72px] drop-shadow-md rounded-xl" />
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-[17px] font-bold text-text-primary">Firelink</h3>
|
||||
<p className="text-text-secondary text-[12px] font-medium">Version {appVersion}</p>
|
||||
<p className="text-text-muted text-[11px]">
|
||||
A fast desktop download manager powered by Rust and Tauri.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Updates Section */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-[12px] font-bold text-text-primary px-1">Updates</h4>
|
||||
<div className="bg-bg-modal border border-border-modal/40 rounded-xl overflow-hidden">
|
||||
<div className="p-4 flex items-center justify-between border-b border-border-modal/40">
|
||||
<div>
|
||||
<p className="text-[13px] font-bold text-text-primary">Check for Updates</p>
|
||||
<p className="text-text-muted text-[11px] mt-0.5">Firelink checks GitHub Releases for new versions.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCheckForUpdates}
|
||||
disabled={isCheckingForUpdates}
|
||||
className="app-button px-4 text-xs disabled:opacity-50"
|
||||
>
|
||||
{isCheckingForUpdates ? (
|
||||
<>
|
||||
<RefreshCw size={13} className="animate-spin" />
|
||||
Checking...
|
||||
</>
|
||||
) : 'Check Now'}
|
||||
</button>
|
||||
</div>
|
||||
<label className="p-4 flex items-center justify-between cursor-default">
|
||||
<span className="text-[13px] font-bold text-text-primary">Automatically check for updates</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={settings.autoCheckUpdates}
|
||||
onClick={() => settings.setAutoCheckUpdates(!settings.autoCheckUpdates)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-default items-center rounded-full transition-colors duration-200 ease-in-out border border-transparent ${settings.autoCheckUpdates ? 'bg-accent' : 'bg-border-color'}`}
|
||||
>
|
||||
<span className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow transition duration-200 ease-in-out ${settings.autoCheckUpdates ? 'translate-x-4' : 'translate-x-0'}`} />
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credits Footer */}
|
||||
<div className="bg-bg-modal border border-border-modal/40 rounded-xl p-4 text-[11px] space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-text-primary font-bold">Created by NimBold</span>
|
||||
<a href="https://github.com/nimbold/Firelink" target="_blank" rel="noreferrer" className="flex items-center gap-1.5 text-text-secondary hover:text-accent transition-colors font-medium">
|
||||
<Code size={14} /> Source Code
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-text-muted">
|
||||
<span>Built with <span className="text-accent">Rust</span> • <span className="text-accent">Tauri</span> • <span className="text-accent">React</span> • <span className="text-accent">TypeScript</span></span>
|
||||
<a href="https://github.com/nimbold/Firelink/blob/main/LICENSE" target="_blank" rel="noreferrer" className="text-accent hover:underline">MIT License</a>
|
||||
</div>
|
||||
<div className="text-text-muted">
|
||||
Download engines: <span className="text-accent">aria2</span> • <span className="text-accent">yt-dlp</span> • <span className="text-accent">FFmpeg</span> • <span className="text-accent">Deno</span>
|
||||
</div>
|
||||
<div className="text-text-muted pt-1 border-t border-border-modal/40">
|
||||
Copyright © 2026 NimBold. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,295 @@
|
||||
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, Play, Pause, Edit2, Trash2, PanelLeft,
|
||||
type LucideIcon
|
||||
} from 'lucide-react';
|
||||
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' | string;
|
||||
|
||||
interface SidebarProps {
|
||||
selectedFilter: SidebarFilter;
|
||||
onSelectFilter: (filter: SidebarFilter) => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
const { selectedFilter, onSelectFilter } = props;
|
||||
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue } = useDownloadStore();
|
||||
const { activeView, setActiveView, toggleSidebar } = 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 as DownloadCategory).length;
|
||||
}
|
||||
};
|
||||
|
||||
const NavItem = ({ icon: Icon, label, filter }: { icon: LucideIcon, label: string, filter: SidebarFilter }) => {
|
||||
const isSelected = activeView === 'downloads' && selectedFilter === filter;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-active={isSelected}
|
||||
className="sidebar-nav-item group flex w-full items-center text-[13px] text-left cursor-default font-medium"
|
||||
onClick={() => onSelectFilter(filter)}
|
||||
>
|
||||
<Icon className="w-[18px] h-[18px] mr-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
|
||||
<span className="truncate">{label}</span>
|
||||
{getCount(filter) > 0 && (
|
||||
<span className="sidebar-count ml-auto min-w-5 px-1.5 py-0.5 rounded-full text-center text-[10px] leading-none font-bold">
|
||||
{getCount(filter)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
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"
|
||||
data-active={isSelected}
|
||||
onContextMenu={e => handleQueueContextMenu(e, queue.id)}
|
||||
onClick={() => onSelectFilter(filterId)}
|
||||
className="sidebar-nav-item group flex w-full items-center text-[13px] text-left cursor-default font-medium"
|
||||
>
|
||||
<List className="w-[18px] h-[18px] mr-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
|
||||
<span className="truncate">{queue.name}</span>
|
||||
{getCount(filterId) > 0 && (
|
||||
<span className="sidebar-count ml-auto min-w-5 px-1.5 py-0.5 rounded-full text-center text-[10px] leading-none font-bold shrink-0">
|
||||
{getCount(filterId)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolItem = ({ icon: Icon, label, view }: { icon: LucideIcon; label: string; view: ActiveView }) => {
|
||||
const isSelected = activeView === view;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-active={isSelected}
|
||||
onClick={() => setActiveView(view)}
|
||||
className="sidebar-nav-item group flex w-full items-center text-[13px] text-left cursor-default font-medium"
|
||||
>
|
||||
<Icon className="w-[18px] h-[18px] mr-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="sidebar-inner">
|
||||
<div className="sidebar-top-region">
|
||||
<WindowDragRegion />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSidebar}
|
||||
className="sidebar-toggle-button"
|
||||
title="Hide Sidebar"
|
||||
>
|
||||
<PanelLeft size={14} strokeWidth={1.9} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="sidebar-scroll">
|
||||
<section className="sidebar-section">
|
||||
<div className="sidebar-section-label">Library</div>
|
||||
<NavItem icon={Inbox} label="All" filter="all" />
|
||||
<NavItem icon={Zap} label="Active" filter="active" />
|
||||
<NavItem icon={CheckCircle2} label="Completed" filter="completed" />
|
||||
<NavItem icon={CircleDashed} label="Unfinished" filter="unfinished" />
|
||||
</section>
|
||||
|
||||
<section className="sidebar-section">
|
||||
<div className="sidebar-section-label">Folders</div>
|
||||
<NavItem icon={Music} label="Musics" filter="Musics" />
|
||||
<NavItem icon={Film} label="Movies" filter="Movies" />
|
||||
<NavItem icon={Archive} label="Compressed" filter="Compressed" />
|
||||
<NavItem icon={FileText} label="Documents" filter="Documents" />
|
||||
<NavItem icon={ImageIcon} label="Pictures" filter="Pictures" />
|
||||
<NavItem icon={Box} label="Applications" filter="Applications" />
|
||||
<NavItem icon={FileQuestion} label="Other" filter="Other" />
|
||||
</section>
|
||||
|
||||
<section className="sidebar-section">
|
||||
<div className="sidebar-section-label">Queues</div>
|
||||
{queues.map(queue => (
|
||||
<QueueItem key={queue.id} queue={queue} />
|
||||
))}
|
||||
{isAddingQueue ? (
|
||||
<div className="flex items-center px-3.5 py-1.5 rounded-lg bg-item-hover mb-1">
|
||||
<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-3.5 py-1.5 rounded-lg text-[13px] text-text-muted hover:bg-item-hover hover:text-text-secondary cursor-default transition-colors mb-1"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2 shrink-0" strokeWidth={2} />
|
||||
<span className="truncate">Add new queue</span>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="sidebar-section">
|
||||
<div className="sidebar-section-label">Tools</div>
|
||||
<ToolItem icon={CalendarClock} label="Scheduler" view="scheduler" />
|
||||
<ToolItem icon={Gauge} label="Speed Limiter" view="speedLimiter" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<button
|
||||
type="button"
|
||||
data-active={activeView === 'settings'}
|
||||
onClick={() => setActiveView('settings')}
|
||||
className="sidebar-nav-item sidebar-settings-button group flex w-full items-center text-[13px] text-left cursor-default font-medium transition-colors"
|
||||
>
|
||||
<Settings className={`w-[18px] h-[18px] mr-3 shrink-0 ${activeView === 'settings' ? 'text-white' : 'text-text-muted'}`} strokeWidth={activeView === 'settings' ? 2.5 : 2} />
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Gauge, Save, Zap } from 'lucide-react';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
|
||||
type SpeedUnit = 'KB/s' | 'MB/s';
|
||||
|
||||
function parseLimit(limit: string, fallback: number): { value: number; unit: SpeedUnit } {
|
||||
const match = limit.trim().match(/^(\d+(?:\.\d+)?)\s*([km]?)b?(?:\/s)?$/i);
|
||||
const valueKiB = match
|
||||
? Math.max(1, Math.round(Number(match[1]) * (match[2].toLowerCase() === 'm' ? 1024 : 1)))
|
||||
: fallback;
|
||||
|
||||
return valueKiB >= 1024 && valueKiB % 1024 === 0
|
||||
? { value: valueKiB / 1024, unit: 'MB/s' }
|
||||
: { value: valueKiB, unit: 'KB/s' };
|
||||
}
|
||||
|
||||
export default function SpeedLimiterView() {
|
||||
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
|
||||
const lastCustomSpeedLimitKiB = useSettingsStore(state => state.lastCustomSpeedLimitKiB);
|
||||
const setGlobalSpeedLimit = useSettingsStore(state => state.setGlobalSpeedLimit);
|
||||
const setLastCustomSpeedLimitKiB = useSettingsStore(state => state.setLastCustomSpeedLimitKiB);
|
||||
const initial = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
|
||||
const [enabled, setEnabled] = useState(Boolean(globalSpeedLimit));
|
||||
const [value, setValue] = useState(initial.value);
|
||||
const [unit, setUnit] = useState<SpeedUnit>(initial.unit);
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const parsed = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
|
||||
setEnabled(Boolean(globalSpeedLimit));
|
||||
setValue(parsed.value);
|
||||
setUnit(parsed.unit);
|
||||
}, [globalSpeedLimit, lastCustomSpeedLimitKiB]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
const timeout = window.setTimeout(() => setToast(''), 2200);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [toast]);
|
||||
|
||||
const save = () => {
|
||||
const numericValue = Math.max(1, Math.min(Number(value) || 1, unit === 'MB/s' ? 10240 : 10_485_760));
|
||||
const valueKiB = Math.min(10_485_760, Math.round(unit === 'MB/s' ? numericValue * 1024 : numericValue));
|
||||
setLastCustomSpeedLimitKiB(valueKiB);
|
||||
setGlobalSpeedLimit(enabled ? `${valueKiB}K` : '');
|
||||
setToast(enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled');
|
||||
};
|
||||
|
||||
const preset = (presetValue: number) => {
|
||||
setEnabled(true);
|
||||
setValue(presetValue);
|
||||
setUnit('MB/s');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex h-full flex-col overflow-hidden bg-main-bg">
|
||||
<WindowDragRegion />
|
||||
|
||||
<div className="flex items-center gap-3 border-b border-border-color px-6 pb-4">
|
||||
<label className="flex items-center gap-3 text-[17px] font-semibold tracking-tight text-text-primary">
|
||||
<input type="checkbox" checked={enabled} onChange={event => setEnabled(event.target.checked)} className="h-4 w-4 accent-accent" />
|
||||
Speed Limiter
|
||||
</label>
|
||||
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
|
||||
enabled ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
|
||||
}`}>
|
||||
{enabled ? `${value} ${unit}` : 'Unlimited'}
|
||||
</span>
|
||||
<button onClick={save} className="app-button app-button-primary ml-auto px-3 text-[11px]">
|
||||
<Save size={14} /> Save Limit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<section className={`app-card max-w-[720px] p-5 ${enabled ? '' : 'opacity-50'}`}>
|
||||
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
||||
<Gauge size={18} className="text-accent" /> Global Speed Limit
|
||||
</div>
|
||||
<p className="max-w-xl text-[12px] leading-relaxed text-text-muted">
|
||||
This cap is shared across the configured concurrent download slots. A lower per-download limit still takes precedence.
|
||||
Saving a new limit gracefully restarts active jobs so the change takes effect immediately.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={value}
|
||||
disabled={!enabled}
|
||||
onChange={event => setValue(Math.max(1, Number(event.target.value) || 1))}
|
||||
className="app-control w-28 px-3 py-2 text-right font-mono"
|
||||
/>
|
||||
<div className="flex rounded-md border border-border-modal bg-bg-input p-1">
|
||||
{(['KB/s', 'MB/s'] as SpeedUnit[]).map(option => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
onClick={() => setUnit(option)}
|
||||
className={`rounded px-3 py-1.5 text-[12px] font-medium ${
|
||||
unit === option ? 'bg-accent text-white' : 'text-text-secondary hover:bg-item-hover'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-6 border-t border-border-color" />
|
||||
<div className="mb-3 flex items-center gap-2 text-[12px] font-medium text-text-secondary">
|
||||
<Zap size={14} /> Quick Presets
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[1, 5, 10].map(presetValue => (
|
||||
<button
|
||||
key={presetValue}
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
onClick={() => preset(presetValue)}
|
||||
className="app-button px-4 text-[12px] disabled:opacity-50"
|
||||
>
|
||||
{presetValue} MB/s
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{toast && (
|
||||
<div className="app-toast pointer-events-none absolute bottom-7 left-1/2 -translate-x-1/2 px-4 py-2 text-[12px] font-medium">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
|
||||
interface WindowDragRegionProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WindowDragRegion({ className = '' }: WindowDragRegionProps) {
|
||||
return (
|
||||
<div
|
||||
className={`h-10 shrink-0 cursor-default ${className}`}
|
||||
data-tauri-drag-region
|
||||
onPointerDown={(event) => {
|
||||
if (event.button === 0) {
|
||||
void getCurrentWindow().startDragging();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+1155
File diff suppressed because it is too large
Load Diff
+125
@@ -0,0 +1,125 @@
|
||||
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
|
||||
import { listen as tauriListen, type Event, type EventCallback, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import type { DownloadCategory } from './bindings/DownloadCategory';
|
||||
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
|
||||
import type { DownloadStatus } from './bindings/DownloadStatus';
|
||||
import type { ExtensionDownload } from './bindings/ExtensionDownload';
|
||||
import type { MediaCookieSource } from './bindings/MediaCookieSource';
|
||||
import type { MetadataResponse } from './bindings/MetadataResponse';
|
||||
import type { PostQueueAction } from './bindings/PostQueueAction';
|
||||
import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome';
|
||||
|
||||
type StartDownloadArgs = {
|
||||
id: string;
|
||||
url: string;
|
||||
destination: string;
|
||||
filename: string;
|
||||
connections: number | null;
|
||||
speedLimit: string | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
headers: string | null;
|
||||
checksum: string | null;
|
||||
cookies: string | null;
|
||||
mirrors: string | null;
|
||||
userAgent: string | null;
|
||||
maxTries: number | null;
|
||||
proxy: string | null;
|
||||
};
|
||||
|
||||
type StartMediaDownloadArgs = {
|
||||
id: string;
|
||||
url: string;
|
||||
destination: string;
|
||||
filename: string;
|
||||
formatSelector: string | null;
|
||||
cookieSource: Exclude<MediaCookieSource, 'none'> | null;
|
||||
speedLimit: string | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
headers: string | null;
|
||||
proxy: string | null;
|
||||
userAgent: string | null;
|
||||
maxTries: number | null;
|
||||
};
|
||||
|
||||
type CommandMap = {
|
||||
fetch_metadata: {
|
||||
args: { url: string; userAgent: string | null; username: string | null; password: string | null };
|
||||
result: MetadataResponse;
|
||||
};
|
||||
fetch_media_metadata: {
|
||||
args: { url: string; cookieBrowser: string | null; username: string | null; password: string | null };
|
||||
result: string;
|
||||
};
|
||||
test_ytdlp: { args: undefined; result: string };
|
||||
test_aria2c: { args: undefined; result: string };
|
||||
test_ffmpeg: { args: undefined; result: string };
|
||||
test_deno: { args: undefined; result: string };
|
||||
open_file: { args: { path: string }; result: void };
|
||||
show_in_folder: { args: { path: string }; result: void };
|
||||
start_download: { args: StartDownloadArgs; result: void };
|
||||
start_media_download: { args: StartMediaDownloadArgs; result: void };
|
||||
pause_download: { args: { id: string }; result: void };
|
||||
remove_download: { args: { id: string; filepath: string | null }; result: void };
|
||||
update_dock_badge: { args: { count: number }; result: void };
|
||||
set_prevent_sleep: { args: { prevent: boolean }; result: void };
|
||||
perform_system_action: { args: { action: PostQueueAction }; result: void };
|
||||
set_concurrent_limit: { args: { limit: number }; result: void };
|
||||
set_global_speed_limit: { args: { limit: string | null }; result: void };
|
||||
request_automation_permission: { args: undefined; result: void };
|
||||
open_automation_settings: { args: undefined; result: void };
|
||||
get_free_space: { args: { path: string }; result: string };
|
||||
set_keychain_password: { args: { id: string; password: string }; result: void };
|
||||
get_keychain_password: { args: { id: string }; result: string };
|
||||
delete_keychain_password: { args: { id: string }; result: void };
|
||||
check_file_exists: { args: { path: string }; result: boolean };
|
||||
delete_file: { args: { path: string }; result: void };
|
||||
toggle_tray_icon: { args: { show: boolean }; result: void };
|
||||
set_extension_pairing_token: { args: { token: string }; result: void };
|
||||
set_extension_frontend_ready: { args: { ready: boolean }; result: void };
|
||||
get_system_proxy: { args: undefined; result: string | null };
|
||||
get_file_category: { args: { filename: string }; result: DownloadCategory };
|
||||
check_for_updates: { args: undefined; result: ReleaseCheckOutcome };
|
||||
is_supported_media: { args: { url: string }; result: boolean };
|
||||
db_save_settings: { args: { data: string }; result: void };
|
||||
db_load_settings: { args: undefined; result: string | null };
|
||||
db_get_all_downloads: { args: undefined; result: string[] };
|
||||
db_save_download: {
|
||||
args: { id: string; status: DownloadStatus; queueId: string; data: string };
|
||||
result: void;
|
||||
};
|
||||
db_delete_download: { args: { id: string }; result: void };
|
||||
db_get_all_queues: { args: undefined; result: string[] };
|
||||
db_save_queue: { args: { id: string; data: string }; result: void };
|
||||
db_delete_queue: { args: { id: string }; result: void };
|
||||
};
|
||||
|
||||
type CommandName = keyof CommandMap;
|
||||
type CommandArgs<K extends CommandName> = CommandMap[K]['args'];
|
||||
type CommandResult<K extends CommandName> = CommandMap[K]['result'];
|
||||
|
||||
export function invokeCommand<K extends CommandName>(
|
||||
command: K,
|
||||
...args: CommandArgs<K> extends undefined ? [] : [args: CommandArgs<K>]
|
||||
): Promise<CommandResult<K>> {
|
||||
return tauriInvoke<CommandResult<K>>(command, args[0]);
|
||||
}
|
||||
|
||||
type EventMap = {
|
||||
'schedule-trigger': 'start' | 'stop';
|
||||
'download-progress': DownloadProgressEvent;
|
||||
'download-complete': string;
|
||||
'download-failed': string;
|
||||
'extension-add-download': ExtensionDownload;
|
||||
'deep-link-add-download': string;
|
||||
};
|
||||
|
||||
export function listenEvent<K extends keyof EventMap>(
|
||||
event: K,
|
||||
handler: EventCallback<EventMap[K]>,
|
||||
): Promise<UnlistenFn> {
|
||||
return tauriListen<EventMap[K]>(event, handler);
|
||||
}
|
||||
|
||||
export type IpcEvent<K extends keyof EventMap> = Event<EventMap[K]>;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (rootElement) {
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import { create } from 'zustand';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
|
||||
import type { Queue } from '../bindings/Queue';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import {
|
||||
categoryForFileName,
|
||||
fileNameFromUrl,
|
||||
isMediaUrl
|
||||
} from '../utils/downloads';
|
||||
|
||||
export type { DownloadCategory } from '../utils/downloads';
|
||||
|
||||
const getProxyArgs = (settings: ReturnType<typeof useSettingsStore.getState>) => {
|
||||
if (settings.proxyMode === 'custom' && settings.proxyHost) {
|
||||
return `http://${settings.proxyHost}:${settings.proxyPort}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getSiteLogin = (url: string, settings: ReturnType<typeof useSettingsStore.getState>) => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const host = urlObj.hostname.toLowerCase();
|
||||
for (const login of settings.siteLogins) {
|
||||
let pattern = login.urlPattern.toLowerCase().trim();
|
||||
if (pattern.startsWith('*.')) {
|
||||
const suffix = pattern.substring(2);
|
||||
if (host === suffix || host.endsWith('.' + suffix)) return login;
|
||||
} else if (pattern.includes('*')) {
|
||||
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
||||
if (regex.test(host)) return login;
|
||||
} else if (host === pattern) {
|
||||
return login;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return null;
|
||||
};
|
||||
|
||||
const syncSystemIntegrations = () => {
|
||||
const settings = useSettingsStore.getState();
|
||||
const activeCount = useDownloadStore.getState().downloads.filter(d => d.status === 'downloading').length;
|
||||
invoke('update_dock_badge', { count: settings.showDockBadge ? activeCount : 0 }).catch(() => {});
|
||||
if (settings.preventsSleepWhileDownloading) {
|
||||
invoke('set_prevent_sleep', { prevent: activeCount > 0 }).catch(() => {});
|
||||
} else {
|
||||
invoke('set_prevent_sleep', { prevent: false }).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
// Legacy manual speed limit math removed
|
||||
|
||||
export type { DownloadStatus };
|
||||
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
export type { DownloadItem, Queue };
|
||||
export type ExtensionDownloadRequest = ExtensionDownload;
|
||||
|
||||
interface DownloadState {
|
||||
downloads: DownloadItem[];
|
||||
queues: Queue[];
|
||||
isAddModalOpen: boolean;
|
||||
pendingAddUrls: string;
|
||||
pendingAddReferer: string;
|
||||
pendingAddFilename: string;
|
||||
selectedPropertiesDownloadId: string | null;
|
||||
toggleAddModal: (isOpen: boolean) => void;
|
||||
openAddModalWithUrls: (urls: string, referer?: string | null, filename?: string | null) => void;
|
||||
handleExtensionDownload: (request: ExtensionDownloadRequest) => void;
|
||||
setSelectedPropertiesDownloadId: (id: string | null) => void;
|
||||
addDownload: (item: DownloadItem) => void;
|
||||
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
|
||||
removeDownload: (id: string) => Promise<void>;
|
||||
clearFinished: () => void;
|
||||
redownload: (id: string) => void;
|
||||
processQueue: () => Promise<void>;
|
||||
startQueue: (queueId: string) => Promise<number>;
|
||||
pauseQueue: (queueId: string) => Promise<number>;
|
||||
addQueue: (name: string) => void;
|
||||
renameQueue: (id: string, name: string) => void;
|
||||
removeQueue: (id: string) => void;
|
||||
initDB: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
downloads: [],
|
||||
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
|
||||
isAddModalOpen: false,
|
||||
pendingAddUrls: '',
|
||||
pendingAddReferer: '',
|
||||
pendingAddFilename: '',
|
||||
selectedPropertiesDownloadId: null,
|
||||
toggleAddModal: (isOpen) => set({
|
||||
isAddModalOpen: isOpen,
|
||||
pendingAddUrls: '',
|
||||
pendingAddReferer: '',
|
||||
pendingAddFilename: ''
|
||||
}),
|
||||
openAddModalWithUrls: (urls, referer, filename) => set({
|
||||
isAddModalOpen: true,
|
||||
pendingAddUrls: urls,
|
||||
pendingAddReferer: referer?.trim() || '',
|
||||
pendingAddFilename: filename?.trim() || ''
|
||||
}),
|
||||
handleExtensionDownload: (request) => {
|
||||
const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))];
|
||||
if (urls.length === 0) return;
|
||||
|
||||
const settings = useSettingsStore.getState();
|
||||
if (!request.silent || settings.askWhereToSaveEachFile) {
|
||||
get().openAddModalWithUrls(
|
||||
urls.join('\n'),
|
||||
request.referer,
|
||||
urls.length === 1 ? request.filename : null
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const referer = request.referer?.trim();
|
||||
const headers = referer ? `Referer: ${referer}` : undefined;
|
||||
const dateAdded = new Date().toISOString();
|
||||
const downloads = urls.map((url, index): DownloadItem => {
|
||||
const fileName = index === 0 && urls.length === 1 && request.filename?.trim()
|
||||
? request.filename.trim()
|
||||
: fileNameFromUrl(url);
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
url,
|
||||
fileName,
|
||||
status: 'queued',
|
||||
category: categoryForFileName(fileName),
|
||||
dateAdded,
|
||||
connections: settings.perServerConnections,
|
||||
headers,
|
||||
isMedia: isMediaUrl(url),
|
||||
queueId: MAIN_QUEUE_ID
|
||||
};
|
||||
});
|
||||
|
||||
set(state => ({ downloads: [...state.downloads, ...downloads] }));
|
||||
downloads.forEach(item => {
|
||||
const toSave = { ...item };
|
||||
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
|
||||
invoke('db_save_download', { id: item.id, status: item.status, queueId: item.queueId, data: JSON.stringify(toSave) }).catch(console.error);
|
||||
});
|
||||
void get().processQueue();
|
||||
},
|
||||
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
|
||||
addDownload: (item) => {
|
||||
set((state) => ({ downloads: [...state.downloads, item] }));
|
||||
const toSave = { ...item };
|
||||
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
|
||||
invoke('db_save_download', { id: item.id, status: item.status, queueId: item.queueId, data: JSON.stringify(toSave) }).catch(console.error);
|
||||
get().processQueue();
|
||||
},
|
||||
updateDownload: (id, updates) => {
|
||||
let updatedItem: DownloadItem | null = null;
|
||||
set((state) => ({
|
||||
downloads: state.downloads.map(d => {
|
||||
if (d.id === id) {
|
||||
let newFraction = updates.fraction;
|
||||
if (newFraction === 0 && d.fraction && d.fraction > 0) {
|
||||
newFraction = d.fraction;
|
||||
}
|
||||
const updated = {
|
||||
...d,
|
||||
...updates,
|
||||
fraction: newFraction !== undefined ? newFraction : updates.fraction !== undefined ? updates.fraction : d.fraction
|
||||
};
|
||||
updatedItem = updated;
|
||||
return updated;
|
||||
}
|
||||
return d;
|
||||
})
|
||||
}));
|
||||
|
||||
if (updatedItem && Object.keys(updates).some(k => !['fraction', 'speed', 'eta'].includes(k))) {
|
||||
const toSave = { ...(updatedItem as DownloadItem) };
|
||||
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
|
||||
invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: toSave.queueId, data: JSON.stringify(toSave) }).catch(console.error);
|
||||
}
|
||||
|
||||
// If status changed to something that frees up a slot, process queue
|
||||
if (updates.status && ['completed', 'failed', 'paused'].includes(updates.status)) {
|
||||
get().processQueue();
|
||||
syncSystemIntegrations();
|
||||
} else if (updates.status === 'downloading') {
|
||||
syncSystemIntegrations();
|
||||
}
|
||||
},
|
||||
removeDownload: async (id) => {
|
||||
const item = get().downloads.find(d => d.id === id);
|
||||
if (item && item.status === 'downloading') {
|
||||
try {
|
||||
await invoke('pause_download', { id });
|
||||
} catch (e) {
|
||||
console.error("Failed to terminate download on deletion:", e);
|
||||
}
|
||||
}
|
||||
set((state) => ({
|
||||
downloads: state.downloads.filter(d => d.id !== id)
|
||||
}));
|
||||
invoke('db_delete_download', { id }).catch(console.error);
|
||||
get().processQueue();
|
||||
syncSystemIntegrations();
|
||||
},
|
||||
clearFinished: () => {
|
||||
const downloads = get().downloads;
|
||||
const toRemove = downloads.filter(d => ['completed', 'failed'].includes(d.status));
|
||||
set((state) => ({
|
||||
downloads: state.downloads.filter(d => !['completed', 'failed'].includes(d.status))
|
||||
}));
|
||||
toRemove.forEach(d => {
|
||||
invoke('db_delete_download', { id: d.id }).catch(console.error);
|
||||
});
|
||||
},
|
||||
redownload: (id) => {
|
||||
let updatedItem: DownloadItem | null = null;
|
||||
set((state) => ({
|
||||
downloads: state.downloads.map(d => {
|
||||
if (d.id === id) {
|
||||
const updated: DownloadItem = { ...d, status: 'queued', _dispatched: false, fraction: 0, speed: '-', eta: '-' };
|
||||
updatedItem = updated;
|
||||
return updated;
|
||||
}
|
||||
return d;
|
||||
})
|
||||
}));
|
||||
if (updatedItem) {
|
||||
const toSave = { ...(updatedItem as DownloadItem) };
|
||||
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
|
||||
invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: toSave.queueId, data: JSON.stringify(toSave) }).catch(console.error);
|
||||
}
|
||||
get().processQueue();
|
||||
},
|
||||
startQueue: async (queueId) => {
|
||||
const runnableIds = get().downloads
|
||||
.filter(item => item.queueId === queueId && (item.status === 'queued' || item.status === 'paused' || item.status === 'failed'))
|
||||
.map(item => item.id);
|
||||
|
||||
if (runnableIds.length === 0) return 0;
|
||||
|
||||
set((state) => ({
|
||||
downloads: state.downloads.map(item =>
|
||||
runnableIds.includes(item.id)
|
||||
? { ...item, status: 'queued', _dispatched: false, speed: '-', eta: '-' }
|
||||
: item
|
||||
)
|
||||
}));
|
||||
await get().processQueue();
|
||||
return runnableIds.length;
|
||||
},
|
||||
pauseQueue: async (queueId) => {
|
||||
const activeIds = get().downloads
|
||||
.filter(item => item.queueId === queueId && item.status === 'downloading')
|
||||
.map(item => item.id);
|
||||
|
||||
if (activeIds.length === 0) return 0;
|
||||
|
||||
set((state) => ({
|
||||
downloads: state.downloads.map(item =>
|
||||
activeIds.includes(item.id)
|
||||
? { ...item, status: 'paused', speed: '-', eta: '-' }
|
||||
: item
|
||||
)
|
||||
}));
|
||||
await Promise.all(activeIds.map(id => invoke('pause_download', { id }).catch(() => {})));
|
||||
syncSystemIntegrations();
|
||||
return activeIds.length;
|
||||
},
|
||||
addQueue: (name) => {
|
||||
const id = crypto.randomUUID();
|
||||
const q = { id, name, isMain: false };
|
||||
set((state) => ({
|
||||
queues: [...state.queues, q]
|
||||
}));
|
||||
invoke('db_save_queue', { id, data: JSON.stringify(q) }).catch(console.error);
|
||||
},
|
||||
renameQueue: (id, name) => {
|
||||
let updatedQ: Queue | null = null;
|
||||
set((state) => ({
|
||||
queues: state.queues.map(q => {
|
||||
if (q.id === id) {
|
||||
const newQ = { ...q, name };
|
||||
updatedQ = newQ;
|
||||
return newQ;
|
||||
}
|
||||
return q;
|
||||
})
|
||||
}));
|
||||
if (updatedQ) {
|
||||
invoke('db_save_queue', { id, data: JSON.stringify(updatedQ) }).catch(console.error);
|
||||
}
|
||||
},
|
||||
removeQueue: (id) => {
|
||||
if (id === MAIN_QUEUE_ID) return;
|
||||
set((state) => ({
|
||||
queues: state.queues.filter(q => q.id !== id),
|
||||
downloads: state.downloads.map(d =>
|
||||
d.queueId === id ? { ...d, queueId: MAIN_QUEUE_ID } : d
|
||||
)
|
||||
}));
|
||||
invoke('db_delete_queue', { id }).catch(console.error);
|
||||
|
||||
// Also we need to save the updated downloads to DB
|
||||
const downloads = get().downloads.filter(d => d.queueId === id);
|
||||
downloads.forEach(d => {
|
||||
const toSave = { ...d, queueId: MAIN_QUEUE_ID };
|
||||
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
|
||||
invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: MAIN_QUEUE_ID, data: JSON.stringify(toSave) }).catch(console.error);
|
||||
});
|
||||
},
|
||||
initDB: async () => {
|
||||
try {
|
||||
const queuesStr = await invoke('db_get_all_queues');
|
||||
const queues = queuesStr.map(q => JSON.parse(q));
|
||||
|
||||
const downloadsStr = await invoke('db_get_all_downloads');
|
||||
const downloads = downloadsStr.map(d => JSON.parse(d));
|
||||
|
||||
set(state => ({
|
||||
queues: queues.length > 0 ? queues : state.queues,
|
||||
downloads: downloads.length > 0 ? downloads : state.downloads
|
||||
}));
|
||||
|
||||
// Auto resume downloads that were active
|
||||
const active = get().downloads.filter(d => d.status === 'downloading');
|
||||
active.forEach(item => {
|
||||
if (item.isMedia) {
|
||||
invoke('start_media_download', {
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
destination: item.destination || '~/Downloads',
|
||||
filename: item.fileName,
|
||||
formatSelector: item.mediaFormatSelector || null,
|
||||
cookieSource: null,
|
||||
speedLimit: item.speedLimit || null,
|
||||
username: item.username || null,
|
||||
password: item.password || null,
|
||||
headers: item.headers || null,
|
||||
proxy: null,
|
||||
userAgent: null,
|
||||
maxTries: null
|
||||
}).catch(console.error);
|
||||
} else {
|
||||
invoke('start_download', {
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
destination: item.destination || '~/Downloads',
|
||||
filename: item.fileName,
|
||||
connections: item.connections ?? null,
|
||||
speedLimit: item.speedLimit || null,
|
||||
username: item.username || null,
|
||||
password: item.password || null,
|
||||
headers: item.headers || null,
|
||||
checksum: item.checksum || null,
|
||||
cookies: item.cookies || null,
|
||||
mirrors: item.mirrors || null,
|
||||
userAgent: null,
|
||||
maxTries: null,
|
||||
proxy: null
|
||||
}).catch(console.error);
|
||||
}
|
||||
});
|
||||
|
||||
void get().processQueue();
|
||||
} catch (e) {
|
||||
console.error("Failed to init DB", e);
|
||||
}
|
||||
},
|
||||
processQueue: async () => {
|
||||
const { downloads, updateDownload } = get();
|
||||
|
||||
// Find all queued items that haven't been dispatched to the backend yet
|
||||
const itemsToStart = downloads.filter(d => d.status === 'queued' && !d._dispatched);
|
||||
|
||||
for (const item of itemsToStart) {
|
||||
// Mark as dispatched so we don't send it again on the next pass
|
||||
updateDownload(item.id, { _dispatched: true });
|
||||
try {
|
||||
const settings = useSettingsStore.getState();
|
||||
const login = getSiteLogin(item.url, settings);
|
||||
let keychainPassword = null;
|
||||
if (login) {
|
||||
try {
|
||||
keychainPassword = await invoke('get_keychain_password', { id: login.id });
|
||||
} catch (e) {
|
||||
console.warn("Could not fetch keychain password for login:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const destPath = item.destination ||
|
||||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
|
||||
settings.defaultDownloadPath ||
|
||||
'~/Downloads';
|
||||
|
||||
if (item.isMedia) {
|
||||
await invoke('start_media_download', {
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
destination: destPath,
|
||||
filename: item.fileName,
|
||||
formatSelector: item.mediaFormatSelector || null,
|
||||
cookieSource: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
|
||||
speedLimit: item.speedLimit || null,
|
||||
username: item.username || (login ? login.username : null),
|
||||
password: item.password || keychainPassword,
|
||||
headers: item.headers || null,
|
||||
proxy: getProxyArgs(settings),
|
||||
userAgent: settings.customUserAgent || null,
|
||||
maxTries: settings.maxAutomaticRetries
|
||||
});
|
||||
} else {
|
||||
await invoke('start_download', {
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
destination: destPath,
|
||||
filename: item.fileName,
|
||||
connections: item.connections || settings.perServerConnections || null,
|
||||
speedLimit: item.speedLimit || null,
|
||||
username: item.username || (login ? login.username : null),
|
||||
password: item.password || keychainPassword,
|
||||
headers: item.headers || null,
|
||||
checksum: item.checksum || null,
|
||||
cookies: item.cookies || null,
|
||||
mirrors: item.mirrors || null,
|
||||
userAgent: settings.customUserAgent || null,
|
||||
maxTries: settings.maxAutomaticRetries,
|
||||
proxy: getProxyArgs(settings)
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to start queued download:", e);
|
||||
updateDownload(item.id, { status: 'failed' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
@@ -0,0 +1,337 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage, StateStorage } from 'zustand/middleware';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import type { ActiveView } from '../bindings/ActiveView';
|
||||
import type { AppFontSize } from '../bindings/AppFontSize';
|
||||
import type { ListRowDensity } from '../bindings/ListRowDensity';
|
||||
import type { MediaCookieSource } from '../bindings/MediaCookieSource';
|
||||
import type { PostQueueAction } from '../bindings/PostQueueAction';
|
||||
import type { PersistedSettings } from '../bindings/PersistedSettings';
|
||||
import type { ProxyMode } from '../bindings/ProxyMode';
|
||||
import type { SchedulerSettings } from '../bindings/SchedulerSettings';
|
||||
import type { SettingsTab } from '../bindings/SettingsTab';
|
||||
import type { SiteLogin } from '../bindings/SiteLogin';
|
||||
import type { Theme } from '../bindings/Theme';
|
||||
|
||||
const tauriStorage: StateStorage = {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
if (name === 'firelink-settings') {
|
||||
try {
|
||||
const data = await invoke('db_load_settings');
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.error("Failed to load settings from DB", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
if (name === 'firelink-settings') {
|
||||
try {
|
||||
await invoke('db_save_settings', { data: value });
|
||||
} catch (e) {
|
||||
console.error("Failed to save settings to DB", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
removeItem: async (_name: string): Promise<void> => {
|
||||
// no-op for now
|
||||
},
|
||||
};
|
||||
|
||||
export type {
|
||||
ActiveView,
|
||||
AppFontSize,
|
||||
ListRowDensity,
|
||||
MediaCookieSource,
|
||||
PostQueueAction,
|
||||
ProxyMode,
|
||||
SchedulerSettings,
|
||||
SettingsTab,
|
||||
SiteLogin,
|
||||
Theme
|
||||
};
|
||||
|
||||
export interface SettingsState {
|
||||
theme: Theme;
|
||||
defaultDownloadPath: string;
|
||||
maxConcurrentDownloads: number;
|
||||
globalSpeedLimit: string;
|
||||
isSidebarVisible: boolean;
|
||||
activeView: ActiveView;
|
||||
activeSettingsTab: SettingsTab;
|
||||
scheduler: SchedulerSettings;
|
||||
schedulerRunning: boolean;
|
||||
schedulerLastStartKey: string;
|
||||
schedulerLastStopKey: string;
|
||||
lastCustomSpeedLimitKiB: number;
|
||||
|
||||
// Replicated SwiftUI App Settings
|
||||
perServerConnections: number;
|
||||
maxAutomaticRetries: number;
|
||||
showNotifications: boolean;
|
||||
playCompletionSound: boolean;
|
||||
appFontSize: AppFontSize;
|
||||
listRowDensity: ListRowDensity;
|
||||
showDockBadge: boolean;
|
||||
showMenuBarIcon: boolean;
|
||||
proxyMode: ProxyMode;
|
||||
proxyHost: string;
|
||||
proxyPort: number;
|
||||
customUserAgent: string;
|
||||
askWhereToSaveEachFile: boolean;
|
||||
preventsSleepWhileDownloading: boolean;
|
||||
mediaCookieSource: MediaCookieSource;
|
||||
downloadDirectories: Record<string, string>;
|
||||
siteLogins: SiteLogin[];
|
||||
extensionPairingToken: string;
|
||||
autoCheckUpdates: boolean;
|
||||
|
||||
setTheme: (theme: Theme) => void;
|
||||
setDefaultDownloadPath: (path: string) => void;
|
||||
setMaxConcurrentDownloads: (count: number) => void;
|
||||
setGlobalSpeedLimit: (limit: string) => void;
|
||||
setActiveView: (view: ActiveView) => void;
|
||||
setActiveSettingsTab: (tab: SettingsTab) => void;
|
||||
setScheduler: (settings: SchedulerSettings) => void;
|
||||
setSchedulerRunning: (running: boolean) => void;
|
||||
setSchedulerLastStartKey: (key: string) => void;
|
||||
setSchedulerLastStopKey: (key: string) => void;
|
||||
setLastCustomSpeedLimitKiB: (limit: number) => void;
|
||||
toggleSidebar: () => void;
|
||||
|
||||
setPerServerConnections: (count: number) => void;
|
||||
setMaxAutomaticRetries: (count: number) => void;
|
||||
setShowNotifications: (show: boolean) => void;
|
||||
setPlayCompletionSound: (play: boolean) => void;
|
||||
setAppFontSize: (size: AppFontSize) => void;
|
||||
setListRowDensity: (density: ListRowDensity) => void;
|
||||
setShowDockBadge: (show: boolean) => void;
|
||||
setShowMenuBarIcon: (show: boolean) => void;
|
||||
setProxyMode: (mode: ProxyMode) => void;
|
||||
setProxyHost: (host: string) => void;
|
||||
setProxyPort: (port: number) => void;
|
||||
setCustomUserAgent: (userAgent: string) => void;
|
||||
setAskWhereToSaveEachFile: (ask: boolean) => void;
|
||||
setPreventsSleepWhileDownloading: (prevent: boolean) => void;
|
||||
setMediaCookieSource: (source: MediaCookieSource) => void;
|
||||
setCategoryDirectory: (category: string, path: string) => void;
|
||||
resetCategoryDirectories: () => void;
|
||||
addSiteLogin: (login: SiteLogin) => void;
|
||||
removeSiteLogin: (id: string) => void;
|
||||
regeneratePairingToken: () => void;
|
||||
setAutoCheckUpdates: (autoCheckUpdates: boolean) => void;
|
||||
}
|
||||
|
||||
const defaultDirectories = {
|
||||
Musics: '~/Downloads/Musics',
|
||||
Movies: '~/Downloads/Movies',
|
||||
Compressed: '~/Downloads/Compressed',
|
||||
Documents: '~/Downloads/Documents',
|
||||
Pictures: '~/Downloads/Pictures',
|
||||
Applications: '~/Downloads/Applications',
|
||||
Other: '~/Downloads/Other'
|
||||
};
|
||||
|
||||
const normalizeDownloadDirectories = (directories: unknown): Record<string, string> => {
|
||||
if (!directories || typeof directories !== 'object') {
|
||||
return { ...defaultDirectories };
|
||||
}
|
||||
|
||||
const values = directories as Record<string, unknown>;
|
||||
const directory = (current: string, legacy?: string) => {
|
||||
const value = values[current] ?? (legacy ? values[legacy] : undefined);
|
||||
return typeof value === 'string' && value.length > 0
|
||||
? value
|
||||
: defaultDirectories[current as keyof typeof defaultDirectories];
|
||||
};
|
||||
|
||||
return {
|
||||
Musics: directory('Musics', 'Audio'),
|
||||
Movies: directory('Movies', 'Video'),
|
||||
Compressed: directory('Compressed', 'Archives'),
|
||||
Documents: directory('Documents'),
|
||||
Pictures: directory('Pictures', 'Images'),
|
||||
Applications: directory('Applications', 'Apps'),
|
||||
Other: directory('Other')
|
||||
};
|
||||
};
|
||||
|
||||
const generateSecureToken = () => {
|
||||
try {
|
||||
const cryptoObj = typeof window !== 'undefined'
|
||||
? (window as Window & { msCrypto?: Crypto }).crypto
|
||||
|| (window as Window & { msCrypto?: Crypto }).msCrypto
|
||||
: null;
|
||||
if (cryptoObj && cryptoObj.getRandomValues) {
|
||||
const arr = new Uint8Array(24);
|
||||
cryptoObj.getRandomValues(arr);
|
||||
let binary = '';
|
||||
for (let i = 0; i < arr.byteLength; i++) {
|
||||
binary += String.fromCharCode(arr[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Secure token generation failed, falling back to random characters", e);
|
||||
}
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
let token = '';
|
||||
for (let i = 0; i < 32; i++) {
|
||||
token += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return token;
|
||||
};
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
theme: 'system',
|
||||
defaultDownloadPath: '~/Downloads',
|
||||
maxConcurrentDownloads: 3,
|
||||
globalSpeedLimit: '',
|
||||
activeView: 'downloads',
|
||||
activeSettingsTab: 'downloads',
|
||||
isSidebarVisible: true,
|
||||
scheduler: {
|
||||
enabled: false,
|
||||
startTime: '00:00',
|
||||
stopTimeEnabled: false,
|
||||
stopTime: '08:00',
|
||||
everyday: true,
|
||||
selectedDays: [0, 1, 2, 3, 4, 5, 6],
|
||||
postQueueAction: 'none'
|
||||
},
|
||||
schedulerRunning: false,
|
||||
schedulerLastStartKey: '',
|
||||
schedulerLastStopKey: '',
|
||||
lastCustomSpeedLimitKiB: 1024,
|
||||
|
||||
// Replicated SwiftUI defaults
|
||||
perServerConnections: 16,
|
||||
maxAutomaticRetries: 3,
|
||||
showNotifications: true,
|
||||
playCompletionSound: true,
|
||||
appFontSize: 'standard',
|
||||
listRowDensity: 'standard',
|
||||
showDockBadge: true,
|
||||
showMenuBarIcon: true,
|
||||
proxyMode: 'none',
|
||||
proxyHost: '',
|
||||
proxyPort: 8080,
|
||||
customUserAgent: '',
|
||||
askWhereToSaveEachFile: false,
|
||||
preventsSleepWhileDownloading: true,
|
||||
mediaCookieSource: 'none',
|
||||
downloadDirectories: { ...defaultDirectories },
|
||||
siteLogins: [],
|
||||
extensionPairingToken: generateSecureToken(),
|
||||
autoCheckUpdates: true,
|
||||
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setDefaultDownloadPath: (path) => set({ defaultDownloadPath: path }),
|
||||
setMaxConcurrentDownloads: (max) => {
|
||||
set({ maxConcurrentDownloads: max });
|
||||
invoke('set_concurrent_limit', { limit: max }).catch(console.error);
|
||||
},
|
||||
setGlobalSpeedLimit: (limit) => {
|
||||
set({ globalSpeedLimit: limit });
|
||||
invoke('set_global_speed_limit', { limit: limit === '' || limit === '0' ? null : limit }).catch(console.error);
|
||||
},
|
||||
setActiveView: (view) => set({ activeView: view }),
|
||||
setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),
|
||||
setScheduler: (scheduler) => set({ scheduler }),
|
||||
setSchedulerRunning: (schedulerRunning) => set({ schedulerRunning }),
|
||||
setSchedulerLastStartKey: (schedulerLastStartKey) => set({ schedulerLastStartKey }),
|
||||
setSchedulerLastStopKey: (schedulerLastStopKey) => set({ schedulerLastStopKey }),
|
||||
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({ lastCustomSpeedLimitKiB }),
|
||||
toggleSidebar: () => set((state) => ({ isSidebarVisible: !state.isSidebarVisible })),
|
||||
|
||||
setPerServerConnections: (perServerConnections) => set({ perServerConnections }),
|
||||
setMaxAutomaticRetries: (maxAutomaticRetries) => set({ maxAutomaticRetries }),
|
||||
setShowNotifications: (showNotifications) => set({ showNotifications }),
|
||||
setPlayCompletionSound: (playCompletionSound) => set({ playCompletionSound }),
|
||||
setAppFontSize: (appFontSize) => set({ appFontSize }),
|
||||
setListRowDensity: (listRowDensity) => set({ listRowDensity }),
|
||||
setShowDockBadge: (showDockBadge) => {
|
||||
set({ showDockBadge });
|
||||
if (!showDockBadge) invoke('update_dock_badge', { count: 0 }).catch(console.error);
|
||||
},
|
||||
setShowMenuBarIcon: (showMenuBarIcon) => set({ showMenuBarIcon }),
|
||||
setProxyMode: (proxyMode) => set({ proxyMode }),
|
||||
setProxyHost: (proxyHost) => set({ proxyHost }),
|
||||
setProxyPort: (proxyPort) => set({ proxyPort }),
|
||||
setCustomUserAgent: (customUserAgent) => set({ customUserAgent }),
|
||||
setAskWhereToSaveEachFile: (askWhereToSaveEachFile) => set({ askWhereToSaveEachFile }),
|
||||
setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => {
|
||||
set({ preventsSleepWhileDownloading });
|
||||
if (!preventsSleepWhileDownloading) invoke('set_prevent_sleep', { prevent: false }).catch(console.error);
|
||||
},
|
||||
setMediaCookieSource: (mediaCookieSource) => set({ mediaCookieSource }),
|
||||
setCategoryDirectory: (category, path) => set((state) => ({
|
||||
downloadDirectories: { ...state.downloadDirectories, [category]: path }
|
||||
})),
|
||||
resetCategoryDirectories: () => set({ downloadDirectories: { ...defaultDirectories } }),
|
||||
addSiteLogin: (login) => set((state) => ({
|
||||
siteLogins: [...state.siteLogins, login]
|
||||
})),
|
||||
removeSiteLogin: (id) => set((state) => ({
|
||||
siteLogins: state.siteLogins.filter((login) => login.id !== id)
|
||||
})),
|
||||
regeneratePairingToken: () => set({ extensionPairingToken: generateSecureToken() }),
|
||||
setAutoCheckUpdates: (autoCheckUpdates) => set({ autoCheckUpdates }),
|
||||
}),
|
||||
{
|
||||
name: 'firelink-settings',
|
||||
storage: createJSONStorage(() => tauriStorage),
|
||||
partialize: (state): PersistedSettings => ({
|
||||
theme: state.theme,
|
||||
defaultDownloadPath: state.defaultDownloadPath,
|
||||
maxConcurrentDownloads: state.maxConcurrentDownloads,
|
||||
globalSpeedLimit: state.globalSpeedLimit,
|
||||
isSidebarVisible: state.isSidebarVisible,
|
||||
activeSettingsTab: state.activeSettingsTab,
|
||||
scheduler: state.scheduler,
|
||||
schedulerLastStartKey: state.schedulerLastStartKey,
|
||||
schedulerLastStopKey: state.schedulerLastStopKey,
|
||||
lastCustomSpeedLimitKiB: state.lastCustomSpeedLimitKiB,
|
||||
|
||||
perServerConnections: state.perServerConnections,
|
||||
maxAutomaticRetries: state.maxAutomaticRetries,
|
||||
showNotifications: state.showNotifications,
|
||||
playCompletionSound: state.playCompletionSound,
|
||||
appFontSize: state.appFontSize,
|
||||
listRowDensity: state.listRowDensity,
|
||||
showDockBadge: state.showDockBadge,
|
||||
showMenuBarIcon: state.showMenuBarIcon,
|
||||
proxyMode: state.proxyMode,
|
||||
proxyHost: state.proxyHost,
|
||||
proxyPort: state.proxyPort,
|
||||
customUserAgent: state.customUserAgent,
|
||||
askWhereToSaveEachFile: state.askWhereToSaveEachFile,
|
||||
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
|
||||
mediaCookieSource: state.mediaCookieSource,
|
||||
downloadDirectories: state.downloadDirectories,
|
||||
siteLogins: state.siteLogins,
|
||||
extensionPairingToken: state.extensionPairingToken,
|
||||
autoCheckUpdates: state.autoCheckUpdates
|
||||
}),
|
||||
merge: (persistedState: unknown, currentState) => {
|
||||
const persisted = persistedState && typeof persistedState === 'object'
|
||||
? persistedState as Partial<SettingsState>
|
||||
: {};
|
||||
return ({
|
||||
...currentState,
|
||||
...persisted,
|
||||
appFontSize: persisted.appFontSize || currentState.appFontSize,
|
||||
listRowDensity: persisted.listRowDensity || currentState.listRowDensity,
|
||||
downloadDirectories: normalizeDownloadDirectories(persisted.downloadDirectories),
|
||||
siteLogins: Array.isArray(persisted.siteLogins)
|
||||
? persisted.siteLogins
|
||||
: currentState.siteLogins
|
||||
});
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { DownloadCategory } from '../bindings/DownloadCategory';
|
||||
export type { DownloadCategory } from '../bindings/DownloadCategory';
|
||||
|
||||
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'
|
||||
];
|
||||
|
||||
export const categoryForFileName = (fileName: string): DownloadCategory => {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
if (['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'].includes(ext)) return 'Movies';
|
||||
if (['mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a', 'wma'].includes(ext)) return 'Musics';
|
||||
if (['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf'].includes(ext)) return 'Documents';
|
||||
if (['exe', 'dmg', 'apk', 'app', 'pkg', 'deb', 'rpm', 'msi', 'iso', 'bin', 'run'].includes(ext)) return 'Applications';
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff', 'svg'].includes(ext)) return 'Pictures';
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz', 'xz', 'bz2'].includes(ext)) return 'Compressed';
|
||||
return 'Other';
|
||||
};
|
||||
|
||||
export const fileNameFromUrl = (rawUrl: string): string => {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
const pathName = url.pathname.split('/').filter(Boolean).pop();
|
||||
if (pathName) {
|
||||
const decoded = decodeURIComponent(pathName).trim();
|
||||
if (decoded && decoded !== '.' && decoded !== '..') {
|
||||
return decoded.replace(/[\/\\?%*:|"<>]/g, '-');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the stable generic name.
|
||||
}
|
||||
return 'download';
|
||||
};
|
||||
|
||||
export const isMediaUrl = (rawUrl: string): boolean => {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
return MEDIA_DOMAINS.some(domain =>
|
||||
url.hostname === domain || url.hostname.endsWith(`.${domain}`)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user