mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-18 07:13:13 +00:00
feat(ui): compact torrent add flow and verify magnet associations
This commit is contained in:
@@ -974,6 +974,24 @@ export const AddDownloadsModal = () => {
|
||||
: root;
|
||||
};
|
||||
|
||||
const revealTorrentAdvanced = (preferredIndex?: number) => {
|
||||
setAdvancedExpanded(true);
|
||||
setSelectedItemIndex(current => {
|
||||
const preferredIsSelectedTorrent = preferredIndex !== undefined
|
||||
&& parsedItems[preferredIndex]?.selected !== false
|
||||
&& parsedItems[preferredIndex]?.isTorrent;
|
||||
if (preferredIsSelectedTorrent) return preferredIndex;
|
||||
|
||||
const currentIsSelectedTorrent = current !== null
|
||||
&& parsedItems[current]?.selected !== false
|
||||
&& parsedItems[current]?.isTorrent;
|
||||
if (currentIsSelectedTorrent) return current;
|
||||
|
||||
const firstSelectedTorrentIndex = parsedItems.findIndex(item => item.selected !== false && item.isTorrent);
|
||||
return firstSelectedTorrentIndex >= 0 ? firstSelectedTorrentIndex : current;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAction = async (action: AddDownloadAction) => {
|
||||
if (isSubmitting || isSubmittingRef.current || !canSubmitMetadataRows(parsedItems)) {
|
||||
return;
|
||||
@@ -1000,41 +1018,50 @@ export const AddDownloadsModal = () => {
|
||||
&& torrentMaxPeers.trim()
|
||||
&& (!Number.isInteger(Number(torrentMaxPeers)) || Number(torrentMaxPeers) < 0 || Number(torrentMaxPeers) > 1000)
|
||||
) {
|
||||
revealTorrentAdvanced();
|
||||
addToast({ message: t($ => $.addDownloads.torrentMaxPeersInvalid), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (hasSelectedTorrent && torrentPeerSpeedLimit.trim() && !normalizeSpeedLimitForBackend(torrentPeerSpeedLimit)) {
|
||||
revealTorrentAdvanced();
|
||||
addToast({ message: t($ => $.addDownloads.torrentPeerSpeedLimitInvalid), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (hasSelectedTorrent && !isValidTorrentTrackerList(torrentTrackers)) {
|
||||
revealTorrentAdvanced();
|
||||
addToast({ message: t($ => $.addDownloads.torrentTrackersInvalid), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (hasSelectedTorrent && !isValidTorrentExcludeTrackerList(torrentExcludeTrackers)) {
|
||||
revealTorrentAdvanced();
|
||||
addToast({ message: t($ => $.addDownloads.torrentExcludeTrackersInvalid), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (hasSelectedTorrent && torrentTrackerConnectTimeout.trim() && !normalizeTorrentTrackerTimeout(torrentTrackerConnectTimeout)) {
|
||||
revealTorrentAdvanced();
|
||||
addToast({ message: t($ => $.addDownloads.torrentTrackerTimeoutInvalid), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (hasSelectedTorrent && torrentTrackerTimeout.trim() && !normalizeTorrentTrackerTimeout(torrentTrackerTimeout)) {
|
||||
revealTorrentAdvanced();
|
||||
addToast({ message: t($ => $.addDownloads.torrentTrackerTimeoutInvalid), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (hasSelectedTorrent && torrentTrackerInterval.trim() && normalizeTorrentTrackerInterval(torrentTrackerInterval) === undefined) {
|
||||
revealTorrentAdvanced();
|
||||
addToast({ message: t($ => $.addDownloads.torrentTrackerIntervalInvalid), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (hasSelectedTorrent && (torrentPreviewHeadEnabled || torrentPreviewTailEnabled) && !torrentPreviewPriority) {
|
||||
revealTorrentAdvanced();
|
||||
addToast({ message: t($ => $.addDownloads.torrentPrioritizePieceInvalid), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
for (const item of selectedItems) {
|
||||
if (!item.isTorrent || !item.torrentFiles?.length) continue;
|
||||
for (const [itemIndex, item] of parsedItems.entries()) {
|
||||
if (item.selected === false || !item.isTorrent || !item.torrentFiles?.length) continue;
|
||||
const rows = item.torrentWebSeedRows ?? [];
|
||||
if (!normalizeTorrentWebSeedDrafts(rows, item.torrentFiles)) {
|
||||
revealTorrentAdvanced(itemIndex);
|
||||
addToast({ message: t($ => $.properties.torrentWebSeedsFailed), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
@@ -1044,6 +1071,7 @@ export const AddDownloadsModal = () => {
|
||||
&& torrentStopTimeout.trim()
|
||||
&& (!Number.isInteger(Number(torrentStopTimeout)) || Number(torrentStopTimeout) < 0 || Number(torrentStopTimeout) > MAX_TORRENT_STOP_TIMEOUT)
|
||||
) {
|
||||
revealTorrentAdvanced();
|
||||
addToast({ message: t($ => $.addDownloads.torrentStopTimeoutInvalid), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
@@ -1711,6 +1739,7 @@ export const AddDownloadsModal = () => {
|
||||
return Boolean(selected && selected.length > 0 && selected.length < item.torrentFiles.length);
|
||||
};
|
||||
const selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex];
|
||||
const selectedItemIsTorrent = selectedItem?.isTorrent === true;
|
||||
const hasSftpRows = parsedItems.some(item => item.selected !== false
|
||||
&& !item.isTorrent
|
||||
&& item.sourceUrl.trim().toLowerCase().startsWith('sftp:'));
|
||||
@@ -1846,6 +1875,30 @@ export const AddDownloadsModal = () => {
|
||||
item => item.metadataBlockedReason === 'unsafe-url'
|
||||
).length;
|
||||
const fallbackMetadataCount = failedMetadataCount - failedMediaMetadataCount - blockedMetadataCount;
|
||||
const readyMetadataCount = selectedItems.filter(item => item.status === 'ready').length;
|
||||
const hasCustomTorrentOptions = Boolean(
|
||||
torrentMaxPeers.trim()
|
||||
|| torrentPeerSpeedLimit.trim()
|
||||
|| torrentCheckIntegrity
|
||||
|| torrentRemoveUnselectedFile
|
||||
|| torrentEncryptionPolicy !== TORRENT_ENCRYPTION_POLICY_DISABLED
|
||||
|| torrentTrackers.trim()
|
||||
|| torrentExcludeTrackers.trim()
|
||||
|| torrentTrackerConnectTimeout.trim()
|
||||
|| torrentTrackerTimeout.trim()
|
||||
|| (torrentTrackerInterval.trim() && normalizeTorrentTrackerInterval(torrentTrackerInterval) !== 0)
|
||||
|| (torrentStopTimeout.trim() && Number(torrentStopTimeout) !== 0)
|
||||
|| torrentFileAllocation !== 'prealloc'
|
||||
|| torrentPreviewHeadEnabled
|
||||
|| torrentPreviewTailEnabled
|
||||
|| parsedItems.some(item => item.selected !== false && item.isTorrent && (item.torrentWebSeedRows?.length ?? 0) > 0)
|
||||
);
|
||||
const localizedSelectedSummary = t($ => $.addDownloads.selectedSummary, {
|
||||
ready: readyMetadataCount,
|
||||
fallback: fallbackMetadataCount,
|
||||
mediaRetry: failedMediaMetadataCount,
|
||||
blocked: blockedMetadataCount,
|
||||
});
|
||||
const activePlaylistUrls = new Set(
|
||||
urls.split('\n').map(url => url.trim()).filter(Boolean).map(normalizeComparableUrl)
|
||||
);
|
||||
@@ -2001,15 +2054,33 @@ export const AddDownloadsModal = () => {
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
<div className="flex justify-between items-center px-1">
|
||||
<span className="text-[11px] text-text-muted font-medium">
|
||||
{t($ => $.addDownloads.selectedSummary, {
|
||||
ready: selectedItems.filter(item => item.status === 'ready').length,
|
||||
fallback: fallbackMetadataCount,
|
||||
mediaRetry: failedMediaMetadataCount,
|
||||
blocked: blockedMetadataCount,
|
||||
})}
|
||||
</span>
|
||||
<div className="add-download-selection-toolbar px-1">
|
||||
<div
|
||||
className="add-download-selection-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
aria-label={localizedSelectedSummary}
|
||||
>
|
||||
<span className="sr-only">{localizedSelectedSummary}</span>
|
||||
<span className="add-download-status-chip" data-tone="ready">
|
||||
<strong>{readyMetadataCount}</strong>
|
||||
<span>{t($ => $.addDownloads.selectedSummaryReady)}</span>
|
||||
</span>
|
||||
<span className="add-download-status-chip" data-tone="fallback">
|
||||
<strong>{fallbackMetadataCount}</strong>
|
||||
<span>{t($ => $.addDownloads.selectedSummaryFallback)}</span>
|
||||
</span>
|
||||
<span className="add-download-status-chip" data-tone="media-retry">
|
||||
<strong>{failedMediaMetadataCount}</strong>
|
||||
<span>{t($ => $.addDownloads.selectedSummaryMediaRetry)}</span>
|
||||
</span>
|
||||
<span className="add-download-status-chip" data-tone="blocked">
|
||||
<strong>{blockedMetadataCount}</strong>
|
||||
<span>{t($ => $.addDownloads.selectedSummaryBlocked)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="add-download-selection-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setParsedItems(refreshFailedMetadataRows)}
|
||||
@@ -2027,6 +2098,7 @@ export const AddDownloadsModal = () => {
|
||||
{allRowsSelected ? t($ => $.addDownloads.clearSelection) : t($ => $.addDownloads.selectAll)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
@@ -2158,24 +2230,6 @@ export const AddDownloadsModal = () => {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent && (
|
||||
<section className="add-download-section relative overflow-hidden p-4">
|
||||
<div className="add-download-section-title flex items-center gap-2 mb-3">
|
||||
<Link size={16} className="text-blue-500" /> {t($ => $.properties.torrentWebSeeds)}
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted mb-3">{t($ => $.properties.torrentWebSeedsHint)}</p>
|
||||
<TorrentWebSeedEditor
|
||||
files={parsedItems[selectedItemIndex].torrentFiles ?? []}
|
||||
rows={parsedItems[selectedItemIndex].torrentWebSeedRows ?? []}
|
||||
onChange={rows => setParsedItems(items => items.map((item, index) => index === selectedItemIndex
|
||||
? { ...item, torrentWebSeedRows: rows }
|
||||
: item
|
||||
))}
|
||||
idPrefix="add-torrent-web-seed"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent && (
|
||||
<section className="add-download-section relative overflow-hidden p-4">
|
||||
<div className="add-download-section-title flex items-center gap-2 mb-3">
|
||||
@@ -2202,6 +2256,7 @@ export const AddDownloadsModal = () => {
|
||||
step={1}
|
||||
value={torrentSeedTime}
|
||||
onChange={event => setTorrentSeedTime(event.target.value)}
|
||||
dir="ltr"
|
||||
className="app-control w-20 px-2 py-1 text-end font-mono"
|
||||
/>
|
||||
<span className="text-text-muted">{t($ => $.addDownloads.minutes)}</span>
|
||||
@@ -2214,6 +2269,7 @@ export const AddDownloadsModal = () => {
|
||||
step={0.1}
|
||||
value={torrentSeedRatio}
|
||||
onChange={event => setTorrentSeedRatio(event.target.value)}
|
||||
dir="ltr"
|
||||
className="app-control w-20 px-2 py-1 text-end font-mono"
|
||||
aria-describedby="torrent-seed-ratio-hint"
|
||||
/>
|
||||
@@ -2239,12 +2295,45 @@ export const AddDownloadsModal = () => {
|
||||
step={128}
|
||||
value={torrentUploadLimit}
|
||||
onChange={event => setTorrentUploadLimit(event.target.value)}
|
||||
dir="ltr"
|
||||
className="app-control w-24 px-2 py-1 text-end font-mono"
|
||||
aria-label={t($ => $.addDownloads.torrentUploadLimit)}
|
||||
/>
|
||||
<span className="text-text-muted">KiB/s</span>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedExpanded(expanded => !expanded)}
|
||||
className="add-download-torrent-advanced-toggle flex w-full items-center justify-between gap-3 border-t border-border-modal/50 pt-3 text-start"
|
||||
aria-expanded={advancedExpanded}
|
||||
aria-controls="add-torrent-advanced-options add-transfer-advanced-options"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{advancedExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{t($ => $.addDownloads.torrentAdvancedOptions)}</span>
|
||||
</span>
|
||||
{!advancedExpanded && hasCustomTorrentOptions && (
|
||||
<span className="shrink-0 text-[10px] font-medium text-blue-400">
|
||||
{t($ => $.addDownloads.torrentAdvancedOptionsCustom)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{advancedExpanded && (
|
||||
<div id="add-torrent-advanced-options" className="add-download-torrent-advanced-fields space-y-4">
|
||||
<div className="border-b border-border-modal/50 pb-3">
|
||||
<div className="add-download-advanced-group-title">{t($ => $.properties.torrentWebSeeds)}</div>
|
||||
<p className="mt-1 mb-3 text-[10px] text-text-muted">{t($ => $.properties.torrentWebSeedsHint)}</p>
|
||||
<TorrentWebSeedEditor
|
||||
files={parsedItems[selectedItemIndex!].torrentFiles ?? []}
|
||||
rows={parsedItems[selectedItemIndex!].torrentWebSeedRows ?? []}
|
||||
onChange={rows => setParsedItems(items => items.map((item, index) => index === selectedItemIndex
|
||||
? { ...item, torrentWebSeedRows: rows }
|
||||
: item
|
||||
))}
|
||||
idPrefix="add-torrent-web-seed"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-start gap-2 text-text-primary pt-2 border-t border-border-modal/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -2366,6 +2455,7 @@ export const AddDownloadsModal = () => {
|
||||
rows={3}
|
||||
value={torrentTrackers}
|
||||
onChange={event => setTorrentTrackers(event.currentTarget.value)}
|
||||
dir="ltr"
|
||||
placeholder="https://tracker.example/announce"
|
||||
aria-describedby="torrent-trackers-hint"
|
||||
className="app-control mt-1 min-h-20 w-full resize-y px-2.5 py-1.5 text-xs font-mono"
|
||||
@@ -2383,6 +2473,7 @@ export const AddDownloadsModal = () => {
|
||||
rows={3}
|
||||
value={torrentExcludeTrackers}
|
||||
onChange={event => setTorrentExcludeTrackers(event.currentTarget.value)}
|
||||
dir="ltr"
|
||||
placeholder="https://tracker.example/announce or *"
|
||||
aria-describedby="torrent-exclude-trackers-hint"
|
||||
className="app-control mt-1 min-h-20 w-full resize-y px-2.5 py-1.5 text-xs font-mono"
|
||||
@@ -2405,6 +2496,7 @@ export const AddDownloadsModal = () => {
|
||||
value={torrentTrackerConnectTimeout}
|
||||
onChange={event => setTorrentTrackerConnectTimeout(event.currentTarget.value)}
|
||||
placeholder="60"
|
||||
dir="ltr"
|
||||
className="app-control w-24 px-2 py-1 text-end font-mono"
|
||||
aria-describedby="torrent-tracker-timing-hint"
|
||||
/>
|
||||
@@ -2423,6 +2515,7 @@ export const AddDownloadsModal = () => {
|
||||
value={torrentTrackerTimeout}
|
||||
onChange={event => setTorrentTrackerTimeout(event.currentTarget.value)}
|
||||
placeholder="60"
|
||||
dir="ltr"
|
||||
className="app-control w-24 px-2 py-1 text-end font-mono"
|
||||
aria-describedby="torrent-tracker-timing-hint"
|
||||
/>
|
||||
@@ -2440,6 +2533,7 @@ export const AddDownloadsModal = () => {
|
||||
step={1}
|
||||
value={torrentTrackerInterval}
|
||||
onChange={event => setTorrentTrackerInterval(event.currentTarget.value)}
|
||||
dir="ltr"
|
||||
className="app-control w-24 px-2 py-1 text-end font-mono"
|
||||
aria-describedby="torrent-tracker-timing-hint"
|
||||
/>
|
||||
@@ -2462,6 +2556,7 @@ export const AddDownloadsModal = () => {
|
||||
value={torrentMaxPeers}
|
||||
onChange={event => setTorrentMaxPeers(event.target.value)}
|
||||
placeholder="55"
|
||||
dir="ltr"
|
||||
className="app-control w-24 px-2 py-1 text-end font-mono"
|
||||
aria-describedby="torrent-peer-options-hint"
|
||||
/>
|
||||
@@ -2475,6 +2570,7 @@ export const AddDownloadsModal = () => {
|
||||
value={torrentPeerSpeedLimit}
|
||||
onChange={event => setTorrentPeerSpeedLimit(event.target.value)}
|
||||
placeholder="50K"
|
||||
dir="ltr"
|
||||
className="app-control w-24 px-2 py-1 text-end font-mono"
|
||||
aria-describedby="torrent-peer-options-hint"
|
||||
/>
|
||||
@@ -2495,6 +2591,7 @@ export const AddDownloadsModal = () => {
|
||||
step={1}
|
||||
value={torrentStopTimeout}
|
||||
onChange={event => setTorrentStopTimeout(event.currentTarget.value)}
|
||||
dir="ltr"
|
||||
className="app-control w-24 px-2 py-1 text-end font-mono"
|
||||
aria-describedby="torrent-stop-timeout-hint"
|
||||
/>
|
||||
@@ -2504,6 +2601,8 @@ export const AddDownloadsModal = () => {
|
||||
{t($ => $.addDownloads.torrentStopTimeoutHint)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@@ -2836,18 +2935,26 @@ export const AddDownloadsModal = () => {
|
||||
</section>
|
||||
|
||||
{/* Advanced */}
|
||||
<section className="add-download-section add-download-advanced">
|
||||
<button
|
||||
onClick={() => setAdvancedExpanded(!advancedExpanded)}
|
||||
className="add-download-advanced-toggle flex items-center gap-2 text-sm font-semibold text-text-primary w-full"
|
||||
aria-expanded={advancedExpanded}
|
||||
>
|
||||
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
{t($ => $.addDownloads.advancedTransfer)}
|
||||
</button>
|
||||
{(!selectedItemIsTorrent || advancedExpanded) && (
|
||||
<section className="add-download-section add-download-advanced">
|
||||
{selectedItemIsTorrent ? (
|
||||
<div className="add-download-section-title flex items-center gap-2 mb-3">
|
||||
<Settings size={16} className="text-blue-500" /> {t($ => $.addDownloads.advancedTransfer)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setAdvancedExpanded(!advancedExpanded)}
|
||||
className="add-download-advanced-toggle flex items-center gap-2 text-sm font-semibold text-text-primary w-full"
|
||||
aria-expanded={advancedExpanded}
|
||||
aria-controls="add-transfer-advanced-options"
|
||||
>
|
||||
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
{t($ => $.addDownloads.advancedTransfer)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{advancedExpanded && (
|
||||
<div className="add-download-advanced-fields mt-4 space-y-4">
|
||||
{advancedExpanded && (
|
||||
<div id="add-transfer-advanced-options" className="add-download-advanced-fields mt-4 space-y-4">
|
||||
<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" />
|
||||
{t($ => $.addDownloads.verifyChecksum)}
|
||||
@@ -2910,9 +3017,10 @@ export const AddDownloadsModal = () => {
|
||||
<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>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1389,14 +1389,6 @@ export const PropertiesWindowApp = () => {
|
||||
</div>}
|
||||
|
||||
{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>
|
||||
|
||||
<section className="properties-option-group" aria-labelledby="properties-options-limits-heading">
|
||||
<div className="properties-option-group-heading">
|
||||
<div>
|
||||
|
||||
@@ -19,13 +19,18 @@ const filePath = (file: TorrentWebSeedFile): string => 'path' in file ? file.pat
|
||||
export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false, idPrefix }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const uriRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const addButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const focusAfterRemoveRef = useRef<number | null>(null);
|
||||
const filesForValidation = files.map(file => ({ index: file.index }));
|
||||
const rowsAreValid = normalizeTorrentWebSeedDrafts(rows, filesForValidation) !== null;
|
||||
useEffect(() => {
|
||||
const rowIndex = focusAfterRemoveRef.current;
|
||||
focusAfterRemoveRef.current = null;
|
||||
if (rowIndex !== null) uriRefs.current[rowIndex]?.focus();
|
||||
if (rowIndex === -1) {
|
||||
addButtonRef.current?.focus();
|
||||
} else if (rowIndex !== null) {
|
||||
uriRefs.current[rowIndex]?.focus();
|
||||
}
|
||||
}, [rows]);
|
||||
const addRow = () => onChange([...rows, { fileIndex: files[0]?.index ?? null, uri: '' }]);
|
||||
const updateRow = (rowIndex: number, update: Partial<TorrentWebSeedDraft>) => onChange(
|
||||
@@ -33,7 +38,7 @@ export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false,
|
||||
);
|
||||
const removeRow = (rowIndex: number) => {
|
||||
const nextRows = rows.filter((_, index) => index !== rowIndex);
|
||||
focusAfterRemoveRef.current = nextRows.length > 0 ? Math.min(rowIndex, nextRows.length - 1) : null;
|
||||
focusAfterRemoveRef.current = nextRows.length > 0 ? Math.min(rowIndex, nextRows.length - 1) : -1;
|
||||
onChange(nextRows);
|
||||
};
|
||||
|
||||
@@ -57,6 +62,7 @@ export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false,
|
||||
value={files[0].index}
|
||||
onChange={() => undefined}
|
||||
disabled
|
||||
dir="ltr"
|
||||
aria-invalid={!rowIsValid}
|
||||
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs disabled:opacity-70"
|
||||
>
|
||||
@@ -68,6 +74,7 @@ export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false,
|
||||
value={row.fileIndex ?? ''}
|
||||
onChange={event => updateRow(rowIndex, { fileIndex: Number(event.currentTarget.value) })}
|
||||
disabled={disabled || files.length === 0}
|
||||
dir="ltr"
|
||||
aria-invalid={!rowIsValid}
|
||||
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs disabled:opacity-50"
|
||||
>
|
||||
@@ -91,6 +98,7 @@ export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false,
|
||||
value={row.uri}
|
||||
onChange={event => updateRow(rowIndex, { uri: event.currentTarget.value })}
|
||||
disabled={disabled}
|
||||
dir="ltr"
|
||||
aria-invalid={!rowIsValid}
|
||||
placeholder="https://mirror.example/torrent/"
|
||||
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs font-mono disabled:opacity-50"
|
||||
@@ -115,6 +123,7 @@ export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false,
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
ref={addButtonRef}
|
||||
onClick={addRow}
|
||||
disabled={disabled || files.length === 0}
|
||||
className="app-button px-3 text-xs disabled:opacity-50"
|
||||
|
||||
@@ -656,6 +656,12 @@ const common = {
|
||||
playlistSummary: 'Playlist “{{title}}”: {{loaded}}{{total}} entries loaded{{truncated}}{{skipped}}',
|
||||
safeEntryLimit: ' (safe entry limit reached)',
|
||||
selectedSummary: '{{ready}} selected ready, {{fallback}} fallback, {{mediaRetry}} media retry, {{blocked}} blocked',
|
||||
selectedSummaryReady: 'Ready',
|
||||
selectedSummaryFallback: 'Fallback',
|
||||
selectedSummaryMediaRetry: 'Media retry',
|
||||
selectedSummaryBlocked: 'Blocked',
|
||||
torrentAdvancedOptions: 'Advanced Torrent options',
|
||||
torrentAdvancedOptionsCustom: 'Custom settings',
|
||||
clearSelection: 'Clear selection',
|
||||
selectAll: 'Select all',
|
||||
refreshMetadata: 'Refresh Metadata',
|
||||
|
||||
@@ -656,6 +656,12 @@ const fa = {
|
||||
playlistSummary: 'لیست پخش "{{title}}": {{loaded}} از {{total}} ورودی بارگیری شد{{truncated}}{{skipped}}',
|
||||
safeEntryLimit: ' (به حد مجاز ایمن ورودیها رسیدیم)',
|
||||
selectedSummary: '{{ready}} انتخابشده آماده، {{fallback}} اطلاعات جایگزین، {{mediaRetry}} تلاش مجدد رسانه، {{blocked}} مسدودشده',
|
||||
selectedSummaryReady: 'آماده',
|
||||
selectedSummaryFallback: 'جایگزین',
|
||||
selectedSummaryMediaRetry: 'تلاش مجدد رسانه',
|
||||
selectedSummaryBlocked: 'مسدود',
|
||||
torrentAdvancedOptions: 'گزینههای پیشرفتهٔ تورنت',
|
||||
torrentAdvancedOptionsCustom: 'تنظیمات سفارشی',
|
||||
clearSelection: 'پاک کردن انتخاب',
|
||||
selectAll: 'انتخاب همه',
|
||||
refreshMetadata: 'تازهسازی متادیتا',
|
||||
|
||||
@@ -656,6 +656,12 @@ const he = {
|
||||
playlistSummary: 'רשימת השמעה "{{title}}": {{loaded}} מתוך {{total}} פריטים נטענו{{truncated}}{{skipped}}',
|
||||
safeEntryLimit: ' (הושגה מגבלת הפריטים הבטוחה)',
|
||||
selectedSummary: '{{ready}} נבחרו ומוכנים, {{fallback}} לגיבוי, {{mediaRetry}} מדיה לניסיון חוזר, {{blocked}} חסומים',
|
||||
selectedSummaryReady: 'מוכנים',
|
||||
selectedSummaryFallback: 'גיבוי',
|
||||
selectedSummaryMediaRetry: 'ניסיון חוזר במדיה',
|
||||
selectedSummaryBlocked: 'חסומים',
|
||||
torrentAdvancedOptions: 'אפשרויות Torrent מתקדמות',
|
||||
torrentAdvancedOptionsCustom: 'הגדרות מותאמות',
|
||||
clearSelection: 'ניקוי בחירה',
|
||||
selectAll: 'בחירת הכל',
|
||||
refreshMetadata: 'רענון מטא נתונים',
|
||||
|
||||
@@ -656,6 +656,12 @@ const ru = {
|
||||
playlistSummary: 'Плейлист «{{title}}»: загружено {{loaded}} из {{total}} элементов{{truncated}}{{skipped}}',
|
||||
safeEntryLimit: ' (достигнут безопасный лимит элементов)',
|
||||
selectedSummary: 'Выбрано: {{ready}} готовых, {{fallback}} с резервными данными, {{mediaRetry}} повторов медиа, {{blocked}} заблокировано',
|
||||
selectedSummaryReady: 'Готово',
|
||||
selectedSummaryFallback: 'Резерв',
|
||||
selectedSummaryMediaRetry: 'Повтор медиа',
|
||||
selectedSummaryBlocked: 'Заблокировано',
|
||||
torrentAdvancedOptions: 'Расширенные параметры Torrent',
|
||||
torrentAdvancedOptionsCustom: 'Пользовательские настройки',
|
||||
clearSelection: 'Очистить выбор',
|
||||
selectAll: 'Выбрать все',
|
||||
refreshMetadata: 'Обновить метаданные',
|
||||
|
||||
@@ -656,6 +656,12 @@ const uk = {
|
||||
playlistSummary: 'Плейлист “{{title}}”: {{loaded}} з {{total}} елементів завантажено{{truncated}}{{skipped}}',
|
||||
safeEntryLimit: ' (досягнуто безпечного ліміту елементів)',
|
||||
selectedSummary: '{{ready}} вибрано готових, {{fallback}} резервних, {{mediaRetry}} повторних медіа, {{blocked}} заблоковано',
|
||||
selectedSummaryReady: 'Готові',
|
||||
selectedSummaryFallback: 'Резервні',
|
||||
selectedSummaryMediaRetry: 'Повтор медіа',
|
||||
selectedSummaryBlocked: 'Заблоковано',
|
||||
torrentAdvancedOptions: 'Розширені параметри Torrent',
|
||||
torrentAdvancedOptionsCustom: 'Власні налаштування',
|
||||
clearSelection: 'Очистити вибір',
|
||||
selectAll: 'Вибрати всі',
|
||||
refreshMetadata: 'Оновити метадані',
|
||||
|
||||
@@ -656,6 +656,12 @@ const zhCN = {
|
||||
playlistSummary: '播放列表“{{title}}”:已加载 {{loaded}} / {{total}} 个条目{{truncated}}{{skipped}}',
|
||||
safeEntryLimit: ' (达到安全条目限制)',
|
||||
selectedSummary: '准备就绪 {{ready}} 个,后备项 {{fallback}} 个,媒体重试 {{mediaRetry}} 个,已屏蔽 {{blocked}} 个',
|
||||
selectedSummaryReady: '就绪',
|
||||
selectedSummaryFallback: '后备',
|
||||
selectedSummaryMediaRetry: '媒体重试',
|
||||
selectedSummaryBlocked: '已屏蔽',
|
||||
torrentAdvancedOptions: 'Torrent 高级选项',
|
||||
torrentAdvancedOptionsCustom: '自定义设置',
|
||||
clearSelection: '清除选择',
|
||||
selectAll: '全选',
|
||||
refreshMetadata: '刷新元数据',
|
||||
|
||||
+96
-29
@@ -1059,23 +1059,12 @@ html[data-list-density="relaxed"] {
|
||||
max-width: 940px;
|
||||
}
|
||||
|
||||
.properties-options-intro,
|
||||
.properties-option-group {
|
||||
border: 1px solid var(--properties-card-border);
|
||||
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;
|
||||
@@ -1083,24 +1072,6 @@ html[data-list-density="relaxed"] {
|
||||
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;
|
||||
}
|
||||
@@ -1642,6 +1613,102 @@ html[data-list-density="relaxed"] {
|
||||
background: hsl(var(--accent-color) / 0.1);
|
||||
}
|
||||
|
||||
.add-download-selection-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.add-download-selection-status {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: hsl(var(--text-muted));
|
||||
font-size: 10px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.add-download-status-chip {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
min-height: 22px;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid hsl(var(--border-modal));
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--surface-raised) / 0.46);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.add-download-status-chip strong {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.add-download-status-chip[data-tone="ready"] strong {
|
||||
color: hsl(214 92% 64%);
|
||||
}
|
||||
|
||||
.add-download-status-chip[data-tone="fallback"] strong {
|
||||
color: hsl(38 92% 62%);
|
||||
}
|
||||
|
||||
.add-download-status-chip[data-tone="media-retry"] strong {
|
||||
color: hsl(272 78% 70%);
|
||||
}
|
||||
|
||||
.add-download-status-chip[data-tone="blocked"] strong {
|
||||
color: hsl(0 78% 68%);
|
||||
}
|
||||
|
||||
.add-download-selection-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.add-download-torrent-advanced-toggle {
|
||||
color: hsl(var(--text-secondary));
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.add-download-torrent-advanced-toggle:hover {
|
||||
color: hsl(var(--accent-color));
|
||||
}
|
||||
|
||||
.add-download-torrent-advanced-toggle:focus-visible {
|
||||
outline: 2px solid hsl(var(--accent-color) / 0.65);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.add-download-torrent-advanced-fields {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.add-download-advanced-group-title {
|
||||
color: hsl(var(--text-primary));
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.add-download-selection-toolbar {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
justify-items: stretch;
|
||||
}
|
||||
|
||||
.add-download-selection-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.add-download-summary-card {
|
||||
min-width: 0;
|
||||
padding: 10px 11px;
|
||||
|
||||
Reference in New Issue
Block a user