refactor(repo): promote tauri app to repository root

This commit is contained in:
NimBold
2026-06-15 10:33:40 +03:30
parent ab7550d39e
commit 6593f9e76a
346 changed files with 1021 additions and 1490 deletions
+951
View File
@@ -0,0 +1,951 @@
import { useState, useEffect } from 'react';
import { useDownloadStore, MAIN_QUEUE_ID, getSiteLogin } from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore';
import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, type LucideIcon } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
import { categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads';
interface RawMediaFormat {
format_id?: string;
ext?: string;
resolution?: string;
format_note?: string;
vcodec?: string;
acodec?: string;
height?: number;
filesize?: number;
filesize_approx?: number;
}
interface MediaFormat {
name: string;
selector: string;
ext: string;
detail: string;
type: string;
bytes: number;
}
interface ParsedDownloadItem {
url: string;
file: string;
size?: string;
sizeBytes?: number;
status?: string;
isMedia?: boolean;
formats?: MediaFormat[];
selectedFormat?: number;
}
const isVideo = (f: RawMediaFormat) => {
const vcodec = f.vcodec?.toLowerCase();
return vcodec && vcodec !== 'none';
};
const isAudio = (f: RawMediaFormat) => {
const acodec = f.acodec?.toLowerCase();
const vcodec = f.vcodec?.toLowerCase();
return acodec && acodec !== 'none' && (!vcodec || vcodec === 'none');
};
const formatSize = (f: RawMediaFormat) => f.filesize ?? f.filesize_approx ?? 0;
const matchesHeight = (f: RawMediaFormat, height: number | null) => {
if (height === null) return true;
const note = f.format_note || "";
if (height === 2160 && (note.includes("2160p") || note.toLowerCase().includes("4k"))) return true;
if (height === 1440 && note.includes("1440p")) return true;
if (height === 1080 && note.includes("1080p")) return true;
if (height === 720 && note.includes("720p")) return true;
if (height === 480 && note.includes("480p")) return true;
if (height === 360 && note.includes("360p")) return true;
if (f.resolution) {
const parts = f.resolution.split('x').map(n => parseInt(n, 10));
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
const maxDim = Math.max(parts[0], parts[1]);
switch (height) {
case 2160: if (maxDim >= 3800) return true; break;
case 1440: if (maxDim >= 2500 && maxDim < 3800) return true; break;
case 1080: if (maxDim >= 1900 && maxDim < 2500) return true; break;
case 720: if (maxDim >= 1200 && maxDim < 1900) return true; break;
case 480: if (maxDim >= 800 && maxDim < 1200) return true; break;
case 360: if (maxDim >= 600 && maxDim < 800) return true; break;
}
}
}
const formatHeight = f.height;
if (!formatHeight) return false;
let tolerance = 100;
if (height >= 2160) tolerance = 600;
else if (height >= 1440) tolerance = 400;
else if (height >= 1080) tolerance = 300;
else if (height >= 720) tolerance = 200;
return formatHeight <= height && formatHeight >= height - tolerance;
};
const hasVideoFormat = (formats: RawMediaFormat[], height: number | null, container: string) => {
return formats.some(f => {
if (!isVideo(f) || !matchesHeight(f, height)) return false;
return container === 'mkv' || f.ext?.toLowerCase() === container.toLowerCase();
});
};
const hasAudioFormat = (formats: RawMediaFormat[], ext: string | null) => {
return formats.some(f => {
if (!isAudio(f)) return false;
if (!ext) return true;
return f.ext?.toLowerCase() === ext.toLowerCase();
});
};
const estimatedVideoBytes = (formats: RawMediaFormat[], height: number | null, container: string) => {
let maxVideo = 0;
for (const f of formats) {
if (isVideo(f) && matchesHeight(f, height) && (container === 'mkv' || f.ext?.toLowerCase() === container.toLowerCase())) {
const size = formatSize(f);
if (size > maxVideo) maxVideo = size;
}
}
if (maxVideo === 0) return null;
let maxAudio = estimatedAudioBytes(formats, container === 'webm' ? 'webm' : 'm4a') || estimatedAudioBytes(formats, null) || 0;
return maxVideo + maxAudio;
};
const estimatedAudioBytes = (formats: RawMediaFormat[], ext: string | null): number | null => {
let maxPreferred = 0;
for (const f of formats) {
if (isAudio(f)) {
if (!ext || f.ext?.toLowerCase() === ext.toLowerCase()) {
const size = formatSize(f);
if (size > maxPreferred) maxPreferred = size;
}
}
}
if (maxPreferred > 0 || !ext) return maxPreferred > 0 ? maxPreferred : null;
return estimatedAudioBytes(formats, null);
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return 'Unknown size';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
const parseMediaFormats = (jsonStr: string) => {
try {
const parsed: unknown = JSON.parse(jsonStr);
if (!parsed || typeof parsed !== 'object') return null;
const data = parsed as { title?: unknown; formats?: unknown };
let title = typeof data.title === 'string' ? data.title : 'Media';
title = title.replace(/[\/\\?%*:|"<>]/g, '-');
const rawFormats = Array.isArray(data.formats)
? data.formats.filter((format): format is RawMediaFormat => Boolean(format) && typeof format === 'object')
: [];
const options = [];
const standardResolutions = [
{ h: 2160, name: "4K" },
{ h: 1440, name: "1440p" },
{ h: 1080, name: "1080p" },
{ h: 720, name: "720p" },
{ h: 480, name: "480p" },
{ h: 360, name: "360p" }
];
const availableResolutions = standardResolutions.filter(res =>
rawFormats.some(f => isVideo(f) && matchesHeight(f, res.h))
);
const videoQualities: { h: number | null, name: string }[] = [{ h: null, name: "Best" }, ...availableResolutions];
const videoContainers = [
{ ext: "mp4", name: "MP4" },
{ ext: "mkv", name: "MKV" },
{ ext: "webm", name: "WebM" }
];
for (const q of videoQualities) {
for (const c of videoContainers) {
if (!hasVideoFormat(rawFormats, q.h, c.ext)) continue;
const est = estimatedVideoBytes(rawFormats, q.h, c.ext);
const filter = q.h ? `[height<=${q.h}]` : '';
let selector = `bestvideo${filter}+bestaudio/best${filter}`;
if (c.ext === 'mp4') {
selector = `bestvideo${filter}[ext=mp4]+bestaudio[ext=m4a]/best${filter}[ext=mp4]/bestvideo${filter}+bestaudio/best${filter}`;
} else if (c.ext === 'webm') {
selector = `bestvideo${filter}[ext=webm]+bestaudio[ext=webm]/best${filter}[ext=webm]/bestvideo${filter}+bestaudio/best${filter}`;
}
options.push({
name: `${q.name} ${c.name}`,
selector,
ext: c.ext,
detail: est ? `~${formatBytes(est)}` : '',
type: 'Video',
bytes: est || 0
});
}
}
if (hasAudioFormat(rawFormats, null)) {
const est = estimatedAudioBytes(rawFormats, null);
options.push({
name: "Audio MP3",
selector: "bestaudio/best",
ext: "mp3",
detail: est ? `~${formatBytes(est)}` : '',
type: 'Audio',
bytes: est || 0
});
}
if (hasAudioFormat(rawFormats, "m4a")) {
const est = estimatedAudioBytes(rawFormats, "m4a");
options.push({
name: "Audio M4A",
selector: "bestaudio[ext=m4a]/bestaudio/best",
ext: "m4a",
detail: est ? `~${formatBytes(est)}` : '',
type: 'Audio',
bytes: est || 0
});
}
if (hasAudioFormat(rawFormats, "webm") || hasAudioFormat(rawFormats, "opus")) {
const est = estimatedAudioBytes(rawFormats, "webm") || estimatedAudioBytes(rawFormats, "opus");
options.push({
name: "Audio Opus",
selector: "bestaudio[ext=webm]/bestaudio/best",
ext: "opus",
detail: est ? `~${formatBytes(est)}` : '',
type: 'Audio',
bytes: est || 0
});
}
return { title, formats: options };
} catch (e) {
return null;
}
};
export const AddDownloadsModal = () => {
const {
isAddModalOpen,
pendingAddUrls,
pendingAddReferer,
pendingAddFilename,
toggleAddModal,
addDownload,
queues
} = useDownloadStore();
const { defaultDownloadPath } = useSettingsStore();
const [selectedQueueId, setSelectedQueueId] = useState<string>(MAIN_QUEUE_ID);
const [urls, setUrls] = useState('');
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
const [parsedItems, setParsedItems] = useState<ParsedDownloadItem[]>([]);
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
const [showingDuplicates, setShowingDuplicates] = useState(false);
const [pendingStartFlag, setPendingStartFlag] = useState(false);
const [resolvedLocation, setResolvedLocation] = useState('');
// Right Form
const [saveLocation, setSaveLocation] = useState(defaultDownloadPath);
const [connections, setConnections] = useState(16);
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
const [speedLimit, setSpeedLimit] = useState('1024');
const [freeSpace, setFreeSpace] = useState('Unknown');
const [useAuth, setUseAuth] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [advancedExpanded, setAdvancedExpanded] = useState(false);
const [checksumEnabled, setChecksumEnabled] = useState(false);
const [checksumAlgo, setChecksumAlgo] = useState('SHA-256');
const [checksumValue, setChecksumValue] = useState('');
const [headers, setHeaders] = useState('');
const [cookies, setCookies] = useState('');
const [mirrors, setMirrors] = useState('');
useEffect(() => {
if (isAddModalOpen) {
setSaveLocation(defaultDownloadPath);
setUrls(pendingAddUrls || '');
setParsedItems([]);
setSelectedItemIndex(null);
setSelectedQueueId(queues.find(q => q.isMain)?.id || MAIN_QUEUE_ID);
setUseAuth(false);
setUsername('');
setPassword('');
setAdvancedExpanded(false);
setChecksumEnabled(false);
setChecksumAlgo('SHA-256');
setChecksumValue('');
setHeaders(pendingAddReferer ? `Referer: ${pendingAddReferer}` : '');
setCookies('');
setMirrors('');
}
}, [
isAddModalOpen,
pendingAddUrls,
pendingAddReferer,
defaultDownloadPath,
queues
]);
useEffect(() => {
if (!saveLocation) return;
invoke('get_free_space', { path: saveLocation })
.then(space => setFreeSpace(space))
.catch(() => setFreeSpace('Unknown'));
}, [saveLocation, isAddModalOpen]);
// Metadata parser
useEffect(() => {
const lines = urls.split('\n').map(u => u.trim()).filter(u => u.length > 0);
// Immediately display items in loading state
const initialItems: ParsedDownloadItem[] = lines.map(url => {
const fallbackFile = lines.length === 1 && pendingAddFilename
? pendingAddFilename
: fileNameFromUrl(url);
return { url, file: fallbackFile, size: '-', status: 'Loading', isMedia: isMediaUrl(url) };
});
setParsedItems(initialItems);
if (lines.length === 0) {
setSelectedItemIndex(null);
return;
} else if (selectedItemIndex === null || selectedItemIndex >= lines.length) {
setSelectedItemIndex(0);
}
const timer = setTimeout(async () => {
const updatedItems = [...initialItems];
let firstReadyIndex: number | null = null;
for (let i = 0; i < lines.length; i++) {
const url = lines[i];
try {
new URL(url);
if (isMediaUrl(url)) {
const settingsStore = useSettingsStore.getState();
const { mediaCookieSource } = settingsStore;
const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null;
const login = getSiteLogin(url, settingsStore);
let keychainPassword = null;
if (login) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password:", e);
}
}
const jsonStr = await invoke('fetch_media_metadata', {
url,
cookieBrowser: browserArg,
username: login?.username || null,
password: keychainPassword
});
const mediaData = parseMediaFormats(jsonStr);
if (mediaData && mediaData.formats.length > 0) {
updatedItems[i] = {
url,
file: `${mediaData.title}.${mediaData.formats[0].ext}`,
size: mediaData.formats[0].detail || 'Unknown (Media)',
sizeBytes: mediaData.formats[0].bytes,
status: 'Ready',
isMedia: true,
formats: mediaData.formats,
selectedFormat: 0
};
} else {
throw new Error("Invalid media metadata or no formats found");
}
} else {
const settingsStore = useSettingsStore.getState();
const login = getSiteLogin(url, settingsStore);
let keychainPassword = null;
if (login) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password:", e);
}
}
const meta = await invoke('fetch_metadata', {
url,
userAgent: settingsStore.customUserAgent || null,
username: login?.username || null,
password: keychainPassword
});
updatedItems[i] = {
url,
file: lines.length === 1 && pendingAddFilename ? pendingAddFilename : meta.filename,
size: meta.size,
sizeBytes: meta.size_bytes,
status: 'Ready'
};
}
if (firstReadyIndex === null) firstReadyIndex = i;
} catch (e) {
console.error("Meta fetch failed", e);
updatedItems[i] = { ...updatedItems[i], size: 'Unknown', sizeBytes: 0, status: 'Error' };
}
setParsedItems([...updatedItems]);
}
if (firstReadyIndex !== null) {
setSelectedItemIndex(firstReadyIndex);
}
}, 400);
return () => clearTimeout(timer);
}, [urls, pendingAddFilename]);
if (!isAddModalOpen) return null;
const handleBrowse = async () => {
try {
const selected = await open({
directory: true,
multiple: false,
defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation
});
if (selected && typeof selected === 'string') {
setSaveLocation(selected);
}
} catch (e) {
console.error("Failed to select folder:", e);
}
};
const handleStart = async (startImmediately: boolean) => {
let finalLocation = saveLocation;
const settings = useSettingsStore.getState();
if (settings.askWhereToSaveEachFile && parsedItems.length > 0) {
try {
const selected = await open({
directory: true,
multiple: false,
defaultPath: finalLocation.startsWith('~') ? undefined : finalLocation
});
if (selected && typeof selected === 'string') {
finalLocation = selected;
} else {
return; // Cancelled
}
} catch (e) {
console.error("Failed to select folder:", e);
}
}
setResolvedLocation(finalLocation);
const store = useDownloadStore.getState();
const newConflicts: DuplicateConflict[] = [];
for (let i = 0; i < parsedItems.length; i++) {
const item = parsedItems[i];
let finalFile = item.file;
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
const isUrlDupe = store.downloads.some(d => d.url === item.url && d.status !== 'failed' && d.status !== 'completed');
if (isUrlDupe) {
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: 'URL already in queue' }, resolution: 'rename' });
} else {
const fileExistsInStore = store.downloads.some(d => {
const dest = d.destination || settings.defaultDownloadPath || '~/Downloads';
return dest === finalLocation && d.fileName === finalFile && d.status !== 'failed';
});
let fileExistsOnDisk = false;
try {
const cleanLocation = finalLocation.endsWith('/') ? finalLocation.slice(0, -1) : finalLocation;
fileExistsOnDisk = await invoke('check_file_exists', { path: `${cleanLocation}/${finalFile}` });
} catch (e) {}
if (fileExistsInStore || fileExistsOnDisk) {
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'file', msg: 'File exists at destination' }, resolution: 'rename' });
}
}
}
if (newConflicts.length > 0) {
setConflicts(newConflicts);
setPendingStartFlag(startImmediately);
setShowingDuplicates(true);
return;
}
await executeAddDownloads(startImmediately, finalLocation);
};
const executeAddDownloads = async (startImmediately: boolean, finalLocation: string, resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => {
let itemsToAdd: Array<ParsedDownloadItem | null> = [...parsedItems];
if (resolutions) {
for (const res of resolutions) {
const idx = parseInt(res.id);
const item = itemsToAdd[idx];
if (!item) continue;
if (res.resolution === 'skip') {
itemsToAdd[idx] = null;
} else if (res.resolution === 'rename') {
let finalFile = item.file;
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
const cleanLocation = finalLocation.endsWith('/') ? finalLocation.slice(0, -1) : finalLocation;
let count = 1;
const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
const ext = finalFile.includes('.') ? finalFile.substring(finalFile.lastIndexOf('.')) : '';
let newName = finalFile;
let exists = true;
while (exists) {
newName = `${base} (${count})${ext}`;
const storeHas = useDownloadStore.getState().downloads.some(d => {
const dest = d.destination || useSettingsStore.getState().defaultDownloadPath || '~/Downloads';
return dest === finalLocation && d.fileName === newName && d.status !== 'failed';
});
let diskHas = false;
try { diskHas = await invoke('check_file_exists', { path: `${cleanLocation}/${newName}` }); } catch(e) {}
exists = storeHas || diskHas;
count++;
}
itemsToAdd[idx] = { ...item, file: newName };
} else if (res.resolution === 'replace') {
let finalFile = item.file;
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
const cleanLocation = finalLocation.endsWith('/') ? finalLocation.slice(0, -1) : finalLocation;
const fullPath = `${cleanLocation}/${finalFile}`;
const store = useDownloadStore.getState();
const existingItem = store.downloads.find(d => {
const dest = d.destination || useSettingsStore.getState().defaultDownloadPath || '~/Downloads';
return (d.url === item.url || (dest === finalLocation && d.fileName === finalFile)) && d.status !== 'failed';
});
if (existingItem) {
await store.removeDownload(existingItem.id);
}
try { await invoke('delete_file', { path: fullPath }); } catch(e) {}
}
}
}
const resolvedItems = itemsToAdd.filter((item): item is ParsedDownloadItem => item !== null);
for (const item of resolvedItems) {
try {
const id = crypto.randomUUID();
let finalFile = item.file;
let formatSelector = undefined;
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
formatSelector = selectedFormat.selector;
if (!finalFile.endsWith(`.${selectedFormat.ext}`)) {
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
}
addDownload({
id,
url: item.url,
fileName: finalFile,
status: startImmediately ? 'queued' : 'paused',
category: categoryForFileName(finalFile),
dateAdded: new Date().toISOString(),
connections: Number(connections),
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
username: useAuth ? username.trim() : undefined,
password: useAuth ? password.trim() : undefined,
headers: headers.trim() || undefined,
checksum: checksumEnabled && checksumValue.trim()
? `${checksumAlgo}=${checksumValue.trim()}`
: undefined,
cookies: cookies.trim() || undefined,
mirrors: mirrors.trim() || undefined,
destination: finalLocation,
isMedia: item.isMedia,
mediaFormatSelector: formatSelector,
queueId: selectedQueueId
});
} catch (e) {
console.error("Invalid URL or failed to add:", e);
}
}
toggleAddModal(false);
};
const SummaryBox = ({ title, value, icon: Icon, color }: {
title: string;
value: string | number;
icon: LucideIcon;
color: string;
}) => (
<div className="flex flex-col bg-bg-input/50 border border-border-modal/40 rounded-lg p-2.5 shadow-sm">
<div className="flex items-center gap-1.5 text-text-muted mb-1">
<Icon size={12} className={color} />
<span className="text-[10px] font-bold uppercase tracking-wider">{title}</span>
</div>
<span className="text-sm font-semibold text-text-primary truncate">{value}</span>
</div>
);
const requiredBytes = parsedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
const requiredStr = requiredBytes > 0
? (requiredBytes < 1024 * 1024 ? `${(requiredBytes / 1024).toFixed(1)} KB`
: requiredBytes < 1024 * 1024 * 1024 ? `${(requiredBytes / 1024 / 1024).toFixed(1)} MB`
: `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`)
: 'Unknown';
return (
<>
{showingDuplicates && (
<DuplicateResolutionModal
conflicts={conflicts}
onConfirm={(resolutions) => {
setShowingDuplicates(false);
executeAddDownloads(pendingStartFlag, resolvedLocation, resolutions);
}}
onCancel={() => setShowingDuplicates(false)}
/>
)}
<div className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center">
<div className="app-modal w-[900px] h-[650px] flex flex-col overflow-hidden text-sm">
{/* Main Content Split */}
<div className="flex flex-1 overflow-hidden">
{/* Left Column: URLs and Preview */}
<div className="w-[55%] border-r border-border-modal flex flex-col bg-main-bg/50">
<div className="p-5 flex-1 flex flex-col gap-5">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-text-primary font-semibold">
<Link size={16} className="text-blue-500" />
Download Links
</div>
</div>
<textarea
className="w-full h-32 bg-bg-input/80 border border-border-modal rounded-lg p-3 text-[13px] text-text-primary focus:outline-none focus:border-accent resize-none font-mono shadow-inner transition-colors"
placeholder="Paste HTTP, HTTPS, FTP, or SFTP URLs here..."
value={urls}
onChange={(e) => setUrls(e.target.value)}
/>
<div className="flex justify-between items-center px-1">
<span className="text-[11px] text-text-muted font-medium">{parsedItems.length} valid link(s) detected</span>
<button className="flex items-center gap-1.5 text-[11px] text-blue-500 hover:text-blue-400 font-medium">
<RefreshCw size={12} /> Refresh Metadata
</button>
</div>
</div>
<div className="grid grid-cols-4 gap-3">
<SummaryBox title="Files" value={parsedItems.length} icon={FileText} color="text-blue-500" />
<SummaryBox title="Required" value={requiredStr} icon={Database} color="text-orange-500" />
<SummaryBox title="Free" value={freeSpace} icon={HardDrive} color="text-green-500" />
<SummaryBox title="Unknown" value={parsedItems.filter(i => !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
</div>
<div className="flex flex-col gap-2 flex-1 overflow-hidden">
<div className="flex items-center gap-2 text-text-primary font-semibold">
<ArrowRight size={16} className="text-blue-500" />
Preview
</div>
<div className="flex-1 border border-border-modal rounded-lg overflow-hidden bg-bg-input/30 flex flex-col">
<div className="bg-sidebar-bg/50 border-b border-border-modal px-3 py-2 flex text-[11px] font-semibold text-text-muted uppercase tracking-wider">
<div className="flex-[2]">File</div>
<div className="flex-1">Size</div>
<div className="flex-[1.5]">Status</div>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{parsedItems.length === 0 ? (
<div className="h-full flex items-center justify-center text-text-muted text-xs italic">
No links added yet.
</div>
) : (
parsedItems.map((item, i) => (
<div
key={i}
onClick={() => setSelectedItemIndex(i)}
className={`flex flex-col text-xs px-2 py-2 cursor-pointer rounded-md transition-all group ${
selectedItemIndex === i
? 'bg-blue-500/10 border border-blue-500/30 shadow-sm'
: 'hover:bg-item-hover border border-transparent'
}`}
>
<div className="flex items-center w-full">
<div className="flex-[2] text-text-primary font-medium truncate pr-2" title={item.file}>{item.file}</div>
<div className={`flex-1 font-mono ${item.status === 'Loading' ? 'text-text-muted/50' : 'text-text-muted'}`}>{item.size || 'Unknown'}</div>
<div className={`flex-[1.5] font-medium ${item.status === 'Error' ? 'text-red-500' : item.status === 'Loading' ? 'text-orange-400' : 'text-blue-500'}`}>
{item.status === 'Loading' ? (
<div className="flex items-center gap-1.5">
<RefreshCw size={12} className="animate-spin" /> Fetching...
</div>
) : (
item.status || 'Ready'
)}
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
</div>
{/* Right Column: Settings */}
<div className="w-[45%] flex flex-col overflow-y-auto bg-bg-modal">
<div className="p-6 space-y-7">
{/* Media Format (Dynamic) */}
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
<section className="app-card relative overflow-hidden p-4">
<div className="absolute top-0 right-0 p-2 opacity-10">
<Video size={48} />
</div>
<div className="flex items-center gap-2 text-sm font-semibold text-text-primary mb-3 relative z-10">
<Video size={16} className="text-purple-500" /> Media Format
</div>
{parsedItems[selectedItemIndex].status === 'Loading' ? (
<div className="flex flex-col items-center justify-center py-6 gap-3 relative z-10">
<RefreshCw size={24} className="animate-spin text-purple-500" />
<span className="text-xs text-text-muted font-medium animate-pulse">Fetching media streams...</span>
</div>
) : parsedItems[selectedItemIndex].formats ? (
<div className="space-y-3 relative z-10">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] uppercase font-bold tracking-wider text-text-muted">Available Streams</label>
<div className="flex flex-col gap-1 max-h-48 overflow-y-auto pr-1">
{parsedItems[selectedItemIndex].formats!.map((f, idx) => {
const isSelected = parsedItems[selectedItemIndex].selectedFormat === idx;
const Icon = f.type === 'Audio' ? Music : Film;
return (
<div
key={idx}
onClick={() => {
const newItems = [...parsedItems];
newItems[selectedItemIndex].selectedFormat = idx;
newItems[selectedItemIndex].size = f.detail || 'Unknown';
newItems[selectedItemIndex].sizeBytes = f.bytes || 0;
// Update filename extension
const baseName = newItems[selectedItemIndex].file.substring(0, newItems[selectedItemIndex].file.lastIndexOf('.')) || newItems[selectedItemIndex].file;
newItems[selectedItemIndex].file = `${baseName}.${f.ext}`;
setParsedItems(newItems);
}}
className={`flex items-center justify-between px-3 py-2 rounded-lg cursor-pointer text-xs border transition-all ${
isSelected ? 'bg-purple-500/10 border-purple-500/30 text-purple-600 dark:text-purple-400 font-semibold shadow-sm' : 'bg-bg-input border-border-modal text-text-secondary hover:border-border-modal/80 hover:bg-item-hover/50'
}`}
>
<div className="flex items-center gap-2">
<Icon size={14} className={isSelected ? 'text-purple-500' : 'text-text-muted'} />
<span>{f.name}</span>
</div>
<span className="font-mono text-[11px] opacity-80">{f.detail}</span>
</div>
);
})}
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-4 relative z-10">
<span className="text-xs text-red-400 font-medium">Failed to load media streams.</span>
</div>
)}
</section>
)}
{/* Save Location */}
<section>
<div className="flex items-center gap-2 text-sm font-semibold text-text-primary mb-3">
<FolderPlus size={16} className="text-blue-500" /> Save Location
</div>
<div className="flex gap-2">
<input
type="text"
readOnly
value={saveLocation}
className="flex-1 bg-bg-input border border-border-modal rounded-md px-3 py-1.5 text-xs text-text-muted font-mono"
/>
<button
onClick={handleBrowse}
className="bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
>
Browse
</button>
</div>
</section>
{/* Transfer Settings */}
<section>
<div className="flex items-center gap-2 text-sm font-semibold text-text-primary mb-3">
<Settings size={16} className="text-blue-500" /> Transfer Settings
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<label className="text-xs text-text-secondary font-medium">Target Queue</label>
<select value={selectedQueueId} onChange={e=>setSelectedQueueId(e.target.value)} className="w-32 bg-bg-input border border-border-modal rounded-lg px-2 py-1 text-xs text-text-primary focus:border-accent focus:outline-none">
{queues.map(q => (
<option key={q.id} value={q.id}>{q.name}</option>
))}
</select>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-text-secondary font-medium">Connections per File</label>
<div className="flex items-center gap-2">
<input type="range" min="1" max="16" value={connections} onChange={e=>setConnections(Number(e.target.value))} className="w-24 accent-blue-500" disabled={parsedItems.some(i => i.isMedia)} />
<span className="text-xs text-text-primary font-mono w-4 text-right">{connections}</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
<input type="checkbox" checked={speedLimitEnabled} onChange={e=>setSpeedLimitEnabled(e.target.checked)} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20" />
Limit speed per file
</label>
{speedLimitEnabled && (
<div className="flex items-center gap-1.5">
<input type="number" value={speedLimit} onChange={e=>setSpeedLimit(e.target.value)} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2 py-1 text-xs font-mono text-text-primary focus:border-accent focus:outline-none" />
<span className="text-[10px] text-text-muted">KiB/s</span>
</div>
)}
</div>
</div>
</section>
{/* Authorization */}
<section>
<div className="flex items-center gap-2 text-sm font-semibold text-text-primary mb-3">
<Shield size={16} className="text-blue-500" /> Authorization
</div>
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer mb-3">
<input type="checkbox" checked={useAuth} onChange={e=>setUseAuth(e.target.checked)} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20" />
Use authorization
</label>
{useAuth && (
<div className="space-y-2.5 pl-5 border-l-2 border-border-modal/50">
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} placeholder="Username" className="w-full bg-bg-input border border-border-modal rounded-lg px-3 py-1.5 text-xs text-text-primary focus:border-accent focus:outline-none" />
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} placeholder="Password" className="w-full bg-bg-input border border-border-modal rounded-lg px-3 py-1.5 text-xs text-text-primary focus:border-accent focus:outline-none" />
</div>
)}
</section>
{/* Advanced */}
<section className="pt-2 border-t border-border-modal/50">
<button
onClick={() => setAdvancedExpanded(!advancedExpanded)}
className="flex items-center gap-2 text-sm font-semibold text-text-primary w-full hover:text-blue-500 transition-colors"
>
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
Advanced Transfer
</button>
{advancedExpanded && (
<div className="mt-4 space-y-4 pl-6">
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
<input type="checkbox" checked={checksumEnabled} onChange={e=>setChecksumEnabled(e.target.checked)} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20" />
Verify Checksum
</label>
{checksumEnabled && (
<div className="flex gap-2">
<select value={checksumAlgo} onChange={e=>setChecksumAlgo(e.target.value)} className="w-24 bg-bg-input border border-border-modal rounded-lg px-2 text-xs text-text-primary focus:border-accent focus:outline-none">
<option>MD5</option><option>SHA-1</option><option>SHA-256</option>
</select>
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} placeholder="Expected digest" className="flex-1 bg-bg-input border border-border-modal rounded-lg px-3 py-1.5 text-xs font-mono text-text-primary focus:border-accent focus:outline-none" />
</div>
)}
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">Headers</label>
<textarea value={headers} onChange={e=>setHeaders(e.target.value)} className="w-full h-12 bg-bg-input border border-border-modal rounded-lg px-3 py-1.5 text-xs font-mono text-text-primary focus:border-accent focus:outline-none resize-none" />
</div>
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">Cookies</label>
<input type="text" value={cookies} onChange={e=>setCookies(e.target.value)} placeholder="name=value; other=value" className="w-full bg-bg-input border border-border-modal rounded-lg px-3 py-1.5 text-xs font-mono text-text-primary focus:border-accent focus:outline-none" />
</div>
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">Mirrors</label>
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} className="w-full h-12 bg-bg-input border border-border-modal rounded-lg px-3 py-1.5 text-xs font-mono text-text-primary focus:border-accent focus:outline-none resize-none" />
</div>
</div>
)}
</section>
</div>
</div>
</div>
{/* Footer */}
<div className="p-4 bg-sidebar-bg/50 border-t border-border-modal flex items-center shrink-0">
<div className="text-[11px] text-text-muted font-medium flex-1">
{parsedItems.length === 0 ? "Paste one or more links." : `Ready to add ${parsedItems.length} download(s).`}
</div>
<div className="flex gap-2.5">
<button onClick={() => toggleAddModal(false)} className="app-button border-transparent bg-transparent px-4 text-xs text-text-secondary">
Cancel
</button>
<button
onClick={() => handleStart(false)}
disabled={parsedItems.length === 0}
className="app-button px-4 text-xs disabled:opacity-50"
>
Add to Queue
</button>
<button
onClick={() => handleStart(true)}
disabled={parsedItems.length === 0}
className="app-button app-button-primary px-5 text-xs disabled:opacity-50"
>
<Play size={12} fill="currentColor" /> Start Downloads
</button>
</div>
</div>
</div>
</div>
</>
);
};
+460
View File
@@ -0,0 +1,460 @@
import React, { useState, useEffect } from 'react';
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore';
import { SidebarFilter } from './Sidebar';
import { Play, Pause, Plus, Trash2, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, MoreVertical, PanelLeft, ArrowDownCircle, Command } from 'lucide-react';
import { invokeCommand as invoke } from '../ipc';
import { homeDir } from '@tauri-apps/api/path';
interface DownloadTableProps {
filter: SidebarFilter;
}
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const { downloads, toggleAddModal, updateDownload, removeDownload, clearFinished, redownload } = useDownloadStore();
const { isSidebarVisible, toggleSidebar } = useSettingsStore();
const isMac = navigator.userAgent.includes('Mac');
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
const [columnWidths, setColumnWidths] = useState([340, 100, 220, 100, 80, 170]);
const columnMinimums = [200, 80, 170, 80, 70, 120];
const tableGridTemplate = columnWidths.map(width => `${width}px`).join(' ');
const startColumnResize = (index: number, event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = columnWidths[index];
const handlePointerMove = (moveEvent: PointerEvent) => {
const nextWidth = Math.max(columnMinimums[index], startWidth + moveEvent.clientX - startX);
setColumnWidths(widths => widths.map((width, columnIndex) => columnIndex === index ? nextWidth : width));
};
const handlePointerUp = () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
document.body.classList.remove('is-resizing');
};
document.body.classList.add('is-resizing');
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
};
useEffect(() => {
const handleCloseMenu = () => setContextMenu(null);
window.addEventListener('click', handleCloseMenu);
return () => window.removeEventListener('click', handleCloseMenu);
}, []);
const resolvePath = async (dir: string, file: string) => {
let resolvedDir = dir;
if (dir.startsWith('~/')) {
const home = await homeDir();
resolvedDir = home + '/' + dir.slice(2);
} else if (dir === '~') {
resolvedDir = await homeDir();
}
return resolvedDir + '/' + file;
};
const filteredDownloads = downloads.filter((d: DownloadItem) => {
if (filter.startsWith('queue:')) {
return d.queueId === filter.replace('queue:', '');
}
switch (filter) {
case 'all': return true;
case 'active': return d.status === 'downloading';
case 'completed': return d.status === 'completed';
case 'unfinished': return d.status !== 'completed';
default: return d.category === filter;
}
});
const getFilterTitle = () => {
if (filter.startsWith('queue:')) {
const qid = filter.replace('queue:', '');
const queue = useDownloadStore.getState().queues.find(q => q.id === qid);
return queue ? queue.name : 'Unknown Queue';
}
switch (filter) {
case 'all': return 'All Downloads';
case 'active': return 'Active';
case 'completed': return 'Completed';
case 'unfinished': return 'Unfinished';
default: return filter;
}
};
const handlePause = async (id: string) => {
try {
await invoke('pause_download', { id });
updateDownload(id, { status: 'paused', speed: '-', eta: '-' });
} catch (e) {
console.error("Failed to pause:", e);
}
};
const handleResume = (item: DownloadItem) => {
useDownloadStore.setState((state) => ({
downloads: state.downloads.map(d => d.id === item.id ? { ...d, status: 'queued', speed: '-', eta: '-' } : d)
}));
useDownloadStore.getState().processQueue();
};
const handleDelete = async (id: string) => {
try {
await removeDownload(id);
} catch (e) {
console.error("Failed to delete download:", e);
}
};
const contextItem = contextMenu ? downloads.find(d => d.id === contextMenu.id) : null;
const getCategoryIcon = (category: string) => {
switch(category) {
case 'Musics': return <Music size={16} className="text-pink-400" />;
case 'Movies': return <Film size={16} className="text-red-400" />;
case 'Documents': return <FileText size={16} className="text-blue-400" />;
case 'Applications': return <Box size={16} className="text-indigo-400" />;
case 'Pictures': return <ImageIcon size={16} className="text-purple-400" />;
case 'Compressed': return <Archive size={16} className="text-amber-600" />;
case 'Other': return <FileQuestion size={16} className="text-gray-400" />;
default: return <FileQuestion size={16} className="text-gray-400" />;
}
}
return (
<div className="downloads-view flex-1 flex flex-col h-full min-w-0">
<div className={`main-titlebar ${!isSidebarVisible ? 'pl-[80px]' : ''}`} data-tauri-drag-region>
{!isSidebarVisible && (
<button
onClick={toggleSidebar}
className="app-icon-button relative z-50 h-7 w-7 mr-2"
title="Show Sidebar"
>
<PanelLeft size={16} strokeWidth={2} />
</button>
)}
<div className="main-titlebar-title cursor-default" data-tauri-drag-region>Firelink</div>
<div className="main-control-group">
<button className="main-control-button primary" onClick={() => toggleAddModal(true)} title="Add Download">
<Plus size={16} />
</button>
<button
className="main-control-button"
disabled={filteredDownloads.length === 0}
onClick={() => {
filteredDownloads.filter(d => d.status === 'paused').forEach(d => handleResume(d));
}}
title="Resume All"
>
<Play size={15} fill="currentColor" />
</button>
<button
className="main-control-button"
disabled={filteredDownloads.length === 0}
onClick={() => {
filteredDownloads.filter(d => d.status === 'downloading').forEach(d => handlePause(d.id));
}}
title="Pause All"
>
<Pause size={15} fill="currentColor" />
</button>
<button
className="main-control-button hover:!text-red-400"
disabled={filteredDownloads.length === 0}
onClick={clearFinished}
title="Clear Finished"
>
<Trash2 size={15} />
</button>
</div>
</div>
<div className="downloads-content-header">
<div className="downloads-title">
{getFilterTitle()}
<span className="downloads-count">{filteredDownloads.length}</span>
</div>
</div>
<div className="downloads-table flex-1 flex flex-col">
{filteredDownloads.length === 0 ? (
<div className="downloads-empty-state">
<ArrowDownCircle aria-hidden="true" />
<div className="downloads-empty-title">No Downloads</div>
<div className="downloads-empty-description flex items-center justify-center mt-2.5 text-[13px] text-text-muted">
Click <Plus size={15} className="text-accent stroke-[3] mx-1.5" /> button or
<span className="flex items-center mx-1.5">
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
{isMac ? <Command size={12} strokeWidth={2.5} className="text-text-primary" /> : <span className="text-[10px] font-bold text-text-primary">Ctrl</span>}
</span>
<span className="text-accent font-bold mx-1.5 text-[14px]">+</span>
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
<span className="text-[11px] font-bold text-text-primary">V</span>
</span>
</span>
to add downloads
</div>
</div>
) : (
<>
<div className="download-table-scroll">
<div className="download-table-header" style={{ gridTemplateColumns: tableGridTemplate }}>
{['File Name', 'Size', 'Status', 'Speed', 'ETA', 'Date Added'].map((label, index) => (
<div key={label} className={index === 5 ? 'download-cell-right' : undefined}>
<span>{label}</span>
<div
className="column-resize-handle"
onPointerDown={(event) => startColumnResize(index, event)}
/>
</div>
))}
</div>
<div className="download-table-body">
<div className="h-full overflow-auto flex flex-col">
{filteredDownloads.map(d => (
<div
key={d.id}
className="download-row group cursor-default relative"
style={{ gridTemplateColumns: tableGridTemplate }}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, id: d.id });
}}
>
<div className="download-file-cell">
<span className="shrink-0 text-text-muted">
{getCategoryIcon(d.category)}
</span>
<span className="download-file-name">
{d.fileName}
</span>
</div>
<div>
<span className="tabular-nums">
{d.size && d.size !== '-' ? d.size : 'Unknown'}
</span>
</div>
<div className="download-status-cell">
{d.status === 'completed' ? (
<span className="download-status download-status-completed">Completed</span>
) : (
<>
<div className="download-progress-track">
<div
className={`download-progress-fill ${d.status === 'paused' ? 'paused' : ''}`}
style={{ width: `${(d.fraction || 0) * 100}%` }}
/>
</div>
<span className={`download-status ${d.status === 'paused' ? 'download-status-paused' : d.status === 'failed' ? 'download-status-failed' : d.status === 'downloading' ? 'download-status-downloading' : ''}`}>
{d.status === 'downloading'
? `${((d.fraction || 0) * 100).toFixed(0)}%`
: d.status.charAt(0).toUpperCase() + d.status.slice(1)}
</span>
</>
)}
</div>
<div>
<span className="tabular-nums">{d.status === 'downloading' ? d.speed : '-'}</span>
</div>
<div>
<span className="tabular-nums">{d.status === 'downloading' ? d.eta : '-'}</span>
</div>
<div className="download-cell-right">
<span className="truncate group-hover:hidden tabular-nums ml-auto">
{d.dateAdded ? new Date(d.dateAdded).toLocaleDateString() : '-'}
</span>
<div className="hidden group-hover:flex items-center justify-end gap-0.5 w-full ml-auto">
{d.status === 'downloading' && (
<button onClick={() => handlePause(d.id)} className="app-icon-button h-7 w-7" title="Pause">
<Pause size={14} fill="currentColor" />
</button>
)}
{d.status === 'paused' && (
<button onClick={() => handleResume(d)} className="app-icon-button h-7 w-7" title="Resume">
<Play size={14} fill="currentColor" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, id: d.id });
}}
className="app-icon-button h-7 w-7"
title="Options"
>
<MoreVertical size={14} />
</button>
</div>
</div>
</div>
))}
<div className="flex-1 overflow-hidden flex flex-col pointer-events-none">
{Array.from({ length: 50 }).map((_, index) => {
const isEven = (filteredDownloads.length + index) % 2 === 1;
return (
<div
key={`ghost-${index}`}
className={`download-ghost-row ${isEven ? 'striped' : ''}`}
/>
);
})}
</div>
</div>
</div>
</div>
</>
)}
</div>
{/* Floating Context Menu */}
{contextMenu && contextItem && (
<div
className="app-modal fixed z-50 min-w-[180px] overflow-hidden py-1.5 text-[12px] font-medium text-text-primary"
style={{
top: Math.min(contextMenu.y, window.innerHeight - 300),
left: Math.min(contextMenu.x, window.innerWidth - 200)
}}
onClick={(e) => e.stopPropagation()}
>
{contextItem.status === 'completed' && (
<button
onClick={async () => {
setContextMenu(null);
try {
const fullPath = await resolvePath(contextItem.destination || '~/Downloads', contextItem.fileName);
await invoke('open_file', { path: fullPath });
} catch (e) {
console.error("Failed to open file:", e);
}
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Open File
</button>
)}
<button
onClick={async () => {
setContextMenu(null);
try {
const fullPath = await resolvePath(contextItem.destination || '~/Downloads', contextItem.fileName);
await invoke('show_in_folder', { path: fullPath });
} catch (e) {
console.error("Failed to show in folder:", e);
}
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Show in Finder
</button>
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
{(contextItem.status === 'downloading' || contextItem.status === 'queued') && (
<button
onClick={() => {
setContextMenu(null);
handlePause(contextItem.id);
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Pause
</button>
)}
{(contextItem.status === 'paused' || contextItem.status === 'failed') && (
<button
onClick={() => {
setContextMenu(null);
handleResume(contextItem);
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Resume
</button>
)}
{['completed', 'failed', 'paused'].includes(contextItem.status) && (
<button
onClick={() => {
setContextMenu(null);
redownload(contextItem.id);
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Redownload
</button>
)}
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
<button
onClick={() => {
setContextMenu(null);
navigator.clipboard.writeText(contextItem.url);
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Copy Address
</button>
{contextItem.status === 'completed' && (
<button
onClick={async () => {
setContextMenu(null);
const fullPath = await resolvePath(contextItem.destination || '~/Downloads', contextItem.fileName);
navigator.clipboard.writeText(fullPath);
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Copy File Path
</button>
)}
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
<button
onClick={() => {
setContextMenu(null);
handleDelete(contextItem.id);
}}
className="w-full text-left px-3 py-2 text-red-400 hover:bg-red-500/10 transition-colors"
>
Remove from List
</button>
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
<button
onClick={() => {
setContextMenu(null);
useDownloadStore.getState().setSelectedPropertiesDownloadId(contextItem.id);
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Properties
</button>
</div>
)}
</div>
);
};
@@ -0,0 +1,68 @@
import { useState } from 'react';
export type DuplicateReason = { type: 'url', msg: string } | { type: 'file', msg: string };
type DuplicateResolution = 'rename' | 'replace' | 'skip';
export interface DuplicateConflict {
id: string; // id of the pending item
fileName: string;
reason: DuplicateReason;
resolution: DuplicateResolution;
}
interface Props {
conflicts: DuplicateConflict[];
onConfirm: (resolutions: { id: string, resolution: DuplicateResolution }[]) => void;
onCancel: () => void;
}
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
const [conflicts, setConflicts] = useState<DuplicateConflict[]>(initialConflicts);
const updateResolution = (id: string, resolution: DuplicateResolution) => {
setConflicts(conflicts.map(c => c.id === id ? { ...c, resolution } : c));
};
return (
<div className="app-modal-backdrop fixed inset-0 z-[60] flex items-center justify-center">
<div className="app-modal w-[500px] flex flex-col overflow-hidden text-sm">
<div className="p-4 border-b border-border-modal flex flex-col gap-2">
<h2 className="text-lg font-semibold text-text-primary">Duplicate Downloads Detected</h2>
<p className="text-xs text-text-muted">Some of the downloads you are adding already exist in the queue or on disk. Please choose how to resolve these conflicts.</p>
</div>
<div className="max-h-[300px] overflow-y-auto p-4 space-y-3">
{conflicts.map(conflict => (
<div key={conflict.id} className="flex items-center justify-between bg-bg-input/50 p-2.5 rounded-lg border border-border-modal/50 gap-4">
<div className="flex flex-col overflow-hidden min-w-0">
<span className="font-medium text-text-primary truncate" title={conflict.fileName}>{conflict.fileName}</span>
<span className="text-[11px] text-orange-400 mt-0.5">{conflict.reason.msg}</span>
</div>
<select
value={conflict.resolution}
onChange={(e) => updateResolution(conflict.id, e.target.value as DuplicateResolution)}
className="app-control w-24 shrink-0 px-2 py-1 text-xs"
>
<option value="rename">Rename</option>
<option value="replace">Replace</option>
<option value="skip">Skip</option>
</select>
</div>
))}
</div>
<div className="p-4 border-t border-border-modal flex items-center justify-between bg-sidebar-bg/50">
<button onClick={onCancel} className="app-button border-transparent bg-transparent px-4 text-xs text-text-secondary">
Cancel
</button>
<button
onClick={() => onConfirm(conflicts.map(c => ({ id: c.id, resolution: c.resolution })))}
className="app-button app-button-primary px-5 text-xs"
>
Continue
</button>
</div>
</div>
</div>
);
};
+355
View File
@@ -0,0 +1,355 @@
import { useState, useEffect } from 'react';
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore';
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
type LoginMode = 'matching' | 'custom' | 'none';
export const PropertiesModal = () => {
const {
selectedPropertiesDownloadId,
setSelectedPropertiesDownloadId,
downloads,
updateDownload
} = useDownloadStore();
const { defaultDownloadPath } = useSettingsStore();
const [item, setItem] = useState<DownloadItem | null>(null);
// Form states
const [url, setUrl] = useState('');
const [fileName, setFileName] = useState('');
const [saveLocation, setSaveLocation] = useState('');
const [connections, setConnections] = useState(16);
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s
const [loginMode, setLoginMode] = useState<LoginMode>('matching');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [advancedExpanded, setAdvancedExpanded] = useState(false);
const [checksumEnabled, setChecksumEnabled] = useState(false);
const [checksumAlgorithm, setChecksumAlgorithm] = useState('SHA-256');
const [checksumValue, setChecksumValue] = useState('');
const [cookies, setCookies] = useState('');
const [headers, setHeaders] = useState('');
const [mirrors, setMirrors] = useState('');
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
if (selectedPropertiesDownloadId) {
const activeItem = downloads.find(d => d.id === selectedPropertiesDownloadId);
if (activeItem) {
setItem(activeItem);
setUrl(activeItem.url);
setFileName(activeItem.fileName);
setSaveLocation(activeItem.destination || defaultDownloadPath || '~/Downloads');
setConnections(activeItem.connections || 16);
if (activeItem.speedLimit) {
setSpeedLimitEnabled(true);
setSpeedLimitValue(activeItem.speedLimit.replace(/[^0-9]/g, ''));
} else {
setSpeedLimitEnabled(false);
}
if (activeItem.username || activeItem.password) {
setLoginMode('custom');
setUsername(activeItem.username || '');
setPassword(activeItem.password || '');
} else {
setLoginMode('matching');
setUsername('');
setPassword('');
}
setHeaders(activeItem.headers || '');
setChecksumEnabled(!!activeItem.checksum);
if (activeItem.checksum) {
const [algo, val] = activeItem.checksum.split('=');
if (val) {
setChecksumAlgorithm(algo);
setChecksumValue(val);
}
} else {
setChecksumAlgorithm('SHA-256');
setChecksumValue('');
}
setCookies(activeItem.cookies || '');
setMirrors(activeItem.mirrors || '');
setErrorMessage('');
} else {
setItem(null);
}
} else {
setItem(null);
}
}, [selectedPropertiesDownloadId, downloads, defaultDownloadPath]);
if (!selectedPropertiesDownloadId || !item) return null;
const handleBrowse = async () => {
if (isLocked) return;
try {
const selected = await open({
directory: true,
multiple: false,
defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation
});
if (selected && typeof selected === 'string') {
setSaveLocation(selected);
}
} catch (e) {
console.error("Failed to select folder:", e);
}
};
const handleSave = () => {
if (!url.trim()) {
setErrorMessage("Enter a valid URL.");
return;
}
if (!fileName.trim()) {
setErrorMessage("File name cannot be empty.");
return;
}
const updates: Partial<DownloadItem> = {
url,
fileName,
destination: saveLocation,
connections: Number(connections),
speedLimit: speedLimitEnabled && speedLimitValue ? `${speedLimitValue}K` : undefined,
username: loginMode === 'custom' ? username.trim() : undefined,
password: loginMode === 'custom' ? password.trim() : undefined,
headers: headers.trim() || undefined,
checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : undefined,
cookies: cookies.trim() || undefined,
mirrors: mirrors.trim() || undefined,
};
updateDownload(item.id, updates);
setSelectedPropertiesDownloadId(null);
};
const isLocked = ['downloading', 'completed'].includes(item.status);
const isTransferLocked = item.status === 'downloading';
let statusColor = 'text-text-secondary';
let StatusIcon = Info;
if (item.status === 'completed') { statusColor = 'text-green-500'; StatusIcon = CheckCircle; }
else if (item.status === 'downloading') { statusColor = 'text-blue-500'; StatusIcon = Play; }
else if (item.status === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; }
else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; }
return (
<div className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center">
<div className="app-modal w-[720px] h-[580px] flex flex-col overflow-hidden text-sm">
{/* Header Summary */}
<div className="p-4 px-5 bg-sidebar-bg/50">
<div className="flex items-center justify-between mb-3">
<h2 className="text-base font-semibold truncate text-text-primary pr-4">{item.fileName}</h2>
<span className={`flex items-center gap-1.5 text-xs font-semibold tracking-wide uppercase ${statusColor}`}>
<StatusIcon size={14} />
{item.status}
</span>
</div>
<div className="w-full bg-border-color rounded-full h-1.5 overflow-hidden mb-4">
<div className={`h-1.5 rounded-full transition-all duration-300 ${item.status === 'completed' ? 'bg-green-500' : item.status === 'paused' ? 'bg-orange-500' : item.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${(item.status === 'completed' ? 1 : item.fraction || 0) * 100}%` }}></div>
</div>
<div className="grid grid-cols-4 gap-y-2 gap-x-4 text-[11px] leading-tight">
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[90px]">Progress</span><span className="text-text-secondary truncate">{item.status === 'completed' ? '100%' : ((item.fraction || 0) * 100).toFixed(0) + '%'}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[40px]">Size</span><span className="text-text-secondary truncate">-</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[40px]">Speed</span><span className="text-text-secondary truncate">{item.status === 'completed' ? '-' : item.speed || '-'}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[30px]">ETA</span><span className="text-text-secondary truncate">{item.status === 'completed' ? '-' : item.eta || '-'}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[90px]">Live connections</span><span className="text-text-secondary truncate">-</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[60px]">Speed cap</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[55px]">Category</span><span className="text-text-secondary truncate">{item.category}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">Last try</span><span className="text-text-secondary truncate">-</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">Date added</span><span className="text-text-secondary truncate">{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">Destination</span><span className="text-text-secondary truncate" title={item.destination}>{item.destination || defaultDownloadPath}</span></div>
</div>
</div>
<div className="h-[1px] bg-border-modal w-full shrink-0"></div>
{/* Scrollable Form Content */}
<div className="flex-1 overflow-y-auto bg-main-bg/30 p-5 space-y-7">
{isLocked && (
<div className="flex gap-2.5 items-center text-xs text-text-secondary bg-border-color/30 p-3 rounded-md border border-border-modal">
{item.status === 'completed' ? <CheckCircle size={16} className="text-green-500" /> : <AlertCircle size={16} className="text-blue-500" />}
<span>
{item.status === 'completed'
? "File identity is read-only. Transfer settings are saved for redownload."
: "Only the speed limit applies to the current transfer. Other settings can be changed after stopping or pausing."}
</span>
</div>
)}
{/* Download Section */}
<section>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">Download</h3>
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
<label className="text-xs text-text-muted text-right">URL</label>
<input type="text" value={url} onChange={e => setUrl(e.target.value)} disabled={isLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
<label className="text-xs text-text-muted text-right">File name</label>
<input type="text" value={fileName} onChange={e => setFileName(e.target.value)} disabled={isLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
<label className="text-xs text-text-muted text-right">Save location</label>
<div className="flex gap-2">
<input type="text" value={saveLocation} readOnly disabled={isLocked} className="flex-1 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
<button onClick={handleBrowse} disabled={isLocked} className="bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal px-3 py-1.5 rounded text-xs transition-colors disabled:opacity-40 flex items-center gap-1.5">
<FolderPlus size={14} /> Select
</button>
</div>
<label className="text-xs text-text-muted text-right">Connections</label>
<div className="flex items-center gap-2">
<input type="number" value={connections} min={1} max={16} onChange={e=>setConnections(Number(e.target.value))} disabled={isTransferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
<span className="text-xs text-text-muted">per file</span>
</div>
<label className="text-xs text-text-muted text-right">Speed</label>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-xs text-text-primary">
<input type="checkbox" checked={speedLimitEnabled} onChange={e => setSpeedLimitEnabled(e.target.checked)} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input" />
Limit
</label>
{speedLimitEnabled && (
<div className="flex items-center gap-2">
<input type="number" value={speedLimitValue} min={1} step={128} onChange={e=>setSpeedLimitValue(e.target.value)} className="w-20 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent" />
<span className="text-xs text-text-muted">KiB/s</span>
</div>
)}
</div>
</div>
</section>
{/* Site Login Section */}
<section>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
{item.status === 'completed' ? 'Site Login for Redownload' : 'Site Login'}
</h3>
<div className="flex gap-1 p-1 bg-border-color rounded-lg mb-4 w-fit mx-auto md:mx-0">
{(['matching', 'custom', 'none'] as const).map((mode) => (
<button
key={mode}
onClick={() => !isTransferLocked && setLoginMode(mode)}
disabled={isTransferLocked}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${loginMode === mode ? 'bg-bg-modal text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}
>
{mode === 'matching' ? 'Matching site login' : mode === 'custom' ? 'Custom credentials' : 'No login'}
</button>
))}
</div>
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
{loginMode === 'matching' && (
<div className="col-start-2 text-xs text-text-secondary italic">
Will use saved login if available.
</div>
)}
{loginMode === 'custom' && (
<>
<label className="text-xs text-text-muted text-right">Username</label>
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} disabled={isTransferLocked} placeholder="Username" className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
<label className="text-xs text-text-muted text-right">Password</label>
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} disabled={isTransferLocked} placeholder="Password" className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
</>
)}
</div>
</section>
{/* Advanced Transfer Section */}
<section>
<button
onClick={() => setAdvancedExpanded(!advancedExpanded)}
className="flex items-center gap-2 text-sm font-semibold text-text-primary w-full pb-1 border-b border-border-modal/50 hover:text-blue-400 transition-colors"
>
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
{item.status === 'completed' ? 'Advanced Transfer for Redownload' : 'Advanced Transfer'}
</button>
{advancedExpanded && (
<div className="mt-4 grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center pl-6">
<label className="text-xs text-text-muted text-right">Checksum</label>
<label className="flex items-center gap-2 text-xs text-text-primary">
<input type="checkbox" checked={checksumEnabled} onChange={e => setChecksumEnabled(e.target.checked)} disabled={isTransferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input" />
Verify
</label>
{checksumEnabled && (
<>
<label className="text-xs text-text-muted text-right">Algorithm</label>
<select value={checksumAlgorithm} onChange={e=>setChecksumAlgorithm(e.target.value)} disabled={isTransferLocked} className="max-w-[150px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50">
<option value="MD5">MD5</option>
<option value="SHA-1">SHA-1</option>
<option value="SHA-256">SHA-256</option>
<option value="SHA-512">SHA-512</option>
</select>
<label className="text-xs text-text-muted text-right">Digest</label>
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} disabled={isTransferLocked} placeholder="Expected digest" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
</>
)}
<label className="text-xs text-text-muted text-right">Cookies</label>
<input type="text" value={cookies} onChange={e=>setCookies(e.target.value)} disabled={isTransferLocked} placeholder="Cookies" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
<div className="col-span-2 mt-2">
<label className="block text-xs text-text-muted mb-1.5">Headers</label>
<textarea value={headers} onChange={e=>setHeaders(e.target.value)} disabled={isTransferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
</div>
<div className="col-span-2">
<label className="block text-xs text-text-muted mb-1.5">Mirrors</label>
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} disabled={isTransferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
</div>
</div>
)}
</section>
</div>
<div className="h-[1px] bg-border-modal w-full shrink-0"></div>
{/* Footer */}
<div className="p-3 px-4 bg-sidebar-bg flex items-center justify-between shrink-0">
<div className="text-red-500 text-xs truncate max-w-[400px]">
{errorMessage}
</div>
<div className="flex gap-2">
<button
onClick={() => setSelectedPropertiesDownloadId(null)}
className="app-button border-transparent bg-transparent px-4 text-xs text-text-secondary"
>
Cancel
</button>
<button
onClick={handleSave}
className="app-button app-button-primary px-4 text-xs"
>
<CheckCircle size={14} />
Save
</button>
</div>
</div>
</div>
</div>
);
};
+262
View File
@@ -0,0 +1,262 @@
import { useEffect, useMemo, useState } from 'react';
import { invokeCommand as invoke } from '../ipc';
import {
CheckCircle2, Clock3, List, Moon, LockKeyhole,
Pause, Play, Power, RotateCcw, Save
} from 'lucide-react';
import { PostQueueAction, SchedulerSettings, useSettingsStore } from '../store/useSettingsStore';
import { useDownloadStore, MAIN_QUEUE_ID } from '../store/useDownloadStore';
import { WindowDragRegion } from './WindowDragRegion';
const days = [
{ value: 0, label: 'Su' },
{ value: 1, label: 'Mo' },
{ value: 2, label: 'Tu' },
{ value: 3, label: 'We' },
{ value: 4, label: 'Th' },
{ value: 5, label: 'Fr' },
{ value: 6, label: 'Sa' },
];
const postActions: { value: PostQueueAction; label: string; icon: typeof Moon }[] = [
{ value: 'none', label: 'Do nothing', icon: CheckCircle2 },
{ value: 'sleep', label: 'Sleep', icon: Moon },
{ value: 'restart', label: 'Restart', icon: RotateCcw },
{ value: 'shutdown', label: 'Shut down', icon: Power },
];
function nextScheduledRun(settings: SchedulerSettings): string {
if (!settings.enabled) return 'Scheduler is disabled';
const [hour, minute] = settings.startTime.split(':').map(Number);
const now = new Date();
for (let offset = 0; offset < 8; offset += 1) {
const candidate = new Date(now);
candidate.setDate(now.getDate() + offset);
candidate.setHours(hour, minute, 0, 0);
const allowedDay = settings.everyday || settings.selectedDays.includes(candidate.getDay());
if (allowedDay && candidate > now) {
return candidate.toLocaleString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
}
}
return 'No scheduled day selected';
}
export default function SchedulerView() {
const savedSettings = useSettingsStore(state => state.scheduler);
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const setScheduler = useSettingsStore(state => state.setScheduler);
const [draft, setDraft] = useState<SchedulerSettings>(savedSettings);
const [toast, setToast] = useState('');
const [permissionMessage, setPermissionMessage] = useState('');
const isMac = navigator.userAgent.includes('Mac');
useEffect(() => {
setDraft(savedSettings);
}, [savedSettings]);
useEffect(() => {
if (!toast) return;
const timeout = window.setTimeout(() => setToast(''), 2200);
return () => window.clearTimeout(timeout);
}, [toast]);
const nextRun = useMemo(() => nextScheduledRun(draft), [draft]);
const updateDraft = <K extends keyof SchedulerSettings>(key: K, value: SchedulerSettings[K]) => {
setDraft(current => ({ ...current, [key]: value }));
};
const toggleDay = (day: number) => {
setDraft(current => ({
...current,
selectedDays: current.selectedDays.includes(day)
? current.selectedDays.filter(value => value !== day)
: [...current.selectedDays, day].sort()
}));
};
const save = () => {
const normalized = {
...draft,
selectedDays: draft.everyday || draft.selectedDays.length > 0
? draft.selectedDays
: savedSettings.selectedDays
};
setScheduler(normalized);
setDraft(normalized);
setToast('Scheduler settings saved');
};
const runNow = async () => {
const count = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID);
if (count > 0) {
useSettingsStore.getState().setSchedulerRunning(true);
setToast(`Started ${count} download${count === 1 ? '' : 's'}`);
} else {
setToast('No paused or failed downloads to start');
}
};
const pauseNow = async () => {
const count = await useDownloadStore.getState().pauseQueue(MAIN_QUEUE_ID);
useSettingsStore.getState().setSchedulerRunning(false);
setToast(count > 0 ? `Paused ${count} active download${count === 1 ? '' : 's'}` : 'No active downloads');
};
const requestPermission = async () => {
setPermissionMessage('Requesting permission...');
try {
await invoke('request_automation_permission');
setPermissionMessage('Automation permission is available.');
} catch (error) {
setPermissionMessage(String(error));
}
};
return (
<div className="flex-1 flex h-full flex-col overflow-hidden bg-main-bg">
<WindowDragRegion />
<div className="flex items-center gap-3 border-b border-border-color px-6 pb-4">
<label className="flex items-center gap-3 text-[17px] font-semibold tracking-tight text-text-primary">
<input
type="checkbox"
checked={draft.enabled}
onChange={event => updateDraft('enabled', event.target.checked)}
className="h-4 w-4 accent-accent"
/>
Scheduler
</label>
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
schedulerRunning ? 'bg-green-500/15 text-green-500' : 'bg-item-hover text-text-muted'
}`}>
{schedulerRunning ? 'Running' : nextRun}
</span>
<div className="ml-auto flex gap-2">
<button onClick={runNow} className="app-button px-3 text-[11px]">
<Play size={14} /> Run Now
</button>
<button onClick={pauseNow} className="app-button px-3 text-[11px]">
<Pause size={14} /> Pause
</button>
<button onClick={save} className="app-button app-button-primary px-3 text-[11px]">
<Save size={14} /> Save Settings
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className={`max-w-[760px] space-y-4 ${draft.enabled ? '' : 'opacity-50'}`}>
<section className="app-card p-5">
<div className="mb-5 flex items-center gap-2 font-semibold text-text-primary">
<Clock3 size={17} className="text-accent" /> Timing
</div>
<div className="flex flex-wrap items-end gap-8">
<label className="space-y-2 text-[12px] text-text-secondary">
<span className="block">Start Time</span>
<input type="time" value={draft.startTime} onChange={event => updateDraft('startTime', event.target.value)} disabled={!draft.enabled} className="app-control px-3 py-2 text-text-primary" />
</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-[12px] text-text-secondary">
<input type="checkbox" checked={draft.stopTimeEnabled} onChange={event => updateDraft('stopTimeEnabled', event.target.checked)} disabled={!draft.enabled} className="accent-accent" />
Stop Time
</label>
<input type="time" value={draft.stopTime} onChange={event => updateDraft('stopTime', event.target.value)} disabled={!draft.enabled || !draft.stopTimeEnabled} className="app-control px-3 py-2 text-text-primary disabled:opacity-50" />
</div>
</div>
<div className="my-5 border-t border-border-color" />
<label className="flex items-center gap-2 text-[13px] font-medium text-text-primary">
<input type="checkbox" checked={draft.everyday} onChange={event => updateDraft('everyday', event.target.checked)} disabled={!draft.enabled} className="accent-accent" />
Run Every Day
</label>
{!draft.everyday && (
<div className="mt-4 flex gap-2">
{days.map(day => {
const selected = draft.selectedDays.includes(day.value);
return (
<button
key={day.value}
type="button"
disabled={!draft.enabled}
onClick={() => toggleDay(day.value)}
className={`h-8 w-8 rounded-full text-[12px] font-semibold ${
selected ? 'bg-accent text-white' : 'bg-bg-input text-text-primary hover:bg-item-hover'
}`}
>
{day.label}
</button>
);
})}
</div>
)}
</section>
<section className="app-card p-5">
<div className="mb-4 flex items-center gap-2 font-semibold text-text-primary">
<List size={17} className="text-accent" /> Queues to Schedule
</div>
<label className="flex items-center gap-3 text-[13px] text-text-primary">
<input type="checkbox" checked readOnly disabled={!draft.enabled} className="accent-accent" />
Main Queue
<span className="text-[11px] text-text-muted">All paused and failed downloads</span>
</label>
</section>
<section className="app-card p-5">
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<Power size={17} className="text-accent" /> After Completion
</div>
<p className="mb-4 text-[12px] text-text-muted">Choose what happens after downloads started by the scheduler finish.</p>
<div className="grid grid-cols-2 gap-2">
{postActions.map(action => {
const Icon = action.icon;
return (
<label key={action.value} className={`flex items-center gap-3 rounded-lg border p-3 text-[13px] ${
draft.postQueueAction === action.value ? 'border-accent bg-item-selected text-text-primary' : 'border-border-modal text-text-secondary'
}`}>
<input type="radio" name="post-action" checked={draft.postQueueAction === action.value} onChange={() => updateDraft('postQueueAction', action.value)} disabled={!draft.enabled} className="accent-accent" />
<Icon size={15} />
{action.label}
</label>
);
})}
</div>
{draft.postQueueAction !== 'none' && (
<p className="mt-3 text-[11px] text-orange-400">This action can interrupt other work on the computer. Firelink invokes it immediately after the scheduled queue finishes.</p>
)}
</section>
</div>
{isMac && (
<section className="app-card mt-4 max-w-[760px] p-5">
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<LockKeyhole size={17} className="text-accent" /> System Permissions
</div>
<p className="mb-4 text-[12px] text-text-muted">Sleep, restart, and shut down require macOS Automation permission for Finder.</p>
<div className="flex gap-2">
<button onClick={requestPermission} className="app-button app-button-primary px-3 text-[11px]">Grant Permission</button>
<button onClick={() => invoke('open_automation_settings')} className="app-button px-3 text-[11px]">Open Settings</button>
</div>
{permissionMessage && <p className="mt-3 text-[11px] text-text-muted">{permissionMessage}</p>}
</section>
)}
</div>
{toast && (
<div className="app-toast pointer-events-none absolute bottom-7 left-1/2 -translate-x-1/2 px-4 py-2 text-[12px] font-medium">
{toast}
</div>
)}
</div>
);
}
+914
View File
@@ -0,0 +1,914 @@
import { useState, useEffect } from 'react';
import {
type AppFontSize,
type ListRowDensity,
SettingsTab,
useSettingsStore
} from '../store/useSettingsStore';
import {
Download, Palette, Globe, Folder, Key,
Moon, Terminal, Puzzle, Info, Plus, Trash2, Copy, RefreshCw, Code
} from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import { getVersion } from '@tauri-apps/api/app';
import { invokeCommand as invoke } from '../ipc';
import { WindowDragRegion } from './WindowDragRegion';
import appIcon from '../assets/app-icon.png';
const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [
{ type: 'downloads', label: 'Downloads', icon: Download },
{ type: 'lookandfeel', label: 'Look and feel', icon: Palette },
{ type: 'network', label: 'Network', icon: Globe },
{ type: 'locations', label: 'Locations', icon: Folder },
{ type: 'sitelogins', label: 'Site Logins', icon: Key },
{ type: 'power', label: 'Power', icon: Moon },
{ type: 'engine', label: 'Engine', icon: Terminal },
{ type: 'integrations', label: 'Integrations', icon: Puzzle },
{ type: 'about', label: 'About', icon: Info },
];
export default function SettingsView() {
const settings = useSettingsStore();
const activeTab = settings.activeSettingsTab;
// Local state for versions
const [aria2Version, setAria2Version] = useState<string>('Checking...');
const [ytdlpVersion, setYtdlpVersion] = useState<string>('Checking...');
const [ffmpegVersion, setFfmpegVersion] = useState<string>('Checking...');
const [denoVersion, setDenoVersion] = useState<string>('Checking...');
const [appVersion, setAppVersion] = useState('0.7.3');
const getEngineStatus = (v: string) => {
if (v === 'Checking...') return <span className="text-text-muted font-medium">Checking...</span>;
if (v.startsWith('Error')) return <span className="text-red-500 font-medium">Error / Missing</span>;
return <span className="text-green-500 font-medium">Ready</span>;
};
// Local state for adding site login
const [loginPattern, setLoginPattern] = useState('');
const [loginUser, setLoginUser] = useState('');
const [loginPass, setLoginPass] = useState('');
const [loginError, setLoginError] = useState('');
// Toast notifications
const [toastMessage, setToastMessage] = useState('');
const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false);
useEffect(() => {
if (toastMessage) {
const t = setTimeout(() => setToastMessage(''), 2000);
return () => clearTimeout(t);
}
}, [toastMessage]);
useEffect(() => {
getVersion().then(setAppVersion).catch(() => undefined);
}, []);
// Fetch engine versions when Engine tab is opened
useEffect(() => {
if (settings.activeView === 'settings' && activeTab === 'engine') {
invoke('test_aria2c')
.then(v => setAria2Version(v))
.catch(e => setAria2Version('Error: ' + e));
invoke('test_ytdlp')
.then(v => setYtdlpVersion(v))
.catch(e => setYtdlpVersion('Error: ' + e));
invoke('test_ffmpeg')
.then(v => setFfmpegVersion(v))
.catch(e => setFfmpegVersion('Error: ' + e));
invoke('test_deno')
.then(v => setDenoVersion(v))
.catch(e => setDenoVersion('Error: ' + e));
}
}, [settings.activeView, activeTab]);
const showToast = (msg: string) => {
setToastMessage(msg);
};
const handleCheckForUpdates = async () => {
if (isCheckingForUpdates) return;
setIsCheckingForUpdates(true);
showToast('Checking for updates...');
try {
const result = await invoke('check_for_updates');
if (result.type === 'UpToDate') {
showToast(`Firelink ${result.latest_version} is up to date`);
} else if (result.type === 'UpdateAvailable') {
showToast(`Firelink ${result.update.version} is available`);
} else {
showToast('The update check returned an unexpected response');
}
} catch (error) {
showToast(`Update check failed: ${String(error)}`);
} finally {
setIsCheckingForUpdates(false);
}
};
const handleBrowseCategory = async (category: string) => {
const currentPath = (settings.downloadDirectories || {})[category] || '';
try {
const selected = await open({
directory: true,
multiple: false,
defaultPath: currentPath.startsWith('~') ? undefined : currentPath
});
if (selected && typeof selected === 'string') {
settings.setCategoryDirectory(category, selected);
}
} catch (e) {
console.error(`Failed to select folder for ${category}:`, e);
}
};
const handleBrowseBulk = async () => {
try {
const base = await open({
directory: true,
multiple: false
});
if (base && typeof base === 'string') {
const cleanBase = base.replace(/\/$/, '');
settings.setCategoryDirectory('Musics', `${cleanBase}/Musics`);
settings.setCategoryDirectory('Movies', `${cleanBase}/Movies`);
settings.setCategoryDirectory('Compressed', `${cleanBase}/Compressed`);
settings.setCategoryDirectory('Documents', `${cleanBase}/Documents`);
settings.setCategoryDirectory('Pictures', `${cleanBase}/Pictures`);
settings.setCategoryDirectory('Applications', `${cleanBase}/Applications`);
settings.setCategoryDirectory('Other', `${cleanBase}/Other`);
showToast("Updated all categories to use base folder");
}
} catch (e) {
console.error("Failed to browse base path:", e);
}
};
const handleAddLogin = async () => {
if (!loginPattern.trim() || !loginUser.trim()) {
setLoginError("Please enter a URL pattern and a username.");
return;
}
const id = crypto.randomUUID();
if (loginPass) {
try {
await invoke('set_keychain_password', { id, password: loginPass });
} catch (e) {
console.error("Failed to save password to keychain:", e);
setLoginError("Failed to save password securely.");
return;
}
}
settings.addSiteLogin({
id,
urlPattern: loginPattern.trim(),
username: loginUser.trim()
});
setLoginPattern('');
setLoginUser('');
setLoginPass('');
setLoginError('');
showToast("Added site credential");
};
const copyToken = () => {
navigator.clipboard.writeText(settings.extensionPairingToken);
showToast("Token copied to clipboard!");
};
const activeTabLabel = settingsTabs.find(tab => tab.type === activeTab)?.label ?? 'Downloads';
const TabButton = ({ type, icon: Icon, label }: { type: SettingsTab; icon: typeof Download; label: string }) => {
const active = activeTab === type;
return (
<button
type="button"
data-active={active}
onClick={() => settings.setActiveSettingsTab(type)}
className={`settings-tab-button flex min-w-0 flex-1 flex-col items-center justify-center px-1 text-center cursor-default ${
active
? 'text-white'
: 'text-text-primary hover:bg-item-hover'
}`}
>
<Icon size={16} strokeWidth={2} />
<span className="settings-tab-label mt-1 w-full whitespace-nowrap font-medium">{label}</span>
</button>
);
};
return (
<div className="settings-view flex-1 flex flex-col relative h-full overflow-hidden">
<WindowDragRegion />
{/* Toast Notification */}
{toastMessage && (
<div className="app-toast absolute top-4 left-1/2 -translate-x-1/2 z-50 px-4 py-2 text-[12px] font-medium">
{toastMessage}
</div>
)}
{/* SwiftUI SettingsPaneContainer-style horizontal tab strip */}
<div className="settings-toolbar">
<div className="settings-tab-strip flex items-stretch gap-1">
{settingsTabs.map(tab => (
<TabButton key={tab.type} {...tab} />
))}
</div>
</div>
{/* Content Area */}
<div className="settings-scroll flex-1 overflow-y-auto">
<div className="settings-content-shell w-full">
<h1 className="settings-title text-text-primary">{activeTabLabel}</h1>
<div className="settings-content max-w-[720px]">
{/* Downloads Pane */}
{activeTab === 'downloads' && (
<div className="settings-pane max-w-[720px]">
<div className="mac-settings-group">
<div className="mac-settings-row">
<div className="settings-row-label">
<span>Default connections:</span>
<small>For new downloads</small>
</div>
<input
type="number" min="1" max="16"
value={settings.perServerConnections}
onChange={(e) => settings.setPerServerConnections(Number(e.target.value))}
className="app-control w-24 text-center"
/>
</div>
<div className="mac-settings-row">
<div className="settings-row-label">
<span>Parallel downloads:</span>
<small>Max simultaneous active files</small>
</div>
<input
type="number" min="1" max="12"
value={settings.maxConcurrentDownloads}
onChange={(e) => settings.setMaxConcurrentDownloads(Number(e.target.value))}
className="app-control w-24 text-center"
/>
</div>
<div className="mac-settings-row">
<div className="settings-row-label">
<span>Global speed limit:</span>
<small>0 = unlimited speed</small>
</div>
<div className="relative">
<input
type="text"
value={settings.globalSpeedLimit}
onChange={(e) => settings.setGlobalSpeedLimit(e.target.value)}
placeholder="0"
className="app-control w-24 text-center font-mono pr-9"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-text-muted pointer-events-none">KiB/s</span>
</div>
</div>
<div className="mac-settings-row">
<div className="settings-row-label">
<span>Automatic retries:</span>
<small>If a connection fails</small>
</div>
<input
type="number" min="0" max="10"
value={settings.maxAutomaticRetries}
onChange={(e) => settings.setMaxAutomaticRetries(Number(e.target.value))}
className="app-control w-24 text-center"
/>
</div>
</div>
<div className="mac-settings-group">
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>Show notification when download completes</span>
<small>Alerts you in Notification Center</small>
</div>
<input
type="checkbox"
checked={settings.showNotifications}
onChange={(e) => settings.setShowNotifications(e.target.checked)}
className="mac-switch"
/>
</label>
<label className="mac-settings-row cursor-default" style={{ opacity: settings.showNotifications ? 1 : 0.5 }}>
<span className="text-[13px] text-text-primary">Play sound when download completes</span>
<input
type="checkbox"
checked={settings.playCompletionSound}
disabled={!settings.showNotifications}
onChange={(e) => settings.setPlayCompletionSound(e.target.checked)}
className="mac-switch"
/>
</label>
</div>
</div>
)}
{/* Look & Feel Pane */}
{activeTab === 'lookandfeel' && (
<div className="settings-pane max-w-[720px]">
<h2 className="settings-section-title">App Theme</h2>
<div className="mac-settings-group">
<div className="mac-settings-row settings-choice-row">
<span className="text-[13px] text-text-primary pt-0.5">Theme</span>
<div className="theme-option-grid" role="radiogroup" aria-label="App theme">
{[
{ value: 'system', label: 'System', colors: ['#f4f4f5', '#252525'] },
{ value: 'light', label: 'Light', colors: ['#ffffff', '#e9e9ec'] },
{ value: 'dark', label: 'Dark', colors: ['#1a1a1a', '#292929'] },
{ value: 'dracula', label: 'Dracula', colors: ['#282a36', '#ff79c6'] },
{ value: 'nord', label: 'Nord', colors: ['#2e3440', '#88c0d0'] },
].map(({ value, label, colors }) => (
<label
key={value}
className="theme-option"
data-active={settings.theme === value}
>
<input
type="radio"
name="app-theme"
checked={settings.theme === value}
onChange={() => settings.setTheme(value as typeof settings.theme)}
/>
<span className="theme-option-preview" aria-hidden="true">
<span style={{ background: colors[0] }} />
<span style={{ background: colors[1] }} />
</span>
<span>{label}</span>
</label>
))}
</div>
</div>
<p className="settings-group-footer">Select a color palette for the app's user interface.</p>
</div>
<h2 className="settings-section-title">Display</h2>
<div className="mac-settings-group">
<div className="mac-settings-row">
<span className="text-[13px] text-text-primary">Font Size</span>
<select
value={settings.appFontSize}
onChange={(e) => settings.setAppFontSize(e.target.value as AppFontSize)}
className="app-control w-40"
>
<option value="small">Small</option>
<option value="standard">Standard</option>
<option value="large">Large</option>
</select>
</div>
<div className="mac-settings-row">
<span className="text-[13px] text-text-primary">List Row Density</span>
<select
value={settings.listRowDensity}
onChange={(e) => settings.setListRowDensity(e.target.value as ListRowDensity)}
className="app-control w-40"
>
<option value="compact">Compact</option>
<option value="standard">Standard</option>
<option value="relaxed">Relaxed</option>
</select>
</div>
</div>
<h2 className="settings-section-title">macOS Integration</h2>
<div className="mac-settings-group">
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>Show badge on Dock icon</span>
<small>Displays the number of active downloads on the Firelink Dock icon.</small>
</div>
<input
type="checkbox"
checked={settings.showDockBadge}
onChange={(e) => settings.setShowDockBadge(e.target.checked)}
className="mac-switch"
/>
</label>
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>Show menu bar icon</span>
<small>Provides quick access to downloads and queues from the macOS menu bar.</small>
</div>
<input
type="checkbox"
checked={settings.showMenuBarIcon}
onChange={(e) => settings.setShowMenuBarIcon(e.target.checked)}
className="mac-switch"
/>
</label>
</div>
</div>
)}
{/* Network Pane */}
{activeTab === 'network' && (
<div className="settings-pane max-w-[720px]">
<h2 className="settings-section-title">Proxy</h2>
<div className="mac-settings-group">
<div className="mac-settings-row settings-choice-row">
<span className="text-[13px] text-text-primary pt-0.5">Mode</span>
<div className="settings-radio-group">
{[
['none', 'No Proxy'],
['system', 'Use System Proxy'],
['custom', 'Custom Proxy'],
].map(([value, label]) => (
<label key={value}>
<input
type="radio"
name="proxy-mode"
checked={settings.proxyMode === value}
onChange={() => settings.setProxyMode(value as typeof settings.proxyMode)}
/>
<span>{label}</span>
</label>
))}
</div>
</div>
{settings.proxyMode === 'custom' && (
<>
<div className="mac-settings-row">
<span className="text-[13px] text-text-primary pl-4">Proxy Host</span>
<input
type="text"
value={settings.proxyHost}
onChange={(e) => settings.setProxyHost(e.target.value)}
placeholder="127.0.0.1"
className="app-control w-40 font-mono"
/>
</div>
<div className="mac-settings-row">
<span className="text-[13px] text-text-primary pl-4">Proxy Port</span>
<input
type="number"
value={settings.proxyPort}
onChange={(e) => settings.setProxyPort(Number(e.target.value))}
className="app-control w-24 text-center"
/>
</div>
</>
)}
<p className="settings-group-footer">
{settings.proxyMode === 'none' && 'Downloads ignore configured proxies.'}
{settings.proxyMode === 'system' && 'Downloads use the matching macOS system proxy when one is configured.'}
{settings.proxyMode === 'custom' && (settings.proxyHost
? `Downloads use http://${settings.proxyHost}:${settings.proxyPort}.`
: 'Enter a proxy host and port to enable the custom proxy.')}
</p>
</div>
<h2 className="settings-section-title">Identity</h2>
<div className="mac-settings-group">
<div className="mac-settings-row">
<span className="text-[13px] text-text-primary">Custom User Agent</span>
<div className="flex-1 ml-4 relative">
<input
type="text"
list="user-agents"
value={settings.customUserAgent}
onChange={(e) => settings.setCustomUserAgent(e.target.value)}
placeholder="e.g. Mozilla/5.0..."
className="app-control w-full font-mono text-[11px]"
/>
<datalist id="user-agents">
<option value="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36">Chrome (Windows)</option>
<option value="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36">Chrome (macOS)</option>
<option value="Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0">Firefox (Windows)</option>
<option value="Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0">Firefox (macOS)</option>
<option value="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15">Safari (macOS)</option>
</datalist>
</div>
</div>
<p className="settings-group-footer">Spoofs the browser User-Agent to bypass download restrictions. Leave blank for default.</p>
</div>
</div>
)}
{/* Locations Pane */}
{activeTab === 'locations' && (
<div className="settings-pane max-w-[760px]">
<div className="mac-settings-group">
<label className="mac-settings-row cursor-default">
<span className="text-[13px] text-text-primary">Ask where to save each file</span>
<input
type="checkbox"
checked={settings.askWhereToSaveEachFile}
onChange={(e) => settings.setAskWhereToSaveEachFile(e.target.checked)}
className="mac-switch"
/>
</label>
</div>
<div className="mac-settings-group">
<div className="mac-settings-row bg-item-hover/20">
<span className="text-[13px] font-semibold text-text-primary">All Categories Base</span>
<div className="flex gap-2">
<input
type="text" readOnly placeholder="Choose base folder..."
className="app-control w-64 text-text-muted text-[11px] px-2"
/>
<button
onClick={handleBrowseBulk}
className="app-button px-3 text-xs font-semibold text-accent border border-accent/20 bg-accent/10 hover:bg-accent/20"
>
Browse
</button>
</div>
</div>
{['Musics', 'Movies', 'Compressed', 'Documents', 'Pictures', 'Applications', 'Other'].map((category) => (
<div key={category} className="mac-settings-row">
<span className="text-[13px] text-text-primary pl-4">{category}</span>
<div className="flex gap-2">
<input
type="text"
value={(settings.downloadDirectories || {})[category] || ''}
onChange={(e) => settings.setCategoryDirectory(category, e.target.value)}
className="app-control w-64 text-[11px] px-2"
/>
<button
onClick={() => handleBrowseCategory(category)}
className="app-button px-3 text-xs text-text-secondary hover:bg-item-hover"
>
Browse
</button>
</div>
</div>
))}
<div className="mac-settings-row justify-end border-t-0">
<button
onClick={() => {
settings.resetCategoryDirectories();
showToast("Reset directories to default");
}}
className="app-control hover:bg-item-hover text-text-secondary px-4 py-1"
>
Reset Defaults
</button>
</div>
</div>
</div>
)}
{/* Site Logins Pane */}
{activeTab === 'sitelogins' && (
<div className="settings-pane space-y-6 max-w-[760px]">
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Site Credentials</h3>
{/* Site Logins List */}
<div className="space-y-2 max-h-[200px] overflow-y-auto border border-border-modal rounded-lg p-2 bg-item-hover/10">
{(settings.siteLogins || []).length === 0 ? (
<p className="text-center text-text-muted text-[13px] py-6">No saved logins.</p>
) : (
(settings.siteLogins || []).map((login) => (
<div key={login.id} className="flex justify-between items-center p-2 rounded bg-bg-modal border border-border-modal/40">
<div className="text-[13px] space-y-0.5">
<p className="font-bold text-text-primary font-mono text-[11px]">{login.urlPattern}</p>
<p className="text-text-secondary text-xs">User: {login.username}</p>
</div>
<button
onClick={async () => {
try {
await invoke('delete_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not delete password from keychain:", e);
}
settings.removeSiteLogin(login.id);
showToast("Deleted credential");
}}
className="p-1.5 hover:bg-item-hover rounded-md text-text-muted hover:text-red-500"
title="Delete credential"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
{/* Add Site Login Form */}
<div className="border-t border-border-color/30 pt-4 space-y-4">
<h4 className="text-[13px] font-bold text-text-primary">Add Site Credentials</h4>
{loginError && (
<p className="text-red-500 text-xs">{loginError}</p>
)}
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
<label className="text-text-secondary">URL Pattern:</label>
<input
type="text"
value={loginPattern}
onChange={(e) => setLoginPattern(e.target.value)}
placeholder="e.g. *.example.com or example.com/downloads"
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
/>
</div>
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
<label className="text-text-secondary">Username:</label>
<input
type="text"
value={loginUser}
onChange={(e) => setLoginUser(e.target.value)}
placeholder="Username"
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
/>
</div>
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
<label className="text-text-secondary">Password:</label>
<input
type="password"
value={loginPass}
onChange={(e) => setLoginPass(e.target.value)}
placeholder="Password"
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
/>
</div>
<div className="flex justify-end pt-2">
<button
onClick={handleAddLogin}
className="bg-accent hover:bg-accent text-white px-4 py-1.5 rounded-lg text-xs font-semibold shadow flex items-center gap-1.5"
>
<Plus size={14} /> Add Login
</button>
</div>
</div>
</div>
)}
{/* Power Pane */}
{activeTab === 'power' && (
<div className="settings-pane space-y-6 max-w-[760px]">
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Power Management</h3>
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary">
<input
type="checkbox"
checked={settings.preventsSleepWhileDownloading}
onChange={(e) => settings.setPreventsSleepWhileDownloading(e.target.checked)}
className="mt-0.5 rounded accent-accent"
/>
<div>
<p className="font-semibold text-text-primary">Prevent system sleep while downloads are active</p>
<p className="text-text-muted text-xs mt-0.5">The display may still turn off. Firelink only keeps the device awake enough to complete active transfers.</p>
</div>
</label>
</div>
)}
{/* Engine Pane */}
{activeTab === 'engine' && (
<div className="settings-pane space-y-6 max-w-[760px]">
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Media Downloader & Engines</h3>
<div className="space-y-4">
<div className="border border-border-modal rounded-lg p-4 space-y-3 bg-item-hover/5">
<h4 className="text-[13px] font-bold text-text-primary flex items-center gap-2 border-b border-border-modal pb-1">
<Terminal size={14} className="text-accent" /> Core Downloader (Aria2)
</h4>
<div className="grid grid-cols-[120px_1fr] text-[13px]">
<span className="text-text-secondary">Version:</span>
<span className="font-mono text-xs text-text-muted select-all">{aria2Version}</span>
</div>
<div className="grid grid-cols-[120px_1fr] text-[13px] items-center">
<span className="text-text-secondary">Status:</span>
{getEngineStatus(aria2Version)}
</div>
</div>
<div className="border border-border-modal rounded-lg p-4 space-y-3 bg-item-hover/5">
<h4 className="text-[13px] font-bold text-text-primary flex items-center gap-2 border-b border-border-modal pb-1">
<Terminal size={14} className="text-orange-500" /> Media Extractors
</h4>
<div className="grid grid-cols-[120px_1fr_80px] text-[13px] pb-1 items-center">
<span className="text-text-secondary font-semibold">yt-dlp:</span>
<span className="font-mono text-xs text-text-muted select-all truncate pr-4">{ytdlpVersion}</span>
{getEngineStatus(ytdlpVersion)}
</div>
<div className="grid grid-cols-[120px_1fr_80px] text-[13px] pb-1 items-center">
<span className="text-text-secondary font-semibold">FFmpeg:</span>
<span className="font-mono text-xs text-text-muted select-all truncate pr-4">{ffmpegVersion}</span>
{getEngineStatus(ffmpegVersion)}
</div>
<div className="grid grid-cols-[120px_1fr_80px] text-[13px] pb-1 items-center">
<span className="text-text-secondary font-semibold">Deno:</span>
<span className="font-mono text-xs text-text-muted select-all truncate pr-4">{denoVersion}</span>
{getEngineStatus(denoVersion)}
</div>
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px] border-t border-border-modal/50 pt-3 mt-2">
<label className="text-text-secondary font-semibold">Browser Cookies Source:</label>
<select
value={settings.mediaCookieSource}
onChange={(e) => settings.setMediaCookieSource(
e.target.value as typeof settings.mediaCookieSource
)}
className="bg-bg-input border border-border-modal rounded-lg p-1.5 text-[13px] text-text-primary focus:outline-none focus:border-accent"
>
<option value="none">None</option>
<option value="safari">Safari</option>
<option value="chrome">Chrome</option>
<option value="firefox">Firefox</option>
<option value="edge">Edge</option>
<option value="brave">Brave</option>
</select>
</div>
<p className="text-text-muted text-xs mt-1">yt-dlp reads browser cookies to bypass video download limits or access restricted media. Firelink does not save browser cookies.</p>
</div>
</div>
</div>
)}
{/* Integrations Pane */}
{activeTab === 'integrations' && (
<div className="settings-pane space-y-6 max-w-[760px]">
<div className="flex items-center gap-3 border-b border-border-color/30 pb-3">
<Puzzle size={28} className="text-orange-500" />
<div>
<h3 className="text-base font-bold text-text-primary">Connect Browser Extension</h3>
<p className="text-text-secondary text-xs">Capture downloads directly from your browser in three easy steps.</p>
</div>
</div>
{/* Step Guide Cards */}
<div className="grid grid-cols-3 gap-4">
{/* Step 1 */}
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col justify-between h-[190px]">
<div>
<div className="flex justify-between items-center mb-2">
<span className="bg-accent/25 text-accent font-bold rounded-full w-5 h-5 flex items-center justify-center text-xs">1</span>
<Copy size={16} className="text-accent" />
</div>
<h4 className="text-[13px] font-bold text-text-primary mb-1">Copy Token</h4>
<p className="text-text-muted text-[11px] leading-relaxed">This secure token authorizes your browser extension.</p>
</div>
<div className="space-y-2">
<button
onClick={copyToken}
className="w-full bg-accent hover:bg-accent text-white font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 shadow transition-colors"
>
<Copy size={11} /> Copy Token
</button>
<button
onClick={() => {
settings.regeneratePairingToken();
showToast("Pairing token regenerated");
}}
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 transition-colors"
>
<RefreshCw size={11} /> Regenerate
</button>
</div>
</div>
{/* Step 2 */}
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col justify-between h-[190px]">
<div>
<div className="flex justify-between items-center mb-2">
<span className="bg-orange-600/25 text-orange-500 font-bold rounded-full w-5 h-5 flex items-center justify-center text-xs">2</span>
<Globe size={16} className="text-orange-500" />
</div>
<h4 className="text-[13px] font-bold text-text-primary mb-1">Get Extension</h4>
<p className="text-text-muted text-[11px] leading-relaxed">Install the Firelink Companion extension on your browser.</p>
</div>
<div className="space-y-2">
<a
href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"
target="_blank" rel="noreferrer"
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] block text-center transition-colors"
>
Firefox Add-ons
</a>
<a
href="https://github.com/nimbold/Firelink-Extension/releases"
target="_blank" rel="noreferrer"
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] block text-center transition-colors"
>
GitHub Releases
</a>
</div>
</div>
{/* Step 3 */}
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col h-[190px]">
<div className="flex justify-between items-center mb-2">
<span className="bg-green-600/25 text-green-500 font-bold rounded-full w-5 h-5 flex items-center justify-center text-xs">3</span>
<Puzzle size={16} className="text-green-500" />
</div>
<h4 className="text-[13px] font-bold text-text-primary mb-1">Paste & Connect</h4>
<p className="text-text-muted text-[11px] leading-relaxed">Click the Firelink icon in your browser's toolbar and paste the copied token.</p>
</div>
</div>
{/* Status Info */}
<div className="border border-border-modal/70 rounded-lg p-3 bg-item-hover/10 flex justify-between items-center text-[12px]">
<span className="text-text-secondary font-medium">Extension Server Status:</span>
<span className="text-green-500 font-semibold flex items-center gap-1">
Listening on 127.0.0.1:23522 (Active)
</span>
</div>
</div>
)}
{/* About Pane */}
{activeTab === 'about' && (
<div className="settings-pane space-y-6 max-w-[760px]">
{/* Header Box */}
<div className="bg-bg-modal border border-border-modal/40 rounded-xl p-6 flex items-center gap-4">
<img src={appIcon} alt="Firelink Icon" className="w-[72px] h-[72px] drop-shadow-md rounded-xl" />
<div className="space-y-1">
<h3 className="text-[17px] font-bold text-text-primary">Firelink</h3>
<p className="text-text-secondary text-[12px] font-medium">Version {appVersion}</p>
<p className="text-text-muted text-[11px]">
A fast desktop download manager powered by Rust and Tauri.
</p>
</div>
</div>
{/* Updates Section */}
<div className="space-y-2">
<h4 className="text-[12px] font-bold text-text-primary px-1">Updates</h4>
<div className="bg-bg-modal border border-border-modal/40 rounded-xl overflow-hidden">
<div className="p-4 flex items-center justify-between border-b border-border-modal/40">
<div>
<p className="text-[13px] font-bold text-text-primary">Check for Updates</p>
<p className="text-text-muted text-[11px] mt-0.5">Firelink checks GitHub Releases for new versions.</p>
</div>
<button
onClick={handleCheckForUpdates}
disabled={isCheckingForUpdates}
className="app-button px-4 text-xs disabled:opacity-50"
>
{isCheckingForUpdates ? (
<>
<RefreshCw size={13} className="animate-spin" />
Checking...
</>
) : 'Check Now'}
</button>
</div>
<label className="p-4 flex items-center justify-between cursor-default">
<span className="text-[13px] font-bold text-text-primary">Automatically check for updates</span>
<button
type="button"
role="switch"
aria-checked={settings.autoCheckUpdates}
onClick={() => settings.setAutoCheckUpdates(!settings.autoCheckUpdates)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-default items-center rounded-full transition-colors duration-200 ease-in-out border border-transparent ${settings.autoCheckUpdates ? 'bg-accent' : 'bg-border-color'}`}
>
<span className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow transition duration-200 ease-in-out ${settings.autoCheckUpdates ? 'translate-x-4' : 'translate-x-0'}`} />
</button>
</label>
</div>
</div>
{/* Credits Footer */}
<div className="bg-bg-modal border border-border-modal/40 rounded-xl p-4 text-[11px] space-y-3">
<div className="flex justify-between items-center">
<span className="text-text-primary font-bold">Created by NimBold</span>
<a href="https://github.com/nimbold/Firelink" target="_blank" rel="noreferrer" className="flex items-center gap-1.5 text-text-secondary hover:text-accent transition-colors font-medium">
<Code size={14} /> Source Code
</a>
</div>
<div className="flex justify-between items-center text-text-muted">
<span>Built with <span className="text-accent">Rust</span> <span className="text-accent">Tauri</span> <span className="text-accent">React</span> <span className="text-accent">TypeScript</span></span>
<a href="https://github.com/nimbold/Firelink/blob/main/LICENSE" target="_blank" rel="noreferrer" className="text-accent hover:underline">MIT License</a>
</div>
<div className="text-text-muted">
Download engines: <span className="text-accent">aria2</span> <span className="text-accent">yt-dlp</span> <span className="text-accent">FFmpeg</span> <span className="text-accent">Deno</span>
</div>
<div className="text-text-muted pt-1 border-t border-border-modal/40">
Copyright © 2026 NimBold. All rights reserved.
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
+295
View File
@@ -0,0 +1,295 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Inbox, Zap, CheckCircle2, CircleDashed,
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion,
List, CalendarClock, Gauge, Settings, Plus, Play, Pause, Edit2, Trash2, PanelLeft,
type LucideIcon
} from 'lucide-react';
import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore';
import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
interface SidebarProps {
selectedFilter: SidebarFilter;
onSelectFilter: (filter: SidebarFilter) => void;
}
export const Sidebar: React.FC<SidebarProps> = (props) => {
const { selectedFilter, onSelectFilter } = props;
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue } = useDownloadStore();
const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
const [isAddingQueue, setIsAddingQueue] = useState(false);
const [newQueueName, setNewQueueName] = useState('');
const [renamingQueueId, setRenamingQueueId] = useState<string | null>(null);
const [editingQueueName, setEditingQueueName] = useState('');
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
const addInputRef = useRef<HTMLInputElement>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handleCloseMenu = () => setContextMenu(null);
window.addEventListener('click', handleCloseMenu);
return () => window.removeEventListener('click', handleCloseMenu);
}, []);
useEffect(() => {
if (isAddingQueue) addInputRef.current?.focus();
}, [isAddingQueue]);
useEffect(() => {
if (renamingQueueId) renameInputRef.current?.focus();
}, [renamingQueueId]);
const getCount = (filter: SidebarFilter) => {
if (filter.startsWith('queue:')) {
const qid = filter.replace('queue:', '');
return downloads.filter(d => d.queueId === qid).length;
}
switch (filter) {
case 'all': return downloads.length;
case 'active': return downloads.filter(d => d.status === 'downloading').length;
case 'completed': return downloads.filter(d => d.status === 'completed').length;
case 'unfinished': return downloads.filter(d => d.status !== 'completed').length;
default: return downloads.filter(d => d.category === filter as DownloadCategory).length;
}
};
const NavItem = ({ icon: Icon, label, filter }: { icon: LucideIcon, label: string, filter: SidebarFilter }) => {
const isSelected = activeView === 'downloads' && selectedFilter === filter;
return (
<button
type="button"
data-active={isSelected}
className="sidebar-nav-item group flex w-full items-center text-[13px] text-left cursor-default font-medium"
onClick={() => onSelectFilter(filter)}
>
<Icon className="w-[18px] h-[18px] mr-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
<span className="truncate">{label}</span>
{getCount(filter) > 0 && (
<span className="sidebar-count ml-auto min-w-5 px-1.5 py-0.5 rounded-full text-center text-[10px] leading-none font-bold">
{getCount(filter)}
</span>
)}
</button>
);
};
const handleQueueContextMenu = (e: React.MouseEvent, id: string) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, id });
};
const handleAddQueueSubmit = () => {
if (newQueueName.trim()) addQueue(newQueueName.trim());
setNewQueueName('');
setIsAddingQueue(false);
};
const handleRenameQueueSubmit = () => {
if (renamingQueueId && editingQueueName.trim()) {
renameQueue(renamingQueueId, editingQueueName.trim());
}
setRenamingQueueId(null);
};
const QueueItem = ({ queue }: { queue: Queue }) => {
const filterId = `queue:${queue.id}`;
const isSelected = activeView === 'downloads' && selectedFilter === filterId;
const isRenaming = renamingQueueId === queue.id;
if (isRenaming) {
return (
<div className="flex items-center px-2.5 py-1 rounded-lg mb-0.5 bg-item-hover">
<List className="w-4 h-4 mr-2 text-text-secondary" strokeWidth={2} />
<input
ref={renameInputRef}
type="text"
className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0"
value={editingQueueName}
onChange={e => setEditingQueueName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleRenameQueueSubmit();
if (e.key === 'Escape') setRenamingQueueId(null);
}}
onBlur={handleRenameQueueSubmit}
/>
</div>
);
}
return (
<button
type="button"
data-active={isSelected}
onContextMenu={e => handleQueueContextMenu(e, queue.id)}
onClick={() => onSelectFilter(filterId)}
className="sidebar-nav-item group flex w-full items-center text-[13px] text-left cursor-default font-medium"
>
<List className="w-[18px] h-[18px] mr-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
<span className="truncate">{queue.name}</span>
{getCount(filterId) > 0 && (
<span className="sidebar-count ml-auto min-w-5 px-1.5 py-0.5 rounded-full text-center text-[10px] leading-none font-bold shrink-0">
{getCount(filterId)}
</span>
)}
</button>
);
};
const ToolItem = ({ icon: Icon, label, view }: { icon: LucideIcon; label: string; view: ActiveView }) => {
const isSelected = activeView === view;
return (
<button
type="button"
data-active={isSelected}
onClick={() => setActiveView(view)}
className="sidebar-nav-item group flex w-full items-center text-[13px] text-left cursor-default font-medium"
>
<Icon className="w-[18px] h-[18px] mr-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
<span>{label}</span>
</button>
);
};
return (
<aside className="sidebar-inner">
<div className="sidebar-top-region">
<WindowDragRegion />
<button
type="button"
onClick={toggleSidebar}
className="sidebar-toggle-button"
title="Hide Sidebar"
>
<PanelLeft size={14} strokeWidth={1.9} />
</button>
</div>
<div className="sidebar-scroll">
<section className="sidebar-section">
<div className="sidebar-section-label">Library</div>
<NavItem icon={Inbox} label="All" filter="all" />
<NavItem icon={Zap} label="Active" filter="active" />
<NavItem icon={CheckCircle2} label="Completed" filter="completed" />
<NavItem icon={CircleDashed} label="Unfinished" filter="unfinished" />
</section>
<section className="sidebar-section">
<div className="sidebar-section-label">Folders</div>
<NavItem icon={Music} label="Musics" filter="Musics" />
<NavItem icon={Film} label="Movies" filter="Movies" />
<NavItem icon={Archive} label="Compressed" filter="Compressed" />
<NavItem icon={FileText} label="Documents" filter="Documents" />
<NavItem icon={ImageIcon} label="Pictures" filter="Pictures" />
<NavItem icon={Box} label="Applications" filter="Applications" />
<NavItem icon={FileQuestion} label="Other" filter="Other" />
</section>
<section className="sidebar-section">
<div className="sidebar-section-label">Queues</div>
{queues.map(queue => (
<QueueItem key={queue.id} queue={queue} />
))}
{isAddingQueue ? (
<div className="flex items-center px-3.5 py-1.5 rounded-lg bg-item-hover mb-1">
<Plus className="w-4 h-4 mr-2 text-text-secondary shrink-0" strokeWidth={2} />
<input
ref={addInputRef}
type="text"
placeholder="Queue name"
className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0"
value={newQueueName}
onChange={e => setNewQueueName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleAddQueueSubmit();
if (e.key === 'Escape') setIsAddingQueue(false);
}}
onBlur={handleAddQueueSubmit}
/>
</div>
) : (
<button
type="button"
onClick={() => { setIsAddingQueue(true); setNewQueueName(''); }}
className="flex w-full items-center px-3.5 py-1.5 rounded-lg text-[13px] text-text-muted hover:bg-item-hover hover:text-text-secondary cursor-default transition-colors mb-1"
>
<Plus className="w-4 h-4 mr-2 shrink-0" strokeWidth={2} />
<span className="truncate">Add new queue</span>
</button>
)}
</section>
<section className="sidebar-section">
<div className="sidebar-section-label">Tools</div>
<ToolItem icon={CalendarClock} label="Scheduler" view="scheduler" />
<ToolItem icon={Gauge} label="Speed Limiter" view="speedLimiter" />
</section>
</div>
<div className="sidebar-footer">
<button
type="button"
data-active={activeView === 'settings'}
onClick={() => setActiveView('settings')}
className="sidebar-nav-item sidebar-settings-button group flex w-full items-center text-[13px] text-left cursor-default font-medium transition-colors"
>
<Settings className={`w-[18px] h-[18px] mr-3 shrink-0 ${activeView === 'settings' ? 'text-white' : 'text-text-muted'}`} strokeWidth={activeView === 'settings' ? 2.5 : 2} />
<span>Settings</span>
</button>
</div>
{contextMenu && (
<div
className="fixed z-50 w-48 py-1 rounded-xl shadow-lg border border-border-modal bg-bg-context-menu backdrop-blur-xl animate-fade-in text-[13px] text-text-primary overflow-hidden"
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={e => e.stopPropagation()}
>
<button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
onClick={() => { startQueue(contextMenu.id); setContextMenu(null); }}
>
<Play size={14} className="mr-2 text-text-secondary" />
Start Queue
</button>
<button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
onClick={() => { pauseQueue(contextMenu.id); setContextMenu(null); }}
>
<Pause size={14} className="mr-2 text-text-secondary" />
Pause Queue
</button>
<div className="h-px bg-border-color my-1 mx-2" />
<button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
onClick={() => {
const q = queues.find(q => q.id === contextMenu.id);
if (q) {
setEditingQueueName(q.name);
setRenamingQueueId(q.id);
}
setContextMenu(null);
}}
>
<Edit2 size={14} className="mr-2 text-text-secondary" />
Rename Queue
</button>
{!queues.find(q => q.id === contextMenu.id)?.isMain && (
<button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-red-500/20 text-red-400"
onClick={() => { removeQueue(contextMenu.id); setContextMenu(null); }}
>
<Trash2 size={14} className="mr-2" />
Delete Queue
</button>
)}
</div>
)}
</aside>
);
};
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useState } from 'react';
import { Gauge, Save, Zap } from 'lucide-react';
import { useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
type SpeedUnit = 'KB/s' | 'MB/s';
function parseLimit(limit: string, fallback: number): { value: number; unit: SpeedUnit } {
const match = limit.trim().match(/^(\d+(?:\.\d+)?)\s*([km]?)b?(?:\/s)?$/i);
const valueKiB = match
? Math.max(1, Math.round(Number(match[1]) * (match[2].toLowerCase() === 'm' ? 1024 : 1)))
: fallback;
return valueKiB >= 1024 && valueKiB % 1024 === 0
? { value: valueKiB / 1024, unit: 'MB/s' }
: { value: valueKiB, unit: 'KB/s' };
}
export default function SpeedLimiterView() {
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
const lastCustomSpeedLimitKiB = useSettingsStore(state => state.lastCustomSpeedLimitKiB);
const setGlobalSpeedLimit = useSettingsStore(state => state.setGlobalSpeedLimit);
const setLastCustomSpeedLimitKiB = useSettingsStore(state => state.setLastCustomSpeedLimitKiB);
const initial = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
const [enabled, setEnabled] = useState(Boolean(globalSpeedLimit));
const [value, setValue] = useState(initial.value);
const [unit, setUnit] = useState<SpeedUnit>(initial.unit);
const [toast, setToast] = useState('');
useEffect(() => {
const parsed = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
setEnabled(Boolean(globalSpeedLimit));
setValue(parsed.value);
setUnit(parsed.unit);
}, [globalSpeedLimit, lastCustomSpeedLimitKiB]);
useEffect(() => {
if (!toast) return;
const timeout = window.setTimeout(() => setToast(''), 2200);
return () => window.clearTimeout(timeout);
}, [toast]);
const save = () => {
const numericValue = Math.max(1, Math.min(Number(value) || 1, unit === 'MB/s' ? 10240 : 10_485_760));
const valueKiB = Math.min(10_485_760, Math.round(unit === 'MB/s' ? numericValue * 1024 : numericValue));
setLastCustomSpeedLimitKiB(valueKiB);
setGlobalSpeedLimit(enabled ? `${valueKiB}K` : '');
setToast(enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled');
};
const preset = (presetValue: number) => {
setEnabled(true);
setValue(presetValue);
setUnit('MB/s');
};
return (
<div className="flex-1 flex h-full flex-col overflow-hidden bg-main-bg">
<WindowDragRegion />
<div className="flex items-center gap-3 border-b border-border-color px-6 pb-4">
<label className="flex items-center gap-3 text-[17px] font-semibold tracking-tight text-text-primary">
<input type="checkbox" checked={enabled} onChange={event => setEnabled(event.target.checked)} className="h-4 w-4 accent-accent" />
Speed Limiter
</label>
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
enabled ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
}`}>
{enabled ? `${value} ${unit}` : 'Unlimited'}
</span>
<button onClick={save} className="app-button app-button-primary ml-auto px-3 text-[11px]">
<Save size={14} /> Save Limit
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<section className={`app-card max-w-[720px] p-5 ${enabled ? '' : 'opacity-50'}`}>
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<Gauge size={18} className="text-accent" /> Global Speed Limit
</div>
<p className="max-w-xl text-[12px] leading-relaxed text-text-muted">
This cap is shared across the configured concurrent download slots. A lower per-download limit still takes precedence.
Saving a new limit gracefully restarts active jobs so the change takes effect immediately.
</p>
<div className="mt-6 flex items-center gap-3">
<input
type="number"
min="1"
value={value}
disabled={!enabled}
onChange={event => setValue(Math.max(1, Number(event.target.value) || 1))}
className="app-control w-28 px-3 py-2 text-right font-mono"
/>
<div className="flex rounded-md border border-border-modal bg-bg-input p-1">
{(['KB/s', 'MB/s'] as SpeedUnit[]).map(option => (
<button
key={option}
type="button"
disabled={!enabled}
onClick={() => setUnit(option)}
className={`rounded px-3 py-1.5 text-[12px] font-medium ${
unit === option ? 'bg-accent text-white' : 'text-text-secondary hover:bg-item-hover'
}`}
>
{option}
</button>
))}
</div>
</div>
<div className="my-6 border-t border-border-color" />
<div className="mb-3 flex items-center gap-2 text-[12px] font-medium text-text-secondary">
<Zap size={14} /> Quick Presets
</div>
<div className="flex flex-wrap gap-2">
{[1, 5, 10].map(presetValue => (
<button
key={presetValue}
type="button"
disabled={!enabled}
onClick={() => preset(presetValue)}
className="app-button px-4 text-[12px] disabled:opacity-50"
>
{presetValue} MB/s
</button>
))}
</div>
</section>
</div>
{toast && (
<div className="app-toast pointer-events-none absolute bottom-7 left-1/2 -translate-x-1/2 px-4 py-2 text-[12px] font-medium">
{toast}
</div>
)}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
interface WindowDragRegionProps {
className?: string;
}
export function WindowDragRegion({ className = '' }: WindowDragRegionProps) {
return (
<div
className={`h-10 shrink-0 cursor-default ${className}`}
data-tauri-drag-region
onPointerDown={(event) => {
if (event.button === 0) {
void getCurrentWindow().startDragging();
}
}}
/>
);
}