feat: initial tauri desktop app rewrite with ui and settings wired

This commit is contained in:
NimBold
2026-06-12 16:21:16 +03:30
parent e6ab997b00
commit d6690c7769
198 changed files with 13473 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
.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;
}
#greet-input {
margin-right: 5px;
}
@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;
}
}
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useState } from "react";
import { Sidebar, SidebarFilter } from "./components/Sidebar";
import { DownloadTable } from "./components/DownloadTable";
import { AddDownloadsModal } from "./components/AddDownloadsModal";
import { SettingsModal } from "./components/SettingsModal";
import { PropertiesModal } from "./components/PropertiesModal";
import { listen } from "@tauri-apps/api/event";
import { useDownloadStore } from "./store/useDownloadStore";
import { useSettingsStore } from "./store/useSettingsStore";
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
function App() {
const [filter, setFilter] = useState<SidebarFilter>('all');
const updateDownload = useDownloadStore(state => state.updateDownload);
const theme = useSettingsStore(state => state.theme);
const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible);
const appFontSize = useSettingsStore(state => state.appFontSize);
useEffect(() => {
window.document.documentElement.setAttribute('data-font-size', appFontSize);
}, [appFontSize]);
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: any) => {
const { id, fraction, speed, eta } = event.payload;
updateDownload(id, { fraction, speed, eta });
});
const unlistenComplete = listen('download-complete', (event: any) => {
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: any) => {
// 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: '-' });
}
});
return () => {
unlistenProgress.then(f => f());
unlistenComplete.then(f => f());
unlistenFailed.then(f => f());
};
}, []);
return (
<div className="flex h-screen w-screen bg-main-bg text-text-primary overflow-hidden">
{isSidebarVisible && <Sidebar selectedFilter={filter} onSelectFilter={setFilter} />}
<DownloadTable filter={filter} />
<AddDownloadsModal />
<SettingsModal />
<PropertiesModal />
</div>
);
}
export default App;
+43
View File
@@ -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;
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,385 @@
import { useState, useEffect } from 'react';
import { useDownloadStore } from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore';
import { X, FolderPlus, Settings, Shield, Globe, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, CheckCircle2, Play, ChevronDown, ChevronRight } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
export const AddDownloadsModal = () => {
const { isAddModalOpen, toggleAddModal, addDownload } = useDownloadStore();
const { defaultDownloadPath } = useSettingsStore();
const [urls, setUrls] = useState('');
const [parsedItems, setParsedItems] = useState<{url: string, file: string, size?: string, sizeBytes?: number, status?: string}[]>([]);
// 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('');
setParsedItems([]);
}
}, [isAddModalOpen, defaultDownloadPath]);
useEffect(() => {
if (!saveLocation) return;
invoke<string>('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 = lines.map(url => {
let fallbackFile = 'URL';
try { fallbackFile = new URL(url).pathname.split('/').pop() || 'download'; } catch {}
return { url, file: fallbackFile, size: '-', status: 'Loading' };
});
setParsedItems(initialItems);
if (lines.length === 0) return;
const timer = setTimeout(async () => {
const updatedItems = [...initialItems];
for (let i = 0; i < lines.length; i++) {
const url = lines[i];
try {
new URL(url);
const meta = await invoke<{filename: string, size: string, size_bytes: number}>('fetch_metadata', { url });
updatedItems[i] = { url, file: meta.filename, size: meta.size, sizeBytes: meta.size_bytes, status: 'Ready' };
} catch (e) {
console.error("Meta fetch failed", e);
updatedItems[i] = { ...updatedItems[i], size: 'Unknown', sizeBytes: 0, status: 'Error' };
}
// Progressively update the UI as each fetch completes
setParsedItems([...updatedItems]);
}
}, 400);
return () => clearTimeout(timer);
}, [urls]);
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);
}
}
for (const item of parsedItems) {
try {
const id = crypto.randomUUID();
addDownload({
id,
url: item.url,
fileName: item.file,
status: startImmediately ? 'queued' : 'paused',
category: 'Other',
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,
destination: finalLocation,
});
} catch (e) {
console.error("Invalid URL or failed to add:", e);
}
}
toggleAddModal(false);
};
const SummaryBox = ({ title, value, icon: Icon, color }: any) => (
<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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md">
<div className="w-[900px] h-[650px] bg-bg-modal border border-border-modal rounded-xl shadow-2xl flex flex-col overflow-hidden text-sm">
{/* 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 gap-2 text-text-primary font-semibold">
<Link size={16} className="text-blue-500" />
Download Links
</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-blue-500 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} className="flex items-center text-xs px-2 py-1.5 hover:bg-item-hover rounded-md transition-colors">
<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>
{/* Right Column: Settings */}
<div className="w-[45%] flex flex-col overflow-y-auto bg-bg-modal">
<div className="p-6 space-y-7">
{/* 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="space-y-4">
<div className="flex items-center justify-between">
<label className="text-xs text-text-secondary font-medium">Connections per File</label>
<div className="flex 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" />
<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 px-2 py-1 text-xs font-mono text-text-primary focus:border-blue-500 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-md px-3 py-1.5 text-xs text-text-primary focus:border-blue-500 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-md px-3 py-1.5 text-xs text-text-primary focus:border-blue-500 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-md px-2 text-xs text-text-primary focus:border-blue-500 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-md px-3 py-1.5 text-xs font-mono text-text-primary focus:border-blue-500 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-md px-3 py-1.5 text-xs font-mono text-text-primary focus:border-blue-500 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-md px-3 py-1.5 text-xs font-mono text-text-primary focus:border-blue-500 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-md px-3 py-1.5 text-xs font-mono text-text-primary focus:border-blue-500 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="px-4 py-1.5 rounded-lg text-xs font-medium text-text-secondary hover:text-text-primary hover:bg-item-hover transition-colors">
Cancel
</button>
<button
onClick={() => handleStart(false)}
disabled={parsedItems.length === 0}
className="px-4 py-1.5 rounded-lg text-xs font-medium bg-item-hover text-text-primary border border-border-modal hover:bg-border-modal/40 transition-colors disabled:opacity-50"
>
Add to Queue
</button>
<button
onClick={() => handleStart(true)}
disabled={parsedItems.length === 0}
className="px-5 py-1.5 rounded-lg text-xs font-medium bg-blue-600 hover:bg-blue-500 text-white shadow-md shadow-blue-500/20 transition-all active:scale-95 disabled:opacity-50 flex items-center gap-1.5"
>
<Play size={12} fill="currentColor" /> Start Downloads
</button>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,397 @@
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 } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { homeDir } from '@tauri-apps/api/path';
import { getCurrentWindow } from '@tauri-apps/api/window';
interface DownloadTableProps {
filter: SidebarFilter;
}
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const { downloads, toggleAddModal, updateDownload, removeDownload, clearFinished, redownload } = useDownloadStore();
const { isSidebarVisible, toggleSidebar, listRowDensity } = useSettingsStore();
const getPaddingY = () => {
switch (listRowDensity) {
case 'compact': return 'py-1';
case 'spacious': return 'py-4';
default: return 'py-3';
}
};
const py = getPaddingY();
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
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) => {
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 = () => {
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 'Documents': return <FileText size={16} className="text-blue-400" />;
case 'Images': return <ImageIcon size={16} className="text-purple-400" />;
case 'Audio': return <Music size={16} className="text-pink-400" />;
case 'Video': return <Film size={16} className="text-red-400" />;
case 'Apps': return <Box size={16} className="text-orange-400" />;
case 'Archives': return <Archive size={16} className="text-yellow-400" />;
default: return <FileQuestion size={16} className="text-gray-400" />;
}
}
return (
<div className="flex-1 flex flex-col bg-main-bg h-full relative">
{/* Modern Toolbar */}
<div
className={`flex px-6 py-4 border-b border-border-color items-center glass-panel z-10 sticky top-0 ${!isSidebarVisible ? 'pl-24' : ''}`}
onPointerDown={(e) => {
if (e.button === 0 && (e.target as HTMLElement).closest('.no-drag') === null) {
getCurrentWindow().startDragging();
}
}}
>
<button
onClick={toggleSidebar}
className="no-drag mr-3 p-1.5 rounded-lg text-text-secondary hover:text-text-primary hover:bg-item-hover transition-colors"
title="Toggle Sidebar"
>
<PanelLeft size={18} strokeWidth={2} />
</button>
<h2 className="text-lg font-bold mr-auto text-text-primary tracking-tight cursor-default">{getFilterTitle()}</h2>
<div className="flex gap-1.5 items-center bg-bg-input/50 p-1 rounded-xl border border-border-modal/50 shadow-sm no-drag">
<button
onClick={() => toggleAddModal(true)}
className="p-2 rounded-lg text-text-secondary hover:text-blue-500 hover:bg-blue-500/10 transition-all duration-200 group relative"
title="Add Download"
>
<Plus size={18} strokeWidth={2.5} />
</button>
<div className="w-[1px] h-4 bg-border-color/60 mx-1"></div>
<button
onClick={() => {
filteredDownloads.filter(d => d.status === 'paused').forEach(d => handleResume(d));
}}
className="p-2 rounded-lg text-text-secondary hover:text-green-500 hover:bg-green-500/10 transition-all duration-200"
title="Resume All"
>
<Play size={18} fill="currentColor" className="opacity-80" />
</button>
<button
onClick={() => {
filteredDownloads.filter(d => d.status === 'downloading').forEach(d => handlePause(d.id));
}}
className="p-2 rounded-lg text-text-secondary hover:text-orange-500 hover:bg-orange-500/10 transition-all duration-200"
title="Pause All"
>
<Pause size={18} fill="currentColor" className="opacity-80" />
</button>
<div className="w-[1px] h-4 bg-border-color/60 mx-1"></div>
<button
onClick={clearFinished}
className="p-2 rounded-lg text-text-secondary hover:text-red-500 hover:bg-red-500/10 transition-all duration-200"
title="Clear Finished"
>
<Trash2 size={18} />
</button>
</div>
</div>
{/* Table */}
<div className="flex-1 overflow-auto">
<table className="w-full border-collapse text-left">
<thead className="sticky top-0 bg-main-bg/95 backdrop-blur-md z-0 shadow-sm border-b border-border-color">
<tr className="text-text-muted text-xs uppercase tracking-wider font-semibold">
<th className={`${py} px-3 pl-6`}>File</th>
<th className={`${py} px-3`}>Size</th>
<th className={`${py} px-3`}>Status</th>
<th className={`${py} px-3`}>Speed</th>
<th className={`${py} px-3 pr-6`}>ETA</th>
<th className={`${py} px-3 w-16`}></th>
</tr>
</thead>
<tbody>
{filteredDownloads.length === 0 ? (
<tr>
<td colSpan={6} className="p-16 text-center">
<div className="flex flex-col items-center justify-center text-text-muted/50 gap-3">
<Box size={48} strokeWidth={1} />
<span className="text-sm font-medium">No downloads in this view</span>
</div>
</td>
</tr>
) : (
filteredDownloads.map(d => (
<tr
key={d.id}
className="border-b border-border-color/30 hover:bg-item-hover transition-colors duration-200 group cursor-default"
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({
x: e.clientX,
y: e.clientY,
id: d.id
});
}}
>
<td className={`${py} px-3 pl-6 text-sm text-text-primary`}>
<div className="flex items-center gap-3">
<div className="p-2 bg-bg-input/50 rounded-lg shadow-sm border border-border-modal/20">
{getCategoryIcon(d.category)}
</div>
<span className="font-medium truncate max-w-[250px]">{d.fileName}</span>
</div>
</td>
<td className={`${py} px-3 text-[13px] text-text-secondary w-32`}>
<div className="w-full bg-border-color rounded-full h-1.5 mb-1.5 mt-0.5 overflow-hidden shadow-inner">
<div className={`h-1.5 rounded-full transition-all duration-300 ${d.status === 'completed' ? 'bg-green-500' : d.status === 'paused' ? 'bg-orange-500' : d.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${(d.fraction || 0) * 100}%` }}></div>
</div>
<div className="text-[11px] font-mono text-text-muted font-medium">
{((d.fraction || 0) * 100).toFixed(1)}%
</div>
</td>
<td className={`${py} px-3 text-[13px]`}>
<span className={`inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-bold uppercase tracking-wider ${
d.status === 'completed' ? 'bg-green-500/10 text-green-500' :
d.status === 'downloading' ? 'bg-blue-500/10 text-blue-500' :
d.status === 'failed' ? 'bg-red-500/10 text-red-500' :
d.status === 'paused' ? 'bg-orange-500/10 text-orange-500' :
'bg-zinc-500/10 text-text-secondary'
}`}>
{d.status}
</span>
</td>
<td className={`${py} px-3 text-[12px] text-text-secondary font-mono`}>{d.speed}</td>
<td className={`${py} px-3 pr-6 text-[12px] text-text-secondary font-mono`}>{d.eta}</td>
<td className={`${py} px-3 pr-6 text-right opacity-0 group-hover:opacity-100 transition-opacity duration-200`}>
<div className="flex justify-end gap-1.5">
{d.status === 'downloading' && (
<button onClick={() => handlePause(d.id)} className="p-1.5 bg-bg-input/80 shadow-sm border border-border-modal/50 hover:bg-item-hover rounded-md text-text-muted hover:text-orange-500 transition-colors" title="Pause">
<Pause size={14} fill="currentColor" />
</button>
)}
{d.status === 'paused' && (
<button onClick={() => handleResume(d)} className="p-1.5 bg-bg-input/80 shadow-sm border border-border-modal/50 hover:bg-item-hover rounded-md text-text-muted hover:text-green-500 transition-colors" title="Resume">
<Play size={14} fill="currentColor" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, id: d.id });
}}
className="p-1.5 bg-bg-input/80 shadow-sm border border-border-modal/50 hover:bg-item-hover rounded-md text-text-muted hover:text-text-primary transition-colors"
>
<MoreVertical size={14} />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Status Bar */}
<div className="px-6 py-2 border-t border-border-color text-[11px] font-medium text-text-muted bg-sidebar-bg/50 backdrop-blur-md">
{downloads.length} Item{downloads.length !== 1 ? 's' : ''}
</div>
{/* Floating Context Menu */}
{contextMenu && contextItem && (
<div
className="fixed z-50 bg-bg-modal/95 backdrop-blur-xl border border-border-modal rounded-xl shadow-2xl py-1.5 min-w-[180px] text-[13px] font-medium text-text-primary overflow-hidden"
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-4 py-1.5 hover:bg-blue-500 hover:text-white 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-4 py-1.5 hover:bg-blue-500 hover:text-white 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-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors"
>
Pause
</button>
)}
{(contextItem.status === 'paused' || contextItem.status === 'failed') && (
<button
onClick={() => {
setContextMenu(null);
handleResume(contextItem);
}}
className="w-full text-left px-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors"
>
Resume
</button>
)}
{['completed', 'failed', 'paused'].includes(contextItem.status) && (
<button
onClick={() => {
setContextMenu(null);
redownload(contextItem.id);
}}
className="w-full text-left px-4 py-1.5 hover:bg-blue-500 hover:text-white 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-4 py-1.5 hover:bg-blue-500 hover:text-white 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-4 py-1.5 hover:bg-blue-500 hover:text-white 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-4 py-1.5 hover:bg-red-500 hover:text-white text-red-500 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-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors"
>
Properties
</button>
</div>
)}
</div>
);
};
@@ -0,0 +1,346 @@
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, FileBox, File, Image as ImageIcon, Music, Video, Box, Archive } 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 || '');
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` : null,
username: loginMode === 'custom' ? username.trim() : null,
password: loginMode === 'custom' ? password.trim() : null,
headers: headers.trim() || null,
};
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; }
let CategoryIcon = File;
if (item.category === 'Images') CategoryIcon = ImageIcon;
if (item.category === 'Audio') CategoryIcon = Music;
if (item.category === 'Video') CategoryIcon = Video;
if (item.category === 'Apps') CategoryIcon = Box;
if (item.category === 'Archives') CategoryIcon = Archive;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-[720px] h-[580px] bg-bg-modal border border-border-modal rounded-xl shadow-2xl flex flex-col overflow-hidden text-sm">
{/* 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 px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-blue-500 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 px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-blue-500 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 px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-blue-500 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 px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-blue-500 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 px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-blue-500" />
<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 px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-blue-500 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 px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-blue-500 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 px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-blue-500 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 px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-blue-500 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 px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-blue-500 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 px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-blue-500 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 px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-blue-500 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="px-4 py-1.5 rounded border border-border-modal text-xs font-medium text-text-secondary hover:text-text-primary hover:bg-item-hover transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-4 py-1.5 rounded text-xs font-medium bg-blue-600 hover:bg-blue-500 text-white shadow-sm transition-all active:scale-95 flex items-center gap-1.5"
>
<CheckCircle size={14} />
Save
</button>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,775 @@
import { useState, useEffect } from 'react';
import { useSettingsStore } from '../store/useSettingsStore';
import {
X, Download, Palette, Globe, Folder, Key,
Moon, Terminal, Puzzle, Info, Plus, Trash2, Copy, RefreshCw
} from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
type TabType = 'downloads' | 'lookandfeel' | 'network' | 'locations' | 'sitelogins' | 'power' | 'engine' | 'integrations' | 'about';
export const SettingsModal = () => {
const settings = useSettingsStore();
const [activeTab, setActiveTab] = useState<TabType>('downloads');
// Local state for versions
const [aria2Version, setAria2Version] = useState('Checking...');
const [ytdlpVersion, setYtdlpVersion] = useState('Checking...');
const [ffmpegVersion, setFfmpegVersion] = useState('Checking...');
// 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('');
useEffect(() => {
if (toastMessage) {
const t = setTimeout(() => setToastMessage(''), 2000);
return () => clearTimeout(t);
}
}, [toastMessage]);
// Fetch engine versions when Engine tab is opened
useEffect(() => {
if (settings.isSettingsModalOpen && activeTab === 'engine') {
invoke<string>('test_aria2c')
.then(v => setAria2Version(v))
.catch(e => setAria2Version('Error: ' + e));
invoke<string>('test_ytdlp')
.then(v => setYtdlpVersion(v))
.catch(e => setYtdlpVersion('Error: ' + e));
invoke<string>('test_ffmpeg')
.then(v => setFfmpegVersion(v))
.catch(e => setFfmpegVersion('Error: ' + e));
}
}, [settings.isSettingsModalOpen, activeTab]);
if (!settings.isSettingsModalOpen) return null;
const showToast = (msg: string) => {
setToastMessage(msg);
};
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 selected = await open({
directory: true,
multiple: false
});
if (selected && typeof selected === 'string') {
// Automatically populate all category folders
const cleanBase = selected.endsWith('/') ? selected.slice(0, -1) : selected;
settings.setCategoryDirectory('Video', `${cleanBase}/Video`);
settings.setCategoryDirectory('Audio', `${cleanBase}/Audio`);
settings.setCategoryDirectory('Documents', `${cleanBase}/Documents`);
settings.setCategoryDirectory('Apps', `${cleanBase}/Apps`);
settings.setCategoryDirectory('Images', `${cleanBase}/Images`);
settings.setCategoryDirectory('Archives', `${cleanBase}/Archives`);
settings.setCategoryDirectory('Other', `${cleanBase}/Other`);
showToast("Created subfolders for all categories");
}
} catch (e) {
console.error("Failed to browse base path:", e);
}
};
const handleAddLogin = () => {
if (!loginPattern.trim() || !loginUser.trim()) {
setLoginError("Please enter a URL pattern and a username.");
return;
}
const id = crypto.randomUUID();
settings.addSiteLogin({
id,
urlPattern: loginPattern.trim(),
username: loginUser.trim(),
password: loginPass
});
setLoginPattern('');
setLoginUser('');
setLoginPass('');
setLoginError('');
showToast("Added site credential");
};
const copyToken = () => {
navigator.clipboard.writeText(settings.extensionPairingToken);
showToast("Token copied to clipboard!");
};
const TabButton = ({ type, icon: Icon, label }: { type: TabType; icon: any; label: string }) => {
const active = activeTab === type;
return (
<button
onClick={() => setActiveTab(type)}
className={`flex flex-col items-center justify-center p-2 rounded-lg transition-all text-center min-w-[76px] cursor-default ${
active
? 'bg-blue-600/15 text-blue-500 font-semibold'
: 'text-text-secondary hover:bg-item-hover hover:text-text-primary'
}`}
>
<Icon size={18} className="mb-1" />
<span className="text-[10px] whitespace-nowrap">{label}</span>
</button>
);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-[840px] h-[640px] bg-bg-modal border border-border-modal rounded-xl shadow-2xl flex flex-col overflow-hidden relative">
{/* Toast Notification */}
{toastMessage && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-blue-600 text-white text-[13px] font-medium py-2 px-4 rounded-full shadow-lg z-50 animate-bounce">
{toastMessage}
</div>
)}
{/* Header (Horizontal Tab Bar) */}
<div className="flex flex-col border-b border-border-modal bg-sidebar-bg/50">
<div className="flex items-center justify-between p-3 pl-4 border-b border-border-modal/50">
<h2 className="text-sm font-semibold tracking-wide text-text-primary">Preferences</h2>
<button onClick={() => settings.toggleSettingsModal(false)} className="text-text-muted hover:text-text-primary transition-colors">
<X size={18} />
</button>
</div>
<div className="flex items-center gap-1.5 p-2 overflow-x-auto justify-center">
<TabButton type="downloads" icon={Download} label="Downloads" />
<TabButton type="lookandfeel" icon={Palette} label="Look & Feel" />
<TabButton type="network" icon={Globe} label="Network" />
<TabButton type="locations" icon={Folder} label="Locations" />
<TabButton type="sitelogins" icon={Key} label="Site Logins" />
<TabButton type="power" icon={Moon} label="Power" />
<TabButton type="engine" icon={Terminal} label="Engine" />
<TabButton type="integrations" icon={Puzzle} label="Integrations" />
<TabButton type="about" icon={Info} label="About" />
</div>
</div>
{/* Content Area */}
<div className="flex-1 overflow-y-auto p-6 bg-main-bg/10">
{/* Downloads Pane */}
{activeTab === 'downloads' && (
<div className="space-y-6 max-w-xl mx-auto">
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Download Options</h3>
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px]">
<label className="text-text-secondary font-medium">Parallel downloads:</label>
<div className="flex items-center gap-4">
<input
type="range" min="1" max="12"
value={settings.maxConcurrentDownloads}
onChange={(e) => settings.setMaxConcurrentDownloads(Number(e.target.value))}
className="flex-1 accent-blue-500"
/>
<span className="w-8 text-center font-mono font-bold bg-item-hover px-2 py-1 rounded border border-border-modal text-text-secondary">
{settings.maxConcurrentDownloads}
</span>
</div>
</div>
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px]">
<label className="text-text-secondary font-medium">Default connections:</label>
<div className="flex items-center gap-4">
<input
type="number" min="1" max="16"
value={settings.perServerConnections}
onChange={(e) => settings.setPerServerConnections(Number(e.target.value))}
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-24 text-text-primary focus:outline-none focus:border-blue-500"
/>
<span className="text-text-muted text-xs">For new downloads (1 to 16)</span>
</div>
</div>
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px]">
<label className="text-text-secondary font-medium">Global speed limit:</label>
<div className="flex items-center gap-4">
<input
type="text"
value={settings.globalSpeedLimit}
onChange={(e) => settings.setGlobalSpeedLimit(e.target.value)}
placeholder="Unlimited"
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-32 font-mono text-text-primary focus:outline-none focus:border-blue-500"
/>
<span className="text-text-muted text-xs">e.g. 500K, 1M, or 0 for unlimited</span>
</div>
</div>
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px]">
<label className="text-text-secondary font-medium">Automatic retries:</label>
<div className="flex items-center gap-4">
<input
type="number" min="0" max="10"
value={settings.maxAutomaticRetries}
onChange={(e) => settings.setMaxAutomaticRetries(Number(e.target.value))}
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-24 text-text-primary focus:outline-none focus:border-blue-500"
/>
<span className="text-text-muted text-xs">If a connection fails (0 to 10)</span>
</div>
</div>
<div className="border-t border-border-color/30 pt-4 space-y-3">
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary">
<input
type="checkbox"
checked={settings.showNotifications}
onChange={(e) => settings.setShowNotifications(e.target.checked)}
className="mt-0.5 rounded accent-blue-500"
/>
<div>
<p className="font-semibold text-text-primary">Show notification when download completes</p>
<p className="text-text-muted text-xs mt-0.5">Alerts you in the System Notification Center</p>
</div>
</label>
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary pl-6">
<input
type="checkbox"
checked={settings.playCompletionSound && settings.showNotifications}
disabled={!settings.showNotifications}
onChange={(e) => settings.setPlayCompletionSound(e.target.checked)}
className="mt-0.5 rounded accent-blue-500 disabled:opacity-40"
/>
<div>
<p className={`font-semibold ${settings.showNotifications ? 'text-text-primary' : 'text-text-muted'}`}>Play sound when download completes</p>
</div>
</label>
</div>
</div>
)}
{/* Look & Feel Pane */}
{activeTab === 'lookandfeel' && (
<div className="space-y-6 max-w-xl mx-auto">
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Appearance Settings</h3>
<div className="grid grid-cols-[180px_1fr] items-start gap-4 text-[13px]">
<label className="text-text-secondary font-medium pt-1">App Theme:</label>
<div className="space-y-2">
{['system', 'dark', 'light', 'dracula', 'nord'].map((t) => (
<label key={t} className="flex items-center gap-2 cursor-default select-none text-text-secondary capitalize">
<input
type="radio"
name="themeRadio"
value={t}
checked={settings.theme === t}
onChange={() => settings.setTheme(t as any)}
className="accent-blue-500"
/>
{t === 'system' ? 'System Default' : t}
</label>
))}
<p className="text-text-muted text-xs mt-2">Select a color palette for the app's user interface.</p>
</div>
</div>
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px]">
<label className="text-text-secondary font-medium">Font Size:</label>
<select
value={settings.appFontSize}
onChange={(e) => settings.setAppFontSize(e.target.value as any)}
className="bg-bg-input border border-border-modal rounded-lg p-2 text-[13px] text-text-primary focus:outline-none focus:border-blue-500 max-w-[200px]"
>
<option value="standard">Standard</option>
<option value="large">Large</option>
<option value="extra-large">Extra Large</option>
</select>
</div>
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px]">
<label className="text-text-secondary font-medium">List Row Density:</label>
<select
value={settings.listRowDensity}
onChange={(e) => settings.setListRowDensity(e.target.value as any)}
className="bg-bg-input border border-border-modal rounded-lg p-2 text-[13px] text-text-primary focus:outline-none focus:border-blue-500 max-w-[200px]"
>
<option value="compact">Compact</option>
<option value="standard">Standard</option>
<option value="spacious">Spacious</option>
</select>
</div>
<div className="border-t border-border-color/30 pt-4 space-y-3">
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary">
<input
type="checkbox"
checked={settings.showNotifications} // mapped to dock badge placeholder
className="mt-0.5 rounded accent-blue-500"
/>
<div>
<p className="font-semibold text-text-primary">Show badge on Dock/Taskbar icon</p>
<p className="text-text-muted text-xs mt-0.5">Displays the number of active downloads on the icon badge.</p>
</div>
</label>
</div>
</div>
)}
{/* Network Pane */}
{activeTab === 'network' && (
<div className="space-y-6 max-w-xl mx-auto">
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Proxy & User Agent</h3>
<div className="grid grid-cols-[180px_1fr] items-start gap-4 text-[13px]">
<label className="text-text-secondary font-medium pt-1">Proxy Mode:</label>
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-default select-none text-text-secondary">
<input
type="radio" name="proxyMode" value="none"
checked={settings.proxyMode === 'none'}
onChange={() => settings.setProxyMode('none')}
className="accent-blue-500"
/>
No proxy
</label>
<label className="flex items-center gap-2 cursor-default select-none text-text-secondary">
<input
type="radio" name="proxyMode" value="system"
checked={settings.proxyMode === 'system'}
onChange={() => settings.setProxyMode('system')}
className="accent-blue-500"
/>
Use system proxy
</label>
<label className="flex items-center gap-2 cursor-default select-none text-text-secondary">
<input
type="radio" name="proxyMode" value="custom"
checked={settings.proxyMode === 'custom'}
onChange={() => settings.setProxyMode('custom')}
className="accent-blue-500"
/>
Set proxy
</label>
</div>
</div>
{settings.proxyMode === 'custom' && (
<div className="bg-item-hover/30 border border-border-modal rounded-lg p-4 pl-6 space-y-4 max-w-[420px] ml-[180px]">
<div className="grid grid-cols-[80px_1fr] items-center gap-2 text-[13px]">
<label className="text-text-secondary">Host:</label>
<input
type="text"
value={settings.proxyHost}
onChange={(e) => settings.setProxyHost(e.target.value)}
placeholder="127.0.0.1"
className="bg-bg-input border border-border-modal rounded-md px-3 py-1 text-text-primary font-mono text-xs focus:outline-none"
/>
</div>
<div className="grid grid-cols-[80px_1fr] items-center gap-2 text-[13px]">
<label className="text-text-secondary">Port:</label>
<input
type="number"
value={settings.proxyPort}
onChange={(e) => settings.setProxyPort(Number(e.target.value))}
className="bg-bg-input border border-border-modal rounded-md px-3 py-1 text-text-primary font-mono text-xs w-[100px] focus:outline-none"
/>
</div>
</div>
)}
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px] border-t border-border-color/30 pt-4">
<label className="text-text-secondary font-medium">User Agent:</label>
<div className="space-y-1">
<input
type="text"
value={settings.customUserAgent}
onChange={(e) => settings.setCustomUserAgent(e.target.value)}
placeholder="e.g. Mozilla/5.0..."
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full font-mono text-[11px] text-text-primary focus:outline-none focus:border-blue-500"
/>
<p className="text-text-muted text-xs">Spoofs browser User-Agent to bypass download restrictions. Leave blank for default.</p>
</div>
</div>
</div>
)}
{/* Locations Pane */}
{activeTab === 'locations' && (
<div className="space-y-6 max-w-xl mx-auto">
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Download Directories</h3>
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary">
<input
type="checkbox"
checked={settings.askWhereToSaveEachFile}
onChange={(e) => settings.setAskWhereToSaveEachFile(e.target.checked)}
className="mt-0.5 rounded accent-blue-500"
/>
<div>
<p className="font-semibold text-text-primary">Ask where to save each file before downloading</p>
<p className="text-text-muted text-xs mt-0.5">When enabled, you choose the download location each time you add links.</p>
</div>
</label>
<div className="space-y-4 border-t border-border-color/30 pt-4">
<h4 className="text-[13px] font-bold text-text-primary">Default Categories Paths</h4>
{/* Bulk Directory Selector */}
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px] bg-item-hover/35 p-3 rounded-lg border border-border-modal/40">
<label className="font-semibold text-text-primary">All Categories Base:</label>
<div className="flex gap-2">
<input
type="text" readOnly placeholder="Choose base folder to sub-categorize..."
className="flex-1 bg-bg-input border border-border-modal rounded-md px-3 py-1 text-xs text-text-muted"
/>
<button
onClick={handleBrowseBulk}
className="bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded-md text-xs font-semibold shadow transition-colors"
>
Choose Base
</button>
</div>
</div>
{Object.keys(settings.downloadDirectories || {}).map((category) => (
<div key={category} className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
<label className="text-text-secondary capitalize">{category} folder:</label>
<div className="flex gap-2">
<input
type="text"
value={(settings.downloadDirectories || {})[category]}
onChange={(e) => settings.setCategoryDirectory(category, e.target.value)}
className="flex-1 bg-bg-input border border-border-modal rounded-md px-3 py-1 text-xs text-text-primary font-mono"
/>
<button
onClick={() => handleBrowseCategory(category)}
className="bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal px-2.5 py-1 rounded-md text-xs"
>
Choose
</button>
</div>
</div>
))}
<div className="flex justify-end gap-2 pt-2 border-t border-border-color/30">
<button
onClick={() => {
settings.resetCategoryDirectories();
showToast("Reset directories to default");
}}
className="bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal px-4 py-1.5 rounded-md text-xs"
>
Reset Defaults
</button>
</div>
</div>
</div>
)}
{/* Site Logins Pane */}
{activeTab === 'sitelogins' && (
<div className="space-y-6 max-w-xl mx-auto">
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Site Credentials</h3>
{/* 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={() => {
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-blue-600 hover:bg-blue-500 text-white px-4 py-1.5 rounded-lg text-xs font-semibold shadow flex items-center gap-1.5"
>
<Plus size={14} /> Add Login
</button>
</div>
</div>
</div>
)}
{/* Power Pane */}
{activeTab === 'power' && (
<div className="space-y-6 max-w-xl mx-auto">
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Power Management</h3>
<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-blue-500"
/>
<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="space-y-6 max-w-xl mx-auto">
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Media Downloader & Engines</h3>
<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-blue-500" /> 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>
<span className="text-green-500 font-medium">Ready</span>
</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] text-[13px] pb-1">
<span className="text-text-secondary font-semibold">yt-dlp:</span>
<span className="font-mono text-xs text-text-muted select-all">{ytdlpVersion}</span>
</div>
<div className="grid grid-cols-[120px_1fr] text-[13px] pb-1">
<span className="text-text-secondary font-semibold">FFmpeg:</span>
<span className="font-mono text-xs text-text-muted select-all">{ffmpegVersion}</span>
</div>
<div className="grid grid-cols-[120px_1fr] text-[13px] pb-1">
<span className="text-text-secondary font-semibold">Deno:</span>
<span className="text-red-500 text-xs font-semibold">Not Installed (Local Extension Only)</span>
</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 any)}
className="bg-bg-input border border-border-modal rounded-lg p-1.5 text-[13px] text-text-primary focus:outline-none focus:border-blue-500"
>
<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="space-y-6 max-w-xl mx-auto">
<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-blue-600/25 text-blue-500 font-bold rounded-full w-5 h-5 flex items-center justify-center text-xs">1</span>
<Copy size={16} className="text-blue-500" />
</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-blue-600 hover:bg-blue-500 text-white font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 shadow transition-colors"
>
<Copy size={11} /> Copy Token
</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 thecopied 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="space-y-6 max-w-md mx-auto text-center py-6">
<div className="w-16 h-16 bg-blue-600 text-white font-extrabold text-2xl flex items-center justify-center rounded-2xl mx-auto shadow-lg shadow-blue-500/20">
FL
</div>
<div className="space-y-2">
<h3 className="text-lg font-bold text-text-primary">Firelink Desktop</h3>
<p className="text-text-secondary text-sm">Version 0.8.0-rewrite (Tauri v2)</p>
<p className="text-text-muted text-xs leading-relaxed max-w-sm mx-auto">
A high-speed, cross-platform download engine rebuilt in Rust, React, and Tailwind, replicating the premium SwiftUI native look.
</p>
</div>
<div className="border-t border-border-color/30 pt-4 flex justify-center gap-4 text-xs text-blue-500">
<a href="https://github.com/nimbold/Firelink" target="_blank" rel="noreferrer" className="hover:underline">GitHub Repository</a>
<span></span>
<a href="https://github.com/nimbold/Firelink/issues" target="_blank" rel="noreferrer" className="hover:underline">Report Issues</a>
</div>
<p className="text-[10px] text-text-muted pt-2">© 2026 Firelink Project. Released under the MIT License.</p>
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-border-modal bg-sidebar-bg/50 flex justify-end gap-3">
<button
onClick={() => settings.toggleSettingsModal(false)}
className="px-5 py-2 rounded-lg text-sm font-medium bg-blue-600 hover:bg-blue-500 text-white shadow-lg shadow-blue-500/20 transition-all active:scale-95"
>
Done
</button>
</div>
</div>
</div>
);
};
+105
View File
@@ -0,0 +1,105 @@
import React from 'react';
// Force Vite HMR rebuild
import {
Inbox, Zap, CheckCircle2, CircleDashed,
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion,
List, CalendarClock, Gauge, Settings
} from 'lucide-react';
import { useDownloadStore, DownloadCategory } from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore';
import { getCurrentWindow } from '@tauri-apps/api/window';
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings';
interface SidebarProps {
selectedFilter: SidebarFilter;
onSelectFilter: (filter: SidebarFilter) => void;
}
export const Sidebar: React.FC<SidebarProps> = ({ selectedFilter, onSelectFilter }) => {
const downloads = useDownloadStore(state => state.downloads);
const getCount = (filter: SidebarFilter) => {
switch (filter) {
case 'all': return downloads.length;
case 'active': return downloads.filter(d => d.status === 'downloading').length;
case 'completed': return downloads.filter(d => d.status === 'completed').length;
case 'unfinished': return downloads.filter(d => d.status !== 'completed').length;
default: return downloads.filter(d => d.category === filter).length;
}
};
const NavItem = ({ icon: Icon, label, filter }: { icon: any, label: string, filter: SidebarFilter }) => (
<div
className={`flex items-center px-2 py-1.5 rounded-md text-[13px] cursor-default transition-colors mb-0.5 ${
selectedFilter === filter
? 'bg-blue-500/20 text-blue-500'
: 'text-text-secondary hover:bg-item-hover'
}`}
onClick={() => onSelectFilter(filter)}
>
<Icon className={`w-4 h-4 mr-2 ${selectedFilter === filter ? 'opacity-100' : 'opacity-80'}`} />
<span>{label}</span>
{getCount(filter) > 0 && (
<span className="ml-auto text-[11px] text-text-muted bg-item-hover px-1.5 py-0.5 rounded-full">
{getCount(filter)}
</span>
)}
</div>
);
return (
<div className="w-[220px] min-w-[190px] max-w-[260px] bg-sidebar-bg/80 backdrop-blur-xl border-r border-border-color flex flex-col p-3 pt-8 pb-4 overflow-y-auto relative shrink-0">
<div
className="absolute top-0 left-0 right-0 h-10 z-50"
onPointerDown={(e) => {
if (e.button === 0) getCurrentWindow().startDragging();
}}
/>
<div className="mb-4 shrink-0 mt-2">
<div className="text-[11px] font-semibold text-text-muted/80 uppercase tracking-wider px-2 mb-1">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" />
</div>
<div className="mb-4 shrink-0">
<div className="text-[11px] font-semibold text-text-muted/80 uppercase tracking-wider px-2 mb-1">Folders</div>
<NavItem icon={Film} label="Video" filter="Video" />
<NavItem icon={Music} label="Audio" filter="Audio" />
<NavItem icon={FileText} label="Documents" filter="Documents" />
<NavItem icon={Box} label="Apps" filter="Apps" />
<NavItem icon={ImageIcon} label="Images" filter="Images" />
<NavItem icon={Archive} label="Archives" filter="Archives" />
<NavItem icon={FileQuestion} label="Other" filter="Other" />
</div>
<div className="mb-4 shrink-0">
<div className="text-[11px] font-semibold text-text-muted/80 uppercase tracking-wider px-2 mb-1">Queues</div>
<div className="flex items-center px-2 py-1.5 rounded-md text-[13px] text-text-secondary hover:bg-item-hover cursor-default transition-colors mb-0.5">
<List className="w-4 h-4 mr-2 opacity-80" />
<span>Main Queue</span>
</div>
</div>
<div className="flex-1 min-h-[16px]"></div>
<div className="shrink-0 pb-2">
<div className="text-[11px] font-semibold text-text-muted/80 uppercase tracking-wider px-2 mb-1">Tools</div>
<div className="flex items-center px-2 py-1.5 rounded-md text-[13px] text-text-secondary hover:bg-item-hover cursor-default transition-colors mb-0.5">
<CalendarClock className="w-4 h-4 mr-2 opacity-80" /><span>Scheduler</span>
</div>
<div className="flex items-center px-2 py-1.5 rounded-md text-[13px] text-text-secondary hover:bg-item-hover cursor-default transition-colors mb-0.5">
<Gauge className="w-4 h-4 mr-2 opacity-80" /><span>Speed Limiter</span>
</div>
<div
onClick={() => useSettingsStore.getState().toggleSettingsModal(true)}
className="flex items-center px-2 py-1.5 rounded-md text-[13px] text-text-secondary hover:bg-item-hover cursor-pointer transition-colors"
>
<Settings className="w-4 h-4 mr-2 opacity-80" /><span>Settings</span>
</div>
</div>
</div>
);
};
+146
View File
@@ -0,0 +1,146 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@import "tailwindcss";
:root {
/* Default/fallback Light */
--main-bg: 0 0% 98%;
--sidebar-bg: 0 0% 93%;
--sidebar-glass: 0 0% 93% / 0.6;
--border-color: 0 0% 88%;
--item-hover: 0 0% 0% / 0.05;
--item-selected: 211 100% 50% / 0.15;
--text-primary: 0 0% 10%;
--text-secondary: 0 0% 30%;
--text-muted: 0 0% 50%;
--bg-modal: 0 0% 100%;
--bg-input: 0 0% 100%;
--border-modal: 0 0% 85%;
}
.theme-light {
--main-bg: 0 0% 98%;
--sidebar-bg: 0 0% 93%;
--sidebar-glass: 0 0% 93% / 0.6;
--border-color: 0 0% 88%;
--item-hover: 0 0% 0% / 0.05;
--item-selected: 211 100% 50% / 0.15;
--text-primary: 0 0% 10%;
--text-secondary: 0 0% 30%;
--text-muted: 0 0% 50%;
--bg-modal: 0 0% 100%;
--bg-input: 0 0% 100%;
--border-modal: 0 0% 85%;
}
.theme-dark {
/* Modern Mac Dark - Lighter Grays matching SwiftUI */
--main-bg: 0 0% 16%;
--sidebar-bg: 0 0% 14%;
--sidebar-glass: 0 0% 14% / 0.6;
--border-color: 0 0% 10%;
--item-hover: 0 0% 100% / 0.08;
--item-selected: 211 100% 50% / 0.2;
--text-primary: 0 0% 98%;
--text-secondary: 0 0% 75%;
--text-muted: 0 0% 55%;
--bg-modal: 0 0% 18%;
--bg-input: 0 0% 0% / 0.3;
--border-modal: 0 0% 10%;
}
.theme-dracula {
/* Dracula Theme */
--main-bg: 231 15% 18%;
--sidebar-bg: 232 14% 14%;
--sidebar-glass: 232 14% 14% / 0.6;
--border-color: 232 14% 10%;
--item-hover: 0 0% 100% / 0.08;
--item-selected: 265 89% 78% / 0.2;
--text-primary: 60 30% 96%;
--text-secondary: 225 27% 65%;
--text-muted: 225 27% 55%;
--bg-modal: 231 15% 20%;
--bg-input: 232 14% 12%;
--border-modal: 232 14% 10%;
}
.theme-nord {
/* Nord Theme */
--main-bg: 220 16% 22%;
--sidebar-bg: 222 16% 19%;
--sidebar-glass: 222 16% 19% / 0.6;
--border-color: 222 16% 15%;
--item-hover: 0 0% 100% / 0.08;
--item-selected: 193 43% 67% / 0.2;
--text-primary: 218 27% 92%;
--text-secondary: 214 20% 78%;
--text-muted: 220 11% 54%;
--bg-modal: 220 16% 25%;
--bg-input: 222 16% 15%;
--border-modal: 222 16% 10%;
}
@theme {
--color-sidebar-bg: hsl(var(--sidebar-bg));
--color-sidebar-glass: hsl(var(--sidebar-glass));
--color-main-bg: hsl(var(--main-bg));
--color-accent: #0A84FF;
--color-border-color: hsl(var(--border-color));
--color-item-hover: hsl(var(--item-hover));
--color-item-selected: hsl(var(--item-selected));
--color-text-primary: hsl(var(--text-primary));
--color-text-secondary: hsl(var(--text-secondary));
--color-text-muted: hsl(var(--text-muted));
--color-bg-modal: hsl(var(--bg-modal));
--color-bg-input: hsl(var(--bg-input));
--color-border-modal: hsl(var(--border-modal));
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
@layer base {
body {
background-color: var(--color-main-bg);
color: var(--color-text-primary);
font-family: var(--font-sans);
overflow: hidden;
user-select: none;
-webkit-user-select: none;
cursor: default;
-webkit-font-smoothing: antialiased;
transition: background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
input, textarea {
user-select: auto;
-webkit-user-select: auto;
cursor: text;
}
html[data-font-size="standard"] { font-size: 14px; }
html[data-font-size="large"] { font-size: 16px; }
html[data-font-size="extra-large"] { font-size: 18px; }
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-border-color);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}
}
.glass-panel {
background: var(--color-sidebar-glass);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
+16
View File
@@ -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>,
);
}
+188
View File
@@ -0,0 +1,188 @@
import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
import { useSettingsStore } from './useSettingsStore';
const getProxyArgs = (settings: ReturnType<typeof useSettingsStore.getState>) => {
if (settings.proxyMode === 'custom' && settings.proxyHost) {
return `http://${settings.proxyHost}:${settings.proxyPort}`;
}
return null;
};
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: activeCount }).catch(() => {});
if (settings.preventsSleepWhileDownloading) {
invoke('set_prevent_sleep', { prevent: activeCount > 0 }).catch(() => {});
} else {
invoke('set_prevent_sleep', { prevent: false }).catch(() => {});
}
};
export type DownloadStatus = 'downloading' | 'paused' | 'completed' | 'failed' | 'queued';
export type DownloadCategory = 'Documents' | 'Images' | 'Audio' | 'Video' | 'Apps' | 'Archives' | 'Other';
export interface DownloadItem {
id: string;
url: string;
fileName: string;
status: DownloadStatus;
fraction?: number;
speed?: string;
eta?: string;
category: DownloadCategory;
dateAdded: string;
// Advanced Settings
connections?: number | null;
speedLimit?: string | null;
username?: string | null;
password?: string | null;
headers?: string | null;
destination?: string;
}
interface DownloadState {
downloads: DownloadItem[];
isAddModalOpen: boolean;
selectedPropertiesDownloadId: string | null;
toggleAddModal: (isOpen: boolean) => 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: () => void;
}
export const useDownloadStore = create<DownloadState>((set, get) => ({
downloads: [],
isAddModalOpen: false,
selectedPropertiesDownloadId: null,
toggleAddModal: (isOpen) => set({ isAddModalOpen: isOpen }),
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
addDownload: (item) => {
set((state) => ({ downloads: [...state.downloads, item] }));
get().processQueue();
},
updateDownload: (id, updates) => {
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;
}
return {
...d,
...updates,
fraction: newFraction !== undefined ? newFraction : updates.fraction !== undefined ? updates.fraction : d.fraction
};
}
return d;
})
}));
// 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)
}));
get().processQueue();
syncSystemIntegrations();
},
clearFinished: () => {
set((state) => ({
downloads: state.downloads.filter(d => !['completed', 'failed'].includes(d.status))
}));
},
redownload: (id) => {
set((state) => ({
downloads: state.downloads.map(d =>
d.id === id
? { ...d, status: 'queued', fraction: 0, speed: '-', eta: '-' }
: d
)
}));
get().processQueue();
},
processQueue: async () => {
const { downloads, updateDownload } = get();
const { maxConcurrentDownloads, globalSpeedLimit, defaultDownloadPath } = useSettingsStore.getState();
const activeCount = downloads.filter(d => d.status === 'downloading').length;
if (activeCount >= maxConcurrentDownloads) return;
const queuedItems = downloads.filter(d => d.status === 'queued');
const slotsAvailable = maxConcurrentDownloads - activeCount;
const itemsToStart = queuedItems.slice(0, slotsAvailable);
for (const item of itemsToStart) {
updateDownload(item.id, { status: 'downloading' });
try {
const settings = useSettingsStore.getState();
const login = getSiteLogin(item.url, settings);
const destPath = item.destination ||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
await invoke('start_download', {
id: item.id,
url: item.url,
destination: destPath,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speedLimit: item.speedLimit || settings.globalSpeedLimit || null,
username: item.username || (login ? login.username : null),
password: item.password || (login ? login.password : null),
headers: item.headers || 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' });
}
}
}
}));
+195
View File
@@ -0,0 +1,195 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface SiteLogin {
id: string;
urlPattern: string;
username: string;
password?: string;
}
export interface SettingsState {
theme: 'dark' | 'light' | 'system' | 'dracula' | 'nord';
defaultDownloadPath: string;
maxConcurrentDownloads: number;
globalSpeedLimit: string;
isSettingsModalOpen: boolean;
isSidebarVisible: boolean;
// Replicated SwiftUI App Settings
perServerConnections: number;
maxAutomaticRetries: number;
showNotifications: boolean;
playCompletionSound: boolean;
appFontSize: 'standard' | 'large' | 'extra-large';
listRowDensity: 'compact' | 'standard' | 'spacious';
proxyMode: 'none' | 'system' | 'custom';
proxyHost: string;
proxyPort: number;
customUserAgent: string;
askWhereToSaveEachFile: boolean;
preventsSleepWhileDownloading: boolean;
mediaCookieSource: 'none' | 'safari' | 'chrome' | 'firefox' | 'edge' | 'brave';
downloadDirectories: Record<string, string>;
siteLogins: SiteLogin[];
extensionPairingToken: string;
setTheme: (theme: 'dark' | 'light' | 'system' | 'dracula' | 'nord') => void;
setDefaultDownloadPath: (path: string) => void;
setMaxConcurrentDownloads: (count: number) => void;
setGlobalSpeedLimit: (limit: string) => void;
toggleSettingsModal: (isOpen: boolean) => void;
toggleSidebar: () => void;
setPerServerConnections: (count: number) => void;
setMaxAutomaticRetries: (count: number) => void;
setShowNotifications: (show: boolean) => void;
setPlayCompletionSound: (play: boolean) => void;
setAppFontSize: (size: 'standard' | 'large' | 'extra-large') => void;
setListRowDensity: (density: 'compact' | 'standard' | 'spacious') => void;
setProxyMode: (mode: 'none' | 'system' | 'custom') => void;
setProxyHost: (host: string) => void;
setProxyPort: (port: number) => void;
setCustomUserAgent: (userAgent: string) => void;
setAskWhereToSaveEachFile: (ask: boolean) => void;
setPreventsSleepWhileDownloading: (prevent: boolean) => void;
setMediaCookieSource: (source: 'none' | 'safari' | 'chrome' | 'firefox' | 'edge' | 'brave') => void;
setCategoryDirectory: (category: string, path: string) => void;
resetCategoryDirectories: () => void;
addSiteLogin: (login: SiteLogin) => void;
removeSiteLogin: (id: string) => void;
regeneratePairingToken: () => void;
}
const defaultDirectories = {
Video: '~/Downloads/Video',
Audio: '~/Downloads/Audio',
Documents: '~/Downloads/Documents',
Apps: '~/Downloads/Apps',
Images: '~/Downloads/Images',
Archives: '~/Downloads/Archives',
Other: '~/Downloads/Other'
};
const generateSecureToken = () => {
try {
const cryptoObj = typeof window !== 'undefined' ? (window.crypto || (window as any).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: '',
isSettingsModalOpen: false,
isSidebarVisible: true,
// Replicated SwiftUI defaults
perServerConnections: 16,
maxAutomaticRetries: 3,
showNotifications: true,
playCompletionSound: true,
appFontSize: 'standard',
listRowDensity: 'standard',
proxyMode: 'none',
proxyHost: '',
proxyPort: 8080,
customUserAgent: '',
askWhereToSaveEachFile: false,
preventsSleepWhileDownloading: true,
mediaCookieSource: 'none',
downloadDirectories: { ...defaultDirectories },
siteLogins: [],
extensionPairingToken: generateSecureToken(),
setTheme: (theme) => set({ theme }),
setDefaultDownloadPath: (defaultDownloadPath) => set({ defaultDownloadPath }),
setMaxConcurrentDownloads: (maxConcurrentDownloads) => set({ maxConcurrentDownloads }),
setGlobalSpeedLimit: (globalSpeedLimit) => set({ globalSpeedLimit }),
toggleSettingsModal: (isSettingsModalOpen) => set({ isSettingsModalOpen }),
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 }),
setProxyMode: (proxyMode) => set({ proxyMode }),
setProxyHost: (proxyHost) => set({ proxyHost }),
setProxyPort: (proxyPort) => set({ proxyPort }),
setCustomUserAgent: (customUserAgent) => set({ customUserAgent }),
setAskWhereToSaveEachFile: (askWhereToSaveEachFile) => set({ askWhereToSaveEachFile }),
setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => set({ preventsSleepWhileDownloading }),
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() }),
}),
{
name: 'firelink-settings',
partialize: (state) => ({
theme: state.theme,
defaultDownloadPath: state.defaultDownloadPath,
maxConcurrentDownloads: state.maxConcurrentDownloads,
globalSpeedLimit: state.globalSpeedLimit,
isSidebarVisible: state.isSidebarVisible,
perServerConnections: state.perServerConnections,
maxAutomaticRetries: state.maxAutomaticRetries,
showNotifications: state.showNotifications,
playCompletionSound: state.playCompletionSound,
appFontSize: state.appFontSize,
listRowDensity: state.listRowDensity,
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
}),
merge: (persistedState: any, currentState) => ({
...currentState,
...persistedState,
downloadDirectories: (persistedState && typeof persistedState === 'object' && persistedState.downloadDirectories)
? persistedState.downloadDirectories
: currentState.downloadDirectories,
siteLogins: (persistedState && typeof persistedState === 'object' && Array.isArray(persistedState.siteLogins))
? persistedState.siteLogins
: currentState.siteLogins
})
}
)
);
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />