feat(ui): modernize desktop interactions

This commit is contained in:
NimBold
2026-06-21 12:53:02 +03:30
parent cd0397ea00
commit 9e8c4aacf7
27 changed files with 1216 additions and 845 deletions
+58 -9
View File
@@ -15,6 +15,8 @@ import {
resolveCategoryDestination,
resolveDownloadFilePath
} from '../utils/downloadLocations';
import { isTransferLocked } from '../utils/downloadActions';
import { useToast } from '../contexts/ToastContext';
interface MediaFormat {
name: string;
@@ -47,6 +49,7 @@ const formatBytes = (bytes: number) => {
};
export const AddDownloadsModal = () => {
const { addToast } = useToast();
const {
isAddModalOpen,
pendingAddUrls,
@@ -58,7 +61,7 @@ export const AddDownloadsModal = () => {
addDownload,
queues
} = useDownloadStore();
const { baseDownloadFolder } = useSettingsStore();
const { baseDownloadFolder, perServerConnections } = useSettingsStore();
const [urls, setUrls] = useState('');
const [metadataRefreshNonce, setMetadataRefreshNonce] = useState(0);
@@ -77,7 +80,7 @@ export const AddDownloadsModal = () => {
// Right Form
const [saveLocation, setSaveLocation] = useState(baseDownloadFolder);
const [isSaveLocationManual, setIsSaveLocationManual] = useState(false);
const [connections, setConnections] = useState(16);
const [connections, setConnections] = useState(perServerConnections);
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
const [speedLimit, setSpeedLimit] = useState('1024');
const [freeSpace, setFreeSpace] = useState('Unknown');
@@ -102,6 +105,7 @@ export const AddDownloadsModal = () => {
setParsedItems([]);
setSelectedItemIndex(null);
setPendingUseSharedDestination(false);
setConnections(perServerConnections);
setUseAuth(false);
setUsername('');
setPassword('');
@@ -126,7 +130,8 @@ export const AddDownloadsModal = () => {
pendingAddReferer,
pendingAddHeaders,
pendingAddCookies,
baseDownloadFolder
baseDownloadFolder,
perServerConnections
]);
useEffect(() => {
@@ -141,6 +146,21 @@ export const AddDownloadsModal = () => {
return () => window.removeEventListener('pointerdown', closeMenu);
}, [isActionMenuOpen]);
useEffect(() => {
if (!isActionMenuOpen && !showingDuplicates) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (showingDuplicates) {
setShowingDuplicates(false);
} else {
setIsActionMenuOpen(false);
setIsQueueMenuOpen(false);
}
};
window.addEventListener('keydown', closeOnEscape);
return () => window.removeEventListener('keydown', closeOnEscape);
}, [isActionMenuOpen, showingDuplicates]);
useEffect(() => {
if (!saveLocation) return;
invoke('get_free_space', { path: saveLocation })
@@ -445,9 +465,12 @@ export const AddDownloadsModal = () => {
exists = storeHas || diskHas;
count++;
}
if (exists) {
throw new Error(`Could not find an available name for ${finalFile}.`);
}
itemsToAdd[idx] = { ...item, file: newName };
} else if (res.resolution === 'replace') {
} else if (res.resolution === 'replace') {
if (conflict?.reason.type !== 'file') {
itemsToAdd[idx] = null;
continue;
@@ -479,16 +502,21 @@ export const AddDownloadsModal = () => {
}
}
if (existingItem) {
await store.removeDownload(existingItem.id);
if (existingItem && isTransferLocked(existingItem.status)) {
throw new Error(`Pause ${existingItem.fileName} before replacing it.`);
}
await invoke('delete_file', { path: fullPath });
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);
let addedCount = 0;
const failures: string[] = [];
for (const item of resolvedItems) {
try {
@@ -527,11 +555,25 @@ export const AddDownloadsModal = () => {
mediaFormatSelector: formatSelector,
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined)
}, action);
addedCount += 1;
} catch (e) {
console.error("Invalid URL or failed to add:", e);
failures.push(`${item.file}: ${e instanceof Error ? e.message : String(e)}`);
}
}
toggleAddModal(false);
if (failures.length > 0) {
addToast({
message: `${addedCount} added, ${failures.length} failed. ${failures[0]}`,
variant: 'error',
isActionable: true
});
} else if (addedCount > 0) {
addToast({
message: `${addedCount} download${addedCount === 1 ? '' : 's'} added`,
variant: 'success'
});
}
};
const SummaryBox = ({ title, value, icon: Icon, color }: {
@@ -578,7 +620,14 @@ export const AddDownloadsModal = () => {
conflicts={conflicts}
onConfirm={(resolutions) => {
setShowingDuplicates(false);
executeAddDownloads(pendingAction, resolvedLocation, pendingUseSharedDestination, resolutions);
void executeAddDownloads(pendingAction, resolvedLocation, pendingUseSharedDestination, resolutions)
.catch(error => {
addToast({
message: `Could not resolve duplicate downloads: ${String(error)}`,
variant: 'error',
isActionable: true
});
});
}}
onCancel={() => setShowingDuplicates(false)}
/>
+23 -20
View File
@@ -13,33 +13,36 @@ export const DeleteConfirmationModal: React.FC = () => {
closeDeleteModal();
};
const handleRemoveFromList = async () => {
if (deleteModalState.downloadIds && deleteModalState.downloadIds.length > 0) {
setIsRemoving(true);
const removeMany = async (deleteFile: boolean) => {
const ids = deleteModalState.downloadIds ?? [];
if (ids.length === 0) {
closeDeleteModal();
return;
}
setIsRemoving(true);
setErrorMessage('');
let succeeded = 0;
const failures: string[] = [];
for (const id of ids) {
try {
await Promise.all(deleteModalState.downloadIds.map(id => removeDownload(id, false)));
await removeDownload(id, deleteFile);
succeeded += 1;
} catch (error) {
setErrorMessage(`Remove failed: ${String(error)}`);
setIsRemoving(false);
return;
failures.push(String(error));
}
}
if (failures.length > 0) {
setErrorMessage(`${succeeded} removed, ${failures.length} failed: ${failures[0]}`);
setIsRemoving(false);
return;
}
closeDeleteModal();
};
const handleDeleteFile = async () => {
if (deleteModalState.downloadIds && deleteModalState.downloadIds.length > 0) {
setIsRemoving(true);
try {
await Promise.all(deleteModalState.downloadIds.map(id => removeDownload(id, true)));
} catch (error) {
setErrorMessage(`Delete failed: ${String(error)}`);
setIsRemoving(false);
return;
}
}
closeDeleteModal();
};
const handleRemoveFromList = () => removeMany(false);
const handleDeleteFile = () => removeMany(true);
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 animate-fade-in">
+4
View File
@@ -4,6 +4,7 @@ import { invokeCommand as invoke } from '../ipc';
import { save } from '@tauri-apps/plugin-dialog';
import { FileDown, Trash2, Terminal, Filter } from 'lucide-react';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
interface LogEntry {
level: 'Trace' | 'Debug' | 'Info' | 'Warn' | 'Error';
@@ -22,6 +23,7 @@ const getLevelStr = (level: number): LogEntry['level'] => {
};
export default function DiagnosticsView() {
const { addToast } = useToast();
const [logs, setLogs] = useState<LogEntry[]>([]);
const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All');
const scrollRef = useRef<HTMLDivElement>(null);
@@ -59,8 +61,10 @@ export default function DiagnosticsView() {
});
if (!path) return;
await invoke('export_logs', { destPath: path });
addToast({ message: 'Diagnostics exported', variant: 'success' });
} catch (e) {
console.error('Export failed:', e);
addToast({ message: `Could not export diagnostics: ${String(e)}`, variant: 'error', isActionable: true });
}
};
+4 -3
View File
@@ -3,6 +3,7 @@ import { useDownloadStore } from '../store/useDownloadStore';
import { useDownloadProgressStore } from '../store/downloadStore';
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react';
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
import { canPauseDownload, canStartDownload, startActionLabel } from '../utils/downloadActions';
interface DownloadItemProps {
downloadId: string;
@@ -200,13 +201,13 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
</button>
</>
)}
{(download.status === 'downloading' || download.status === 'processing' || download.status === 'retrying') && (
{canPauseDownload(download.status) && (
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title="Pause">
<Pause size={14} fill="currentColor" />
</button>
)}
{(download.status === 'ready' || download.status === 'paused') && (
<button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title={download.status === 'ready' ? 'Start' : 'Resume'}>
{canStartDownload(download.status) && (
<button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title={startActionLabel(download.status)}>
<Play size={14} fill="currentColor" />
</button>
)}
+46 -24
View File
@@ -10,13 +10,22 @@ import {
resolveCategoryDestination,
resolveDownloadFilePath
} from '../utils/downloadLocations';
import {
canPauseDownload,
canRedownload,
canStartDownload,
startActionLabel
} from '../utils/downloadActions';
interface DownloadTableProps {
filter: SidebarFilter;
}
const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170];
const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const { downloads, queues, updateDownload, toggleAddModal, openDeleteModal, redownload } = useDownloadStore();
const { downloads, queues, assignToQueue, toggleAddModal, openDeleteModal, redownload } = useDownloadStore();
const { isSidebarVisible, toggleSidebar } = useSettingsStore();
const { addToast } = useToast();
@@ -25,7 +34,18 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
const [columnWidths, setColumnWidths] = useState([340, 100, 220, 100, 80, 170]);
const [columnWidths, setColumnWidths] = useState(() => {
try {
const stored = JSON.parse(window.localStorage.getItem(COLUMN_WIDTHS_STORAGE_KEY) || 'null');
return Array.isArray(stored) &&
stored.length === DEFAULT_COLUMN_WIDTHS.length &&
stored.every(value => typeof value === 'number' && Number.isFinite(value))
? stored
: DEFAULT_COLUMN_WIDTHS;
} catch {
return DEFAULT_COLUMN_WIDTHS;
}
});
const columnMinimums = [0, 58, 92, 58, 48, 112];
const tableGridTemplate = columnWidths.map((width, index) => `minmax(${columnMinimums[index]}px, ${width}fr)`).join(' ');
@@ -53,10 +73,21 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
useEffect(() => {
const handleCloseMenu = () => setContextMenu(null);
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setContextMenu(null);
};
window.addEventListener('click', handleCloseMenu);
return () => window.removeEventListener('click', handleCloseMenu);
window.addEventListener('keydown', handleEscape);
return () => {
window.removeEventListener('click', handleCloseMenu);
window.removeEventListener('keydown', handleEscape);
};
}, []);
useEffect(() => {
window.localStorage.setItem(COLUMN_WIDTHS_STORAGE_KEY, JSON.stringify(columnWidths));
}, [columnWidths]);
const showInteractionError = (message: string, error: unknown) => {
const detail = error instanceof Error ? error.message : String(error);
@@ -97,13 +128,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
};
const revealDownloadFile = async (item: DownloadItem) => {
let pathToReveal: string | null = null;
if (item.status === 'completed') {
pathToReveal = await getDownloadPath(item);
} else {
const settings = useSettingsStore.getState();
pathToReveal = item.destination || await resolveCategoryDestination(settings, item.category);
}
const pathToReveal = await getDownloadPath(item);
if (!pathToReveal) {
openProperties(item.id);
@@ -198,6 +223,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
await invoke('pause_download', { id });
} catch (e) {
console.error("Failed to pause:", e);
showInteractionError('Could not pause download', e);
}
};
@@ -249,7 +275,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
disabled={filteredDownloads.length === 0}
onClick={() => {
filteredDownloads
.filter(d => d.status === 'ready' || d.status === 'paused')
.filter(d => canStartDownload(d.status))
.forEach(d => handleResume(d));
}}
title="Resume All"
@@ -261,7 +287,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
className="main-control-button"
disabled={filteredDownloads.length === 0}
onClick={() => {
filteredDownloads.filter(d => d.status === 'downloading').forEach(d => handlePause(d.id));
filteredDownloads.filter(d => canPauseDownload(d.status)).forEach(d => handlePause(d.id));
}}
title="Pause All"
>
@@ -348,6 +374,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
{/* Floating Context Menu */}
{contextMenu && contextItem && (
<div
role="menu"
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),
@@ -363,7 +390,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
setContextMenu(null);
Array.from(selectedIds).forEach(id => {
const item = downloads.find(d => d.id === id);
if (item && (item.status === 'ready' || item.status === 'paused' || item.status === 'failed' || item.status === 'retrying')) {
if (item && canStartDownload(item.status)) {
handleResume(item);
}
});
@@ -384,12 +411,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
{queues.map(q => (
<button key={q.id} onClick={() => {
setContextMenu(null);
Array.from(selectedIds).forEach(id => {
const item = downloads.find(d => d.id === id);
if (item && item.status !== 'completed') {
updateDownload(id, { queueId: q.id });
}
});
assignToQueue(Array.from(selectedIds), q.id);
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
{q.name}
</button>
@@ -454,7 +476,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
{(contextItem.status === 'downloading' || contextItem.status === 'queued' || contextItem.status === 'retrying') && (
{canPauseDownload(contextItem.status) && (
<button
onClick={() => {
setContextMenu(null);
@@ -466,7 +488,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
</button>
)}
{(contextItem.status === 'ready' || contextItem.status === 'paused' || contextItem.status === 'failed' || contextItem.status === 'retrying') && (
{canStartDownload(contextItem.status) && (
<button
onClick={() => {
setContextMenu(null);
@@ -474,11 +496,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
{contextItem.status === 'ready' ? 'Start' : 'Resume'}
{startActionLabel(contextItem.status)}
</button>
)}
{['completed', 'failed', 'paused'].includes(contextItem.status) && (
{canRedownload(contextItem.status) && (
<button
onClick={async () => {
setContextMenu(null);
@@ -504,7 +526,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
{queues.map(q => (
<button key={q.id} onClick={() => {
setContextMenu(null);
updateDownload(contextItem.id, { queueId: q.id });
assignToQueue([contextItem.id], q.id);
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
{q.name}
</button>
+27 -23
View File
@@ -4,6 +4,10 @@ import { useSettingsStore } from '../store/useSettingsStore';
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import { resolveCategoryDestination } from '../utils/downloadLocations';
import {
isIdentityLocked as getIdentityLocked,
isTransferLocked as getTransferLocked
} from '../utils/downloadActions';
type LoginMode = 'matching' | 'custom' | 'none';
@@ -98,7 +102,7 @@ export const PropertiesModal = () => {
if (!selectedPropertiesDownloadId || !item) return null;
const handleBrowse = async () => {
if (isLocked) return;
if (identityLocked) return;
try {
const selected = await open({
directory: true,
@@ -146,8 +150,8 @@ export const PropertiesModal = () => {
}
};
const isLocked = ['downloading', 'processing', 'completed', 'retrying'].includes(item.status);
const isTransferLocked = item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying';
const identityLocked = getIdentityLocked(item.status);
const transferLocked = getTransferLocked(item.status);
let statusColor = 'text-text-secondary';
let StatusIcon = Info;
@@ -196,7 +200,7 @@ export const PropertiesModal = () => {
{/* Scrollable Form Content */}
<div className="flex-1 overflow-y-auto bg-main-bg/30 p-5 space-y-7">
{isLocked && (
{identityLocked && (
<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>
@@ -212,34 +216,34 @@ export const PropertiesModal = () => {
<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" />
<input type="text" value={url} onChange={e => setUrl(e.target.value)} disabled={identityLocked} 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" />
<input type="text" value={fileName} onChange={e => setFileName(e.target.value)} disabled={identityLocked} 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">
<input type="text" value={saveLocation} readOnly disabled={identityLocked} 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={identityLocked} 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" />
<input type="number" value={connections} min={1} max={16} onChange={e=>setConnections(Number(e.target.value))} disabled={transferLocked} 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)} disabled={isTransferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input disabled:opacity-50" />
<input type="checkbox" checked={speedLimitEnabled} onChange={e => setSpeedLimitEnabled(e.target.checked)} disabled={transferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input disabled:opacity-50" />
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)} disabled={isTransferLocked} 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 disabled:opacity-50" />
<input type="number" value={speedLimitValue} min={1} step={128} onChange={e=>setSpeedLimitValue(e.target.value)} disabled={transferLocked} 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 disabled:opacity-50" />
<span className="text-xs text-text-muted">KiB/s</span>
</div>
)}
@@ -257,8 +261,8 @@ export const PropertiesModal = () => {
{(['matching', 'custom', 'none'] as const).map((mode) => (
<button
key={mode}
onClick={() => !isTransferLocked && setLoginMode(mode)}
disabled={isTransferLocked}
onClick={() => !transferLocked && setLoginMode(mode)}
disabled={transferLocked}
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'}
@@ -275,10 +279,10 @@ export const PropertiesModal = () => {
{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" />
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} disabled={transferLocked} 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" />
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} disabled={transferLocked} 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>
@@ -298,14 +302,14 @@ export const PropertiesModal = () => {
<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" />
<input type="checkbox" checked={checksumEnabled} onChange={e => setChecksumEnabled(e.target.checked)} disabled={transferLocked} 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">
<select value={checksumAlgorithm} onChange={e=>setChecksumAlgorithm(e.target.value)} disabled={transferLocked} 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>
@@ -313,21 +317,21 @@ export const PropertiesModal = () => {
</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" />
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} disabled={transferLocked} 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" />
<input type="text" value={cookies} onChange={e=>setCookies(e.target.value)} disabled={transferLocked} 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>
<textarea value={headers} onChange={e=>setHeaders(e.target.value)} disabled={transferLocked} 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>
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} disabled={transferLocked} 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>
)}
@@ -351,8 +355,8 @@ export const PropertiesModal = () => {
</button>
<button
onClick={handleSave}
disabled={isTransferLocked}
className={`app-button app-button-primary px-4 text-xs ${isTransferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={transferLocked}
className={`app-button app-button-primary px-4 text-xs ${transferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<CheckCircle size={14} />
Save
-126
View File
@@ -1,126 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useDownloadStore } from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore';
import { categoryForFileName } from '../utils/downloads';
import { resolveCategoryDestination } from '../utils/downloadLocations';
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];
};
export const QualityModal = React.memo(() => {
const { activeMetadata, activeMetadataUrl, isParsing, parsingError, clearMetadata, addDownload } = useDownloadStore();
const [selectedFormatId, setSelectedFormatId] = useState<string>('');
useEffect(() => {
if (activeMetadata && activeMetadata.formats.length > 0 && !selectedFormatId) {
setSelectedFormatId(activeMetadata.formats[0].format_id);
}
}, [activeMetadata]);
if (!isParsing && !activeMetadata && !parsingError) return null;
const handleConfirm = async () => {
if (!activeMetadata || !activeMetadataUrl || !selectedFormatId) return;
const format = activeMetadata.formats.find(f => f.format_id === selectedFormatId);
if (!format) return;
const settings = useSettingsStore.getState();
const id = crypto.randomUUID();
const filename = `${activeMetadata.title}.${format.ext}`.replace(/[\/\\?%*:|"<>]/g, '-');
const estimatedBytes = format.filesize || format.filesize_approx || 0;
const category = categoryForFileName(filename);
const destination = await resolveCategoryDestination(settings, category);
const downloadItem = {
id,
url: activeMetadataUrl,
fileName: filename,
destination,
fraction: 0,
size: estimatedBytes
? `${format.filesize ? '' : '~'}${formatBytes(estimatedBytes)}`
: 'Unknown',
speed: '-',
eta: '-',
category,
dateAdded: new Date().toISOString(),
isMedia: true,
mediaFormatSelector: format.format_id
};
await addDownload(downloadItem, { type: 'start-now' });
clearMetadata();
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-[#1a1b1e] rounded-xl p-6 max-w-lg w-full shadow-2xl border border-gray-200 dark:border-gray-800">
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-gray-100">Media Quality Selection</h2>
{isParsing && (
<div className="py-8 flex flex-col items-center justify-center text-gray-500 dark:text-gray-400">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500 mb-4"></div>
<p>Parsing media metadata...</p>
</div>
)}
{parsingError && (
<div className="py-4 text-red-500 bg-red-50 dark:bg-red-900/20 p-4 rounded-lg">
<p className="font-semibold mb-1">Parsing Failed</p>
<p className="text-sm">{parsingError}</p>
<div className="mt-4 flex justify-end">
<button onClick={clearMetadata} className="px-4 py-2 border border-red-200 dark:border-red-800 rounded hover:bg-red-100 dark:hover:bg-red-900/40">Close</button>
</div>
</div>
)}
{activeMetadata && (
<>
<div className="mb-6">
<p className="font-semibold text-gray-900 dark:text-gray-100 mb-1 truncate">{activeMetadata.title}</p>
{activeMetadata.duration && <p className="text-sm text-gray-500">Duration: {Math.floor(activeMetadata.duration / 60)}:{String(activeMetadata.duration % 60).padStart(2, '0')}</p>}
</div>
<div className="mb-6">
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Select Format</label>
<select
className="w-full border rounded-lg p-3 bg-gray-50 dark:bg-[#25262b] border-gray-200 dark:border-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 outline-none transition-shadow"
value={selectedFormatId}
onChange={(e) => setSelectedFormatId(e.target.value)}
>
{activeMetadata.formats.map(f => (
<option key={f.format_id} value={f.format_id}>
{f.resolution || 'Video'} {f.format_label || f.ext.toUpperCase()} {f.filesize || f.filesize_approx ? `${f.filesize ? '' : '~'}${formatBytes(f.filesize || f.filesize_approx || 0)}` : 'Unknown size'}
</option>
))}
</select>
</div>
<div className="flex justify-end gap-3 mt-8">
<button
onClick={clearMetadata}
className="px-5 py-2.5 text-sm font-medium border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={!selectedFormatId}
className="px-5 py-2.5 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Confirm Download
</button>
</div>
</>
)}
</div>
</div>
);
});
+83 -41
View File
@@ -12,6 +12,7 @@ import {
} from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import { getVersion } from '@tauri-apps/api/app';
import { openUrl } from '@tauri-apps/plugin-opener';
import { invokeCommand as invoke } from '../ipc';
import { useToast, ToastVariant } from '../contexts/ToastContext';
@@ -126,34 +127,27 @@ const CategoryFolderInput = ({
type="text"
value={value}
onFocus={() => setLocalValue(displayPath)}
onChange={(e) => {
const val = e.target.value;
setLocalValue(val);
onChange={(e) => setLocalValue(e.target.value)}
onBlur={() => {
const val = localValue ?? displayPath;
const basePrefix = base + '/';
if (!val.trim()) {
settings.setCategoryDirectoryOverride(category, undefined);
settings.setCategorySubfolder(category, '');
} else if (val.startsWith(basePrefix)) {
settings.setCategoryDirectoryOverride(category, undefined);
settings.setCategorySubfolder(category, val.substring(basePrefix.length));
} else {
settings.setCategoryDirectoryOverride(category, val);
}
}}
onBlur={() => {
setLocalValue(null);
// Normalize subfolder if not an override
const currentOverride = settings.categoryDirectoryOverrides[category];
if (!currentOverride) {
settings.setCategorySubfolder(
category,
normalizeCategorySubfolder(
settings.categorySubfolders[category] || '',
val.substring(basePrefix.length),
DEFAULT_CATEGORY_SUBFOLDERS[category as keyof typeof DEFAULT_CATEGORY_SUBFOLDERS]
)
);
} else {
settings.setCategoryDirectoryOverride(category, val.trim());
}
setLocalValue(null);
}}
className="app-control flex-1 max-w-[280px] text-[12px] px-3 py-1.5 bg-surface-overlay/50 border-border-color/50 focus:border-accent-color focus:bg-surface-overlay"
aria-label={`${category} subfolder`}
@@ -189,6 +183,7 @@ const [expandedEngine, setExpandedEngine] = useState<string | null>(null);
const [isRecheckingEngines, setIsRecheckingEngines] = useState(false);
const engineRunId = useRef(0);
const [appVersion, setAppVersion] = useState('0.7.3');
const [extensionServerPort, setExtensionServerPort] = useState<number | null>(null);
// Local state for adding site login
const [loginPattern, setLoginPattern] = useState('');
@@ -204,6 +199,27 @@ useEffect(() => {
getVersion().then(setAppVersion).catch(() => undefined);
}, []);
useEffect(() => {
if (settings.activeView !== 'settings' || activeTab !== 'integrations') return;
let active = true;
const refresh = () => {
invoke('get_extension_server_port')
.then(port => {
if (active) setExtensionServerPort(port);
})
.catch(() => {
if (active) setExtensionServerPort(null);
});
};
refresh();
const timer = window.setInterval(refresh, 3000);
return () => {
active = false;
window.clearInterval(timer);
};
}, [settings.activeView, activeTab]);
const runEngineChecks = useCallback((force = false) => {
const runId = ++engineRunId.current;
const cached = engineChecks
@@ -309,7 +325,24 @@ runEngineChecks(false);
if (result.type === 'UpToDate') {
showToast(`Firelink ${result.latest_version} is up to date`, 'success');
} else if (result.type === 'UpdateAvailable') {
showToast(`Firelink ${result.update.version} is available`, 'info');
addToast({
variant: 'info',
isActionable: true,
message: (
<div className="flex items-center gap-3">
<span>Firelink {result.update.version} is available.</span>
<button
type="button"
className="app-button px-2 py-1"
onClick={() => {
void openUrl(result.update.release_url);
}}
>
View release
</button>
</div>
)
});
} else {
showToast('The update check returned an unexpected response', 'warning');
}
@@ -363,6 +396,8 @@ runEngineChecks(false);
});
} catch (e) {
console.error("Failed to create directories on disk:", e);
showToast(`Base folder saved, but category folders could not be created: ${String(e)}`, 'warning');
return;
}
showToast("Base download folder updated", 'success');
}
@@ -400,9 +435,13 @@ runEngineChecks(false);
showToast("Added site credential", 'success');
};
const copyToken = () => {
navigator.clipboard.writeText(settings.extensionPairingToken);
showToast("Token copied to clipboard!", 'success');
const copyToken = async () => {
try {
await navigator.clipboard.writeText(settings.extensionPairingToken);
showToast("Token copied to clipboard!", 'success');
} catch (error) {
showToast(`Could not copy token: ${String(error)}`, 'error');
}
};
const activeTabLabel = settingsTabs.find(tab => tab.type === activeTab)?.label ?? 'Downloads';
@@ -486,18 +525,15 @@ runEngineChecks(false);
<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>
<small>{settings.globalSpeedLimit || 'Unlimited'}</small>
</div>
<button
type="button"
onClick={() => settings.setActiveView('speedLimiter')}
className="app-button px-3 text-xs"
>
Configure
</button>
</div>
<div className="mac-settings-row">
<div className="settings-row-label">
@@ -759,7 +795,7 @@ runEngineChecks(false);
<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>
<span className="text-[13px] text-text-primary">Ask where to save when adding downloads</span>
<input
type="checkbox"
checked={settings.askWhereToSaveEachFile}
@@ -826,11 +862,11 @@ runEngineChecks(false);
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", 'success');
} catch (error) {
showToast(`Could not delete credential: ${String(error)}`, 'error');
}
settings.removeSiteLogin(login.id);
showToast("Deleted credential", 'success');
}}
className="p-1.5 hover:bg-item-hover rounded-md text-text-muted hover:text-red-500"
title="Delete credential"
@@ -1026,15 +1062,19 @@ className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-bg-modal hover:bg
</div>
<div className="space-y-2">
<button
onClick={copyToken}
onClick={() => void 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", 'success');
onClick={async () => {
try {
await settings.regeneratePairingToken();
showToast("Pairing token regenerated", 'success');
} catch (error) {
showToast(`Could not regenerate pairing token: ${String(error)}`, 'error');
}
}}
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"
>
@@ -1085,8 +1125,10 @@ className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-bg-modal hover:bg
{/* 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:6412-6422 (Active)
<span className={`${extensionServerPort ? 'text-green-500' : 'text-orange-400'} font-semibold flex items-center gap-1`}>
{extensionServerPort
? `● Listening on 127.0.0.1:${extensionServerPort}`
: '● Server unavailable'}
</span>
</div>
</div>
+9 -1
View File
@@ -32,8 +32,15 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
useEffect(() => {
const handleCloseMenu = () => setContextMenu(null);
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setContextMenu(null);
};
window.addEventListener('click', handleCloseMenu);
return () => window.removeEventListener('click', handleCloseMenu);
window.addEventListener('keydown', handleEscape);
return () => {
window.removeEventListener('click', handleCloseMenu);
window.removeEventListener('keydown', handleEscape);
};
}, []);
useEffect(() => {
@@ -247,6 +254,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
{contextMenu && (
<div
role="menu"
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: Math.min(contextMenu.y, window.innerHeight - 200),
+2 -2
View File
@@ -78,8 +78,8 @@ export default function SpeedLimiterView() {
<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.
This cap is shared by transfers running through the core downloader. A lower per-download limit still takes precedence.
Saving updates the core downloader immediately; media extraction keeps its existing per-download options.
</p>
<div className="mt-6 flex items-center gap-3">