mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-09 10:49:29 +00:00
fix(ui): clarify download properties inputs
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
PROPERTIES_WINDOW_SNAPSHOT,
|
||||
attachAsyncPropertiesListener,
|
||||
DEFAULT_PROPERTIES_WINDOW_CHROME,
|
||||
encodePropertiesPatchValue,
|
||||
formatPropertiesQueuePlacement,
|
||||
getPropertiesLifecycleAction,
|
||||
propertiesTorrentPeerLimit,
|
||||
propertiesDiagnosticRequestState,
|
||||
sendPropertiesActionRequest,
|
||||
sendPropertiesReady,
|
||||
@@ -73,6 +73,68 @@ const safeTitle = (name: string) => {
|
||||
|
||||
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
||||
|
||||
const PropertiesHelp = ({ text }: { text: string }) => (
|
||||
<button type="button" className="properties-help" aria-label={text}>
|
||||
<Info size={13} strokeWidth={2} aria-hidden="true" />
|
||||
<span className="properties-help-tooltip" aria-hidden="true">{text}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const PropertiesField = ({
|
||||
label,
|
||||
controlId,
|
||||
hint,
|
||||
meta,
|
||||
format,
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
label: ReactNode;
|
||||
controlId: string;
|
||||
hint?: string;
|
||||
meta?: ReactNode;
|
||||
format?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div className={`properties-field ${className}`}>
|
||||
<div className="properties-field-label">
|
||||
<label className="properties-field-label-text" htmlFor={controlId}>
|
||||
<span className="min-w-0">{label}</span>
|
||||
</label>
|
||||
{hint && <PropertiesHelp text={hint} />}
|
||||
{meta && <span className="properties-field-meta">{meta}</span>}
|
||||
</div>
|
||||
{children}
|
||||
{format && <span className="properties-field-format">{format}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const PropertiesOptionToggle = ({
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
hint: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) => {
|
||||
const controlId = useId();
|
||||
return (
|
||||
<div className="properties-option-toggle">
|
||||
<input id={controlId} type="checkbox" checked={checked} onChange={event => onChange(event.target.checked)} disabled={disabled} />
|
||||
<span className="properties-option-toggle-copy">
|
||||
<label className="properties-option-toggle-label" htmlFor={controlId}>{label}</label>
|
||||
</span>
|
||||
<PropertiesHelp text={hint} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const propertiesStatusTone = (status: string) => {
|
||||
if (status === 'paused') return 'paused';
|
||||
if (status === 'seeding') return 'seeding';
|
||||
@@ -156,8 +218,8 @@ export const PropertiesWindowApp = () => {
|
||||
const [removeUnselectedFile, setRemoveUnselectedFile] = useState(false);
|
||||
const [stopTimeout, setStopTimeout] = useState('');
|
||||
const [prioritizePiece, setPrioritizePiece] = useState('');
|
||||
const [encryptionPolicy, setEncryptionPolicy] = useState<TorrentEncryptionPolicy>(TORRENT_ENCRYPTION_POLICY_DISABLED);
|
||||
const [fileAllocation, setFileAllocation] = useState<TorrentFileAllocation>('prealloc');
|
||||
const [encryptionPolicy, setEncryptionPolicy] = useState<TorrentEncryptionPolicy | ''>('');
|
||||
const [fileAllocation, setFileAllocation] = useState<TorrentFileAllocation | ''>('');
|
||||
const [trackerConnectTimeout, setTrackerConnectTimeout] = useState('');
|
||||
const [trackerTimeout, setTrackerTimeout] = useState('');
|
||||
const [trackerInterval, setTrackerInterval] = useState('');
|
||||
@@ -335,8 +397,8 @@ export const PropertiesWindowApp = () => {
|
||||
setRemoveUnselectedFile(next.torrentRemoveUnselectedFile === true);
|
||||
setStopTimeout(next.torrentStopTimeout === undefined ? '' : String(next.torrentStopTimeout));
|
||||
setPrioritizePiece(next.torrentPrioritizePiece ?? '');
|
||||
setEncryptionPolicy((next.torrentEncryptionPolicy as TorrentEncryptionPolicy | undefined) ?? TORRENT_ENCRYPTION_POLICY_DISABLED);
|
||||
setFileAllocation((next.torrentFileAllocation as TorrentFileAllocation | undefined) ?? 'prealloc');
|
||||
setEncryptionPolicy((next.torrentEncryptionPolicy as TorrentEncryptionPolicy | undefined) ?? '');
|
||||
setFileAllocation((next.torrentFileAllocation as TorrentFileAllocation | undefined) ?? '');
|
||||
setTrackerConnectTimeout(next.torrentTrackerConnectTimeout === undefined ? '' : String(next.torrentTrackerConnectTimeout));
|
||||
setTrackerTimeout(next.torrentTrackerTimeout === undefined ? '' : String(next.torrentTrackerTimeout));
|
||||
setTrackerInterval(next.torrentTrackerInterval === undefined ? '' : String(next.torrentTrackerInterval));
|
||||
@@ -773,7 +835,9 @@ export const PropertiesWindowApp = () => {
|
||||
const patch: PropertiesPatch = {};
|
||||
if (activeTab === 'overview') {
|
||||
if (fileName !== snapshot.fileName) patch.fileName = fileName;
|
||||
if (destination !== (snapshot.destination ?? '')) patch.destination = destination || undefined;
|
||||
const nextDestination = destination.trim() ? destination : undefined;
|
||||
const currentDestination = snapshot.destination?.trim() ? snapshot.destination : undefined;
|
||||
if (nextDestination !== currentDestination) patch.destination = encodePropertiesPatchValue(nextDestination);
|
||||
if (!isTorrent && connections.trim() && Number(connections) !== snapshot.connections) {
|
||||
patch.connections = Number(connections);
|
||||
}
|
||||
@@ -796,19 +860,23 @@ export const PropertiesWindowApp = () => {
|
||||
await requestAction('set-torrent-file-selection', { selectedIndices: nextSelectedFiles });
|
||||
return;
|
||||
} else if (activeTab === 'trackers') {
|
||||
if (trackers !== (snapshot.torrentTrackers ?? '')) patch.torrentTrackers = trackers;
|
||||
if (excludedTrackers !== (snapshot.torrentExcludeTrackers ?? '')) patch.torrentExcludeTrackers = excludedTrackers;
|
||||
const nextTrackers = trackers.trim();
|
||||
const currentTrackers = (snapshot.torrentTrackers ?? '').trim();
|
||||
if (nextTrackers !== currentTrackers) patch.torrentTrackers = encodePropertiesPatchValue(nextTrackers || undefined);
|
||||
const nextExcludedTrackers = excludedTrackers.trim();
|
||||
const currentExcludedTrackers = (snapshot.torrentExcludeTrackers ?? '').trim();
|
||||
if (nextExcludedTrackers !== currentExcludedTrackers) patch.torrentExcludeTrackers = encodePropertiesPatchValue(nextExcludedTrackers || undefined);
|
||||
if (trackerConnectTimeout !== String(snapshot.torrentTrackerConnectTimeout ?? '')) {
|
||||
patch.torrentTrackerConnectTimeout = trackerConnectTimeout.trim() ? Number(trackerConnectTimeout) : undefined;
|
||||
patch.torrentTrackerConnectTimeout = encodePropertiesPatchValue(trackerConnectTimeout.trim() ? Number(trackerConnectTimeout) : undefined);
|
||||
}
|
||||
if (trackerTimeout !== String(snapshot.torrentTrackerTimeout ?? '')) {
|
||||
patch.torrentTrackerTimeout = trackerTimeout.trim() ? Number(trackerTimeout) : undefined;
|
||||
patch.torrentTrackerTimeout = encodePropertiesPatchValue(trackerTimeout.trim() ? Number(trackerTimeout) : undefined);
|
||||
}
|
||||
if (trackerInterval !== String(snapshot.torrentTrackerInterval ?? '')) {
|
||||
patch.torrentTrackerInterval = trackerInterval.trim() ? Number(trackerInterval) : undefined;
|
||||
patch.torrentTrackerInterval = encodePropertiesPatchValue(trackerInterval.trim() ? Number(trackerInterval) : undefined);
|
||||
}
|
||||
} else if (activeTab === 'options' || activeTab === 'transfer') {
|
||||
if (downloadLimit !== (snapshot.speedLimit ?? '')) patch.speedLimit = downloadLimit;
|
||||
if (downloadLimit !== (snapshot.speedLimit ?? '')) patch.speedLimit = encodePropertiesPatchValue(downloadLimit.trim() ? downloadLimit : undefined);
|
||||
if (activeTab === 'transfer' && !isTorrent && connections.trim()) {
|
||||
const nextConnections = Number(connections);
|
||||
if (nextConnections !== snapshot.connections) patch.connections = nextConnections;
|
||||
@@ -820,28 +888,27 @@ export const PropertiesWindowApp = () => {
|
||||
switchAfterSaveRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (uploadLimit !== (snapshot.torrentUploadLimit ?? '')) patch.torrentUploadLimit = uploadLimit;
|
||||
if (uploadLimit !== (snapshot.torrentUploadLimit ?? '')) patch.torrentUploadLimit = encodePropertiesPatchValue(uploadLimit.trim() ? uploadLimit : undefined);
|
||||
if (maxPeers !== String(snapshot.torrentMaxPeers ?? '')) {
|
||||
patch.torrentMaxPeers = maxPeers.trim() ? Number(maxPeers) : undefined;
|
||||
patch.torrentMaxPeers = encodePropertiesPatchValue(maxPeers.trim() ? Number(maxPeers) : undefined);
|
||||
}
|
||||
if (peerSpeedLimit !== (snapshot.torrentPeerSpeedLimit ?? '')) patch.torrentPeerSpeedLimit = peerSpeedLimit;
|
||||
if (peerSpeedLimit !== (snapshot.torrentPeerSpeedLimit ?? '')) patch.torrentPeerSpeedLimit = encodePropertiesPatchValue(peerSpeedLimit.trim() ? peerSpeedLimit : undefined);
|
||||
if (seedTime !== String(snapshot.torrentSeedTime ?? '')) {
|
||||
patch.torrentSeedTime = seedTime.trim() ? Number(seedTime) : undefined;
|
||||
patch.torrentSeedTime = encodePropertiesPatchValue(seedTime.trim() ? Number(seedTime) : undefined);
|
||||
}
|
||||
if (seedRatio !== String(snapshot.torrentSeedRatio ?? '')) {
|
||||
patch.torrentSeedRatio = seedRatio.trim() ? Number(seedRatio) : undefined;
|
||||
patch.torrentSeedRatio = encodePropertiesPatchValue(seedRatio.trim() ? Number(seedRatio) : undefined);
|
||||
}
|
||||
if (checkIntegrity !== (snapshot.torrentCheckIntegrity === true)) patch.torrentCheckIntegrity = checkIntegrity;
|
||||
if (removeUnselectedFile !== (snapshot.torrentRemoveUnselectedFile === true)) patch.torrentRemoveUnselectedFile = removeUnselectedFile;
|
||||
if (stopTimeout !== String(snapshot.torrentStopTimeout ?? '')) {
|
||||
patch.torrentStopTimeout = stopTimeout.trim() ? Number(stopTimeout) : undefined;
|
||||
patch.torrentStopTimeout = encodePropertiesPatchValue(stopTimeout.trim() ? Number(stopTimeout) : undefined);
|
||||
}
|
||||
if (prioritizePiece !== (snapshot.torrentPrioritizePiece ?? '')) patch.torrentPrioritizePiece = prioritizePiece.trim() || undefined;
|
||||
const snapshotEncryptionPolicy = (snapshot.torrentEncryptionPolicy as TorrentEncryptionPolicy | undefined) ?? TORRENT_ENCRYPTION_POLICY_DISABLED;
|
||||
if (encryptionPolicy !== snapshotEncryptionPolicy) {
|
||||
patch.torrentEncryptionPolicy = encryptionPolicy === TORRENT_ENCRYPTION_POLICY_DISABLED ? undefined : encryptionPolicy;
|
||||
}
|
||||
if (fileAllocation !== ((snapshot.torrentFileAllocation as TorrentFileAllocation | undefined) ?? 'prealloc')) patch.torrentFileAllocation = fileAllocation;
|
||||
if (prioritizePiece !== (snapshot.torrentPrioritizePiece ?? '')) patch.torrentPrioritizePiece = encodePropertiesPatchValue(prioritizePiece.trim() || undefined);
|
||||
const nextEncryptionPolicy = encryptionPolicy || undefined;
|
||||
if (nextEncryptionPolicy !== snapshot.torrentEncryptionPolicy) patch.torrentEncryptionPolicy = encodePropertiesPatchValue(nextEncryptionPolicy);
|
||||
const nextFileAllocation = fileAllocation || undefined;
|
||||
if (nextFileAllocation !== snapshot.torrentFileAllocation) patch.torrentFileAllocation = encodePropertiesPatchValue(nextFileAllocation);
|
||||
}
|
||||
} else if (activeTab === 'advanced') {
|
||||
for (const name of SECRET_NAMES) {
|
||||
@@ -994,7 +1061,7 @@ export const PropertiesWindowApp = () => {
|
||||
<div className="properties-window-titlebar" data-tauri-drag-region>
|
||||
<span data-tauri-drag-region>{snapshot.fileName} - {t($ => $.downloadTable.properties)} - Firelink</span>
|
||||
</div>
|
||||
<header className="properties-window-header shrink-0 border-b border-border-modal bg-sidebar-bg px-5 py-4">
|
||||
<header className="properties-window-header shrink-0 border-b border-border-modal px-5 py-4">
|
||||
<div className="properties-window-hero-top">
|
||||
<div className="properties-window-title-block min-w-0">
|
||||
<div className="properties-window-title-line">
|
||||
@@ -1201,31 +1268,173 @@ export const PropertiesWindowApp = () => {
|
||||
|
||||
{activeTab === 'transfer' && <div className="space-y-4">
|
||||
<div className="grid max-w-2xl gap-4 sm:grid-cols-2">
|
||||
<label className="text-xs text-text-muted">{t($ => $.properties.speedCap)}<input className="app-control mt-1 w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab('transfer'); }} placeholder="1024K" disabled={!editingEnabled} /></label>
|
||||
<PropertiesField
|
||||
label={t($ => $.properties.speedCap)}
|
||||
controlId="properties-transfer-speed-cap"
|
||||
hint={t($ => $.properties.speedLimitHint)}
|
||||
meta={downloadLimit.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
|
||||
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSpeedLimit) })}
|
||||
>
|
||||
<input id="properties-transfer-speed-cap" className="app-control w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab('transfer'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} />
|
||||
</PropertiesField>
|
||||
<div className="rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs"><span className="text-text-muted">{snapshot.isMedia === true ? t($ => $.properties.configuredConcurrency) : t($ => $.properties.connections)}</span><p className="mt-1">{snapshot.isMedia === true ? snapshot.connections ?? '—' : `${snapshot.activeConnections ?? '—'} / ${snapshot.requestedConnections ?? snapshot.connections ?? '—'}`}</p></div>
|
||||
</div>
|
||||
<label className="block max-w-2xl text-xs text-text-muted">{snapshot.isMedia === true ? t($ => $.properties.configuredConcurrency) : t($ => $.properties.connections)}<div className="mt-2 flex items-center gap-3"><input type="range" min="1" max="16" value={connections || '1'} onChange={event => { setConnections(event.target.value); setDraftTab('transfer'); }} disabled={!editingEnabled} className="min-w-0 flex-1 accent-blue-500" aria-label={t($ => $.properties.connections)} /><span className="w-8 text-center font-mono text-text-primary">{connections || '1'}</span></div></label>
|
||||
<p className="text-xs text-text-muted">{t($ => $.properties.transferSettings)}</p>
|
||||
</div>}
|
||||
|
||||
{activeTab === 'options' && isTorrent && <div className="space-y-5 text-xs">
|
||||
<div className="grid max-w-3xl gap-4 sm:grid-cols-2">
|
||||
<label className="text-text-muted">{t($ => $.properties.speedCap)}<input className="app-control mt-1 w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab('options'); }} placeholder="1024K" disabled={!editingEnabled} /></label>
|
||||
<label className="text-text-muted">{t($ => $.properties.liveTorrentUploadLimit)}<input className="app-control mt-1 w-full" value={uploadLimit} onChange={event => { setUploadLimit(event.target.value); setDraftTab('options'); }} placeholder="1024K" disabled={!editingEnabled} /></label>
|
||||
<label className="text-text-muted">{t($ => $.properties.torrentMaxPeers)}<input className="app-control mt-1 w-full" value={maxPeers} onChange={event => { setMaxPeers(event.target.value); setDraftTab('options'); }} inputMode="numeric" placeholder={String(propertiesTorrentPeerLimit(undefined))} disabled={!editingEnabled} /></label>
|
||||
<label className="text-text-muted">{t($ => $.properties.torrentPeerSpeedLimit)}<input className="app-control mt-1 w-full" value={peerSpeedLimit} onChange={event => { setPeerSpeedLimit(event.target.value); setDraftTab('options'); }} placeholder="50K" disabled={!editingEnabled} /></label>
|
||||
<label className="text-text-muted">{t($ => $.addDownloads.seedTime)}<input className="app-control mt-1 w-full" value={seedTime} onChange={event => { setSeedTime(event.target.value); setDraftTab('options'); }} inputMode="decimal" placeholder={t($ => $.properties.defaultValue)} disabled={!editingEnabled} /></label>
|
||||
<label className="text-text-muted">{t($ => $.addDownloads.seedRatio)}<input className="app-control mt-1 w-full" value={seedRatio} onChange={event => { setSeedRatio(event.target.value); setDraftTab('options'); }} inputMode="decimal" placeholder="0" disabled={!editingEnabled} /></label>
|
||||
<label className="text-text-muted">{t($ => $.properties.torrentStopTimeout)}<input className="app-control mt-1 w-full" value={stopTimeout} onChange={event => { setStopTimeout(event.target.value); setDraftTab('options'); }} inputMode="numeric" placeholder="0" disabled={!editingEnabled} /></label>
|
||||
<label className="text-text-muted">{t($ => $.properties.torrentPrioritizePiece)}<input className="app-control mt-1 w-full" value={prioritizePiece} onChange={event => { setPrioritizePiece(event.target.value); setDraftTab('options'); }} placeholder="head=1M,tail=1M" disabled={!editingEnabled} /></label>
|
||||
{activeTab === 'options' && isTorrent && <div className="properties-options space-y-5 text-xs">
|
||||
<div className="properties-options-intro">
|
||||
<div className="min-w-0">
|
||||
<p className="properties-section-eyebrow">{t($ => $.properties.tabs.options)}</p>
|
||||
<p className="mt-1 text-text-muted">{t($ => $.properties.torrentPeerOptionsSavedHint)}</p>
|
||||
</div>
|
||||
<span className="properties-default-legend"><span className="properties-default-legend-dot" />{t($ => $.properties.blankUsesDefault)}</span>
|
||||
</div>
|
||||
<div className="grid max-w-3xl gap-3 sm:grid-cols-2">
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={checkIntegrity} onChange={event => { setCheckIntegrity(event.target.checked); setDraftTab('options'); }} disabled={!editingEnabled} />{t($ => $.properties.torrentVerifyIntegrity)}</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={removeUnselectedFile} onChange={event => { setRemoveUnselectedFile(event.target.checked); setDraftTab('options'); }} disabled={!editingEnabled || (!removeUnselectedFile && (!snapshot.torrentFileIndices || snapshot.torrentFileIndices.length === 0))} />{t($ => $.properties.torrentRemoveUnselectedFile)}</label>
|
||||
<label className="flex items-center gap-2 text-text-muted">{t($ => $.properties.torrentFileAllocation)}<select className="app-control" value={fileAllocation} onChange={event => { setFileAllocation(event.target.value as TorrentFileAllocation); setDraftTab('options'); }} disabled={!editingEnabled}><option value="prealloc">{t($ => $.properties.torrentFileAllocationPrealloc)}</option><option value="none">{t($ => $.properties.torrentFileAllocationNone)}</option></select></label>
|
||||
<label className="flex items-center gap-2 text-text-muted">{t($ => $.properties.torrentEncryptionPolicy)}<select className="app-control" value={encryptionPolicy} onChange={event => { setEncryptionPolicy(event.target.value as TorrentEncryptionPolicy); setDraftTab('options'); }} disabled={!editingEnabled}><option value={TORRENT_ENCRYPTION_POLICY_DISABLED}>{t($ => $.properties.torrentEncryptionDisabled)}</option><option value={TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO}>{t($ => $.properties.torrentEncryptionRequireCrypto)}</option><option value={TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION}>{t($ => $.properties.torrentEncryptionForceEncryption)}</option></select></label>
|
||||
</div>
|
||||
<p className="max-w-3xl text-text-muted">{t($ => $.properties.torrentPeerOptionsSavedHint)}</p>
|
||||
|
||||
<section className="properties-option-group" aria-labelledby="properties-options-limits-heading">
|
||||
<div className="properties-option-group-heading">
|
||||
<div>
|
||||
<h2 id="properties-options-limits-heading">{t($ => $.properties.liveTorrentPeerOptions)}</h2>
|
||||
<p>{t($ => $.properties.speedLimitHint)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid max-w-4xl gap-4 sm:grid-cols-2">
|
||||
<PropertiesField
|
||||
label={t($ => $.properties.speedCap)}
|
||||
controlId="properties-options-speed-cap"
|
||||
hint={t($ => $.properties.speedLimitHint)}
|
||||
meta={downloadLimit.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
|
||||
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSpeedLimit) })}
|
||||
>
|
||||
<input id="properties-options-speed-cap" className="app-control w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} />
|
||||
</PropertiesField>
|
||||
<PropertiesField
|
||||
label={t($ => $.properties.liveTorrentUploadLimit)}
|
||||
controlId="properties-options-upload-limit"
|
||||
hint={t($ => $.properties.liveTorrentUploadLimitHint)}
|
||||
meta={uploadLimit.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
|
||||
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSpeedLimit) })}
|
||||
>
|
||||
<input id="properties-options-upload-limit" className="app-control w-full" value={uploadLimit} onChange={event => { setUploadLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} />
|
||||
</PropertiesField>
|
||||
<PropertiesField
|
||||
label={t($ => $.properties.torrentMaxPeers)}
|
||||
controlId="properties-options-max-peers"
|
||||
hint={t($ => $.properties.torrentPeerOptionsSavedHint)}
|
||||
meta={maxPeers.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
|
||||
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatMaxPeers) })}
|
||||
>
|
||||
<input id="properties-options-max-peers" className="app-control w-full" value={maxPeers} onChange={event => { setMaxPeers(event.target.value); setDraftTab('options'); }} inputMode="numeric" placeholder={t($ => $.properties.inputExampleMaxPeers)} disabled={!editingEnabled} />
|
||||
</PropertiesField>
|
||||
<PropertiesField
|
||||
label={t($ => $.properties.torrentPeerSpeedLimit)}
|
||||
controlId="properties-options-peer-speed-limit"
|
||||
hint={t($ => $.properties.torrentPeerOptionsSavedHint)}
|
||||
meta={peerSpeedLimit.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
|
||||
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSpeedLimit) })}
|
||||
>
|
||||
<input id="properties-options-peer-speed-limit" className="app-control w-full" value={peerSpeedLimit} onChange={event => { setPeerSpeedLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} />
|
||||
</PropertiesField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="properties-option-group" aria-labelledby="properties-options-seeding-heading">
|
||||
<div className="properties-option-group-heading">
|
||||
<div>
|
||||
<h2 id="properties-options-seeding-heading">{t($ => $.addDownloads.torrentSeeding)}</h2>
|
||||
<p>{t($ => $.addDownloads.seedRatioHint)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid max-w-4xl gap-4 sm:grid-cols-2">
|
||||
<PropertiesField
|
||||
label={t($ => $.addDownloads.seedTime)}
|
||||
controlId="properties-options-seed-time"
|
||||
hint={t($ => $.properties.torrentSeedTimeHint)}
|
||||
meta={seedTime.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
|
||||
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSeedTime) })}
|
||||
>
|
||||
<input id="properties-options-seed-time" className="app-control w-full" value={seedTime} onChange={event => { setSeedTime(event.target.value); setDraftTab('options'); }} inputMode="decimal" placeholder={t($ => $.properties.inputExampleSeedTime)} disabled={!editingEnabled} />
|
||||
</PropertiesField>
|
||||
<PropertiesField
|
||||
label={t($ => $.addDownloads.seedRatio)}
|
||||
controlId="properties-options-seed-ratio"
|
||||
hint={t($ => $.addDownloads.seedRatioHint)}
|
||||
meta={seedRatio.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
|
||||
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSeedRatio) })}
|
||||
>
|
||||
<input id="properties-options-seed-ratio" className="app-control w-full" value={seedRatio} onChange={event => { setSeedRatio(event.target.value); setDraftTab('options'); }} inputMode="decimal" placeholder={t($ => $.properties.inputExampleSeedRatio)} disabled={!editingEnabled} />
|
||||
</PropertiesField>
|
||||
<PropertiesField
|
||||
label={t($ => $.properties.torrentStopTimeout)}
|
||||
controlId="properties-options-stop-timeout"
|
||||
hint={t($ => $.properties.torrentStopTimeoutHint)}
|
||||
meta={stopTimeout.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
|
||||
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatStopTimeout) })}
|
||||
>
|
||||
<input id="properties-options-stop-timeout" className="app-control w-full" value={stopTimeout} onChange={event => { setStopTimeout(event.target.value); setDraftTab('options'); }} inputMode="numeric" placeholder={t($ => $.properties.inputExampleStopTimeout)} disabled={!editingEnabled} />
|
||||
</PropertiesField>
|
||||
<PropertiesField
|
||||
label={t($ => $.properties.torrentPrioritizePiece)}
|
||||
controlId="properties-options-prioritize-piece"
|
||||
hint={t($ => $.properties.torrentPrioritizePieceHint)}
|
||||
meta={prioritizePiece.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)}
|
||||
format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatPiecePriority) })}
|
||||
>
|
||||
<input id="properties-options-prioritize-piece" className="app-control w-full" value={prioritizePiece} onChange={event => { setPrioritizePiece(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExamplePiecePriority)} disabled={!editingEnabled} />
|
||||
</PropertiesField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="properties-option-group" aria-labelledby="properties-options-behavior-heading">
|
||||
<div className="properties-option-group-heading">
|
||||
<div>
|
||||
<h2 id="properties-options-behavior-heading">{t($ => $.properties.torrentOptionsBehavior)}</h2>
|
||||
<p>{t($ => $.properties.torrentOptionsBehaviorHint)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid max-w-4xl gap-3 sm:grid-cols-2">
|
||||
<PropertiesOptionToggle
|
||||
label={t($ => $.properties.torrentVerifyIntegrity)}
|
||||
hint={t($ => $.properties.torrentVerifyIntegrityHint)}
|
||||
checked={checkIntegrity}
|
||||
onChange={checked => { setCheckIntegrity(checked); setDraftTab('options'); }}
|
||||
disabled={!editingEnabled}
|
||||
/>
|
||||
<PropertiesOptionToggle
|
||||
label={t($ => $.properties.torrentRemoveUnselectedFile)}
|
||||
hint={t($ => $.properties.torrentRemoveUnselectedFileHint)}
|
||||
checked={removeUnselectedFile}
|
||||
onChange={checked => { setRemoveUnselectedFile(checked); setDraftTab('options'); }}
|
||||
disabled={!editingEnabled || (!removeUnselectedFile && (!snapshot.torrentFileIndices || snapshot.torrentFileIndices.length === 0))}
|
||||
/>
|
||||
<PropertiesField
|
||||
label={t($ => $.properties.torrentFileAllocation)}
|
||||
controlId="properties-options-file-allocation"
|
||||
hint={t($ => $.properties.torrentFileAllocationHint)}
|
||||
meta={fileAllocation === '' ? t($ => $.properties.usingDefault) : t($ => $.properties.customPerDownload)}
|
||||
>
|
||||
<select id="properties-options-file-allocation" className="app-control w-full" value={fileAllocation} onChange={event => { setFileAllocation(event.target.value as TorrentFileAllocation | ''); setDraftTab('options'); }} disabled={!editingEnabled}>
|
||||
<option value="">{t($ => $.properties.usingDefault)}</option>
|
||||
<option value="prealloc">{t($ => $.properties.torrentFileAllocationPrealloc)}</option>
|
||||
<option value="none">{t($ => $.properties.torrentFileAllocationNone)}</option>
|
||||
</select>
|
||||
</PropertiesField>
|
||||
<PropertiesField
|
||||
label={t($ => $.properties.torrentEncryptionPolicy)}
|
||||
controlId="properties-options-encryption-policy"
|
||||
hint={t($ => $.properties.torrentEncryptionPolicyHint)}
|
||||
meta={encryptionPolicy === '' ? t($ => $.properties.usingDefault) : t($ => $.properties.customPerDownload)}
|
||||
>
|
||||
<select id="properties-options-encryption-policy" className="app-control w-full" value={encryptionPolicy} onChange={event => { setEncryptionPolicy(event.target.value as TorrentEncryptionPolicy | ''); setDraftTab('options'); }} disabled={!editingEnabled}>
|
||||
<option value="">{t($ => $.properties.usingDefault)}</option>
|
||||
<option value={TORRENT_ENCRYPTION_POLICY_DISABLED}>{t($ => $.properties.torrentEncryptionDisabled)}</option>
|
||||
<option value={TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO}>{t($ => $.properties.torrentEncryptionRequireCrypto)}</option>
|
||||
<option value={TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION}>{t($ => $.properties.torrentEncryptionForceEncryption)}</option>
|
||||
</select>
|
||||
</PropertiesField>
|
||||
</div>
|
||||
</section>
|
||||
</div>}
|
||||
|
||||
{activeTab === 'advanced' && <div className="space-y-4">
|
||||
|
||||
@@ -25,9 +25,11 @@ import {
|
||||
beginExclusivePropertiesAction,
|
||||
classifyPropertiesActionRequest,
|
||||
createFrameCoalescer,
|
||||
decodePropertiesPatchValue,
|
||||
enqueuePropertiesAction,
|
||||
getPropertiesLifecycleAction,
|
||||
propertiesActionRequestKey,
|
||||
PROPERTIES_PATCH_CLEARABLE_KEYS,
|
||||
sanitizePropertiesSnapshot,
|
||||
sendPropertiesActionResult,
|
||||
sendPropertiesRemoved,
|
||||
@@ -86,6 +88,13 @@ const copyEditablePatch = (rawPatch: PropertiesPatch): Partial<DownloadItem> =>
|
||||
'torrentFileAllocation',
|
||||
] as const) copy(key);
|
||||
|
||||
for (const key of PROPERTIES_PATCH_CLEARABLE_KEYS) {
|
||||
if (Object.prototype.hasOwnProperty.call(rawPatch, key)) {
|
||||
const value = (rawPatch as Record<string, unknown>)[key];
|
||||
(safePatch as Record<string, unknown>)[key] = decodePropertiesPatchValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (safePatch.fileName !== undefined && typeof safePatch.fileName !== 'string') {
|
||||
throw new Error('Invalid file name');
|
||||
}
|
||||
|
||||
@@ -247,6 +247,20 @@ const common = {
|
||||
connectionCountUnknown: '—/{{total}}',
|
||||
connectionsUnavailable: '—',
|
||||
speedCap: 'Speed cap',
|
||||
inputFormat: 'Format: {{format}}',
|
||||
inputFormatSpeedLimit: '512K, 2M, or 1G',
|
||||
inputFormatMaxPeers: '0–1000; 0 means unlimited',
|
||||
inputFormatSeedTime: 'minutes, e.g. 60',
|
||||
inputFormatSeedRatio: 'decimal, e.g. 1.5; 0 means time-only',
|
||||
inputFormatStopTimeout: 'whole seconds; 0 disables it',
|
||||
inputFormatPiecePriority: 'head=1M,tail=1M',
|
||||
inputExampleSpeedLimit: 'e.g. 512K',
|
||||
inputExampleMaxPeers: 'e.g. 55',
|
||||
inputExampleSeedTime: 'e.g. 60',
|
||||
inputExampleSeedRatio: 'e.g. 1.5',
|
||||
inputExampleStopTimeout: 'e.g. 300',
|
||||
inputExamplePiecePriority: 'e.g. head=1M,tail=1M',
|
||||
speedLimitHint: 'Leave blank to use the global default; enter a value to set a cap for this download.',
|
||||
liveSpeedLimit: 'Live speed cap',
|
||||
liveSpeedLimitHint: 'Applies to active normal downloads only. Media downloads cannot be changed while running.',
|
||||
liveSpeedLimitPlaceholder: 'e.g. 1024K',
|
||||
@@ -336,6 +350,7 @@ const common = {
|
||||
torrentUploaded: 'Uploaded',
|
||||
torrentRatio: 'Ratio',
|
||||
torrentSeededDuration: 'Seeded',
|
||||
torrentSeedTimeHint: 'How long this Torrent may continue seeding after its files finish downloading. Leave blank to use the default.',
|
||||
torrentConnectedPeers: 'Peers',
|
||||
torrentSeeders: 'Seeders',
|
||||
torrentUploadSpeed: 'Upload speed',
|
||||
@@ -354,6 +369,8 @@ const common = {
|
||||
torrentFileAllocationPrealloc: 'Preallocate files',
|
||||
torrentFileAllocationNone: 'Allocate as needed',
|
||||
torrentFileAllocationHint: 'Preallocation reserves the selected files before transfer. Allocation as needed avoids that upfront disk reservation.',
|
||||
torrentOptionsBehavior: 'Torrent behavior',
|
||||
torrentOptionsBehaviorHint: 'Controls verification, storage allocation, encryption, and cleanup for this Torrent.',
|
||||
torrentDetails: 'Torrent details',
|
||||
torrentCopyMagnet: 'Copy magnet link',
|
||||
torrentExportMetadata: 'Export .torrent',
|
||||
@@ -412,6 +429,9 @@ const common = {
|
||||
defaultValue: ' (default)',
|
||||
savedTooltip: 'Saved for this download; Settings changes apply to new downloads.',
|
||||
defaultTooltip: 'Using the current default for new downloads.',
|
||||
blankUsesDefault: 'Leave blank · use default',
|
||||
usingDefault: 'Using default',
|
||||
customPerDownload: 'Custom for this download',
|
||||
identityReadOnly: 'File identity is read-only. Transfer settings are saved for redownload.',
|
||||
transferSettings: 'Transfer settings can be changed after stopping or pausing. Current transfers keep their existing backend options.',
|
||||
download: 'Download',
|
||||
|
||||
@@ -247,6 +247,20 @@ const fa = {
|
||||
connectionCountUnknown: '—/{{total}} فعال',
|
||||
connectionsUnavailable: '—',
|
||||
speedCap: 'سقف سرعت',
|
||||
inputFormat: 'قالب: {{format}}',
|
||||
inputFormatSpeedLimit: '512K، 2M یا 1G',
|
||||
inputFormatMaxPeers: '0 تا 1000؛ 0 یعنی نامحدود',
|
||||
inputFormatSeedTime: 'دقیقه، مثلاً 60',
|
||||
inputFormatSeedRatio: 'عدد اعشاری، مثلاً 1.5؛ 0 یعنی فقط بر اساس زمان',
|
||||
inputFormatStopTimeout: 'تعداد ثانیهٔ کامل؛ 0 غیرفعال میکند',
|
||||
inputFormatPiecePriority: 'head=1M,tail=1M',
|
||||
inputExampleSpeedLimit: 'مثلاً 512K',
|
||||
inputExampleMaxPeers: 'مثلاً 55',
|
||||
inputExampleSeedTime: 'مثلاً 60',
|
||||
inputExampleSeedRatio: 'مثلاً 1.5',
|
||||
inputExampleStopTimeout: 'مثلاً 300',
|
||||
inputExamplePiecePriority: 'مثلاً head=1M,tail=1M',
|
||||
speedLimitHint: 'برای استفاده از مقدار سراسری خالی بگذارید؛ برای این دانلود یک سقف مشخص وارد کنید.',
|
||||
liveSpeedLimit: 'سقف سرعت زنده',
|
||||
liveSpeedLimitHint: 'فقط برای دانلودهای عادیِ فعال اعمال میشود. سرعت دانلودهای رسانهای هنگام اجرا قابل تغییر نیست.',
|
||||
liveSpeedLimitPlaceholder: 'مثلاً 1024K',
|
||||
@@ -336,6 +350,7 @@ const fa = {
|
||||
torrentUploaded: 'آپلودشده',
|
||||
torrentRatio: 'نسبت',
|
||||
torrentSeededDuration: 'مدت سید',
|
||||
torrentSeedTimeHint: 'مدتی که تورنت پس از تکمیل دانلود به سید ادامه میدهد. برای استفاده از پیشفرض خالی بگذارید.',
|
||||
torrentConnectedPeers: 'همتاها',
|
||||
torrentSeeders: 'سیدها',
|
||||
torrentUploadSpeed: 'سرعت آپلود',
|
||||
@@ -354,6 +369,8 @@ const fa = {
|
||||
torrentFileAllocationPrealloc: 'تخصیص از پیش',
|
||||
torrentFileAllocationNone: 'تخصیص هنگام نیاز',
|
||||
torrentFileAllocationHint: 'تخصیص از پیش فضای فایلهای انتخابشده را قبل از انتقال رزرو میکند؛ تخصیص هنگام نیاز این رزرو اولیه را انجام نمیدهد.',
|
||||
torrentOptionsBehavior: 'رفتار تورنت',
|
||||
torrentOptionsBehaviorHint: 'بررسی صحت، تخصیص فضا، رمزنگاری و پاکسازی این تورنت را کنترل میکند.',
|
||||
torrentDetails: 'جزئیات تورنت',
|
||||
torrentCopyMagnet: 'کپی پیوند مگنت',
|
||||
torrentExportMetadata: 'خروجی .torrent',
|
||||
@@ -412,6 +429,9 @@ const fa = {
|
||||
defaultValue: ' (پیشفرض)',
|
||||
savedTooltip: 'برای این دانلود ذخیرهشده است؛ تغییرات تنظیمات روی دانلودهای جدید اعمال میشود.',
|
||||
defaultTooltip: 'استفاده از پیشفرض کنونی برای دانلودهای جدید.',
|
||||
blankUsesDefault: 'خالی = استفاده از پیشفرض',
|
||||
usingDefault: 'استفاده از پیشفرض',
|
||||
customPerDownload: 'سفارشی برای این دانلود',
|
||||
identityReadOnly: 'هویت فایل فقطخواندنی است. تنظیمات انتقال برای دانلود مجدد ذخیره میشوند.',
|
||||
transferSettings: 'تنظیمات انتقال را میتوان پس از توقف تغییر داد. انتقالهای کنونی گزینههای فعلی خود را حفظ میکنند.',
|
||||
download: 'دانلود',
|
||||
|
||||
@@ -247,6 +247,20 @@ const he = {
|
||||
connectionCountUnknown: '—/{{total}} פעילות',
|
||||
connectionsUnavailable: '—',
|
||||
speedCap: 'הגבלת מהירות',
|
||||
inputFormat: 'תבנית: {{format}}',
|
||||
inputFormatSpeedLimit: '512K, 2M או 1G',
|
||||
inputFormatMaxPeers: '0–1000; 0 פירושו ללא הגבלה',
|
||||
inputFormatSeedTime: 'בדקות, למשל 60',
|
||||
inputFormatSeedRatio: 'מספר עשרוני, למשל 1.5; 0 פירושו לפי זמן בלבד',
|
||||
inputFormatStopTimeout: 'שניות שלמות; 0 משבית',
|
||||
inputFormatPiecePriority: 'head=1M,tail=1M',
|
||||
inputExampleSpeedLimit: 'לדוגמה 512K',
|
||||
inputExampleMaxPeers: 'לדוגמה 55',
|
||||
inputExampleSeedTime: 'לדוגמה 60',
|
||||
inputExampleSeedRatio: 'לדוגמה 1.5',
|
||||
inputExampleStopTimeout: 'לדוגמה 300',
|
||||
inputExamplePiecePriority: 'לדוגמה head=1M,tail=1M',
|
||||
speedLimitHint: 'השאר ריק כדי להשתמש בערך הגלובלי, או הזן הגבלה עבור הורדה זו.',
|
||||
liveSpeedLimit: 'הגבלת מהירות בזמן אמת',
|
||||
liveSpeedLimitHint: 'חל על הורדות רגילות פעילות בלבד. אי אפשר לשנות הורדות מדיה בזמן שהן פועלות.',
|
||||
liveSpeedLimitPlaceholder: 'לדוגמה 1024K',
|
||||
@@ -336,6 +350,7 @@ const he = {
|
||||
torrentUploaded: 'הועלה',
|
||||
torrentRatio: 'יחס',
|
||||
torrentSeededDuration: 'משך שיתוף',
|
||||
torrentSeedTimeHint: 'משך הזמן שבו הטורנט ימשיך לשתף לאחר סיום ההורדה. השאר ריק כדי להשתמש בברירת המחדל.',
|
||||
torrentConnectedPeers: 'עמיתים',
|
||||
torrentSeeders: 'משתפים',
|
||||
torrentUploadSpeed: 'מהירות העלאה',
|
||||
@@ -354,6 +369,8 @@ const he = {
|
||||
torrentFileAllocationPrealloc: 'הקצאה מראש',
|
||||
torrentFileAllocationNone: 'הקצאה לפי הצורך',
|
||||
torrentFileAllocationHint: 'הקצאה מראש שומרת מקום לקבצים לפני ההעברה; הקצאה לפי הצורך נמנעת מהשמירה הראשונית.',
|
||||
torrentOptionsBehavior: 'התנהגות Torrent',
|
||||
torrentOptionsBehaviorHint: 'שולט באימות, הקצאת האחסון, ההצפנה והניקוי של Torrent זה.',
|
||||
torrentDetails: 'פרטי טורנט',
|
||||
torrentCopyMagnet: 'העתקת קישור מגנט',
|
||||
torrentExportMetadata: 'ייצוא .torrent',
|
||||
@@ -412,6 +429,9 @@ const he = {
|
||||
defaultValue: ' (ברירת מחדל)',
|
||||
savedTooltip: 'נשמר עבור הורדה זו; שינויים בהגדרות יחולו על הורדות חדשות.',
|
||||
defaultTooltip: 'שימוש בברירת המחדל הנוכחית להורדות חדשות.',
|
||||
blankUsesDefault: 'ריק · שימוש בברירת המחדל',
|
||||
usingDefault: 'ברירת מחדל',
|
||||
customPerDownload: 'מותאם להורדה זו',
|
||||
identityReadOnly: 'זהות הקובץ היא לקריאה בלבד. הגדרות ההעברה נשמרות להורדה מחדש.',
|
||||
transferSettings: 'ניתן לשנות את הגדרות ההעברה לאחר עצירה או השהייה. העברות נוכחיות שומרות על אפשרויות המנוע הקיימות שלהן.',
|
||||
download: 'הורדה',
|
||||
|
||||
@@ -247,6 +247,20 @@ const ru = {
|
||||
connectionCountUnknown: '—/{{total}} активных',
|
||||
connectionsUnavailable: '—',
|
||||
speedCap: 'Ограничение скорости',
|
||||
inputFormat: 'Формат: {{format}}',
|
||||
inputFormatSpeedLimit: '512K, 2M или 1G',
|
||||
inputFormatMaxPeers: '0–1000; 0 — без ограничений',
|
||||
inputFormatSeedTime: 'минуты, например 60',
|
||||
inputFormatSeedRatio: 'десятичное число, например 1.5; 0 — только по времени',
|
||||
inputFormatStopTimeout: 'целые секунды; 0 отключает',
|
||||
inputFormatPiecePriority: 'head=1M,tail=1M',
|
||||
inputExampleSpeedLimit: 'например, 512K',
|
||||
inputExampleMaxPeers: 'например, 55',
|
||||
inputExampleSeedTime: 'например, 60',
|
||||
inputExampleSeedRatio: 'например, 1.5',
|
||||
inputExampleStopTimeout: 'например, 300',
|
||||
inputExamplePiecePriority: 'например, head=1M,tail=1M',
|
||||
speedLimitHint: 'Оставьте пустым для глобального значения по умолчанию или задайте ограничение для этой загрузки.',
|
||||
liveSpeedLimit: 'Текущее ограничение скорости',
|
||||
liveSpeedLimitHint: 'Применяется только к активным обычным загрузкам. Скорость медиазагрузок нельзя изменить во время работы.',
|
||||
liveSpeedLimitPlaceholder: 'например, 1024K',
|
||||
@@ -336,6 +350,7 @@ const ru = {
|
||||
torrentUploaded: 'Отдано',
|
||||
torrentRatio: 'Коэффициент',
|
||||
torrentSeededDuration: 'Время раздачи',
|
||||
torrentSeedTimeHint: 'Как долго Torrent продолжает раздачу после завершения загрузки. Оставьте пустым для значения по умолчанию.',
|
||||
torrentConnectedPeers: 'Пиры',
|
||||
torrentSeeders: 'Сиды',
|
||||
torrentUploadSpeed: 'Скорость отдачи',
|
||||
@@ -354,6 +369,8 @@ const ru = {
|
||||
torrentFileAllocationPrealloc: 'Предварительное выделение',
|
||||
torrentFileAllocationNone: 'Выделять по мере необходимости',
|
||||
torrentFileAllocationHint: 'Предварительное выделение резервирует место до передачи; выделение по мере необходимости не делает начальное резервирование.',
|
||||
torrentOptionsBehavior: 'Поведение Torrent',
|
||||
torrentOptionsBehaviorHint: 'Управляет проверкой, выделением места, шифрованием и очисткой этого Torrent.',
|
||||
torrentEncryptionPolicyHint: 'Применяется при запуске или повторной попытке Torrent. Выберите одну политику, чтобы параметры handshake и шифрования payload оставались согласованными.',
|
||||
torrentDetails: 'Сведения о Torrent',
|
||||
torrentCopyMagnet: 'Копировать magnet-ссылку',
|
||||
@@ -412,6 +429,9 @@ const ru = {
|
||||
defaultValue: ' (по умолчанию)',
|
||||
savedTooltip: 'Сохранено для этой загрузки. Изменения в Настройках будут применяться к новым загрузкам.',
|
||||
defaultTooltip: 'Используется текущее значение по умолчанию для новых загрузок.',
|
||||
blankUsesDefault: 'Пусто · значение по умолчанию',
|
||||
usingDefault: 'По умолчанию',
|
||||
customPerDownload: 'Для этой загрузки',
|
||||
identityReadOnly: 'Идентификация файла доступна только для чтения. Настройки передачи сохранены для повторного скачивания.',
|
||||
transferSettings: 'Настройки передачи можно изменить после остановки или приостановки. Текущие загрузки сохраняют свои параметры.',
|
||||
download: 'Загрузка',
|
||||
|
||||
@@ -247,6 +247,20 @@ const uk = {
|
||||
connectionCountUnknown: '—/{{total}} активних',
|
||||
connectionsUnavailable: '—',
|
||||
speedCap: 'Обмеження швидкості',
|
||||
inputFormat: 'Формат: {{format}}',
|
||||
inputFormatSpeedLimit: '512K, 2M або 1G',
|
||||
inputFormatMaxPeers: '0–1000; 0 означає без обмежень',
|
||||
inputFormatSeedTime: 'хвилини, наприклад 60',
|
||||
inputFormatSeedRatio: 'десяткове число, наприклад 1.5; 0 — лише за часом',
|
||||
inputFormatStopTimeout: 'цілі секунди; 0 вимикає',
|
||||
inputFormatPiecePriority: 'head=1M,tail=1M',
|
||||
inputExampleSpeedLimit: 'наприклад, 512K',
|
||||
inputExampleMaxPeers: 'наприклад, 55',
|
||||
inputExampleSeedTime: 'наприклад, 60',
|
||||
inputExampleSeedRatio: 'наприклад, 1.5',
|
||||
inputExampleStopTimeout: 'наприклад, 300',
|
||||
inputExamplePiecePriority: 'наприклад, head=1M,tail=1M',
|
||||
speedLimitHint: 'Залиште порожнім для глобального значення за замовчуванням або задайте обмеження для цього завантаження.',
|
||||
liveSpeedLimit: 'Поточне обмеження швидкості',
|
||||
liveSpeedLimitHint: 'Застосовується лише до активних звичайних завантажень. Швидкість медіазавантажень не можна змінити під час роботи.',
|
||||
liveSpeedLimitPlaceholder: 'наприклад, 1024K',
|
||||
@@ -336,6 +350,7 @@ const uk = {
|
||||
torrentUploaded: 'Віддано',
|
||||
torrentRatio: 'Коефіцієнт',
|
||||
torrentSeededDuration: 'Час роздачі',
|
||||
torrentSeedTimeHint: 'Як довго Torrent продовжує роздачу після завершення завантаження. Залиште порожнім для значення за замовчуванням.',
|
||||
torrentConnectedPeers: 'Піри',
|
||||
torrentSeeders: 'Сіди',
|
||||
torrentUploadSpeed: 'Швидкість віддачі',
|
||||
@@ -354,6 +369,8 @@ const uk = {
|
||||
torrentFileAllocationPrealloc: 'Попереднє виділення',
|
||||
torrentFileAllocationNone: 'Виділяти за потреби',
|
||||
torrentFileAllocationHint: 'Попереднє виділення резервує місце до передачі; виділення за потреби не робить початкового резервування.',
|
||||
torrentOptionsBehavior: 'Поведінка Torrent',
|
||||
torrentOptionsBehaviorHint: 'Керує перевіркою, виділенням місця, шифруванням і очищенням цього Torrent.',
|
||||
torrentEncryptionPolicyHint: 'Застосовується під час запуску або повторної спроби Torrent. Виберіть одну політику, щоб параметри handshake і шифрування payload залишалися узгодженими.',
|
||||
torrentDetails: 'Відомості про Torrent',
|
||||
torrentCopyMagnet: 'Копіювати magnet-посилання',
|
||||
@@ -412,6 +429,9 @@ const uk = {
|
||||
defaultValue: ' (за замовчуванням)',
|
||||
savedTooltip: 'Збережено для цього завантаження; зміни в налаштуваннях застосовуються до нових завантажень.',
|
||||
defaultTooltip: 'Використовується поточне значення за замовчуванням для нових завантажень.',
|
||||
blankUsesDefault: 'Порожньо · значення за замовчуванням',
|
||||
usingDefault: 'За замовчуванням',
|
||||
customPerDownload: 'Для цього завантаження',
|
||||
identityReadOnly: 'Ідентифікатор файлу доступний лише для читання. Налаштування передачі збережено для повторного завантаження.',
|
||||
transferSettings: 'Налаштування передачі можна змінити після зупинки або призупинення. Поточні передачі зберігають свої існуючі налаштування бекенду.',
|
||||
download: 'Завантаження',
|
||||
|
||||
@@ -247,6 +247,20 @@ const zhCN = {
|
||||
connectionCountUnknown: '—/{{total}} 个连接',
|
||||
connectionsUnavailable: '—',
|
||||
speedCap: '速度上限',
|
||||
inputFormat: '格式:{{format}}',
|
||||
inputFormatSpeedLimit: '512K、2M 或 1G',
|
||||
inputFormatMaxPeers: '0–1000;0 表示不限',
|
||||
inputFormatSeedTime: '分钟,例如 60',
|
||||
inputFormatSeedRatio: '小数,例如 1.5;0 表示仅按时间',
|
||||
inputFormatStopTimeout: '整数秒;0 表示禁用',
|
||||
inputFormatPiecePriority: 'head=1M,tail=1M',
|
||||
inputExampleSpeedLimit: '例如 512K',
|
||||
inputExampleMaxPeers: '例如 55',
|
||||
inputExampleSeedTime: '例如 60',
|
||||
inputExampleSeedRatio: '例如 1.5',
|
||||
inputExampleStopTimeout: '例如 300',
|
||||
inputExamplePiecePriority: '例如 head=1M,tail=1M',
|
||||
speedLimitHint: '留空以使用全局默认值;输入数值可为此下载设置上限。',
|
||||
liveSpeedLimit: '实时速度上限',
|
||||
liveSpeedLimitHint: '仅适用于正在进行的普通下载。媒体下载运行时无法更改速度。',
|
||||
liveSpeedLimitPlaceholder: '例如 1024K',
|
||||
@@ -336,6 +350,7 @@ const zhCN = {
|
||||
torrentUploaded: '已上传',
|
||||
torrentRatio: '分享率',
|
||||
torrentSeededDuration: '做种时长',
|
||||
torrentSeedTimeHint: '文件下载完成后继续做种的时长。留空以使用默认值。',
|
||||
torrentConnectedPeers: '连接数',
|
||||
torrentSeeders: '种子数',
|
||||
torrentUploadSpeed: '上传速度',
|
||||
@@ -354,6 +369,8 @@ const zhCN = {
|
||||
torrentFileAllocationPrealloc: '预分配文件',
|
||||
torrentFileAllocationNone: '按需分配',
|
||||
torrentFileAllocationHint: '预分配会在传输前为选中文件预留空间;按需分配不会进行初始磁盘预留。',
|
||||
torrentOptionsBehavior: 'Torrent 行为',
|
||||
torrentOptionsBehaviorHint: '控制此 Torrent 的校验、存储分配、加密和清理。',
|
||||
torrentEncryptionPolicyHint: '在 Torrent 启动或重试时应用。选择单一策略,确保握手和 payload 加密设置保持一致。',
|
||||
torrentDetails: 'Torrent 详细信息',
|
||||
torrentCopyMagnet: '复制磁力链接',
|
||||
@@ -412,6 +429,9 @@ const zhCN = {
|
||||
defaultValue: ' (默认)',
|
||||
savedTooltip: '已为此下载保存;设置中的更改将应用于新的下载。',
|
||||
defaultTooltip: '对新的下载使用当前默认值。',
|
||||
blankUsesDefault: '留空 · 使用默认值',
|
||||
usingDefault: '使用默认值',
|
||||
customPerDownload: '此下载的自定义值',
|
||||
identityReadOnly: '文件标识为只读。传输设置会保存以备重新下载使用。',
|
||||
transferSettings: '停止或暂停后可以更改传输设置。当前的传输会保留其现有的后端选项。',
|
||||
download: '下载',
|
||||
|
||||
@@ -112,6 +112,7 @@ describe('translation catalogs', () => {
|
||||
'settings.network.firefoxMacos',
|
||||
'settings.network.safariMacos',
|
||||
'settings.network.torrentExternalIpPlaceholder',
|
||||
'properties.inputFormatPiecePriority',
|
||||
]);
|
||||
|
||||
const unexpectedDuplicates = duplicates
|
||||
|
||||
+247
-6
@@ -572,8 +572,8 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.properties-window-shell {
|
||||
--properties-header-surface: hsl(var(--surface-raised));
|
||||
--properties-card-surface: hsl(var(--bg-input) / 0.42);
|
||||
--properties-header-surface: hsl(var(--main-bg));
|
||||
--properties-card-surface: hsl(var(--surface-raised) / 0.38);
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
@@ -612,8 +612,10 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.properties-window-header {
|
||||
background: var(--properties-header-surface);
|
||||
box-shadow: inset 0 -1px 0 hsl(0 0% 100% / 0.025);
|
||||
background:
|
||||
radial-gradient(circle at 100% 0%, hsl(var(--accent-color) / 0.055), transparent 38%),
|
||||
var(--properties-header-surface);
|
||||
box-shadow: inset 0 -1px 0 hsl(var(--text-primary) / 0.025);
|
||||
}
|
||||
|
||||
.properties-window-hero-top {
|
||||
@@ -793,7 +795,8 @@ html[data-list-density="relaxed"] {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid hsl(var(--border-modal) / 0.72);
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--bg-input) / 0.28);
|
||||
background: var(--properties-card-surface);
|
||||
box-shadow: inset 0 1px 0 hsl(var(--text-primary) / 0.025);
|
||||
}
|
||||
|
||||
.properties-metric-card > svg {
|
||||
@@ -856,7 +859,7 @@ html[data-list-density="relaxed"] {
|
||||
gap: 10px;
|
||||
padding: 7px 16px;
|
||||
border-bottom: 1px solid hsl(var(--border-modal));
|
||||
background: hsl(var(--surface-raised));
|
||||
background: hsl(var(--surface-raised) / 0.42);
|
||||
}
|
||||
|
||||
.properties-window-tab-navigation--overflow .properties-window-tabs {
|
||||
@@ -968,6 +971,209 @@ html[data-list-density="relaxed"] {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.properties-options {
|
||||
max-width: 940px;
|
||||
}
|
||||
|
||||
.properties-options-intro,
|
||||
.properties-option-group {
|
||||
border: 1px solid hsl(var(--border-modal) / 0.82);
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--surface-raised) / 0.22);
|
||||
}
|
||||
|
||||
.properties-options-intro {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 14px 16px;
|
||||
box-shadow: inset 0 1px 0 hsl(var(--text-primary) / 0.025);
|
||||
}
|
||||
|
||||
.properties-section-eyebrow,
|
||||
.properties-option-group-heading h2 {
|
||||
color: hsl(var(--text-primary));
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.properties-default-legend {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: hsl(var(--text-muted));
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.properties-default-legend-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border: 1px solid hsl(var(--accent-color) / 0.72);
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--accent-color) / 0.18);
|
||||
}
|
||||
|
||||
.properties-option-group {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.properties-option-group-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 11px;
|
||||
border-bottom: 1px solid hsl(var(--border-modal) / 0.64);
|
||||
}
|
||||
|
||||
.properties-option-group-heading p {
|
||||
max-width: 680px;
|
||||
margin-top: 4px;
|
||||
color: hsl(var(--text-muted));
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.properties-field {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.properties-field-label {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: hsl(var(--text-muted));
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.properties-field-meta {
|
||||
overflow: hidden;
|
||||
margin-inline-start: auto;
|
||||
color: hsl(var(--accent-color) / 0.88);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.properties-help {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: hsl(var(--text-muted));
|
||||
cursor: help;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.properties-help:hover,
|
||||
.properties-help:focus-visible {
|
||||
color: hsl(var(--accent-color));
|
||||
background: hsl(var(--accent-color) / 0.1);
|
||||
}
|
||||
|
||||
.properties-help:focus-visible {
|
||||
outline: 2px solid hsl(var(--accent-color) / 0.72);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.properties-help-tooltip {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
inset-inline-start: 0;
|
||||
top: calc(100% + 7px);
|
||||
width: min(260px, 56vw);
|
||||
padding: 8px 10px;
|
||||
border: 1px solid hsl(var(--border-modal));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--surface-overlay) / 0.98);
|
||||
box-shadow: 0 10px 26px hsl(var(--shadow-color));
|
||||
color: hsl(var(--text-primary));
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-2px);
|
||||
transition: opacity 120ms ease, transform 120ms ease, visibility 120ms ease;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.properties-help:hover .properties-help-tooltip,
|
||||
.properties-help:focus-visible .properties-help-tooltip {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.properties-options .properties-option-group:last-child .properties-help-tooltip {
|
||||
top: auto;
|
||||
bottom: calc(100% + 7px);
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
.properties-options .properties-option-group:last-child .properties-help:hover .properties-help-tooltip,
|
||||
.properties-options .properties-option-group:last-child .properties-help:focus-visible .properties-help-tooltip {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.properties-option-toggle {
|
||||
display: flex !important;
|
||||
min-width: 0;
|
||||
min-height: 56px;
|
||||
align-items: flex-start !important;
|
||||
flex-direction: row !important;
|
||||
gap: 10px !important;
|
||||
padding: 10px 11px;
|
||||
border: 1px solid hsl(var(--border-modal) / 0.82);
|
||||
border-radius: 9px;
|
||||
background: hsl(var(--bg-input) / 0.2);
|
||||
}
|
||||
|
||||
.properties-option-toggle:has(input:not(:disabled)):hover {
|
||||
border-color: hsl(var(--accent-color) / 0.42);
|
||||
background: hsl(var(--item-hover));
|
||||
}
|
||||
|
||||
.properties-option-toggle input {
|
||||
margin-top: 2px !important;
|
||||
}
|
||||
|
||||
.properties-option-toggle-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.properties-option-toggle-label {
|
||||
color: hsl(var(--text-primary));
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.properties-option-toggle-hint {
|
||||
color: hsl(var(--text-muted));
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.properties-window-panel textarea.app-control {
|
||||
min-height: 104px;
|
||||
resize: vertical;
|
||||
@@ -997,6 +1203,41 @@ html[data-list-density="relaxed"] {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.properties-window-panel .properties-field-label {
|
||||
display: flex;
|
||||
min-height: 20px;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.properties-field-label-text {
|
||||
display: inline-flex !important;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.properties-field-format {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: hsl(var(--text-muted) / 0.88);
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.properties-option-toggle-label {
|
||||
display: block !important;
|
||||
margin: 0;
|
||||
color: hsl(var(--text-primary));
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.properties-window-panel > div > p,
|
||||
.properties-window-panel .text-text-muted {
|
||||
line-height: 1.45;
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
beginExclusivePropertiesAction,
|
||||
classifyPropertiesActionRequest,
|
||||
createFrameCoalescer,
|
||||
decodePropertiesPatchValue,
|
||||
encodePropertiesPatchValue,
|
||||
enqueuePropertiesAction,
|
||||
formatPropertiesQueuePlacement,
|
||||
getPropertiesLifecycleAction,
|
||||
@@ -33,6 +35,17 @@ import {
|
||||
} from './propertiesBridge';
|
||||
|
||||
describe('Properties window bridge', () => {
|
||||
it('keeps optional override resets explicit across the JSON IPC boundary', () => {
|
||||
const encoded = encodePropertiesPatchValue<string>(undefined);
|
||||
|
||||
expect(encoded).toBeNull();
|
||||
expect(JSON.parse(JSON.stringify({ torrentEncryptionPolicy: encoded }))).toEqual({
|
||||
torrentEncryptionPolicy: null,
|
||||
});
|
||||
expect(decodePropertiesPatchValue(encoded)).toBeUndefined();
|
||||
expect(decodePropertiesPatchValue('prealloc')).toBe('prealloc');
|
||||
});
|
||||
|
||||
it('uses a WebviewWindow target for directed child events', () => {
|
||||
expect(propertiesWindowEventTarget('properties-1')).toEqual({
|
||||
kind: 'WebviewWindow',
|
||||
|
||||
+36
-1
@@ -193,13 +193,48 @@ export type SecretPatch =
|
||||
| { kind: 'replace'; value: string }
|
||||
| { kind: 'clear' };
|
||||
|
||||
export type PropertiesPatch = Partial<Omit<DownloadItem, 'password' | 'cookies' | 'headers' | 'username'>> & {
|
||||
export const PROPERTIES_PATCH_CLEARABLE_KEYS = [
|
||||
'destination',
|
||||
'speedLimit',
|
||||
'torrentTrackers',
|
||||
'torrentExcludeTrackers',
|
||||
'torrentSeedTime',
|
||||
'torrentSeedRatio',
|
||||
'torrentUploadLimit',
|
||||
'torrentMaxPeers',
|
||||
'torrentPeerSpeedLimit',
|
||||
'torrentTrackerConnectTimeout',
|
||||
'torrentTrackerTimeout',
|
||||
'torrentTrackerInterval',
|
||||
'torrentStopTimeout',
|
||||
'torrentPrioritizePiece',
|
||||
'torrentEncryptionPolicy',
|
||||
'torrentFileAllocation',
|
||||
] as const;
|
||||
|
||||
type PropertiesPatchClearableKey = typeof PROPERTIES_PATCH_CLEARABLE_KEYS[number];
|
||||
|
||||
type PropertiesPatchClearableValues = {
|
||||
[Key in PropertiesPatchClearableKey]?: DownloadItem[Key] | null;
|
||||
};
|
||||
|
||||
export type PropertiesPatch = Partial<Omit<DownloadItem,
|
||||
'password' | 'cookies' | 'headers' | 'username' | PropertiesPatchClearableKey
|
||||
>> & PropertiesPatchClearableValues & {
|
||||
username?: SecretPatch;
|
||||
password?: SecretPatch;
|
||||
cookies?: SecretPatch;
|
||||
headers?: SecretPatch;
|
||||
};
|
||||
|
||||
// Tauri command arguments cross a JSON boundary. `undefined` object members
|
||||
// may be omitted before they reach Rust, so nullable fields are the explicit
|
||||
// wire-level sentinel for clearing an optional per-download override.
|
||||
export const encodePropertiesPatchValue = <T>(value: T | undefined): T | null => value ?? null;
|
||||
|
||||
export const decodePropertiesPatchValue = <T>(value: T | null | undefined): T | undefined =>
|
||||
value === null ? undefined : value;
|
||||
|
||||
export type PropertiesAction =
|
||||
| 'apply-properties'
|
||||
| 'set-torrent-file-selection'
|
||||
|
||||
Reference in New Issue
Block a user