feat(i18n): add localization infrastructure and English catalog

Refs #17
This commit is contained in:
NimBold
2026-07-18 01:06:21 +03:30
parent 62365f514e
commit c914eeb7b3
28 changed files with 1845 additions and 652 deletions
+105 -78
View File
@@ -22,12 +22,13 @@ import {
import { getPlatformInfo } from '../utils/platform';
import { isTransferLocked } from '../utils/downloadActions';
import { useToast } from '../contexts/ToastContext';
import { useTranslation } from 'react-i18next';
import {
canSubmitMetadataRows,
appendRequestUrlsAfterVersion,
mediaFileNameForSelectedFormat,
mediaFormatSelectorForRow,
metadataSummaryMessage,
metadataSummaryState,
playlistFilePrefix,
reconcileDownloadRows,
refreshFailedMetadataRows,
@@ -36,7 +37,6 @@ import {
} from '../utils/addDownloadMetadata';
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));
@@ -112,6 +112,7 @@ const extensionHeaders = (context: PendingAddRequestContext | undefined) => [
].filter(Boolean).join('\n');
export const AddDownloadsModal = () => {
const { t } = useTranslation();
const { addToast } = useToast();
const {
isAddModalOpen,
@@ -214,7 +215,7 @@ export const AddDownloadsModal = () => {
const hasPendingInput = Boolean(
urls.trim() || pendingAddUrls.trim() || parsedItems.length || headers.trim() || cookies.trim()
);
if (hasPendingInput && !window.confirm('Discard this download setup?')) return;
if (hasPendingInput && !window.confirm(t($ => $.addDownloads.discardSetup))) return;
toggleAddModal(false);
}, [cookies, headers, isSubmitting, parsedItems.length, pendingAddUrls, showKeychainModal, toggleAddModal, urls]);
@@ -468,7 +469,7 @@ export const AddDownloadsModal = () => {
});
if (latestPlaylistRequestRef.current.get(row.sourceUrl) !== requestKey) return;
if (!playlistData.entries.length) {
throw new Error('Playlist contains no downloadable entries');
throw new Error(t($ => $.addDownloads.playlistNoEntries));
}
setPlaylistExpansions(current => ({
...current,
@@ -492,7 +493,7 @@ export const AddDownloadsModal = () => {
bytes,
isApproximate,
formatLabel: f.format_label || f.ext.toUpperCase(),
detail: bytes ? `${isApproximate ? '~' : ''}${formatBytes(bytes)}` : 'Unknown size',
detail: bytes ? `${isApproximate ? '~' : ''}${formatBytes(bytes)}` : t($ => $.addDownloads.unknownSize),
selector: f.format_id,
type: quality.toLowerCase().includes('audio') ? 'Audio' : 'Video'
};
@@ -517,7 +518,7 @@ export const AddDownloadsModal = () => {
})
));
} else {
throw new Error("Invalid media metadata or no formats found");
throw new Error(t($ => $.addDownloads.invalidMediaMetadata));
}
} else {
let keychainPassword = null;
@@ -657,7 +658,7 @@ export const AddDownloadsModal = () => {
return;
}
if (speedLimitEnabled && (!Number.isFinite(Number(speedLimit)) || Number(speedLimit) <= 0)) {
addToast({ message: 'Speed limit must be greater than zero', variant: 'error', isActionable: true });
addToast({ message: t($ => $.addDownloads.speedInvalid), variant: 'error', isActionable: true });
return;
}
isSubmittingRef.current = true;
@@ -723,12 +724,12 @@ export const AddDownloadsModal = () => {
)
);
if (isUrlDupe) {
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: 'URL already in queue' }, resolution: 'rename' });
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: t($ => $.addDownloads.urlAlreadyQueued) }, resolution: 'rename' });
} else if (hasBatchConflict) {
newConflicts.push({
id: i.toString(),
fileName: finalFile,
reason: { type: 'file', msg: 'Another selected download uses this destination' },
reason: { type: 'file', msg: t($ => $.addDownloads.destinationConflict) },
resolution: 'rename',
replaceAllowed: false
});
@@ -767,8 +768,8 @@ export const AddDownloadsModal = () => {
reason: {
type: 'file',
msg: fileExistsInStore
? 'Existing Firelink download uses this destination'
: 'File exists on disk; rename or skip to avoid deleting unrelated data'
? t($ => $.addDownloads.existingDownloadDestination)
: t($ => $.addDownloads.fileExistsOnDisk)
},
resolution: 'rename',
replaceAllowed: fileExistsInStore
@@ -881,7 +882,7 @@ export const AddDownloadsModal = () => {
count++;
}
if (exists) {
throw new Error(`Could not find an available name for ${finalFile}.`);
throw new Error(t($ => $.addDownloads.noAvailableName, { file: finalFile }));
}
itemsToAdd[idx] = { ...item, file: newName };
@@ -917,11 +918,11 @@ export const AddDownloadsModal = () => {
}
if (existingItem && isTransferLocked(existingItem.status)) {
throw new Error(`Pause ${existingItem.fileName} before replacing it.`);
throw new Error(t($ => $.addDownloads.pauseBeforeReplace, { file: existingItem.fileName }));
}
if (!existingItem) {
throw new Error(`Cannot replace ${finalFile}: file is not owned by a Firelink download.`);
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
}
// Let the backend decide whether resumable sidecars still
// exist after stopping the old transfer. This avoids a race
@@ -972,7 +973,7 @@ export const AddDownloadsModal = () => {
sizeBytes: item.sizeBytes
}, action);
if (!added) {
throw new Error('Backend rejected download start.');
throw new Error(t($ => $.addDownloads.backendRejectedStart));
}
addedCount += 1;
} catch (e) {
@@ -983,13 +984,15 @@ export const AddDownloadsModal = () => {
toggleAddModal(false);
if (failures.length > 0) {
addToast({
message: `${addedCount} added, ${failures.length} failed. ${failures[0]}`,
message: t($ => $.addDownloads.addedWithFailures, { added: addedCount, failed: failures.length, detail: failures[0] }),
variant: 'error',
isActionable: true
});
} else if (addedCount > 0) {
addToast({
message: `${addedCount} download${addedCount === 1 ? '' : 's'} added`,
message: addedCount === 1
? t($ => $.addDownloads.addedOne)
: t($ => $.addDownloads.addedMany, { count: addedCount }),
variant: 'success'
});
}
@@ -1070,6 +1073,21 @@ export const AddDownloadsModal = () => {
);
const playlistSummaries = Object.entries(playlistExpansions)
.filter(([sourceUrl]) => activePlaylistUrls.has(sourceUrl));
const metadataSummary = (() => {
const summary = metadataSummaryState(parsedItems);
const plural = (count: number) => count === 1 ? '' : 's';
switch (summary.type) {
case 'empty': return t($ => $.addDownloads.pasteOneOrMore);
case 'none-selected': return t($ => $.addDownloads.selectAtLeastOne);
case 'invalid': return t($ => $.addDownloads.correctInvalid, { count: summary.count, plural: plural(summary.count) });
case 'loading': return t($ => $.addDownloads.waitingForMetadata, { count: summary.count, plural: plural(summary.count) });
case 'unsafe': return t($ => $.addDownloads.removeUnsafe, { count: summary.count, plural: plural(summary.count) });
case 'media-error': return t($ => $.addDownloads.mediaMetadataUnavailableSummary, { count: summary.count, plural: plural(summary.count) });
case 'all-error': return t($ => $.addDownloads.metadataUnavailableFallback);
case 'fallback': return t($ => $.addDownloads.fallbackReady, { ready: summary.ready, readyPlural: plural(summary.ready), failed: summary.failed });
case 'ready': return t($ => $.addDownloads.readyToAdd, { count: summary.count, plural: plural(summary.count) });
}
})();
return (
<>
@@ -1090,7 +1108,7 @@ export const AddDownloadsModal = () => {
)
.catch(error => {
addToast({
message: `Could not resolve duplicate downloads: ${String(error)}`,
message: t($ => $.addDownloads.duplicateResolveFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
@@ -1124,12 +1142,12 @@ export const AddDownloadsModal = () => {
<div className="flex items-center justify-between">
<div className="add-download-section-title flex items-center gap-2">
<Link size={16} className="text-blue-500" />
Download Links
{t($ => $.addDownloads.downloadLinks)}
</div>
</div>
<textarea
className="add-download-control add-download-links-input w-full h-32 p-3 text-[13px] resize-none"
placeholder={"Paste HTTP, HTTPS, FTP, or SFTP URLs here...\n\nFor media downloads, paste links from Youtube, X, TikTok, Instagram, Reddit, etc."}
placeholder={t($ => $.addDownloads.pastePlaceholder)}
value={urls}
onChange={(e) => setUrls(e.target.value)}
/>
@@ -1137,15 +1155,24 @@ export const AddDownloadsModal = () => {
const total = playlist.entry_count || playlist.entries.length;
return (
<p key={sourceUrl} className="px-1 text-[11px] text-purple-500 dark:text-purple-400">
Playlist {playlist.title}: {playlist.entries.length}{total > playlist.entries.length ? ` of ${total}` : ''} entries loaded
{playlist.truncated ? ' (safe entry limit reached)' : ''}
{playlist.skipped_entries > 0 ? `; ${playlist.skipped_entries} skipped, unavailable, duplicated, or outside the safe limit` : ''}.
{t($ => $.addDownloads.playlistSummary, {
title: playlist.title,
loaded: playlist.entries.length,
total: total > playlist.entries.length ? ` of ${total}` : '',
truncated: playlist.truncated ? t($ => $.addDownloads.safeEntryLimit) : '',
skipped: playlist.skipped_entries > 0 ? `; ${playlist.skipped_entries} skipped, unavailable, duplicated, or outside the safe limit` : '',
})}.
</p>
);
})}
<div className="flex justify-between items-center px-1">
<span className="text-[11px] text-text-muted font-medium">
{selectedItems.filter(item => item.status === 'ready').length} selected ready, {fallbackMetadataCount} fallback, {failedMediaMetadataCount} media retry, {blockedMetadataCount} blocked
{t($ => $.addDownloads.selectedSummary, {
ready: selectedItems.filter(item => item.status === 'ready').length,
fallback: fallbackMetadataCount,
mediaRetry: failedMediaMetadataCount,
blocked: blockedMetadataCount,
})}
</span>
<button
type="button"
@@ -1153,7 +1180,7 @@ export const AddDownloadsModal = () => {
disabled={failedMetadataCount === 0}
className="add-download-link-button flex items-center gap-1.5 text-[11px] font-medium"
>
<RefreshCw size={12} /> Refresh Metadata
<RefreshCw size={12} /> {t($ => $.addDownloads.refreshMetadata)}
</button>
<button
type="button"
@@ -1161,33 +1188,33 @@ export const AddDownloadsModal = () => {
disabled={parsedItems.length === 0}
className="add-download-link-button ml-3 text-[11px] font-medium"
>
{allRowsSelected ? 'Clear selection' : 'Select all'}
{allRowsSelected ? t($ => $.addDownloads.clearSelection) : t($ => $.addDownloads.selectAll)}
</button>
</div>
</div>
<div className="grid grid-cols-4 gap-3">
<SummaryBox title="Files" value={selectedItems.length === parsedItems.length ? parsedItems.length : `${selectedItems.length}/${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={selectedItems.filter(i => !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
<SummaryBox title={t($ => $.addDownloads.files)} value={selectedItems.length === parsedItems.length ? parsedItems.length : `${selectedItems.length}/${parsedItems.length}`} icon={FileText} color="text-blue-500" />
<SummaryBox title={t($ => $.addDownloads.required)} value={requiredStr === 'Unknown' ? t($ => $.addDownloads.unknown) : requiredStr} icon={Database} color="text-orange-500" />
<SummaryBox title={t($ => $.addDownloads.free)} value={freeSpace === 'Unknown' ? t($ => $.addDownloads.unknown) : freeSpace} icon={HardDrive} color="text-green-500" />
<SummaryBox title={t($ => $.addDownloads.unknown)} value={selectedItems.filter(i => !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
</div>
<div className="flex flex-col gap-2 flex-1 min-h-0 min-w-0 overflow-hidden">
<div className="add-download-section-title flex items-center gap-2">
<ArrowRight size={16} className="text-blue-500" />
Preview
{t($ => $.addDownloads.preview)}
</div>
<div className="add-download-preview flex-1 min-h-0 min-w-0 overflow-hidden flex flex-col">
<div className="add-download-preview-header 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 className="flex-[2]">{t($ => $.addDownloads.file)}</div>
<div className="flex-1">{t($ => $.addDownloads.size)}</div>
<div className="flex-[1.5]">{t($ => $.addDownloads.status)}</div>
</div>
<div className="flex-1 min-h-0 min-w-0 overflow-y-auto p-2 space-y-1" role="listbox" aria-label="Download preview">
<div className="flex-1 min-h-0 min-w-0 overflow-y-auto p-2 space-y-1" role="listbox" aria-label={t($ => $.addDownloads.downloadPreview)}>
{parsedItems.length === 0 ? (
<div className="add-download-empty h-full flex items-center justify-center text-text-muted text-xs">
No links added yet.
{t($ => $.addDownloads.noLinks)}
</div>
) : (
parsedItems.map((item, i) => (
@@ -1215,22 +1242,22 @@ export const AddDownloadsModal = () => {
checked={item.selected !== false}
onChange={() => toggleRowSelection(i)}
onClick={event => event.stopPropagation()}
aria-label={`Select ${item.file}`}
aria-label={t($ => $.addDownloads.selectItem, { file: item.file })}
className="mr-2 shrink-0 accent-purple-500"
/>
<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 font-mono ${item.status === 'loading' ? 'text-text-muted/50' : 'text-text-muted'}`}>{item.size || t($ => $.addDownloads.unknown)}</div>
<div className={`flex-[1.5] font-medium ${item.status === 'metadata-error' || item.status === 'invalid' ? '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" /> {item.isPlaylist ? 'Fetching playlist...' : 'Fetching...'}
<RefreshCw size={12} className="animate-spin" /> {item.isPlaylist ? t($ => $.addDownloads.fetchingPlaylist) : t($ => $.addDownloads.fetching)}
</div>
) : (
item.status === 'metadata-error'
? item.metadataBlockedReason === 'unsafe-url' ? 'Unsafe URL' : item.isPlaylist ? 'Playlist failed' : item.isMedia ? 'Metadata failed' : 'Fallback'
? item.metadataBlockedReason === 'unsafe-url' ? t($ => $.addDownloads.unsafeUrl) : item.isPlaylist ? t($ => $.addDownloads.playlistFailed) : item.isMedia ? t($ => $.addDownloads.metadataFailed) : t($ => $.addDownloads.fallback)
: item.status === 'invalid'
? 'Invalid'
: 'Ready'
? t($ => $.addDownloads.invalid)
: t($ => $.addDownloads.ready)
)}
</div>
</div>
@@ -1255,10 +1282,10 @@ export const AddDownloadsModal = () => {
<Video size={48} />
</div>
<div className="add-download-section-title flex items-center gap-2 mb-3 relative z-10">
<Video size={16} className="text-purple-500" /> Media Format
<Video size={16} className="text-purple-500" /> {t($ => $.addDownloads.mediaFormat)}
{parsedItems[selectedItemIndex].playlistSourceUrl && (
<span className="text-[10px] font-normal text-text-muted">
Playlist item {parsedItems[selectedItemIndex].playlistIndex || '?'}
{t($ => $.addDownloads.playlistItem, { index: parsedItems[selectedItemIndex].playlistIndex || '?' })}
</span>
)}
</div>
@@ -1266,13 +1293,13 @@ export const AddDownloadsModal = () => {
{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>
<span className="text-xs text-text-muted font-medium animate-pulse">{t($ => $.addDownloads.fetchingMediaStreams)}</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" role="radiogroup" aria-label="Available media streams">
<label className="text-[10px] uppercase font-bold tracking-wider text-text-muted">{t($ => $.addDownloads.availableStreams)}</label>
<div className="flex flex-col gap-1 max-h-48 overflow-y-auto pr-1" role="radiogroup" aria-label={t($ => $.addDownloads.availableMediaStreams)}>
{parsedItems[selectedItemIndex].formats!.map((f, idx) => {
const isSelected = parsedItems[selectedItemIndex].selectedFormat === idx;
const Icon = f.type === 'Audio' ? Music : Film;
@@ -1309,7 +1336,7 @@ export const AddDownloadsModal = () => {
</div>
) : (
<div className="flex flex-col items-center justify-center py-4 relative z-10">
<span className="text-xs text-red-400 font-medium">Metadata unavailable. Refresh metadata before adding this media.</span>
<span className="text-xs text-red-400 font-medium">{t($ => $.addDownloads.metadataUnavailable)}</span>
</div>
)}
</section>
@@ -1318,7 +1345,7 @@ export const AddDownloadsModal = () => {
{/* Save Location */}
<section className="add-download-section">
<div className="add-download-section-title flex items-center gap-2 mb-3">
<FolderPlus size={16} className="text-blue-500" /> Save Location
<FolderPlus size={16} className="text-blue-500" /> {t($ => $.addDownloads.saveLocation)}
</div>
<div className="flex gap-2">
<input
@@ -1326,23 +1353,23 @@ export const AddDownloadsModal = () => {
readOnly
value={saveLocation}
className="add-download-control flex-1 px-3 py-1.5 text-xs text-text-muted font-mono"
aria-label="Save location"
aria-label={t($ => $.addDownloads.saveLocation)}
/>
<button
onClick={handleBrowse}
className="add-download-button add-download-button-secondary px-3 text-xs font-medium"
>
Browse
{t($ => $.addDownloads.browse)}
</button>
</div>
{parsedItems.length > 1 && !isSaveLocationManual && (
<p className="mt-2 text-[11px] text-text-muted">
Files will be organized into category folders.
{t($ => $.addDownloads.categoryFolders)}
</p>
)}
{isSaveLocationManual && (
<p className="mt-2 text-[11px] text-text-muted">
All selected downloads will use this folder.
{t($ => $.addDownloads.sharedFolder)}
</p>
)}
</section>
@@ -1350,13 +1377,13 @@ export const AddDownloadsModal = () => {
{/* Transfer Settings */}
<section className="add-download-section">
<div className="add-download-section-title flex items-center gap-2 mb-3">
<Settings size={16} className="text-blue-500" /> Transfer Settings
<Settings size={16} className="text-blue-500" /> {t($ => $.addDownloads.transferSettings)}
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<label className="text-xs text-text-secondary font-medium">Connections per File</label>
<label className="text-xs text-text-secondary font-medium">{t($ => $.addDownloads.connectionsPerFile)}</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="add-download-range w-24 accent-blue-500 cursor-pointer" aria-label="Connections per file" />
<input type="range" min="1" max="16" value={connections} onChange={e=>setConnections(Number(e.target.value))} className="add-download-range w-24 accent-blue-500 cursor-pointer" aria-label={t($ => $.addDownloads.connectionsPerFileAria)} />
<span className="add-download-value text-xs text-text-primary font-mono w-6 text-center">{connections}</span>
</div>
</div>
@@ -1364,11 +1391,11 @@ export const AddDownloadsModal = () => {
<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="add-download-checkbox" />
Limit speed per file
{t($ => $.addDownloads.limitSpeedPerFile)}
</label>
{speedLimitEnabled && (
<div className="flex items-center gap-1.5">
<input type="number" value={speedLimit} onChange={e=>setSpeedLimit(e.target.value)} className="add-download-control w-16 px-2 py-1 text-xs font-mono" aria-label="Speed limit per file" />
<input type="number" value={speedLimit} onChange={e=>setSpeedLimit(e.target.value)} className="add-download-control w-16 px-2 py-1 text-xs font-mono" aria-label={t($ => $.addDownloads.speedLimitPerFileAria)} />
<span className="text-[10px] text-text-muted">KiB/s</span>
</div>
)}
@@ -1379,17 +1406,17 @@ export const AddDownloadsModal = () => {
{/* Authorization */}
<section className="add-download-section">
<div className="add-download-section-title flex items-center gap-2 mb-3">
<Shield size={16} className="text-blue-500" /> Authorization
<Shield size={16} className="text-blue-500" /> {t($ => $.addDownloads.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="add-download-checkbox" />
Use authorization
{t($ => $.addDownloads.useAuthorization)}
</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="add-download-control w-full px-3 py-1.5 text-xs" />
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} placeholder="Password" className="add-download-control w-full px-3 py-1.5 text-xs" />
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} placeholder={t($ => $.addDownloads.username)} className="add-download-control w-full px-3 py-1.5 text-xs" />
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} placeholder={t($ => $.addDownloads.password)} className="add-download-control w-full px-3 py-1.5 text-xs" />
</div>
)}
</section>
@@ -1402,27 +1429,27 @@ export const AddDownloadsModal = () => {
aria-expanded={advancedExpanded}
>
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
Advanced Transfer
{t($ => $.addDownloads.advancedTransfer)}
</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="add-download-checkbox" />
Verify Checksum
{t($ => $.addDownloads.verifyChecksum)}
</label>
{checksumEnabled && (
<div className="flex gap-2">
<select value={checksumAlgo} onChange={e=>setChecksumAlgo(e.target.value)} className="add-download-control add-download-select w-24 px-2 text-xs" aria-label="Checksum algorithm">
<select value={checksumAlgo} onChange={e=>setChecksumAlgo(e.target.value)} className="add-download-control add-download-select w-24 px-2 text-xs" aria-label={t($ => $.addDownloads.checksumAlgorithm)}>
<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="add-download-control flex-1 px-3 py-1.5 text-xs font-mono" />
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} placeholder={t($ => $.addDownloads.expectedDigest)} className="add-download-control flex-1 px-3 py-1.5 text-xs font-mono" />
</div>
)}
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">Headers</label>
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">{t($ => $.addDownloads.headers)}</label>
<textarea
value={headers}
onChange={e => {
@@ -1430,11 +1457,11 @@ export const AddDownloadsModal = () => {
setHeaders(e.target.value);
}}
className="add-download-control w-full h-12 px-3 py-1.5 text-xs font-mono resize-none"
aria-label="Request headers"
aria-label={t($ => $.addDownloads.requestHeaders)}
/>
</div>
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">Cookies</label>
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">{t($ => $.addDownloads.cookies)}</label>
<input
type="text"
value={cookies}
@@ -1442,14 +1469,14 @@ export const AddDownloadsModal = () => {
cookiesManuallyEditedRef.current = true;
setCookies(e.target.value);
}}
placeholder="name=value; other=value"
placeholder={t($ => $.addDownloads.cookiePlaceholder)}
className="add-download-control w-full px-3 py-1.5 text-xs font-mono"
aria-label="Cookies"
aria-label={t($ => $.addDownloads.cookies)}
/>
</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="add-download-control w-full h-12 px-3 py-1.5 text-xs font-mono resize-none" aria-label="Mirrors" />
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">{t($ => $.addDownloads.mirrors)}</label>
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} className="add-download-control w-full h-12 px-3 py-1.5 text-xs font-mono resize-none" aria-label={t($ => $.addDownloads.mirrors)} />
</div>
</div>
)}
@@ -1462,11 +1489,11 @@ export const AddDownloadsModal = () => {
{/* Footer */}
<div className="add-download-footer p-4 flex items-center shrink-0">
<div className="text-[11px] text-text-muted font-medium flex-1">
{metadataSummaryMessage(parsedItems)}
{metadataSummary}
</div>
<div className="flex gap-2.5">
<button onClick={closeModalFromDismissAction} disabled={isSubmitting || showKeychainModal} className="add-download-button add-download-button-secondary px-4 text-xs">
Cancel
{t($ => $.addDownloads.cancel)}
</button>
<div ref={actionMenuRef} className="relative flex gap-2.5">
<button
@@ -1474,7 +1501,7 @@ export const AddDownloadsModal = () => {
disabled={!canSubmit || isSubmitting}
className="add-download-button add-download-button-primary px-5 text-xs"
>
<Play size={12} fill="currentColor" /> Start Downloads
<Play size={12} fill="currentColor" /> {t($ => $.addDownloads.startDownloads)}
</button>
<div className="relative">
<button
@@ -1482,11 +1509,11 @@ export const AddDownloadsModal = () => {
onClick={() => setIsQueueMenuOpen(open => !open)}
disabled={!canSubmit || isSubmitting}
className="add-download-button add-download-button-secondary px-4 text-xs"
aria-label="Add to queue"
aria-label={t($ => $.addDownloads.addToQueue)}
aria-haspopup="menu"
aria-expanded={isQueueMenuOpen}
>
Add to queue <ChevronDown size={14} className="ml-1" />
{t($ => $.addDownloads.addToQueue)} <ChevronDown size={14} className="ml-1" />
</button>
{isQueueMenuOpen && (
<div
+15 -6
View File
@@ -1,8 +1,10 @@
import React, { useState, useEffect } from 'react';
import { useDownloadStore } from '../store/useDownloadStore';
import { AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export const DeleteConfirmationModal: React.FC = () => {
const { t } = useTranslation();
const { deleteModalState, closeDeleteModal, removeDownload } = useDownloadStore();
const [errorMessage, setErrorMessage] = useState('');
const [isRemoving, setIsRemoving] = useState(false);
@@ -50,7 +52,11 @@ export const DeleteConfirmationModal: React.FC = () => {
}
if (failures.length > 0) {
setErrorMessage(`${succeeded} removed, ${failures.length} failed: ${failures[0]}`);
setErrorMessage(t($ => $.dialogs.removeDownload.errorSummary, {
succeeded,
failed: failures.length,
detail: failures[0],
}));
setIsRemoving(false);
return;
}
@@ -60,6 +66,7 @@ export const DeleteConfirmationModal: React.FC = () => {
const handleRemoveFromList = () => removeMany(false);
const handleDeleteFile = () => removeMany(true);
const itemCount = deleteModalState.downloadIds?.length ?? 0;
return (
<div
@@ -78,11 +85,13 @@ export const DeleteConfirmationModal: React.FC = () => {
<div className="p-2 bg-red-500/10 rounded-full flex items-center justify-center">
<AlertTriangle size={20} className="text-red-400" />
</div>
<h2 className="text-lg font-semibold text-text-primary m-0">Remove Download</h2>
<h2 className="text-lg font-semibold text-text-primary m-0">{t($ => $.dialogs.removeDownload.title)}</h2>
</div>
<div className="px-5 py-6 flex-1 text-sm text-text-secondary leading-relaxed">
{`Are you sure you want to remove ${deleteModalState.downloadIds?.length && deleteModalState.downloadIds.length > 1 ? 'these ' + deleteModalState.downloadIds.length + ' items' : 'this item'} from the list? You can also choose to delete the underlying file from your hard drive.`}
{itemCount > 1
? t($ => $.dialogs.removeDownload.confirmationMultiple, { count: itemCount })
: t($ => $.dialogs.removeDownload.confirmationSingle)}
{errorMessage && <div className="mt-3 text-xs text-red-400">{errorMessage}</div>}
</div>
@@ -92,21 +101,21 @@ export const DeleteConfirmationModal: React.FC = () => {
disabled={isRemoving}
className="app-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
Cancel
{t($ => $.actions.cancel)}
</button>
<button
onClick={handleRemoveFromList}
disabled={isRemoving}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-border-modal hover:bg-border-modal/80 text-text-primary disabled:opacity-50"
>
Remove
{t($ => $.dialogs.removeDownload.remove)}
</button>
<button
onClick={handleDeleteFile}
disabled={isRemoving}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-50"
>
Delete file
{t($ => $.dialogs.removeDownload.deleteFile)}
</button>
</div>
</div>
+37 -22
View File
@@ -2,7 +2,8 @@ import React from 'react';
import { useDownloadProgressStore } from '../store/downloadProgressStore';
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';
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
import { useTranslation } from 'react-i18next';
import {
downloadProgressColorClass,
formatDownloadTotal,
@@ -38,6 +39,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
onMoveInQueue,
onClick,
}) => {
const { t } = useTranslation();
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
const displayFraction = download.status === 'downloading'
@@ -47,12 +49,12 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
const displaySpeed = download.status === 'downloading'
? liveProgress?.speed ?? download.speed
: download.status === 'processing'
? 'Processing...'
? t($ => $.downloads.values.processing)
: '-';
const displayEta = download.status === 'downloading'
? liveProgress?.eta ?? download.eta
: download.status === 'processing'
? 'Muxing...'
? t($ => $.downloads.values.muxing)
: '-';
const sizeDisplay = resolveDownloadSizeDisplay({
downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes,
@@ -62,9 +64,22 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
});
const hasDownloadedAmount = download.status !== 'completed' &&
Boolean(sizeDisplay.downloaded && sizeDisplay.total);
const completedSizeLabel = download.status === 'completed'
? formatDownloadTotal(sizeDisplay)
: sizeDisplay.fallback;
const completedSizeLabel = (() => {
const value = download.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
})();
const downloadStatusLabel = t($ => $.downloads.status[download.status]);
const downloadedSizeLabel = sizeDisplay.totalIsEstimate
? t($ => $.downloads.size.downloadedOfApproximate, {
downloaded: sizeDisplay.downloaded ?? '',
total: sizeDisplay.total ?? '',
unit: sizeDisplay.unit ?? '',
})
: t($ => $.downloads.size.downloadedOf, {
downloaded: sizeDisplay.downloaded ?? '',
total: sizeDisplay.total ?? '',
unit: sizeDisplay.unit ?? '',
});
return (
<div
@@ -88,10 +103,10 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<div
className="download-cell-truncate download-size-cell tabular-nums"
title={hasDownloadedAmount
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? '~' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
? downloadedSizeLabel
: completedSizeLabel}
aria-label={hasDownloadedAmount
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? 'approximately ' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
? downloadedSizeLabel
: completedSizeLabel}
>
<div className="download-size-content">
@@ -111,7 +126,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<div className="download-status-cell">
{download.status === 'completed' ? (
<span className="download-status download-status-completed" title="Completed">Completed</span>
<span className="download-status download-status-completed" title={downloadStatusLabel}>{downloadStatusLabel}</span>
) : (
<>
<div className="download-progress-track">
@@ -130,12 +145,12 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
download.lastError && (download.status === 'failed' || download.status === 'retrying')
? download.lastError
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
? `${download.status === 'staged' ? 'In queue' : 'Queued'} #${queueIndex + 1}`
? `${downloadStatusLabel} #${queueIndex + 1}`
: download.status === 'downloading'
? displayPercent
: download.status === 'processing'
? 'Processing'
: download.status.charAt(0).toUpperCase() + download.status.slice(1)
? displayPercent
: download.status === 'processing'
? downloadStatusLabel
: downloadStatusLabel
}
className={`download-status flex items-center gap-1.5 ${
download.status === 'paused' ? 'download-status-paused' :
@@ -150,15 +165,15 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<>
<Clock size={12} className={download.status === 'queued' ? 'animate-pulse shrink-0' : 'shrink-0'} />
<span className="truncate">
{download.status === 'staged' ? 'In queue' : 'Queued'} #{queueIndex + 1}
{downloadStatusLabel} #{queueIndex + 1}
</span>
</>
) : download.status === 'downloading' ? (
displayPercent
) : download.status === 'processing' ? (
'Processing'
downloadStatusLabel
) : (
download.status.charAt(0).toUpperCase() + download.status.slice(1)
downloadStatusLabel
)}
</span>
</>
@@ -202,7 +217,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
onClick={() => onMoveInQueue(download.id, 'up')}
disabled={queueIndex === 0}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title="Move Up"
title={t($ => $.downloads.actions.moveUp)}
>
<ArrowUp size={14} />
</button>
@@ -210,19 +225,19 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
onClick={() => onMoveInQueue(download.id, 'down')}
disabled={queueIndex === queueLength - 1}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title="Move Down"
title={t($ => $.downloads.actions.moveDown)}
>
<ArrowDown size={14} />
</button>
</>
)}
{canPauseDownload(download.status) && (
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title="Pause">
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title={t($ => $.downloads.actions.pause)}>
<Pause size={14} fill="currentColor" />
</button>
)}
{canStartDownload(download.status) && (
<button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title={startActionLabel(download.status)}>
<button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title={download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)}>
<Play size={14} fill="currentColor" />
</button>
)}
@@ -232,7 +247,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
}}
className="app-icon-button h-7 w-7"
title="Options"
title={t($ => $.downloads.actions.options)}
>
<MoreVertical size={14} />
</button>
+66 -51
View File
@@ -14,11 +14,11 @@ import {
import {
canPauseDownload,
canRedownload,
canStartDownload,
startActionLabel
canStartDownload
} from '../utils/downloadActions';
import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads';
import { readClipboardDownloadUrls } from '../utils/clipboard';
import { useTranslation } from 'react-i18next';
import {
sortDownloads,
type DownloadSortColumn,
@@ -33,6 +33,7 @@ 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 { t } = useTranslation();
const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore();
const { addToast } = useToast();
const isMac = navigator.userAgent.includes('Mac');
@@ -174,7 +175,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
await invoke('open_downloaded_file', { path: fullPath });
} catch (error) {
console.error("Failed to open file:", error);
showInteractionError('Could not open downloaded file', error);
showInteractionError(t($ => $.downloadTable.openFileFailed), error);
}
}, [getDownloadPath, openProperties, showInteractionError]);
@@ -190,7 +191,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
await invoke('reveal_in_file_manager', { path: pathToReveal });
} catch (error) {
console.error("Failed to show in Finder:", error);
showInteractionError('Could not show download in Finder', error);
showInteractionError(t($ => $.downloadTable.revealFileFailed), error);
}
};
@@ -351,13 +352,20 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
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';
return queue ? queue.name : t($ => $.downloadTable.unknownQueue);
}
switch (filter) {
case 'all': return 'All Downloads';
case 'active': return 'Active';
case 'completed': return 'Completed';
case 'unfinished': return 'Unfinished';
case 'all': return t($ => $.downloadTable.allDownloads);
case 'active': return t($ => $.downloadTable.active);
case 'completed': return t($ => $.downloadTable.completed);
case 'unfinished': return t($ => $.downloadTable.unfinished);
case 'Musics': return t($ => $.navigation.categories.musics);
case 'Movies': return t($ => $.navigation.categories.movies);
case 'Compressed': return t($ => $.navigation.categories.compressed);
case 'Documents': return t($ => $.navigation.categories.documents);
case 'Pictures': return t($ => $.navigation.categories.pictures);
case 'Applications': return t($ => $.navigation.categories.applications);
case 'Other': return t($ => $.navigation.categories.other);
default: return filter;
}
};
@@ -365,7 +373,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const handlePause = useCallback(async (id: string, skipConfirm = false) => {
const download = useDownloadStore.getState().downloads.find(d => d.id === id);
if (!skipConfirm && download && download.resumable === false) {
const confirmPause = window.confirm("This download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?");
const confirmPause = window.confirm(t($ => $.downloadTable.nonResumableOne));
if (!confirmPause) {
return;
}
@@ -375,7 +383,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
await useDownloadStore.getState().pauseDownload(id);
} catch (e) {
console.error("Failed to pause:", e);
showInteractionError('Could not pause download', e);
showInteractionError(t($ => $.downloadTable.pauseFailed), e);
}
}, [showInteractionError]);
@@ -383,11 +391,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
try {
const resumed = await useDownloadStore.getState().resumeDownload(item.id);
if (!resumed) {
throw new Error('The backend rejected the start/resume request.');
throw new Error(t($ => $.downloadTable.backendRejectedStart));
}
} catch (error) {
console.error("Failed to resume:", error);
showInteractionError(`Could not resume ${item.fileName}`, error);
showInteractionError(t($ => $.downloadTable.resumeFailed, { fileName: item.fileName }), error);
}
}, [showInteractionError]);
@@ -480,7 +488,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
onClick={() => void handleAddDownload()}
disabled={isReadingClipboard}
aria-busy={isReadingClipboard}
title="Add Download"
title={t($ => $.downloadTable.addDownload)}
>
<Plus size={16} />
</button>
@@ -491,7 +499,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
onClick={() => {
void resumeItemsSequentially(sortedDownloads.filter(d => canStartDownload(d.status)));
}}
title="Resume All"
title={t($ => $.downloadTable.resumeAll)}
>
<Play size={15} fill="currentColor" />
</button>
@@ -505,8 +513,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
if (nonResumableCount > 0) {
const confirmPause = window.confirm(
nonResumableCount === 1
? "1 download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?"
: `${nonResumableCount} downloads do not support resuming. If you pause them, you will have to start over again later. Are you sure you want to pause?`
? t($ => $.downloadTable.nonResumableOne)
: t($ => $.downloadTable.nonResumableMany, { count: nonResumableCount })
);
if (!confirmPause) {
return;
@@ -515,7 +523,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
// Skip the individual check by passing a flag to handlePause, or just invoking directly.
toPause.forEach(d => handlePause(d.id, true));
}}
title="Pause All"
title={t($ => $.downloadTable.pauseAll)}
>
<Pause size={15} fill="currentColor" />
</button>
@@ -532,15 +540,22 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
<div className="downloads-table flex-1 flex flex-col">
<div className="download-table-scroll">
<div className="download-table-header" style={{ gridTemplateColumns: tableGridTemplate }}>
{['File Name', 'Size', 'Status', 'Speed', 'ETA', 'Date Added'].map((label, index) => (
{[
{ key: 'File Name' as const, label: t($ => $.downloadTable.headers.fileName) },
{ key: 'Size' as const, label: t($ => $.downloadTable.headers.size) },
{ key: 'Status' as const, label: t($ => $.downloadTable.headers.status) },
{ key: 'Speed' as const, label: t($ => $.downloadTable.headers.speed) },
{ key: 'ETA' as const, label: t($ => $.downloadTable.headers.eta) },
{ key: 'Date Added' as const, label: t($ => $.downloadTable.headers.dateAdded) },
].map(({ key, label }, index) => (
<div
key={label}
key={key}
className={`${index === 5 ? 'download-cell-right' : ''} cursor-pointer hover:text-text-primary transition-colors flex items-center justify-between`}
onClick={() => handleSort(label as DownloadSortColumn)}
onClick={() => handleSort(key)}
>
<div className="flex items-center gap-1 w-full h-full select-none">
<span>{label}</span>
{(isQueueFilter ? queueSortConfig : sortConfig)?.column === label && (
{(isQueueFilter ? queueSortConfig : sortConfig)?.column === key && (
(isQueueFilter ? queueSortConfig : sortConfig)?.direction === 'asc'
? <ChevronUp size={14} />
: <ChevronDown size={14} />
@@ -560,16 +575,16 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
<div className="downloads-empty-state">
<ArrowDownCircle aria-hidden="true" />
<div className="downloads-empty-title">
{isQueueFilter ? 'Queue is empty' : filter === 'completed' ? 'No Completed Downloads' : 'No Downloads'}
{isQueueFilter ? t($ => $.downloadTable.queueEmpty) : filter === 'completed' ? t($ => $.downloadTable.noCompletedDownloads) : t($ => $.downloadTable.noDownloads)}
</div>
<div className="downloads-empty-description flex items-center justify-center mt-2.5 text-[13px] text-text-muted">
{isQueueFilter ? (
'Add downloads to this queue from an item menu or the Add window.'
t($ => $.downloadTable.queueEmptyDescription)
) : filter === 'completed' ? (
'Completed downloads will appear here.'
t($ => $.downloadTable.completedDescription)
) : (
<>
Click <Plus size={15} className="text-accent stroke-[3] mx-1.5" /> button or
{t($ => $.downloadTable.clickToAdd)} <Plus size={15} className="text-accent stroke-[3] mx-1.5" /> {t($ => $.downloadTable.addButtonOr)}
<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>}
@@ -579,7 +594,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
<span className="text-[11px] font-bold text-text-primary">V</span>
</span>
</span>
to add downloads
{t($ => $.downloadTable.toAddDownloads)}
</>
)}
</div>
@@ -642,7 +657,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Start/Resume
{t($ => $.downloadTable.startResume)}
</button>
)}
@@ -654,8 +669,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
if (nonResumableCount > 0) {
const confirmPause = window.confirm(
nonResumableCount === 1
? "1 download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?"
: `${nonResumableCount} downloads do not support resuming. If you pause them, you will have to start over again later. Are you sure you want to pause?`
? t($ => $.downloadTable.nonResumableOne)
: t($ => $.downloadTable.nonResumableMany, { count: nonResumableCount })
);
if (!confirmPause) {
return;
@@ -665,7 +680,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Pause
{t($ => $.downloadTable.pause)}
</button>
)}
@@ -676,7 +691,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
{itemsToQueue.length > 0 && (
<div className="group relative">
<button className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center">
Add to Queue
{t($ => $.downloadTable.addToQueue)}
<ChevronRight size={14} />
</button>
<div className="absolute left-full top-0 hidden group-hover:block ml-1 min-w-[150px] bg-bg-modal border border-border-modal rounded-lg shadow-lg py-1.5 z-50">
@@ -684,7 +699,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
<button key={q.id} onClick={() => {
setContextMenu(null);
void assignToQueue(itemsToQueue.map(item => item.id), q.id).catch(error => {
showInteractionError('Could not move downloads to queue', error);
showInteractionError(t($ => $.downloadTable.moveManyFailed), error);
});
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
{q.name}
@@ -704,12 +719,12 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
.filter(Boolean)
.join('\n');
navigator.clipboard.writeText(urls).catch(error => {
showInteractionError('Could not copy addresses', error);
showInteractionError(t($ => $.downloadTable.copyAddressesFailed), error);
});
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Copy Address
{t($ => $.downloadTable.copyAddress)}
</button>
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
@@ -721,7 +736,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}}
className="w-full text-left px-3 py-2 text-red-400 hover:bg-red-500/10 transition-colors"
>
Remove
{t($ => $.downloadTable.remove)}
</button>
</>
);
@@ -736,7 +751,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Open
{t($ => $.downloadTable.open)}
</button>
)}
@@ -747,7 +762,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Show in Folder
{t($ => $.downloadTable.showInFolder)}
</button>
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
@@ -760,7 +775,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Pause
{t($ => $.downloadTable.pause)}
</button>
)}
@@ -772,7 +787,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
{startActionLabel(contextItem.status)}
{contextItem.status === 'paused' ? t($ => $.downloadTable.resume) : t($ => $.downloadTable.start)}
</button>
)}
@@ -783,19 +798,19 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
try {
await redownload(contextItem.id);
} catch (error) {
showInteractionError('Redownload failed', error);
showInteractionError(t($ => $.downloadTable.redownloadFailed), error);
}
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Redownload
{t($ => $.downloadTable.redownload)}
</button>
)}
{contextItem.status !== 'completed' && (
<div className="group relative">
<button className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center">
Add to Queue
{t($ => $.downloadTable.addToQueue)}
<ChevronRight size={14} />
</button>
<div className="absolute left-full top-0 hidden group-hover:block ml-1 min-w-[150px] bg-bg-modal border border-border-modal rounded-lg shadow-lg py-1.5 z-50">
@@ -803,7 +818,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
<button key={q.id} onClick={() => {
setContextMenu(null);
void assignToQueue([contextItem.id], q.id).catch(error => {
showInteractionError('Could not move download to queue', error);
showInteractionError(t($ => $.downloadTable.moveOneFailed), error);
});
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
{q.name}
@@ -819,12 +834,12 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
onClick={() => {
setContextMenu(null);
navigator.clipboard.writeText(contextItem.url).catch(error => {
showInteractionError('Could not copy address', error);
showInteractionError(t($ => $.downloadTable.copyAddressFailed), error);
});
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Copy Address
{t($ => $.downloadTable.copyAddress)}
</button>
{contextItem.status === 'completed' && (
@@ -833,18 +848,18 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
setContextMenu(null);
const fullPath = await getDownloadPath(contextItem);
if (!fullPath) {
showInteractionError('Could not copy file path', 'File name is missing');
showInteractionError(t($ => $.downloadTable.copyPathFailed), t($ => $.downloadTable.missingFileName));
return;
}
try {
await navigator.clipboard.writeText(fullPath);
} catch (error) {
showInteractionError('Could not copy file path', error);
showInteractionError(t($ => $.downloadTable.copyPathFailed), error);
}
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Copy File Path
{t($ => $.downloadTable.copyFilePath)}
</button>
)}
@@ -857,7 +872,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}}
className="w-full text-left px-3 py-2 text-red-400 hover:bg-red-500/10 transition-colors"
>
Remove
{t($ => $.downloadTable.remove)}
</button>
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
@@ -869,7 +884,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Properties
{t($ => $.downloadTable.properties)}
</button>
</>
)}
+9 -7
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
export type DuplicateReason = { type: 'url', msg: string } | { type: 'file', msg: string };
type DuplicateResolution = 'rename' | 'replace' | 'skip';
@@ -18,6 +19,7 @@ interface Props {
}
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
const { t } = useTranslation();
const [conflicts, setConflicts] = useState<DuplicateConflict[]>(initialConflicts);
useEffect(() => {
@@ -43,8 +45,8 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
>
<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>
<h2 className="text-lg font-semibold text-text-primary">{t($ => $.dialogs.duplicateDownloads.title)}</h2>
<p className="text-xs text-text-muted">{t($ => $.dialogs.duplicateDownloads.description)}</p>
</div>
<div className="max-h-[300px] overflow-y-auto p-4 space-y-3">
@@ -59,9 +61,9 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
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>
{conflict.reason.type === 'file' && conflict.replaceAllowed && <option value="replace">Replace</option>}
<option value="skip">Skip</option>
<option value="rename">{t($ => $.dialogs.duplicateDownloads.rename)}</option>
{conflict.reason.type === 'file' && conflict.replaceAllowed && <option value="replace">{t($ => $.dialogs.duplicateDownloads.replace)}</option>}
<option value="skip">{t($ => $.dialogs.duplicateDownloads.skip)}</option>
</select>
</div>
))}
@@ -69,13 +71,13 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
<div className="p-4 border-t border-border-modal flex items-center justify-between bg-sidebar-bg/50">
<button onClick={onCancel} className="app-button px-4 text-xs">
Cancel
{t($ => $.actions.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
{t($ => $.actions.continue)}
</button>
</div>
</div>
+4 -3
View File
@@ -1,4 +1,5 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import i18n from '../i18n';
interface Props {
children: ReactNode;
@@ -38,10 +39,10 @@ export class ErrorBoundary extends Component<Props, State> {
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<h1 className="text-xl font-bold">Something went wrong.</h1>
<h1 className="text-xl font-bold">{i18n.t($ => $.dialogs.errorBoundary.title)}</h1>
</div>
<div className="text-sm text-text-secondary mb-6">
A critical error occurred in the React component tree. The error details below can help identify the root cause.
{i18n.t($ => $.dialogs.errorBoundary.description)}
</div>
<div className="bg-black/50 p-4 rounded text-xs font-mono text-red-300 overflow-x-auto mb-4">
{this.state.error && this.state.error.toString()}
@@ -52,7 +53,7 @@ export class ErrorBoundary extends Component<Props, State> {
className="app-button px-4 py-2 bg-red-600/20 text-red-400 border border-red-600/50 hover:bg-red-600/30 transition-colors rounded"
onClick={() => window.location.reload()}
>
Reload App
{i18n.t($ => $.dialogs.errorBoundary.reload)}
</button>
</div>
</div>
+27 -26
View File
@@ -6,6 +6,7 @@ import { usePlatformInfo } from '../utils/platform';
import { getKeychainConsentVersion } from '../utils/keychainStartup';
import { getVersion } from '@tauri-apps/api/app';
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
import { useTranslation } from 'react-i18next';
const KEYCHAIN_GRANT_TIMEOUT_MS = 30_000;
@@ -14,6 +15,7 @@ type KeychainPermissionModalProps = {
};
export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = ({ consentVersion }) => {
const { t } = useTranslation();
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt);
const platform = usePlatformInfo();
@@ -36,22 +38,22 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
const isMac = platform.os === 'macos';
const pairingStoreName =
platform.portable
? 'the portable Firelink data folder'
? t($ => $.keychain.stores.portable)
: platform.os === 'windows'
? 'Windows Credential Manager'
? t($ => $.keychain.stores.windows)
: platform.os === 'linux'
? 'your Linux credential store'
? t($ => $.keychain.stores.linux)
: platform.os === 'macos'
? 'macOS Keychain'
: "this system's credential store";
? t($ => $.keychain.stores.macos)
: t($ => $.keychain.stores.system);
const siteCredentialStoreName = platform.portable
? "the system's credential store"
? t($ => $.keychain.stores.siteCredentials)
: pairingStoreName;
const grantLabel = platform.portable
? 'Continue'
? t($ => $.keychain.grantLabelPortable)
: isMac
? 'Grant Access'
: 'Enable Secure Storage';
? t($ => $.keychain.grantLabelMacos)
: t($ => $.keychain.grantLabelDefault);
const handleGrant = async () => {
setIsGranting(true);
@@ -87,13 +89,13 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
grantRequest,
new Promise<never>((_, reject) => {
timeoutId = window.setTimeout(
() => reject(new Error('Credential storage request timed out. You can select Later and try again.')),
() => reject(new Error(t($ => $.keychain.timeout))),
KEYCHAIN_GRANT_TIMEOUT_MS
);
})
]);
if (!(await applyPersistentGrant(result))) {
setError(result.error || `${siteCredentialStoreName} is unavailable.`);
setError(result.error || t($ => $.keychain.unavailable, { store: siteCredentialStoreName }));
}
} catch (e: any) {
setError(e.toString());
@@ -124,28 +126,27 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
<div className="p-2 bg-blue-500/10 rounded-full items-center justify-center">
<KeyRound size={20} className="text-blue-500" />
</div>
<h2 className="text-lg font-semibold text-text-primary m-0">Credential Storage Access Needed</h2>
<h2 className="text-lg font-semibold text-text-primary m-0">{t($ => $.keychain.title)}</h2>
</div>
<div className="px-5 py-6 flex-1 min-h-0 overflow-y-auto text-sm text-text-secondary leading-relaxed space-y-4">
<p>
Firelink uses the browser extension to capture downloads. To keep the extension paired after restarts,
Firelink stores its pairing token in {pairingStoreName}. Optional site credentials are stored in {siteCredentialStoreName}.
{t($ => $.keychain.description, { pairingStore: pairingStoreName, siteCredentialStore: siteCredentialStoreName })}
</p>
<p>
{platform.portable
? 'The pairing token is portable with this folder. Site credentials remain in the system credential store; a system prompt may appear after you grant access.'
? t($ => $.keychain.portableExplanation)
: isMac
? 'macOS may show a Keychain prompt after you grant access.'
: 'This usually completes silently. If the credential service is unavailable, Firelink will show the error here and the extension will stay paired for this session only.'}
? t($ => $.keychain.macosExplanation)
: t($ => $.keychain.defaultExplanation)}
</p>
<p>
<strong>Note:</strong>{' '}
<strong>{t($ => $.keychain.note)}</strong>{' '}
{platform.portable
? 'Portable mode stores only the pairing token in this folder. It does not copy site passwords or browser credentials.'
: 'Firelink only writes its own dedicated credential entry. It cannot access other saved passwords or credential items on your system.'}
? t($ => $.keychain.portableNote)
: t($ => $.keychain.defaultNote)}
</p>
{error && (
@@ -156,11 +157,11 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
)}
<div className="bg-bg-modal-accent p-3 rounded-lg border border-border-modal text-xs">
<strong>Hint:</strong>{' '}
<strong>{t($ => $.keychain.hint)}</strong>{' '}
{platform.portable
? 'The portable pairing token is already stored with this folder; you can enable it here or select Later.'
: 'If you select Later, the extension will only work for this session.'}
You can enable storage anytime from <strong>Settings &gt; Integrations</strong>.
? t($ => $.keychain.portableHint)
: t($ => $.keychain.defaultHint)}{' '}
{t($ => $.keychain.enableFromSettings)}
</div>
</div>
@@ -170,14 +171,14 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
disabled={isGranting}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors text-text-secondary hover:bg-item-hover hover:text-text-primary disabled:opacity-50"
>
Later
{t($ => $.keychain.later)}
</button>
<button
onClick={handleGrant}
disabled={isGranting}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-white hover:bg-accent/90 disabled:opacity-50"
>
{isGranting ? 'Enabling...' : grantLabel}
{isGranting ? t($ => $.keychain.enabling) : grantLabel}
</button>
</div>
</div>
+28 -26
View File
@@ -7,6 +7,7 @@ import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'luc
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { useSettingsStore } from '../store/useSettingsStore';
import { useTranslation } from 'react-i18next';
import {
MAX_LOG_LINES,
appendBoundedLogEntries,
@@ -18,6 +19,7 @@ import {
} from '../utils/logEntries';
export default function LogsView() {
const { t } = useTranslation();
const { addToast } = useToast();
const logsEnabled = useSettingsStore(state => state.logsEnabled);
const setLogsEnabled = useSettingsStore(state => state.setLogsEnabled);
@@ -169,10 +171,10 @@ export default function LogsView() {
if (contextMenu?.text) {
try {
await navigator.clipboard.writeText(contextMenu.text);
addToast({ message: 'Copied to clipboard', variant: 'success' });
addToast({ message: t($ => $.logs.copied), variant: 'success' });
} catch (err) {
console.error('Clipboard write error:', err);
addToast({ message: 'Failed to copy to clipboard', variant: 'error' });
addToast({ message: t($ => $.logs.copyFailed), variant: 'error' });
}
}
setContextMenu(null);
@@ -182,14 +184,14 @@ export default function LogsView() {
try {
const path = await save({
defaultPath: 'Firelink-Support-Logs.log',
filters: [{ name: 'Log Files', extensions: ['log'] }],
filters: [{ name: t($ => $.logs.logFiles), extensions: ['log'] }],
});
if (!path) return;
await invoke('export_logs', { destination: path });
addToast({ message: 'Support logs exported', variant: 'success' });
addToast({ message: t($ => $.logs.exported), variant: 'success' });
} catch (e) {
console.error('Export failed:', e);
addToast({ message: `Could not export logs: ${String(e)}`, variant: 'error', isActionable: true });
addToast({ message: t($ => $.logs.exportFailed, { detail: String(e) }), variant: 'error', isActionable: true });
}
};
@@ -208,10 +210,10 @@ export default function LogsView() {
liveFrameRef.current = null;
}
setLogs([]);
addToast({ message: 'Logs cleared', variant: 'info' });
addToast({ message: t($ => $.logs.cleared), variant: 'info' });
} catch (error) {
addToast({
message: `Could not clear logs: ${String(error)}`,
message: t($ => $.logs.clearFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
@@ -231,7 +233,7 @@ export default function LogsView() {
await setLogPaused(!nextEnabled);
setLogsEnabled(nextEnabled);
addToast({
message: nextEnabled ? 'Diagnostic logging enabled' : 'Diagnostic logging disabled',
message: nextEnabled ? t($ => $.logs.enabled) : t($ => $.logs.disabled),
variant: 'success'
});
})();
@@ -241,7 +243,7 @@ export default function LogsView() {
await toggleOperation;
} catch (error) {
addToast({
message: `Could not update diagnostic logging: ${String(error)}`,
message: t($ => $.logs.updateFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
@@ -270,12 +272,12 @@ export default function LogsView() {
<div className="logs-toolbar flex items-center justify-between px-4 py-2 shrink-0">
<div className="flex items-center gap-2 text-text-secondary">
<Terminal size={16} strokeWidth={1.8} />
<span className="text-[13px] font-semibold text-text-primary">Logs</span>
<span className="text-[11px] text-text-muted">({logs.length} entries)</span>
<span className="text-[13px] font-semibold text-text-primary">{t($ => $.logs.title)}</span>
<span className="text-[11px] text-text-muted">{t($ => $.logs.entries, { count: logs.length })}</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
logsEnabled ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
}`}>
{logsEnabled ? 'Collecting' : 'Off'}
{logsEnabled ? t($ => $.logs.collecting) : t($ => $.logs.off)}
</span>
</div>
<div className="flex items-center gap-3">
@@ -286,12 +288,12 @@ export default function LogsView() {
onChange={e => setLevelFilter(e.target.value as LogEntry['level'] | 'All')}
className="bg-bg-input border border-border-modal rounded px-1.5 py-0.5 text-[11px] text-text-primary focus:outline-none focus:border-accent"
>
<option value="All">All Levels</option>
<option value="Error">Error</option>
<option value="Warn">Warn</option>
<option value="Info">Info</option>
<option value="Debug">Debug</option>
<option value="Trace">Trace</option>
<option value="All">{t($ => $.logs.allLevels)}</option>
<option value="Error">{t($ => $.logs.levels.error)}</option>
<option value="Warn">{t($ => $.logs.levels.warn)}</option>
<option value="Info">{t($ => $.logs.levels.info)}</option>
<option value="Debug">{t($ => $.logs.levels.debug)}</option>
<option value="Trace">{t($ => $.logs.levels.trace)}</option>
</select>
</div>
<div className="w-[1px] h-4 bg-border-modal mx-0.5" />
@@ -299,7 +301,7 @@ export default function LogsView() {
onClick={handleToggleLogging}
disabled={isToggling}
className={`app-icon-button disabled:cursor-not-allowed disabled:opacity-50 ${logsEnabled ? 'text-accent' : ''}`}
title={logsEnabled ? "Pause diagnostic logging" : "Enable diagnostic logging"}
title={logsEnabled ? t($ => $.logs.pauseLogging) : t($ => $.logs.enableLogging)}
>
{logsEnabled ? <Pause size={14} /> : <Play size={14} />}
</button>
@@ -307,17 +309,17 @@ export default function LogsView() {
onClick={handleClear}
disabled={isClearing}
className="app-icon-button disabled:cursor-not-allowed disabled:opacity-50"
title="Clear displayed logs"
title={t($ => $.logs.clearDisplayed)}
>
<Trash2 size={14} />
</button>
<button
onClick={handleExport}
className="app-button px-3 text-[11px] gap-1.5"
title="Export logs"
title={t($ => $.logs.export)}
>
<FileDown size={13} />
Export Logs
{t($ => $.logs.exportButton)}
</button>
</div>
</div>
@@ -326,8 +328,8 @@ export default function LogsView() {
<div className="bg-black/10 border-y border-border-modal px-4 py-2 shrink-0 flex items-center gap-2 text-text-muted text-[10px] select-none">
<Info size={12} className="text-text-muted opacity-80 shrink-0" />
<span className="opacity-90 leading-tight">
<strong className="font-medium text-text-primary mr-1">Local diagnostics:</strong>
Diagnostic collection is opt-in, bounded, and local. Common secrets, URL queries, and home-directory paths are redacted during display and export. Nothing is uploaded automatically.
<strong className="font-medium text-text-primary mr-1">{t($ => $.logs.localDiagnostics)}</strong>
{t($ => $.logs.diagnosticsDescription)}
</span>
</div>
@@ -340,7 +342,7 @@ export default function LogsView() {
>
{logs.length === 0 && (
<div className="text-text-muted italic select-none">
{logsEnabled ? 'No persisted log entries are available yet.' : 'Diagnostic logging is off. Existing support logs will appear here when available.'}
{logsEnabled ? t($ => $.logs.noEntries) : t($ => $.logs.disabledDescription)}
</div>
)}
{logs.filter(entry => levelFilter === 'All' || entry.level === levelFilter).map((entry, i) => (
@@ -364,7 +366,7 @@ export default function LogsView() {
onClick={handleCopy}
>
<Copy size={13} className="mr-2 text-text-secondary" />
Copy
{t($ => $.logs.copy)}
</button>
</div>
)}
+76 -50
View File
@@ -16,6 +16,7 @@ import {
resolveDownloadSizeDisplay
} from '../utils/downloadProgress';
import { resolveDownloadConnections } from '../utils/downloads';
import { useTranslation } from 'react-i18next';
type LoginMode = 'matching' | 'custom' | 'none';
@@ -28,6 +29,18 @@ const formatLastTry = (value?: string): string => {
};
export const PropertiesModal = () => {
const { t } = useTranslation();
const categoryLabel = (category: string) => {
switch (category) {
case 'Musics': return t($ => $.navigation.categories.musics);
case 'Movies': return t($ => $.navigation.categories.movies);
case 'Compressed': return t($ => $.navigation.categories.compressed);
case 'Documents': return t($ => $.navigation.categories.documents);
case 'Pictures': return t($ => $.navigation.categories.pictures);
case 'Applications': return t($ => $.navigation.categories.applications);
default: return t($ => $.navigation.categories.other);
}
};
const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
const setSelectedPropertiesDownloadId = useDownloadStore(state => state.setSelectedPropertiesDownloadId);
const item = useDownloadStore(useShallow(state =>
@@ -159,11 +172,11 @@ export const PropertiesModal = () => {
const handleSave = async () => {
if (!url.trim()) {
setErrorMessage("Enter a valid URL.");
setErrorMessage(t($ => $.properties.enterValidUrl));
return;
}
if (!fileName.trim()) {
setErrorMessage("File name cannot be empty.");
setErrorMessage(t($ => $.properties.fileNameEmpty));
return;
}
@@ -211,9 +224,22 @@ export const PropertiesModal = () => {
});
const hasDownloadedAmount = item.status !== 'completed' &&
Boolean(sizeDisplay.downloaded && sizeDisplay.total);
const completedSizeLabel = item.status === 'completed'
? formatDownloadTotal(sizeDisplay)
: sizeDisplay.fallback;
const completedSizeLabel = (() => {
const value = item.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
})();
const statusLabel = t($ => $.downloads.status[item.status]);
const sizeDescription = sizeDisplay.totalIsEstimate
? t($ => $.downloads.size.downloadedOfApproximate, {
downloaded: sizeDisplay.downloaded ?? '',
total: sizeDisplay.total ?? '',
unit: sizeDisplay.unit ?? '',
})
: t($ => $.downloads.size.downloadedOf, {
downloaded: sizeDisplay.downloaded ?? '',
total: sizeDisplay.total ?? '',
unit: sizeDisplay.unit ?? '',
});
let statusColor = 'text-text-secondary';
let StatusIcon = Info;
@@ -240,7 +266,7 @@ export const PropertiesModal = () => {
<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}
{statusLabel}
</span>
</div>
@@ -249,13 +275,13 @@ export const PropertiesModal = () => {
</div>
<div className="grid grid-cols-4 gap-y-2 gap-x-4 text-[11px] leading-tight">
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Progress</span><span className="text-text-secondary truncate">{`${(displayedFraction * 100).toFixed(0)}%`}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">{t($ => $.properties.progress)}</span><span className="text-text-secondary truncate">{`${(displayedFraction * 100).toFixed(0)}%`}</span></div>
<div className="flex gap-1.5 min-w-0">
<span className="text-text-muted font-medium w-[40px] shrink-0">Size</span>
<span className="text-text-muted font-medium w-[40px] shrink-0">{t($ => $.properties.size)}</span>
<span
className="truncate"
title={hasDownloadedAmount
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? 'approximately ' : ''}${sizeDisplay.total} ${sizeDisplay.unit}`
? sizeDescription
: completedSizeLabel}
>
{hasDownloadedAmount ? (
@@ -269,19 +295,19 @@ export const PropertiesModal = () => {
) : completedSizeLabel}
</span>
</div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Speed</span><span className="text-text-secondary truncate">{displayedSpeed}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[30px] shrink-0">ETA</span><span className="text-text-secondary truncate">{displayedEta}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">{t($ => $.properties.speed)}</span><span className="text-text-secondary truncate">{displayedSpeed}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[30px] shrink-0">{t($ => $.properties.eta)}</span><span className="text-text-secondary truncate">{displayedEta}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Connections</span><span className="text-text-secondary truncate" title={item.connections !== undefined ? 'Saved for this download; Settings changes apply to new downloads.' : 'Using the current default for new downloads.'}>{resolveDownloadConnections(item.connections, perServerConnections)}{item.connections !== undefined ? ' (saved)' : ' (default)'}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">Speed cap</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[55px] shrink-0">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">{formatLastTry(item.lastTry)}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">{t($ => $.properties.connections)}</span><span className="text-text-secondary truncate" title={item.connections !== undefined ? t($ => $.properties.savedTooltip) : t($ => $.properties.defaultTooltip)}>{resolveDownloadConnections(item.connections, perServerConnections)}{item.connections !== undefined ? t($ => $.properties.saved) : t($ => $.properties.defaultValue)}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">{t($ => $.properties.speedCap)}</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[55px] shrink-0">{t($ => $.properties.category)}</span><span className="text-text-secondary truncate">{categoryLabel(item.category)}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">{t($ => $.properties.lastTry)}</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry)}</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={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">{t($ => $.properties.dateAdded)}</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]">{t($ => $.properties.destination)}</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
{item.lastError && (item.status === 'failed' || item.status === 'retrying') && (
<div className="flex gap-1.5 col-span-4 min-w-0">
<span className="text-text-muted font-medium w-[90px] shrink-0">Last error</span>
<span className="text-text-muted font-medium w-[90px] shrink-0">{t($ => $.properties.lastError)}</span>
<span className="text-red-400 truncate" title={item.lastError}>{item.lastError}</span>
</div>
)}
@@ -298,53 +324,53 @@ export const PropertiesModal = () => {
{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."
: "Transfer settings can be changed after stopping or pausing. Current transfers keep their existing backend options."}
? t($ => $.properties.identityReadOnly)
: t($ => $.properties.transferSettings)}
</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>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">{t($ => $.properties.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>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.url)}</label>
<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>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.fileName)}</label>
<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>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.saveLocation)}</label>
<div className="flex gap-2">
<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
<FolderPlus size={14} /> {t($ => $.properties.select)}
</button>
</div>
<label className="text-xs text-text-muted text-right">Connections</label>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.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)); setConnectionsDirty(true); }} 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>
<span className="text-xs text-text-muted">{t($ => $.properties.perFile)}</span>
{!transferLocked && item.connections !== undefined && item.connections !== perServerConnections && (
<button
type="button"
onClick={() => { setConnections(perServerConnections); setConnectionsDirty(true); }}
className="text-[11px] text-accent hover:underline whitespace-nowrap"
>
Use current default ({perServerConnections})
{t($ => $.properties.useCurrentDefault, { count: perServerConnections })}
</button>
)}
</div>
<div className="col-start-2 text-[11px] text-text-muted">
Saved per download. The Settings default applies to new downloads.
{t($ => $.properties.savedPerDownload)}
</div>
<label className="text-xs text-text-muted text-right">Speed</label>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.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={transferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input disabled:opacity-50" />
Limit
{t($ => $.properties.limit)}
</label>
{speedLimitEnabled && (
<div className="flex items-center gap-2">
@@ -359,7 +385,7 @@ export const PropertiesModal = () => {
{/* 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'}
{item.status === 'completed' ? t($ => $.properties.siteLoginRedownload) : t($ => $.properties.siteLogin)}
</h3>
<div className="flex gap-1 p-1 bg-border-color rounded-lg mb-4 w-fit mx-auto md:mx-0">
@@ -370,7 +396,7 @@ export const PropertiesModal = () => {
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'}
{mode === 'matching' ? t($ => $.properties.matchingSiteLogin) : mode === 'custom' ? t($ => $.properties.customCredentials) : t($ => $.properties.noLogin)}
</button>
))}
</div>
@@ -378,16 +404,16 @@ export const PropertiesModal = () => {
<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.
{t($ => $.properties.useSavedLogin)}
</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={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">{t($ => $.properties.username)}</label>
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.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={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" />
<label className="text-xs text-text-muted text-right">{t($ => $.properties.password)}</label>
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.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>
@@ -400,20 +426,20 @@ export const PropertiesModal = () => {
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'}
{item.status === 'completed' ? t($ => $.properties.advancedTransferRedownload) : t($ => $.properties.advancedTransfer)}
</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="text-xs text-text-muted text-right">{t($ => $.properties.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={transferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input" />
Verify
{t($ => $.properties.verify)}
</label>
{checksumEnabled && (
<>
<label className="text-xs text-text-muted text-right">Algorithm</label>
<label className="text-xs text-text-muted text-right">{t($ => $.properties.algorithm)}</label>
<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>
@@ -421,21 +447,21 @@ export const PropertiesModal = () => {
<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={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">{t($ => $.properties.digest)}</label>
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.expectedDigest)} 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={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" />
<label className="text-xs text-text-muted text-right">{t($ => $.properties.cookies)}</label>
<input type="text" value={cookies} onChange={e=>setCookies(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.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>
<label className="block text-xs text-text-muted mb-1.5">{t($ => $.properties.headers)}</label>
<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>
<label className="block text-xs text-text-muted mb-1.5">{t($ => $.properties.mirrors)}</label>
<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>
@@ -456,7 +482,7 @@ export const PropertiesModal = () => {
onClick={() => setSelectedPropertiesDownloadId(null)}
className="app-button px-4 text-xs"
>
Cancel
{t($ => $.properties.cancel)}
</button>
<button
onClick={handleSave}
@@ -464,7 +490,7 @@ export const PropertiesModal = () => {
className={`app-button app-button-primary px-4 text-xs ${transferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<CheckCircle size={14} />
Save
{t($ => $.properties.save)}
</button>
</div>
</div>
+83 -63
View File
@@ -10,22 +10,23 @@ import { isActiveDownloadStatus } from '../utils/downloads';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { usePlatformInfo } from '../utils/platform';
import { useTranslation } from 'react-i18next';
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' },
];
{ value: 0, key: 'su' },
{ value: 1, key: 'mo' },
{ value: 2, key: 'tu' },
{ value: 3, key: 'we' },
{ value: 4, key: 'th' },
{ value: 5, key: 'fr' },
{ value: 6, key: 'sa' },
] as const;
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 },
const postActions: { value: PostQueueAction; icon: typeof Moon }[] = [
{ value: 'none', icon: CheckCircle2 },
{ value: 'sleep', icon: Moon },
{ value: 'restart', icon: RotateCcw },
{ value: 'shutdown', icon: Power },
];
const minuteOfDay = (value: string) => {
@@ -33,8 +34,8 @@ const minuteOfDay = (value: string) => {
return hour * 60 + minute;
};
function nextScheduledRun(settings: SchedulerSettings): string {
if (!settings.enabled) return 'Scheduler is disabled';
function nextScheduledRun(settings: SchedulerSettings): Date | 'disabled' | 'none' {
if (!settings.enabled) return 'disabled';
const [hour, minute] = settings.startTime.split(':').map(Number);
const now = new Date();
@@ -45,20 +46,15 @@ function nextScheduledRun(settings: SchedulerSettings): string {
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 candidate;
}
}
return 'No scheduled day selected';
return 'none';
}
export default function SchedulerView() {
const { t } = useTranslation();
const savedSettings = useSettingsStore(state => state.scheduler);
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const setScheduler = useSettingsStore(state => state.setScheduler);
@@ -75,7 +71,18 @@ export default function SchedulerView() {
}, [savedSettings]);
const nextRun = useMemo(() => nextScheduledRun(draft), [draft]);
const nextRun = useMemo(() => {
const scheduledRun = nextScheduledRun(draft);
if (scheduledRun === 'disabled') return t($ => $.scheduler.disabled);
if (scheduledRun === 'none') return t($ => $.scheduler.noScheduledDay);
return scheduledRun.toLocaleString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
}, [draft, t]);
const hasUnsavedChanges = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(savedSettings),
[draft, savedSettings]
@@ -116,15 +123,15 @@ export default function SchedulerView() {
const save = () => {
if (draft.enabled && !draft.everyday && draft.selectedDays.length === 0) {
addToast({ message: 'Select at least one day for the scheduler', variant: 'error', isActionable: true });
addToast({ message: t($ => $.scheduler.validationDay), variant: 'error', isActionable: true });
return;
}
if (draft.enabled && effectiveSelectedQueueIds.length === 0) {
addToast({ message: 'Select at least one queue for the scheduler', variant: 'error', isActionable: true });
addToast({ message: t($ => $.scheduler.validationQueue), variant: 'error', isActionable: true });
return;
}
if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) <= minuteOfDay(draft.startTime)) {
addToast({ message: 'Stop time must be later than start time', variant: 'error', isActionable: true });
addToast({ message: t($ => $.scheduler.validationStopTime), variant: 'error', isActionable: true });
return;
}
const normalized = {
@@ -134,7 +141,7 @@ export default function SchedulerView() {
};
setScheduler(normalized);
setDraft(normalized);
addToast({ message: 'Scheduler settings saved', variant: 'success' });
addToast({ message: t($ => $.scheduler.saved), variant: 'success' });
};
const runNow = async () => {
@@ -155,9 +162,14 @@ export default function SchedulerView() {
if (activeIds.length > 0) {
useSettingsStore.getState().setSchedulerRunning(true);
useSettingsStore.getState().setSchedulerActiveDownloadIds(activeIds);
addToast({ message: `Tracking ${activeIds.length} scheduled download${activeIds.length === 1 ? '' : 's'}`, variant: 'success' });
addToast({
message: activeIds.length === 1
? t($ => $.scheduler.trackingOne)
: t($ => $.scheduler.trackingMany, { count: activeIds.length }),
variant: 'success'
});
} else {
addToast({ message: 'No downloads in the selected queues can be started', variant: 'info' });
addToast({ message: t($ => $.scheduler.noStartableDownloads), variant: 'info' });
}
};
@@ -168,7 +180,14 @@ export default function SchedulerView() {
const count = counts.reduce((total, queueCount) => total + queueCount, 0);
useSettingsStore.getState().setSchedulerRunning(false);
useSettingsStore.getState().setSchedulerActiveDownloadIds([]);
addToast({ message: count > 0 ? `Paused ${count} active download${count === 1 ? '' : 's'}` : 'No active downloads', variant: 'info' });
addToast({
message: count === 0
? t($ => $.scheduler.noActiveDownloads)
: count === 1
? t($ => $.scheduler.pausedOne)
: t($ => $.scheduler.pausedMany, { count }),
variant: 'info'
});
};
const refreshPermissionStatus = useCallback(async (showMessage = false) => {
@@ -178,12 +197,12 @@ export default function SchedulerView() {
await invoke('check_automation_permission');
setAutomationPermissionGranted(true);
if (showMessage) {
setPermissionMessage('Automation permission is available.');
setPermissionMessage(t($ => $.scheduler.permissionAvailable));
}
} catch {
setAutomationPermissionGranted(false);
if (showMessage) {
setPermissionMessage('Automation permission is missing. Enable Firelink under Automation for System Events in System Settings.');
setPermissionMessage(t($ => $.scheduler.permissionMissingDetails));
}
}
}, [isMac]);
@@ -222,18 +241,18 @@ export default function SchedulerView() {
const handlePermissionAction = async () => {
if (automationPermissionGranted) {
await openAutomationSettings('macOS does not allow Firelink to revoke Automation permission directly. Revoke System Events access in System Settings, then return to Firelink.');
await openAutomationSettings(t($ => $.scheduler.revokePermissionDetails));
return;
}
setPermissionMessage('Requesting Automation permission...');
setPermissionMessage(t($ => $.scheduler.requestingPermission));
try {
await invoke('request_automation_permission');
setAutomationPermissionGranted(true);
setPermissionMessage('Automation permission is available.');
setPermissionMessage(t($ => $.scheduler.permissionAvailable));
} catch {
setAutomationPermissionGranted(false);
await openAutomationSettings('Enable Firelink under Automation for System Events in System Settings, then return to Firelink.');
await openAutomationSettings(t($ => $.scheduler.enablePermissionDetails));
}
};
@@ -253,27 +272,27 @@ export default function SchedulerView() {
className={`inline-block h-4 w-4 transform rounded-full bg-white transition duration-200 ease-in-out ${draft.enabled ? 'translate-x-4' : 'translate-x-1'}`}
/>
</button>
Scheduler
{t($ => $.scheduler.title)}
</div>
<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}
{schedulerRunning ? t($ => $.scheduler.running) : nextRun}
</span>
{hasUnsavedChanges && (
<span className="rounded-full bg-orange-500/10 px-2.5 py-1 text-[11px] font-semibold text-orange-300">
Unsaved changes
{t($ => $.scheduler.unsavedChanges)}
</span>
)}
<div className="ml-auto flex gap-2">
<button onClick={runNow} className="app-button px-3 text-[11px]">
<Play size={14} /> Run Now
<Play size={14} /> {t($ => $.scheduler.runNow)}
</button>
<button onClick={pauseNow} className="app-button px-3 text-[11px]">
<Pause size={14} /> Pause
<Pause size={14} /> {t($ => $.scheduler.pause)}
</button>
<button onClick={save} className="app-button app-button-primary px-3 text-[11px]">
<Save size={14} /> Save Settings
<Save size={14} /> {t($ => $.scheduler.saveSettings)}
</button>
</div>
</div>
@@ -282,29 +301,29 @@ export default function SchedulerView() {
<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
<Clock3 size={17} className="text-accent" /> {t($ => $.scheduler.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>
<span className="block">{t($ => $.scheduler.startTime)}</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
{t($ => $.scheduler.stopTime)}
</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>
<p className="mt-4 text-[11px] text-text-muted">
If Firelink is asleep at the start time, it starts the selected queues when it returns later that day, unless the stop time has already passed.
{t($ => $.scheduler.timingDescription)}
</p>
<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
{t($ => $.scheduler.runEveryDay)}
</label>
{!draft.everyday && (
<div className="mt-4 flex gap-2">
@@ -320,7 +339,7 @@ export default function SchedulerView() {
selected ? 'bg-accent text-white' : 'bg-bg-input text-text-primary hover:bg-item-hover'
}`}
>
{day.label}
{t($ => $.scheduler.days[day.key])}
</button>
);
})}
@@ -330,7 +349,7 @@ export default function SchedulerView() {
<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
<List size={17} className="text-accent" /> {t($ => $.scheduler.queuesToSchedule)}
</div>
<div className="space-y-3">
{queues.map(queue => {
@@ -346,7 +365,7 @@ export default function SchedulerView() {
/>
{queue.name}
{queue.isMain && (
<span className="text-[11px] text-text-muted">Default queue</span>
<span className="text-[11px] text-text-muted">{t($ => $.scheduler.defaultQueue)}</span>
)}
</label>
);
@@ -356,9 +375,9 @@ export default function SchedulerView() {
<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
<Power size={17} className="text-accent" /> {t($ => $.scheduler.afterCompletion)}
</div>
<p className="mb-4 text-[12px] text-text-muted">Choose what happens after downloads started by the scheduler finish.</p>
<p className="mb-4 text-[12px] text-text-muted">{t($ => $.scheduler.afterCompletionDescription)}</p>
<div className="grid grid-cols-2 gap-2">
{postActions.map(action => {
const Icon = action.icon;
@@ -368,13 +387,13 @@ export default function SchedulerView() {
}`}>
<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}
{t($ => $.scheduler.postActions[action.value])}
</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>
<p className="mt-3 text-[11px] text-orange-400">{t($ => $.scheduler.actionWarning)}</p>
)}
</section>
</div>
@@ -382,27 +401,27 @@ export default function SchedulerView() {
{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
<LockKeyhole size={17} className="text-accent" /> {t($ => $.scheduler.systemPermissions)}
</div>
<p className="mb-4 text-[12px] text-text-muted">Sleep, restart, and shut down require macOS Automation permission for System Events.</p>
<p className="mb-4 text-[12px] text-text-muted">{t($ => $.scheduler.macPermissionDescription)}</p>
<div className="mb-4 flex items-center gap-2 text-[12px]">
{automationPermissionGranted ? (
<>
<CheckCircle2 size={16} className="text-green-500" />
<span className="font-medium text-green-500">Automation permission granted</span>
<span className="font-medium text-green-500">{t($ => $.scheduler.permissionGranted)}</span>
</>
) : (
<>
<AlertCircle size={16} className="text-orange-400" />
<span className="font-medium text-orange-400">
{automationPermissionGranted === null ? 'Checking Automation permission...' : 'Automation permission missing'}
{automationPermissionGranted === null ? t($ => $.scheduler.permissionChecking) : t($ => $.scheduler.permissionMissing)}
</span>
</>
)}
</div>
<div className="flex gap-2">
<button onClick={handlePermissionAction} className="app-button app-button-primary px-3 text-[11px]">
{automationPermissionGranted ? 'Revoke permission' : 'Grant permission'}
{automationPermissionGranted ? t($ => $.scheduler.revokePermission) : t($ => $.scheduler.grantPermission)}
</button>
</div>
{permissionMessage && <p className="mt-3 text-[11px] text-text-muted">{permissionMessage}</p>}
@@ -410,11 +429,12 @@ export default function SchedulerView() {
) : (
<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 Actions
<LockKeyhole size={17} className="text-accent" /> {t($ => $.scheduler.systemActions)}
</div>
<p className="text-[12px] text-text-muted">
Sleep, restart, and shut down use {platform.os === 'windows' ? 'Windows system privileges' : 'your Linux desktop and system policy'}.
Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.
{platform.os === 'windows'
? t($ => $.scheduler.windowsActionsDescription)
: t($ => $.scheduler.linuxActionsDescription)}
</p>
</section>
)}
File diff suppressed because it is too large Load Diff
+38 -36
View File
@@ -11,6 +11,7 @@ import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { isTransferActiveStatus } from '../utils/downloads';
import { useTranslation } from 'react-i18next';
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
@@ -24,6 +25,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue } = useDownloadStore();
const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
const { addToast } = useToast();
const { t } = useTranslation();
const [isAddingQueue, setIsAddingQueue] = useState(false);
const [newQueueName, setNewQueueName] = useState('');
@@ -84,11 +86,11 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const queueId = selectedFilter.replace('queue:', '');
const q = queues.find(q => q.id === queueId);
if (q && !q.isMain) {
if (!window.confirm(`Delete queue "${q.name}"? Its unfinished downloads will move to Main Queue.`)) {
if (!window.confirm(t($ => $.sidebar.deleteQueueConfirm, { name: q.name }))) {
return;
}
void removeQueue(queueId).catch(error => {
addToast({ message: `Could not delete queue: ${String(error)}`, variant: 'error', isActionable: true });
addToast({ message: t($ => $.sidebar.deleteQueueFailed, { detail: String(error) }), variant: 'error', isActionable: true });
});
}
}
@@ -144,11 +146,11 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
if (addQueueSubmitRef.current) return;
const normalizedName = newQueueName.trim();
if (!normalizedName) {
addToast({ message: 'Queue name cannot be empty', variant: 'error', isActionable: true });
addToast({ message: t($ => $.sidebar.queueNameEmpty), variant: 'error', isActionable: true });
return;
}
if (!addQueue(normalizedName)) {
addToast({ message: 'A queue with this name already exists', variant: 'error', isActionable: true });
addToast({ message: t($ => $.sidebar.queueNameExists), variant: 'error', isActionable: true });
return;
}
addQueueSubmitRef.current = true;
@@ -161,11 +163,11 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const normalizedName = editingQueueName.trim();
if (!renamingQueueId) return;
if (!normalizedName) {
addToast({ message: 'Queue name cannot be empty', variant: 'error', isActionable: true });
addToast({ message: t($ => $.sidebar.queueNameEmpty), variant: 'error', isActionable: true });
return;
}
if (!renameQueue(renamingQueueId, normalizedName)) {
addToast({ message: 'A queue with this name already exists', variant: 'error', isActionable: true });
addToast({ message: t($ => $.sidebar.queueNameExists), variant: 'error', isActionable: true });
return;
}
renameQueueSubmitRef.current = true;
@@ -240,18 +242,18 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
type="button"
onClick={toggleSidebar}
className="sidebar-toggle-button"
title="Hide Sidebar"
title={t($ => $.actions.hideSidebar)}
>
<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" />
<div className="sidebar-section-label">{t($ => $.navigation.library)}</div>
<NavItem icon={Inbox} label={t($ => $.navigation.filters.all)} filter="all" />
<NavItem icon={Zap} label={t($ => $.navigation.filters.active)} filter="active" />
<NavItem icon={CheckCircle2} label={t($ => $.navigation.filters.completed)} filter="completed" />
<NavItem icon={CircleDashed} label={t($ => $.navigation.filters.unfinished)} filter="unfinished" />
</section>
<section className="sidebar-section">
@@ -263,7 +265,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
aria-controls="sidebar-folders-list"
onClick={() => setFoldersCollapsed(collapsed => !collapsed)}
>
<span>Folders</span>
<span>{t($ => $.navigation.folders)}</span>
<ChevronDown
aria-hidden="true"
size={13}
@@ -278,19 +280,19 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
inert={foldersCollapsed}
>
<div className="sidebar-collapse-content">
<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" />
<NavItem icon={Music} label={t($ => $.navigation.categories.musics)} filter="Musics" />
<NavItem icon={Film} label={t($ => $.navigation.categories.movies)} filter="Movies" />
<NavItem icon={Archive} label={t($ => $.navigation.categories.compressed)} filter="Compressed" />
<NavItem icon={FileText} label={t($ => $.navigation.categories.documents)} filter="Documents" />
<NavItem icon={ImageIcon} label={t($ => $.navigation.categories.pictures)} filter="Pictures" />
<NavItem icon={Box} label={t($ => $.navigation.categories.applications)} filter="Applications" />
<NavItem icon={FileQuestion} label={t($ => $.navigation.categories.other)} filter="Other" />
</div>
</div>
</section>
<section className="sidebar-section">
<div className="sidebar-section-label">Queues</div>
<div className="sidebar-section-label">{t($ => $.navigation.queues)}</div>
{queues.map(queue => (
<QueueItem key={queue.id} queue={queue} />
))}
@@ -300,7 +302,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<input
ref={addInputRef}
type="text"
placeholder="Queue name"
placeholder={t($ => $.actions.queueName)}
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)}
@@ -318,16 +320,16 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
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>
<span className="truncate">{t($ => $.actions.addNewQueue)}</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" />
<ToolItem icon={Bug} label="Logs" view="logs" />
<div className="sidebar-section-label">{t($ => $.navigation.tools)}</div>
<ToolItem icon={CalendarClock} label={t($ => $.navigation.scheduler)} view="scheduler" />
<ToolItem icon={Gauge} label={t($ => $.navigation.speedLimiter)} view="speedLimiter" />
<ToolItem icon={Bug} label={t($ => $.navigation.logs)} view="logs" />
</section>
</div>
@@ -339,7 +341,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
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>
<span>{t($ => $.navigation.settings)}</span>
</button>
</div>
@@ -360,7 +362,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
setContextMenu(null);
void startQueue(queueId).catch(error => {
addToast({
message: `Could not start queue: ${String(error)}`,
message: t($ => $.sidebar.startQueueFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
@@ -368,7 +370,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
}}
>
<Play size={14} className="mr-2 text-text-secondary" />
Start Queue
{t($ => $.actions.startQueue)}
</button>
<button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
@@ -377,7 +379,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
setContextMenu(null);
void pauseQueue(queueId).catch(error => {
addToast({
message: `Could not pause queue: ${String(error)}`,
message: t($ => $.sidebar.pauseQueueFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
@@ -385,7 +387,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
}}
>
<Pause size={14} className="mr-2 text-text-secondary" />
Pause Queue
{t($ => $.actions.pauseQueue)}
</button>
<div className="h-px bg-border-color my-1 mx-2" />
<button
@@ -401,7 +403,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
}}
>
<Edit2 size={14} className="mr-2 text-text-secondary" />
Rename Queue
{t($ => $.actions.renameQueue)}
</button>
{!queues.find(q => q.id === contextMenu.id)?.isMain && (
<button
@@ -413,13 +415,13 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
setContextMenu(null);
return;
}
if (!window.confirm(`Delete queue "${queue.name}"? Its unfinished downloads will move to Main Queue.`)) {
if (!window.confirm(t($ => $.sidebar.deleteQueueConfirm, { name: queue.name }))) {
return;
}
setContextMenu(null);
void removeQueue(queueId).catch(error => {
addToast({
message: `Could not delete queue: ${String(error)}`,
message: t($ => $.sidebar.deleteQueueFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
@@ -427,7 +429,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
}}
>
<Trash2 size={14} className="mr-2" />
Delete Queue
{t($ => $.actions.deleteQueue)}
</button>
)}
</div>
+20 -16
View File
@@ -3,6 +3,7 @@ import { Gauge, Plus, Save, X, Zap } from 'lucide-react';
import { useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { useTranslation } from 'react-i18next';
type SpeedUnit = 'KB/s' | 'MB/s';
@@ -89,6 +90,7 @@ export function formatPresetValue(value: number): string {
}
export default function SpeedLimiterView() {
const { t } = useTranslation();
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
const lastCustomSpeedLimitKiB = useSettingsStore(state => state.lastCustomSpeedLimitKiB);
const lastCustomSpeedLimitUnit = useSettingsStore(state => state.lastCustomSpeedLimitUnit);
@@ -132,12 +134,14 @@ export default function SpeedLimiterView() {
setLastCustomSpeedLimitKiB(valueKiB);
setLastCustomSpeedLimitUnit(unit);
addToast({
message: enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled',
message: enabled
? t($ => $.speedLimiter.globalLimitSaved, { value: numericValue, unit })
: t($ => $.speedLimiter.globalLimitDisabled),
variant: 'success'
});
} catch (error) {
addToast({
message: `Could not save global speed limit: ${String(error)}`,
message: t($ => $.speedLimiter.saveFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
@@ -163,8 +167,8 @@ export default function SpeedLimiterView() {
setValue(String(storedPresetDisplayValue));
addToast({
message: alreadyExists
? `${formatPresetValue(storedPresetDisplayValue)} ${unit} is already in quick presets`
: `Added ${formatPresetValue(storedPresetDisplayValue)} ${unit} quick preset`,
? t($ => $.speedLimiter.presetAlreadyExists, { value: formatPresetValue(storedPresetDisplayValue), unit })
: t($ => $.speedLimiter.presetAdded, { value: formatPresetValue(storedPresetDisplayValue), unit }),
variant: alreadyExists ? 'info' : 'success'
});
};
@@ -174,7 +178,7 @@ export default function SpeedLimiterView() {
const nextPresets = presetValues.filter(value => value !== presetValue);
setSpeedLimitPresetValues(nextPresets);
addToast({
message: `Removed ${formatPresetValue(displayValue)} ${unit} quick preset`,
message: t($ => $.speedLimiter.presetRemoved, { value: formatPresetValue(displayValue), unit }),
variant: 'info'
});
};
@@ -207,25 +211,25 @@ export default function SpeedLimiterView() {
className={`inline-block h-4 w-4 transform rounded-full bg-white transition duration-200 ease-in-out ${enabled ? 'translate-x-4' : 'translate-x-1'}`}
/>
</button>
Speed Limiter
{t($ => $.speedLimiter.title)}
</div>
<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 ? `${currentDisplayValue} ${unit}` : 'Unlimited'}
{enabled ? `${currentDisplayValue} ${unit}` : t($ => $.speedLimiter.unlimited)}
</span>
<button onClick={() => void save()} disabled={isSaving} className="app-button app-button-primary ml-auto px-3 text-[11px] disabled:opacity-50">
<Save size={14} /> Save Limit
<Save size={14} /> {t($ => $.speedLimiter.saveLimit)}
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<section className={`app-card max-w-[760px] p-5 ${enabled ? '' : 'opacity-55'}`}>
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<Gauge size={18} className="text-accent" /> Global Speed Limit
<Gauge size={18} className="text-accent" /> {t($ => $.speedLimiter.globalSpeedLimit)}
</div>
<p className="max-w-2xl text-[12px] leading-relaxed text-text-muted">
Applies to new and active aria2 transfers and new yt-dlp media downloads. Explicit per-download limits remain attached to those transfers.
{t($ => $.speedLimiter.description)}
</p>
<div className="mt-6 flex items-center gap-3">
@@ -257,7 +261,7 @@ export default function SpeedLimiterView() {
<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
<Zap size={14} /> {t($ => $.speedLimiter.quickPresets)}
</div>
<div className="flex flex-wrap items-center gap-2">
{presetValues.map(presetValue => {
@@ -280,8 +284,8 @@ export default function SpeedLimiterView() {
disabled={!enabled || isSaving}
onClick={() => removePreset(presetValue)}
className="flex h-full w-7 items-center justify-center border-l border-border-modal text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400 focus-visible:bg-red-500/10 focus-visible:text-red-400 disabled:opacity-50"
title={`Remove ${formatPresetValue(displayValue)} ${unit} preset`}
aria-label={`Remove ${formatPresetValue(displayValue)} ${unit} preset`}
title={t($ => $.speedLimiter.removePreset, { value: formatPresetValue(displayValue), unit })}
aria-label={t($ => $.speedLimiter.removePreset, { value: formatPresetValue(displayValue), unit })}
>
<X size={12} />
</button>
@@ -297,7 +301,7 @@ export default function SpeedLimiterView() {
disabled={!enabled || isSaving}
onChange={event => setCustomPresetValue(event.target.value)}
className="w-12 bg-transparent text-right font-mono text-[12px] text-text-primary outline-none disabled:opacity-50"
aria-label={`Custom preset in ${unit}`}
aria-label={t($ => $.speedLimiter.customPresetIn, { unit })}
/>
<span className="text-[11px] text-text-muted">{unit}</span>
<button
@@ -305,8 +309,8 @@ export default function SpeedLimiterView() {
disabled={!enabled || isSaving}
onClick={applyCustomPreset}
className="app-icon-button h-6 w-6 disabled:opacity-50"
title="Add quick preset"
aria-label="Add quick preset"
title={t($ => $.speedLimiter.addQuickPreset)}
aria-label={t($ => $.speedLimiter.addQuickPreset)}
>
<Plus size={14} />
</button>
+10 -7
View File
@@ -1,6 +1,7 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
import { Minus, Square, X } from 'lucide-react';
import type { PointerEvent } from 'react';
import { useTranslation } from 'react-i18next';
const appWindow = getCurrentWindow();
@@ -9,13 +10,15 @@ const stopTitlebarDrag = (event: PointerEvent<HTMLButtonElement>) => {
};
export function WindowControls() {
const { t } = useTranslation();
return (
<div className="window-controls" aria-label="Window controls">
<div className="window-controls" aria-label={t($ => $.window.controls)}>
<button
type="button"
className="window-control close"
title="Close"
aria-label="Close"
title={t($ => $.window.close)}
aria-label={t($ => $.window.close)}
onPointerDown={stopTitlebarDrag}
onClick={(event) => {
event.stopPropagation();
@@ -27,8 +30,8 @@ export function WindowControls() {
<button
type="button"
className="window-control minimize"
title="Minimize"
aria-label="Minimize"
title={t($ => $.window.minimize)}
aria-label={t($ => $.window.minimize)}
onPointerDown={stopTitlebarDrag}
onClick={(event) => {
event.stopPropagation();
@@ -40,8 +43,8 @@ export function WindowControls() {
<button
type="button"
className="window-control maximize"
title="Maximize"
aria-label="Maximize"
title={t($ => $.window.maximize)}
aria-label={t($ => $.window.maximize)}
onPointerDown={stopTitlebarDrag}
onClick={(event) => {
event.stopPropagation();