mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-09 02:40:21 +00:00
fix(ui): harden download properties window
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } 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';
|
||||
import { open, save } from '@tauri-apps/plugin-dialog';
|
||||
import { Activity, Copy, Download, FileDown, FolderOpen, Gauge, MapPin, MoreHorizontal, Pause, Play, RefreshCw, Save, Timer, Upload, Users, X } from 'lucide-react';
|
||||
import { Activity, Copy, Download, FileDown, FileText, FolderOpen, Gauge, Info, List, MapPin, MoreHorizontal, Pause, Play, RefreshCw, Save, SlidersHorizontal, Timer, Upload, Users, Wrench, X, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilitySnapshot';
|
||||
import type { TorrentDetails } from '../bindings/TorrentDetails';
|
||||
@@ -39,8 +39,10 @@ import {
|
||||
import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress';
|
||||
import { changeAppLocale } from '../i18n';
|
||||
import { synchronizeDocumentAppearance } from '../utils/documentAppearance';
|
||||
import { getWindowControlRevealOffset } from '../utils/windowControlStyle';
|
||||
import { getWindowControlRailWidth } from '../utils/windowControlStyle';
|
||||
import { getPropertiesFooterActions } from '../utils/propertiesFooter';
|
||||
import { shouldOfferPropertiesUrlExpansion, shouldResetPropertiesUrlExpansion } from '../utils/propertiesUrl';
|
||||
import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREAKPOINT, shouldUsePropertiesTabOverflow, type PropertiesTab } from '../utils/propertiesTabs';
|
||||
import { WindowControls } from './WindowControls';
|
||||
import {
|
||||
TORRENT_ENCRYPTION_POLICY_DISABLED,
|
||||
@@ -50,7 +52,6 @@ import {
|
||||
type TorrentFileAllocation,
|
||||
} from '../utils/downloads';
|
||||
|
||||
type PropertiesTab = 'overview' | 'files' | 'trackers' | 'peers' | 'options' | 'transfer' | 'advanced';
|
||||
type SecretName = 'username' | 'password' | 'cookies' | 'headers';
|
||||
type SecretDraft = { value: string; touched: boolean; clear: boolean };
|
||||
const SECRET_NAMES: SecretName[] = ['username', 'password', 'cookies', 'headers'];
|
||||
@@ -83,6 +84,19 @@ const propertiesStatusTone = (status: string) => {
|
||||
return 'downloading';
|
||||
};
|
||||
|
||||
const propertiesTabIcon = (tab: PropertiesTab) => {
|
||||
const props = { className: 'properties-window-tab-icon', size: 15, strokeWidth: 2, 'aria-hidden': true } as const;
|
||||
switch (tab) {
|
||||
case 'overview': return <Info {...props} />;
|
||||
case 'files': return <FileText {...props} />;
|
||||
case 'trackers': return <List {...props} />;
|
||||
case 'peers': return <Users {...props} />;
|
||||
case 'options': return <SlidersHorizontal {...props} />;
|
||||
case 'transfer': return <Gauge {...props} />;
|
||||
case 'advanced': return <Wrench {...props} />;
|
||||
}
|
||||
};
|
||||
|
||||
const propertiesDiagnosticLifecycleKey = (snapshot: PropertiesSnapshot): string => [
|
||||
snapshot.id,
|
||||
snapshot.status,
|
||||
@@ -106,6 +120,9 @@ export const PropertiesWindowApp = () => {
|
||||
const sessionId = useMemo(() => crypto.randomUUID(), []);
|
||||
const [downloadId, setDownloadId] = useState<string | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<PropertiesSnapshot | null>(null);
|
||||
const [isUrlExpanded, setIsUrlExpanded] = useState(false);
|
||||
const [urlHasOverflow, setUrlHasOverflow] = useState(false);
|
||||
const [useTabOverflow, setUseTabOverflow] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<PropertiesTab>('overview');
|
||||
const [pendingTab, setPendingTab] = useState<PropertiesTab | null>(null);
|
||||
const [closePrompt, setClosePrompt] = useState(false);
|
||||
@@ -178,6 +195,10 @@ export const PropertiesWindowApp = () => {
|
||||
const allowWindowCloseRef = useRef(false);
|
||||
const isDirtyRef = useRef(false);
|
||||
const windowChromeRef = useRef(DEFAULT_PROPERTIES_WINDOW_CHROME);
|
||||
const previousUrlDownloadIdRef = useRef<string | null>(downloadId);
|
||||
const previousUrlRef = useRef<string | null>(null);
|
||||
const urlCardRef = useRef<HTMLDivElement | null>(null);
|
||||
const urlValueRef = useRef<HTMLParagraphElement | null>(null);
|
||||
snapshotRef.current = snapshot;
|
||||
activeTabRef.current = activeTab;
|
||||
downloadIdRef.current = downloadId;
|
||||
@@ -187,9 +208,8 @@ export const PropertiesWindowApp = () => {
|
||||
detailsRef.current = details;
|
||||
|
||||
const isTorrent = snapshot?.isTorrent === true;
|
||||
const tabs = useMemo<PropertiesTab[]>(() => isTorrent
|
||||
? ['overview', 'files', 'trackers', 'peers', 'options']
|
||||
: ['overview', 'transfer', 'advanced'], [isTorrent]);
|
||||
const tabs = useMemo(() => getPropertiesTabs(isTorrent), [isTorrent]);
|
||||
const urlCanExpand = Boolean(snapshot && (shouldOfferPropertiesUrlExpansion(snapshot.url) || urlHasOverflow));
|
||||
const isDirty = draftTab !== null;
|
||||
isDirtyRef.current = isDirty;
|
||||
if (isDirty && allowWindowCloseRef.current) {
|
||||
@@ -199,6 +219,60 @@ export const PropertiesWindowApp = () => {
|
||||
allowWindowCloseRef.current = false;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const nextUrl = snapshot?.url ?? null;
|
||||
if (shouldResetPropertiesUrlExpansion(
|
||||
previousUrlDownloadIdRef.current,
|
||||
downloadId,
|
||||
previousUrlRef.current,
|
||||
nextUrl,
|
||||
)) {
|
||||
setIsUrlExpanded(false);
|
||||
setUrlHasOverflow(false);
|
||||
}
|
||||
previousUrlDownloadIdRef.current = downloadId;
|
||||
previousUrlRef.current = nextUrl;
|
||||
}, [downloadId, snapshot?.url]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateOverflowState = () => setUseTabOverflow(shouldUsePropertiesTabOverflow(window.innerWidth));
|
||||
updateOverflowState();
|
||||
const media = typeof window.matchMedia === 'function'
|
||||
? window.matchMedia(`(max-width: ${PROPERTIES_TABS_OVERFLOW_BREAKPOINT}px)`)
|
||||
: null;
|
||||
if (media && typeof media.addEventListener === 'function') {
|
||||
media.addEventListener('change', updateOverflowState);
|
||||
return () => media.removeEventListener('change', updateOverflowState);
|
||||
}
|
||||
if (media && typeof media.addListener === 'function') {
|
||||
media.addListener(updateOverflowState);
|
||||
return () => media.removeListener(updateOverflowState);
|
||||
}
|
||||
window.addEventListener('resize', updateOverflowState);
|
||||
return () => window.removeEventListener('resize', updateOverflowState);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isUrlExpanded) return;
|
||||
const card = urlCardRef.current;
|
||||
const value = urlValueRef.current;
|
||||
if (!card || !value) {
|
||||
setUrlHasOverflow(false);
|
||||
return;
|
||||
}
|
||||
const measureOverflow = () => {
|
||||
setUrlHasOverflow(value.scrollHeight > value.clientHeight + 1);
|
||||
};
|
||||
measureOverflow();
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
const observer = new ResizeObserver(measureOverflow);
|
||||
observer.observe(card);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
window.addEventListener('resize', measureOverflow);
|
||||
return () => window.removeEventListener('resize', measureOverflow);
|
||||
}, [activeTab, isUrlExpanded, snapshot?.url, useTabOverflow]);
|
||||
|
||||
const closeCurrentWindow = useCallback(async (allowDirtyClose = false) => {
|
||||
allowWindowCloseRef.current = allowDirtyClose;
|
||||
try {
|
||||
@@ -780,9 +854,16 @@ export const PropertiesWindowApp = () => {
|
||||
}, [activeTab, checkIntegrity, connections, destination, downloadLimit, encryptionPolicy, excludedTrackers, fileAllocation, fileName, fileProgress, isTorrent, maxPeers, peerSpeedLimit, prioritizePiece, removeUnselectedFile, requestAction, secretDrafts, seedRatio, seedTime, selectedFiles, snapshot, stopTimeout, trackerConnectTimeout, trackerInterval, trackerTimeout, trackers, t, uploadLimit]);
|
||||
|
||||
const chooseTab = (tab: PropertiesTab) => {
|
||||
if (tab === activeTab) return;
|
||||
if (isDirty) setPendingTab(tab);
|
||||
else setActiveTab(tab);
|
||||
if (tab === activeTab) {
|
||||
if (pendingTab !== null) setPendingTab(null);
|
||||
return false;
|
||||
}
|
||||
if (isDirty) {
|
||||
setPendingTab(tab);
|
||||
return false;
|
||||
}
|
||||
setActiveTab(tab);
|
||||
return true;
|
||||
};
|
||||
|
||||
const discardDraft = () => {
|
||||
@@ -838,12 +919,14 @@ export const PropertiesWindowApp = () => {
|
||||
};
|
||||
|
||||
const windowChrome = snapshot?.windowChrome ?? windowChromeRef.current;
|
||||
const windowControlRevealOffset = getWindowControlRevealOffset(windowChrome.controlStyle);
|
||||
const windowControlRailWidth = getWindowControlRailWidth(windowChrome.controlStyle);
|
||||
const windowShellClassName = `properties-window-shell properties-window-shell--controls-${windowChrome.side} properties-window-shell--style-${windowChrome.controlStyle} flex h-screen min-h-0 flex-col bg-main-bg text-text-primary`;
|
||||
const windowShellStyle = { '--properties-window-control-rail-width': `${windowControlRailWidth}px` } as CSSProperties;
|
||||
if (!downloadId || !snapshot) {
|
||||
return (
|
||||
<main
|
||||
className={`properties-window-shell properties-window-shell--controls-${windowChrome.side} flex h-screen min-h-0 flex-col bg-main-bg text-text-primary`}
|
||||
style={{ '--window-control-reveal-offset': `${windowControlRevealOffset}px` } as CSSProperties}
|
||||
className={windowShellClassName}
|
||||
style={windowShellStyle}
|
||||
aria-labelledby="properties-window-title"
|
||||
>
|
||||
<WindowControls side={windowChrome.side} controlStyle={windowChrome.controlStyle} />
|
||||
@@ -891,20 +974,20 @@ export const PropertiesWindowApp = () => {
|
||||
: t($ => $.downloads.actions.start);
|
||||
const tabLabel = (tab: PropertiesTab) => {
|
||||
switch (tab) {
|
||||
case 'overview': return t($ => $.properties.details);
|
||||
case 'files': return t($ => $.properties.torrentFileProgress);
|
||||
case 'trackers': return t($ => $.properties.torrentTrackers);
|
||||
case 'peers': return t($ => $.properties.torrentPeerDiagnostics);
|
||||
case 'options': return t($ => $.downloads.actions.options);
|
||||
case 'transfer': return t($ => $.properties.connections);
|
||||
case 'advanced': return t($ => $.properties.advancedTransfer);
|
||||
case 'overview': return t($ => $.properties.tabs.overview);
|
||||
case 'files': return t($ => $.properties.tabs.files);
|
||||
case 'trackers': return t($ => $.properties.tabs.trackers);
|
||||
case 'peers': return t($ => $.properties.tabs.peers);
|
||||
case 'options': return t($ => $.properties.tabs.options);
|
||||
case 'transfer': return t($ => $.properties.tabs.transfer);
|
||||
case 'advanced': return t($ => $.properties.tabs.advanced);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
className={`properties-window-shell properties-window-shell--controls-${windowChrome.side} flex h-screen min-h-0 flex-col bg-main-bg text-text-primary`}
|
||||
style={{ '--window-control-reveal-offset': `${windowControlRevealOffset}px` } as CSSProperties}
|
||||
className={windowShellClassName}
|
||||
style={windowShellStyle}
|
||||
aria-labelledby="properties-window-title"
|
||||
>
|
||||
<WindowControls side={windowChrome.side} controlStyle={windowChrome.controlStyle} />
|
||||
@@ -975,40 +1058,78 @@ export const PropertiesWindowApp = () => {
|
||||
<div className="properties-window-destination" title={snapshot.destination || undefined}><MapPin size={13} /><span>{snapshot.destination || '—'}</span></div>
|
||||
</header>
|
||||
|
||||
<nav className="properties-window-tabs flex shrink-0 gap-1 overflow-x-auto border-b border-border-modal px-4" role="tablist" aria-label={t($ => $.downloadTable.properties)}>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
aria-controls={`properties-panel-${tab}`}
|
||||
tabIndex={activeTab === tab ? 0 : -1}
|
||||
className={`whitespace-nowrap border-b-2 px-3 py-2 text-xs font-medium ${activeTab === tab ? 'border-accent text-text-primary' : 'border-transparent text-text-muted hover:text-text-primary'}`}
|
||||
onClick={() => chooseTab(tab)}
|
||||
onKeyDown={event => {
|
||||
const index = tabs.indexOf(tab);
|
||||
const nextIndex = event.key === 'ArrowRight' ? (index + 1) % tabs.length : event.key === 'ArrowLeft' ? (index - 1 + tabs.length) % tabs.length : event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : -1;
|
||||
if (nextIndex >= 0) {
|
||||
event.preventDefault();
|
||||
const next = tabs[nextIndex];
|
||||
chooseTab(next);
|
||||
window.setTimeout(() => document.getElementById(`properties-tab-${next}`)?.focus(), 0);
|
||||
}
|
||||
}}
|
||||
id={`properties-tab-${tab}`}
|
||||
>{tabLabel(tab)}</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className={`properties-window-tab-navigation ${useTabOverflow ? 'properties-window-tab-navigation--overflow' : ''}`}>
|
||||
<span id="properties-active-section-label" className="sr-only">{tabLabel(activeTab)}</span>
|
||||
<nav className="properties-window-tabs" role="tablist" aria-label={t($ => $.properties.tabs.label)}>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
aria-controls={`properties-panel-${tab}`}
|
||||
tabIndex={(pendingTab ?? activeTab) === tab ? 0 : -1}
|
||||
className="properties-window-tab"
|
||||
onClick={() => chooseTab(tab)}
|
||||
onKeyDown={event => {
|
||||
const index = tabs.indexOf(tab);
|
||||
const nextIndex = getPropertiesTabIndex(
|
||||
tabs,
|
||||
index,
|
||||
event.key,
|
||||
document.documentElement.dir === 'rtl' ? 'rtl' : 'ltr',
|
||||
);
|
||||
if (nextIndex >= 0) {
|
||||
event.preventDefault();
|
||||
const next = tabs[nextIndex];
|
||||
if (chooseTab(next) || isDirty) {
|
||||
window.setTimeout(() => document.getElementById(`properties-tab-${next}`)?.focus(), 0);
|
||||
}
|
||||
}
|
||||
}}
|
||||
id={`properties-tab-${tab}`}
|
||||
>
|
||||
{propertiesTabIcon(tab)}
|
||||
<span>{tabLabel(tab)}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<label className="properties-window-tab-overflow">
|
||||
<span className="sr-only">{t($ => $.properties.tabs.label)}</span>
|
||||
<select
|
||||
aria-label={t($ => $.properties.tabs.label)}
|
||||
value={pendingTab ?? activeTab}
|
||||
aria-controls={`properties-panel-${activeTab}`}
|
||||
onChange={event => chooseTab(event.target.value as PropertiesTab)}
|
||||
>
|
||||
{tabs.map(tab => <option key={tab} value={tab}>{tabLabel(tab)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<section id={`properties-panel-${activeTab}`} role="tabpanel" aria-labelledby={`properties-tab-${activeTab}`} className="properties-window-panel min-h-0 flex-1 overflow-auto p-5" data-diagnostic-phase={diagnosticPhase} tabIndex={0}>
|
||||
<section id={`properties-panel-${activeTab}`} role="tabpanel" aria-labelledby={useTabOverflow ? 'properties-active-section-label' : `properties-tab-${activeTab}`} className="properties-window-panel min-h-0 flex-1 overflow-auto p-5" data-diagnostic-phase={diagnosticPhase} tabIndex={0}>
|
||||
{activeTab === 'overview' && <div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="text-xs text-text-muted">{t($ => $.properties.fileName)}<input className="app-control mt-1 w-full" value={fileName} onChange={event => { setFileName(event.target.value); setDraftTab('overview'); }} disabled={!editingEnabled} /></label>
|
||||
<label className="text-xs text-text-muted">{t($ => $.properties.destination)}<input className="app-control mt-1 w-full" value={destination} onChange={event => { setDestination(event.target.value); setDraftTab('overview'); }} disabled={!editingEnabled} /></label>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs"><span className="text-text-muted">{t($ => $.properties.url)}</span><p className="mt-1 break-all" dir="ltr">{snapshot.url}</p></div>
|
||||
<div ref={urlCardRef} className="properties-url-card rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs">
|
||||
<div className="properties-url-card-header">
|
||||
<span className="text-text-muted">{t($ => $.properties.url)}</span>
|
||||
{urlCanExpand && <button
|
||||
type="button"
|
||||
className="properties-url-toggle"
|
||||
aria-expanded={isUrlExpanded}
|
||||
aria-controls="properties-url-value"
|
||||
onClick={() => setIsUrlExpanded(expanded => !expanded)}
|
||||
>
|
||||
{isUrlExpanded ? <ChevronUp size={14} aria-hidden="true" /> : <ChevronDown size={14} aria-hidden="true" />}
|
||||
{isUrlExpanded ? t($ => $.properties.urlShowLess) : t($ => $.properties.urlShowMore)}
|
||||
</button>}
|
||||
</div>
|
||||
<p ref={urlValueRef} id="properties-url-value" className={`properties-url-value mt-1 ${!isUrlExpanded ? 'properties-url-value--collapsed' : ''}`} dir="ltr">{snapshot.url}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs"><span className="text-text-muted">{t($ => $.properties.category)}</span><p className="mt-1">{snapshot.category}</p></div>
|
||||
</div>
|
||||
<div className="grid gap-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
||||
|
||||
@@ -22,6 +22,7 @@ export function WindowControls({ side, controlStyle }: WindowControlsProps) {
|
||||
<div
|
||||
className={`window-controls window-controls--${side} window-controls--style-${controlStyle}`}
|
||||
aria-label={t($ => $.window.controls)}
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -230,6 +230,16 @@ const common = {
|
||||
configuredConcurrency: 'Configured concurrency',
|
||||
connectedPeers: 'connected peers',
|
||||
details: 'Details',
|
||||
tabs: {
|
||||
label: 'Properties sections',
|
||||
overview: 'Overview',
|
||||
files: 'Files',
|
||||
trackers: 'Trackers',
|
||||
peers: 'Peers',
|
||||
transfer: 'Transfer',
|
||||
options: 'Options',
|
||||
advanced: 'Advanced',
|
||||
},
|
||||
queueId: 'Queue',
|
||||
queuePosition: 'Position {{position}}',
|
||||
resumable: 'Resumable',
|
||||
@@ -406,6 +416,8 @@ const common = {
|
||||
transferSettings: 'Transfer settings can be changed after stopping or pausing. Current transfers keep their existing backend options.',
|
||||
download: 'Download',
|
||||
url: 'URL',
|
||||
urlShowMore: 'Show full address',
|
||||
urlShowLess: 'Show less',
|
||||
fileName: 'File name',
|
||||
saveLocation: 'Save location',
|
||||
select: 'Select',
|
||||
|
||||
@@ -230,6 +230,16 @@ const fa = {
|
||||
configuredConcurrency: 'همزمانی پیکربندیشده',
|
||||
connectedPeers: 'همتای متصل',
|
||||
details: 'جزئیات',
|
||||
tabs: {
|
||||
label: 'بخشهای ویژگیها',
|
||||
overview: 'نمای کلی',
|
||||
files: 'فایلها',
|
||||
trackers: 'Trackerها',
|
||||
peers: 'همتاها',
|
||||
transfer: 'انتقال',
|
||||
options: 'گزینهها',
|
||||
advanced: 'پیشرفته',
|
||||
},
|
||||
queueId: 'صف',
|
||||
queuePosition: 'موقعیت {{position}}',
|
||||
resumable: 'قابل ادامه',
|
||||
@@ -406,6 +416,8 @@ const fa = {
|
||||
transferSettings: 'تنظیمات انتقال را میتوان پس از توقف تغییر داد. انتقالهای کنونی گزینههای فعلی خود را حفظ میکنند.',
|
||||
download: 'دانلود',
|
||||
url: 'URL',
|
||||
urlShowMore: 'نمایش نشانی کامل',
|
||||
urlShowLess: 'نمایش کمتر',
|
||||
fileName: 'نام فایل',
|
||||
saveLocation: 'محل ذخیره',
|
||||
select: 'انتخاب',
|
||||
|
||||
@@ -230,6 +230,16 @@ const he = {
|
||||
configuredConcurrency: 'מקביליות מוגדרת',
|
||||
connectedPeers: 'עמיתים מחוברים',
|
||||
details: 'פרטים',
|
||||
tabs: {
|
||||
label: 'מקטעי המאפיינים',
|
||||
overview: 'סקירה',
|
||||
files: 'קבצים',
|
||||
trackers: 'עוקבים',
|
||||
peers: 'עמיתים',
|
||||
transfer: 'העברה',
|
||||
options: 'אפשרויות',
|
||||
advanced: 'מתקדם',
|
||||
},
|
||||
queueId: 'תור',
|
||||
queuePosition: 'מיקום {{position}}',
|
||||
resumable: 'ניתן להמשך',
|
||||
@@ -406,6 +416,8 @@ const he = {
|
||||
transferSettings: 'ניתן לשנות את הגדרות ההעברה לאחר עצירה או השהייה. העברות נוכחיות שומרות על אפשרויות המנוע הקיימות שלהן.',
|
||||
download: 'הורדה',
|
||||
url: 'URL',
|
||||
urlShowMore: 'הצג כתובת מלאה',
|
||||
urlShowLess: 'הצג פחות',
|
||||
fileName: 'שם קובץ',
|
||||
saveLocation: 'מיקום שמירה',
|
||||
select: 'בחירה',
|
||||
|
||||
@@ -230,6 +230,16 @@ const ru = {
|
||||
configuredConcurrency: 'Настроенная параллельность',
|
||||
connectedPeers: 'подключённых пиров',
|
||||
details: 'Подробности',
|
||||
tabs: {
|
||||
label: 'Разделы свойств',
|
||||
overview: 'Обзор',
|
||||
files: 'Файлы',
|
||||
trackers: 'Трекеры',
|
||||
peers: 'Пиры',
|
||||
transfer: 'Передача',
|
||||
options: 'Параметры',
|
||||
advanced: 'Дополнительно',
|
||||
},
|
||||
queueId: 'Очередь',
|
||||
queuePosition: 'Позиция {{position}}',
|
||||
resumable: 'Возобновляемая',
|
||||
@@ -406,6 +416,8 @@ const ru = {
|
||||
transferSettings: 'Настройки передачи можно изменить после остановки или приостановки. Текущие загрузки сохраняют свои параметры.',
|
||||
download: 'Загрузка',
|
||||
url: 'URL',
|
||||
urlShowMore: 'Показать полный адрес',
|
||||
urlShowLess: 'Свернуть',
|
||||
fileName: 'Имя файла',
|
||||
saveLocation: 'Место сохранения',
|
||||
select: 'Выбрать',
|
||||
|
||||
@@ -230,6 +230,16 @@ const uk = {
|
||||
configuredConcurrency: 'Налаштована паралельність',
|
||||
connectedPeers: 'підключених пірів',
|
||||
details: 'Деталі',
|
||||
tabs: {
|
||||
label: 'Розділи властивостей',
|
||||
overview: 'Огляд',
|
||||
files: 'Файли',
|
||||
trackers: 'Трекери',
|
||||
peers: 'Піри',
|
||||
transfer: 'Передавання',
|
||||
options: 'Параметри',
|
||||
advanced: 'Додатково',
|
||||
},
|
||||
queueId: 'Черга',
|
||||
queuePosition: 'Позиція {{position}}',
|
||||
resumable: 'Можна продовжити',
|
||||
@@ -406,6 +416,8 @@ const uk = {
|
||||
transferSettings: 'Налаштування передачі можна змінити після зупинки або призупинення. Поточні передачі зберігають свої існуючі налаштування бекенду.',
|
||||
download: 'Завантаження',
|
||||
url: 'URL',
|
||||
urlShowMore: 'Показати повну адресу',
|
||||
urlShowLess: 'Згорнути',
|
||||
fileName: 'Ім\'я файлу',
|
||||
saveLocation: 'Місце збереження',
|
||||
select: 'Вибрати',
|
||||
|
||||
@@ -230,6 +230,16 @@ const zhCN = {
|
||||
configuredConcurrency: '已配置并发数',
|
||||
connectedPeers: '已连接对等端',
|
||||
details: '详细信息',
|
||||
tabs: {
|
||||
label: '属性部分',
|
||||
overview: '概览',
|
||||
files: '文件',
|
||||
trackers: 'Tracker',
|
||||
peers: '对等端',
|
||||
transfer: '传输',
|
||||
options: '选项',
|
||||
advanced: '高级',
|
||||
},
|
||||
queueId: '队列',
|
||||
queuePosition: '位置 {{position}}',
|
||||
resumable: '可续传',
|
||||
@@ -406,6 +416,8 @@ const zhCN = {
|
||||
transferSettings: '停止或暂停后可以更改传输设置。当前的传输会保留其现有的后端选项。',
|
||||
download: '下载',
|
||||
url: 'URL',
|
||||
urlShowMore: '显示完整地址',
|
||||
urlShowLess: '收起',
|
||||
fileName: '文件名',
|
||||
saveLocation: '保存位置',
|
||||
select: '选择',
|
||||
|
||||
+191
-8
@@ -574,6 +574,7 @@ html[data-list-density="relaxed"] {
|
||||
.properties-window-shell {
|
||||
--properties-header-surface: hsl(var(--surface-raised));
|
||||
--properties-card-surface: hsl(var(--bg-input) / 0.42);
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--border-color));
|
||||
@@ -586,6 +587,7 @@ html[data-list-density="relaxed"] {
|
||||
height: 52px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 18px;
|
||||
direction: ltr;
|
||||
border-bottom: 1px solid hsl(var(--border-color));
|
||||
@@ -601,16 +603,12 @@ html[data-list-density="relaxed"] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.properties-window-shell--controls-left .properties-window-titlebar {
|
||||
padding-left: calc(var(--window-control-reveal-offset, 88px) + 18px);
|
||||
}
|
||||
|
||||
.properties-window-shell--controls-right .properties-window-titlebar {
|
||||
justify-content: flex-end;
|
||||
padding-right: calc(var(--window-control-reveal-offset, 88px) + 18px);
|
||||
.properties-window-shell .properties-window-titlebar {
|
||||
padding-inline: calc(var(--properties-window-control-rail-width, 60px) + 22px);
|
||||
}
|
||||
|
||||
.properties-window-header {
|
||||
@@ -850,6 +848,116 @@ html[data-list-density="relaxed"] {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.properties-window-tab-navigation {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 16px;
|
||||
border-bottom: 1px solid hsl(var(--border-modal));
|
||||
background: hsl(var(--surface-raised));
|
||||
}
|
||||
|
||||
.properties-window-tab-navigation--overflow .properties-window-tabs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.properties-window-tab-navigation--overflow .properties-window-tab-overflow {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.properties-window-tabs {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.properties-window-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.properties-window-tab {
|
||||
display: inline-flex;
|
||||
min-height: 38px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
color: hsl(var(--text-muted));
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
border-color 120ms ease,
|
||||
color 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.properties-window-tab:hover {
|
||||
border-color: hsl(var(--border-modal));
|
||||
background: hsl(var(--item-hover));
|
||||
color: hsl(var(--text-primary));
|
||||
}
|
||||
|
||||
.properties-window-tab[aria-selected="true"] {
|
||||
border-color: hsl(var(--accent-color) / 0.34);
|
||||
background: hsl(var(--accent-color) / 0.15);
|
||||
color: hsl(var(--text-primary));
|
||||
box-shadow: inset 0 0 0 1px hsl(var(--accent-color) / 0.08);
|
||||
}
|
||||
|
||||
.properties-window-tab:focus-visible {
|
||||
outline: 2px solid hsl(var(--accent-color) / 0.76);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.properties-window-tab:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.properties-window-tab-icon {
|
||||
flex: 0 0 auto;
|
||||
color: hsl(var(--accent-color));
|
||||
}
|
||||
|
||||
.properties-window-tab-overflow {
|
||||
display: none;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: hsl(var(--text-muted));
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.properties-window-tab-overflow select {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
min-height: 36px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid hsl(var(--border-modal));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--bg-input) / 0.55);
|
||||
color: hsl(var(--text-primary));
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.properties-window-tab-overflow select:focus-visible {
|
||||
outline: 2px solid hsl(var(--accent-color) / 0.76);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.properties-window-panel {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
@@ -901,6 +1009,55 @@ html[data-list-density="relaxed"] {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.properties-url-card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.properties-url-card-header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.properties-url-toggle {
|
||||
display: inline-flex;
|
||||
min-height: 28px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--accent-color));
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.properties-url-toggle:hover {
|
||||
background: hsl(var(--accent-color) / 0.1);
|
||||
color: hsl(var(--text-primary));
|
||||
}
|
||||
|
||||
.properties-url-toggle:focus-visible {
|
||||
outline: 2px solid hsl(var(--accent-color) / 0.76);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.properties-url-value {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.properties-url-value--collapsed {
|
||||
display: -webkit-box;
|
||||
max-height: calc(1.45em * 2);
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.properties-window-hero-top {
|
||||
align-items: stretch;
|
||||
@@ -925,6 +1082,20 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.properties-window-tab-navigation {
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.properties-window-tabs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.properties-window-tab-overflow {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 440px) {
|
||||
.properties-window-header {
|
||||
padding-inline: 14px;
|
||||
@@ -950,6 +1121,14 @@ html[data-list-density="relaxed"] {
|
||||
.properties-window-progress-fill {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.properties-window-tab {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.properties-window-tab:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.app-icon-button {
|
||||
@@ -2430,7 +2609,7 @@ html[data-list-density="relaxed"] {
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 22px;
|
||||
right: auto;
|
||||
@@ -2443,6 +2622,10 @@ html[data-list-density="relaxed"] {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-shell--sidebar-right .window-controls {
|
||||
left: auto;
|
||||
right: 22px;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getPropertiesTabIndex,
|
||||
getPropertiesTabs,
|
||||
PROPERTIES_TABS_OVERFLOW_BREAKPOINT,
|
||||
shouldUsePropertiesTabOverflow,
|
||||
} from './propertiesTabs';
|
||||
|
||||
describe('Properties tabs', () => {
|
||||
it('keeps torrent and normal-download sections concise and stable', () => {
|
||||
expect(getPropertiesTabs(false)).toEqual(['overview', 'transfer', 'advanced']);
|
||||
expect(getPropertiesTabs(true)).toEqual(['overview', 'files', 'trackers', 'peers', 'options']);
|
||||
});
|
||||
|
||||
it('uses the responsive overflow control at narrow widths', () => {
|
||||
expect(shouldUsePropertiesTabOverflow(PROPERTIES_TABS_OVERFLOW_BREAKPOINT)).toBe(true);
|
||||
expect(shouldUsePropertiesTabOverflow(PROPERTIES_TABS_OVERFLOW_BREAKPOINT + 1)).toBe(false);
|
||||
expect(shouldUsePropertiesTabOverflow(Number.NaN)).toBe(false);
|
||||
});
|
||||
|
||||
it('moves through tabs according to physical direction', () => {
|
||||
const tabs = getPropertiesTabs(true);
|
||||
expect(getPropertiesTabIndex(tabs, 0, 'ArrowRight', 'ltr')).toBe(1);
|
||||
expect(getPropertiesTabIndex(tabs, 0, 'ArrowLeft', 'ltr')).toBe(tabs.length - 1);
|
||||
expect(getPropertiesTabIndex(tabs, 0, 'ArrowRight', 'rtl')).toBe(tabs.length - 1);
|
||||
expect(getPropertiesTabIndex(tabs, 0, 'ArrowLeft', 'rtl')).toBe(1);
|
||||
expect(getPropertiesTabIndex(tabs, 2, 'Home', 'ltr')).toBe(0);
|
||||
expect(getPropertiesTabIndex(tabs, 2, 'End', 'ltr')).toBe(tabs.length - 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
export type PropertiesTab = 'overview' | 'files' | 'trackers' | 'peers' | 'options' | 'transfer' | 'advanced';
|
||||
|
||||
const TORRENT_PROPERTIES_TABS: readonly PropertiesTab[] = [
|
||||
'overview',
|
||||
'files',
|
||||
'trackers',
|
||||
'peers',
|
||||
'options',
|
||||
];
|
||||
|
||||
const DOWNLOAD_PROPERTIES_TABS: readonly PropertiesTab[] = [
|
||||
'overview',
|
||||
'transfer',
|
||||
'advanced',
|
||||
];
|
||||
|
||||
export const PROPERTIES_TABS_OVERFLOW_BREAKPOINT = 620;
|
||||
|
||||
export const getPropertiesTabs = (isTorrent: boolean): readonly PropertiesTab[] =>
|
||||
isTorrent ? TORRENT_PROPERTIES_TABS : DOWNLOAD_PROPERTIES_TABS;
|
||||
|
||||
export const shouldUsePropertiesTabOverflow = (width: number): boolean =>
|
||||
Number.isFinite(width) && width <= PROPERTIES_TABS_OVERFLOW_BREAKPOINT;
|
||||
|
||||
export const getPropertiesTabIndex = (
|
||||
tabs: readonly PropertiesTab[],
|
||||
currentIndex: number,
|
||||
key: string,
|
||||
direction: 'ltr' | 'rtl',
|
||||
): number => {
|
||||
if (tabs.length === 0) return -1;
|
||||
if (key === 'Home') return 0;
|
||||
if (key === 'End') return tabs.length - 1;
|
||||
if (key !== 'ArrowLeft' && key !== 'ArrowRight') return -1;
|
||||
|
||||
const step = key === 'ArrowRight'
|
||||
? (direction === 'rtl' ? -1 : 1)
|
||||
: (direction === 'rtl' ? 1 : -1);
|
||||
return (currentIndex + step + tabs.length) % tabs.length;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
PROPERTIES_URL_PREVIEW_MAX_LENGTH,
|
||||
shouldOfferPropertiesUrlExpansion,
|
||||
shouldResetPropertiesUrlExpansion,
|
||||
} from './propertiesUrl';
|
||||
|
||||
describe('Properties URL disclosure', () => {
|
||||
it('offers expansion only for URLs longer than the preview budget', () => {
|
||||
expect(shouldOfferPropertiesUrlExpansion('x'.repeat(PROPERTIES_URL_PREVIEW_MAX_LENGTH))).toBe(false);
|
||||
expect(shouldOfferPropertiesUrlExpansion('x'.repeat(PROPERTIES_URL_PREVIEW_MAX_LENGTH + 1))).toBe(true);
|
||||
});
|
||||
|
||||
it('resets expansion when the displayed download changes', () => {
|
||||
expect(shouldResetPropertiesUrlExpansion('download-1', 'download-1')).toBe(false);
|
||||
expect(shouldResetPropertiesUrlExpansion('download-1', 'download-2')).toBe(true);
|
||||
expect(shouldResetPropertiesUrlExpansion(null, 'download-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('resets expansion when the displayed address changes', () => {
|
||||
expect(shouldResetPropertiesUrlExpansion('download-1', 'download-1', 'magnet:?xt=old', 'magnet:?xt=old')).toBe(false);
|
||||
expect(shouldResetPropertiesUrlExpansion('download-1', 'download-1', 'magnet:?xt=old', 'magnet:?xt=new')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export const PROPERTIES_URL_PREVIEW_MAX_LENGTH = 180;
|
||||
|
||||
export const shouldOfferPropertiesUrlExpansion = (url: string): boolean =>
|
||||
url.length > PROPERTIES_URL_PREVIEW_MAX_LENGTH;
|
||||
|
||||
export const shouldResetPropertiesUrlExpansion = (
|
||||
previousDownloadId: string | null,
|
||||
nextDownloadId: string | null,
|
||||
previousUrl?: string | null,
|
||||
nextUrl?: string | null,
|
||||
): boolean => previousDownloadId !== nextDownloadId || previousUrl !== nextUrl;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getWindowControlRailWidth,
|
||||
getWindowControlRevealOffset,
|
||||
resolveWindowControlSide,
|
||||
resolveWindowControlStyle,
|
||||
@@ -37,6 +38,13 @@ describe('resolveWindowControlStyle', () => {
|
||||
expect(getWindowControlRevealOffset('minimal')).toBe(104);
|
||||
});
|
||||
|
||||
it('reports the shared titlebar rail width for each control style', () => {
|
||||
expect(getWindowControlRailWidth('macos')).toBe(60);
|
||||
expect(getWindowControlRailWidth('windows')).toBe(138);
|
||||
expect(getWindowControlRailWidth('gnome')).toBe(104);
|
||||
expect(getWindowControlRailWidth('minimal')).toBe(74);
|
||||
});
|
||||
|
||||
it('resolves automatic control placement from the effective document direction', () => {
|
||||
expect(resolveWindowControlSide('auto', 'ltr')).toBe('left');
|
||||
expect(resolveWindowControlSide('auto', 'rtl')).toBe('right');
|
||||
|
||||
@@ -14,9 +14,19 @@ const WINDOW_CONTROL_REVEAL_OFFSETS: Record<ResolvedWindowControlStyle, number>
|
||||
minimal: 104,
|
||||
};
|
||||
|
||||
const WINDOW_CONTROL_RAIL_WIDTHS: Record<ResolvedWindowControlStyle, number> = {
|
||||
macos: 60,
|
||||
windows: 138,
|
||||
gnome: 104,
|
||||
minimal: 74,
|
||||
};
|
||||
|
||||
export const getWindowControlRevealOffset = (style: ResolvedWindowControlStyle): number =>
|
||||
WINDOW_CONTROL_REVEAL_OFFSETS[style];
|
||||
|
||||
export const getWindowControlRailWidth = (style: ResolvedWindowControlStyle): number =>
|
||||
WINDOW_CONTROL_RAIL_WIDTHS[style];
|
||||
|
||||
export const resolveWindowControlSide = (
|
||||
sidebarPosition: SidebarPosition,
|
||||
direction: 'ltr' | 'rtl',
|
||||
|
||||
Reference in New Issue
Block a user