mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-04 15:05:22 +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 { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
|
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
|
||||||
import { open, save } from '@tauri-apps/plugin-dialog';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilitySnapshot';
|
import type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilitySnapshot';
|
||||||
import type { TorrentDetails } from '../bindings/TorrentDetails';
|
import type { TorrentDetails } from '../bindings/TorrentDetails';
|
||||||
@@ -39,8 +39,10 @@ import {
|
|||||||
import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress';
|
import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress';
|
||||||
import { changeAppLocale } from '../i18n';
|
import { changeAppLocale } from '../i18n';
|
||||||
import { synchronizeDocumentAppearance } from '../utils/documentAppearance';
|
import { synchronizeDocumentAppearance } from '../utils/documentAppearance';
|
||||||
import { getWindowControlRevealOffset } from '../utils/windowControlStyle';
|
import { getWindowControlRailWidth } from '../utils/windowControlStyle';
|
||||||
import { getPropertiesFooterActions } from '../utils/propertiesFooter';
|
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 { WindowControls } from './WindowControls';
|
||||||
import {
|
import {
|
||||||
TORRENT_ENCRYPTION_POLICY_DISABLED,
|
TORRENT_ENCRYPTION_POLICY_DISABLED,
|
||||||
@@ -50,7 +52,6 @@ import {
|
|||||||
type TorrentFileAllocation,
|
type TorrentFileAllocation,
|
||||||
} from '../utils/downloads';
|
} from '../utils/downloads';
|
||||||
|
|
||||||
type PropertiesTab = 'overview' | 'files' | 'trackers' | 'peers' | 'options' | 'transfer' | 'advanced';
|
|
||||||
type SecretName = 'username' | 'password' | 'cookies' | 'headers';
|
type SecretName = 'username' | 'password' | 'cookies' | 'headers';
|
||||||
type SecretDraft = { value: string; touched: boolean; clear: boolean };
|
type SecretDraft = { value: string; touched: boolean; clear: boolean };
|
||||||
const SECRET_NAMES: SecretName[] = ['username', 'password', 'cookies', 'headers'];
|
const SECRET_NAMES: SecretName[] = ['username', 'password', 'cookies', 'headers'];
|
||||||
@@ -83,6 +84,19 @@ const propertiesStatusTone = (status: string) => {
|
|||||||
return 'downloading';
|
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 => [
|
const propertiesDiagnosticLifecycleKey = (snapshot: PropertiesSnapshot): string => [
|
||||||
snapshot.id,
|
snapshot.id,
|
||||||
snapshot.status,
|
snapshot.status,
|
||||||
@@ -106,6 +120,9 @@ export const PropertiesWindowApp = () => {
|
|||||||
const sessionId = useMemo(() => crypto.randomUUID(), []);
|
const sessionId = useMemo(() => crypto.randomUUID(), []);
|
||||||
const [downloadId, setDownloadId] = useState<string | null>(null);
|
const [downloadId, setDownloadId] = useState<string | null>(null);
|
||||||
const [snapshot, setSnapshot] = useState<PropertiesSnapshot | 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 [activeTab, setActiveTab] = useState<PropertiesTab>('overview');
|
||||||
const [pendingTab, setPendingTab] = useState<PropertiesTab | null>(null);
|
const [pendingTab, setPendingTab] = useState<PropertiesTab | null>(null);
|
||||||
const [closePrompt, setClosePrompt] = useState(false);
|
const [closePrompt, setClosePrompt] = useState(false);
|
||||||
@@ -178,6 +195,10 @@ export const PropertiesWindowApp = () => {
|
|||||||
const allowWindowCloseRef = useRef(false);
|
const allowWindowCloseRef = useRef(false);
|
||||||
const isDirtyRef = useRef(false);
|
const isDirtyRef = useRef(false);
|
||||||
const windowChromeRef = useRef(DEFAULT_PROPERTIES_WINDOW_CHROME);
|
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;
|
snapshotRef.current = snapshot;
|
||||||
activeTabRef.current = activeTab;
|
activeTabRef.current = activeTab;
|
||||||
downloadIdRef.current = downloadId;
|
downloadIdRef.current = downloadId;
|
||||||
@@ -187,9 +208,8 @@ export const PropertiesWindowApp = () => {
|
|||||||
detailsRef.current = details;
|
detailsRef.current = details;
|
||||||
|
|
||||||
const isTorrent = snapshot?.isTorrent === true;
|
const isTorrent = snapshot?.isTorrent === true;
|
||||||
const tabs = useMemo<PropertiesTab[]>(() => isTorrent
|
const tabs = useMemo(() => getPropertiesTabs(isTorrent), [isTorrent]);
|
||||||
? ['overview', 'files', 'trackers', 'peers', 'options']
|
const urlCanExpand = Boolean(snapshot && (shouldOfferPropertiesUrlExpansion(snapshot.url) || urlHasOverflow));
|
||||||
: ['overview', 'transfer', 'advanced'], [isTorrent]);
|
|
||||||
const isDirty = draftTab !== null;
|
const isDirty = draftTab !== null;
|
||||||
isDirtyRef.current = isDirty;
|
isDirtyRef.current = isDirty;
|
||||||
if (isDirty && allowWindowCloseRef.current) {
|
if (isDirty && allowWindowCloseRef.current) {
|
||||||
@@ -199,6 +219,60 @@ export const PropertiesWindowApp = () => {
|
|||||||
allowWindowCloseRef.current = false;
|
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) => {
|
const closeCurrentWindow = useCallback(async (allowDirtyClose = false) => {
|
||||||
allowWindowCloseRef.current = allowDirtyClose;
|
allowWindowCloseRef.current = allowDirtyClose;
|
||||||
try {
|
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]);
|
}, [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) => {
|
const chooseTab = (tab: PropertiesTab) => {
|
||||||
if (tab === activeTab) return;
|
if (tab === activeTab) {
|
||||||
if (isDirty) setPendingTab(tab);
|
if (pendingTab !== null) setPendingTab(null);
|
||||||
else setActiveTab(tab);
|
return false;
|
||||||
|
}
|
||||||
|
if (isDirty) {
|
||||||
|
setPendingTab(tab);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setActiveTab(tab);
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const discardDraft = () => {
|
const discardDraft = () => {
|
||||||
@@ -838,12 +919,14 @@ export const PropertiesWindowApp = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const windowChrome = snapshot?.windowChrome ?? windowChromeRef.current;
|
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) {
|
if (!downloadId || !snapshot) {
|
||||||
return (
|
return (
|
||||||
<main
|
<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`}
|
className={windowShellClassName}
|
||||||
style={{ '--window-control-reveal-offset': `${windowControlRevealOffset}px` } as CSSProperties}
|
style={windowShellStyle}
|
||||||
aria-labelledby="properties-window-title"
|
aria-labelledby="properties-window-title"
|
||||||
>
|
>
|
||||||
<WindowControls side={windowChrome.side} controlStyle={windowChrome.controlStyle} />
|
<WindowControls side={windowChrome.side} controlStyle={windowChrome.controlStyle} />
|
||||||
@@ -891,20 +974,20 @@ export const PropertiesWindowApp = () => {
|
|||||||
: t($ => $.downloads.actions.start);
|
: t($ => $.downloads.actions.start);
|
||||||
const tabLabel = (tab: PropertiesTab) => {
|
const tabLabel = (tab: PropertiesTab) => {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case 'overview': return t($ => $.properties.details);
|
case 'overview': return t($ => $.properties.tabs.overview);
|
||||||
case 'files': return t($ => $.properties.torrentFileProgress);
|
case 'files': return t($ => $.properties.tabs.files);
|
||||||
case 'trackers': return t($ => $.properties.torrentTrackers);
|
case 'trackers': return t($ => $.properties.tabs.trackers);
|
||||||
case 'peers': return t($ => $.properties.torrentPeerDiagnostics);
|
case 'peers': return t($ => $.properties.tabs.peers);
|
||||||
case 'options': return t($ => $.downloads.actions.options);
|
case 'options': return t($ => $.properties.tabs.options);
|
||||||
case 'transfer': return t($ => $.properties.connections);
|
case 'transfer': return t($ => $.properties.tabs.transfer);
|
||||||
case 'advanced': return t($ => $.properties.advancedTransfer);
|
case 'advanced': return t($ => $.properties.tabs.advanced);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<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`}
|
className={windowShellClassName}
|
||||||
style={{ '--window-control-reveal-offset': `${windowControlRevealOffset}px` } as CSSProperties}
|
style={windowShellStyle}
|
||||||
aria-labelledby="properties-window-title"
|
aria-labelledby="properties-window-title"
|
||||||
>
|
>
|
||||||
<WindowControls side={windowChrome.side} controlStyle={windowChrome.controlStyle} />
|
<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>
|
<div className="properties-window-destination" title={snapshot.destination || undefined}><MapPin size={13} /><span>{snapshot.destination || '—'}</span></div>
|
||||||
</header>
|
</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)}>
|
<div className={`properties-window-tab-navigation ${useTabOverflow ? 'properties-window-tab-navigation--overflow' : ''}`}>
|
||||||
{tabs.map(tab => (
|
<span id="properties-active-section-label" className="sr-only">{tabLabel(activeTab)}</span>
|
||||||
<button
|
<nav className="properties-window-tabs" role="tablist" aria-label={t($ => $.properties.tabs.label)}>
|
||||||
key={tab}
|
{tabs.map(tab => (
|
||||||
type="button"
|
<button
|
||||||
role="tab"
|
key={tab}
|
||||||
aria-selected={activeTab === tab}
|
type="button"
|
||||||
aria-controls={`properties-panel-${tab}`}
|
role="tab"
|
||||||
tabIndex={activeTab === tab ? 0 : -1}
|
aria-selected={activeTab === tab}
|
||||||
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'}`}
|
aria-controls={`properties-panel-${tab}`}
|
||||||
onClick={() => chooseTab(tab)}
|
tabIndex={(pendingTab ?? activeTab) === tab ? 0 : -1}
|
||||||
onKeyDown={event => {
|
className="properties-window-tab"
|
||||||
const index = tabs.indexOf(tab);
|
onClick={() => chooseTab(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;
|
onKeyDown={event => {
|
||||||
if (nextIndex >= 0) {
|
const index = tabs.indexOf(tab);
|
||||||
event.preventDefault();
|
const nextIndex = getPropertiesTabIndex(
|
||||||
const next = tabs[nextIndex];
|
tabs,
|
||||||
chooseTab(next);
|
index,
|
||||||
window.setTimeout(() => document.getElementById(`properties-tab-${next}`)?.focus(), 0);
|
event.key,
|
||||||
}
|
document.documentElement.dir === 'rtl' ? 'rtl' : 'ltr',
|
||||||
}}
|
);
|
||||||
id={`properties-tab-${tab}`}
|
if (nextIndex >= 0) {
|
||||||
>{tabLabel(tab)}</button>
|
event.preventDefault();
|
||||||
))}
|
const next = tabs[nextIndex];
|
||||||
</nav>
|
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">
|
{activeTab === 'overview' && <div className="space-y-4">
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<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.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>
|
<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>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<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 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>
|
||||||
<div className="grid gap-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
<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
|
<div
|
||||||
className={`window-controls window-controls--${side} window-controls--style-${controlStyle}`}
|
className={`window-controls window-controls--${side} window-controls--style-${controlStyle}`}
|
||||||
aria-label={t($ => $.window.controls)}
|
aria-label={t($ => $.window.controls)}
|
||||||
|
role="group"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -230,6 +230,16 @@ const common = {
|
|||||||
configuredConcurrency: 'Configured concurrency',
|
configuredConcurrency: 'Configured concurrency',
|
||||||
connectedPeers: 'connected peers',
|
connectedPeers: 'connected peers',
|
||||||
details: 'Details',
|
details: 'Details',
|
||||||
|
tabs: {
|
||||||
|
label: 'Properties sections',
|
||||||
|
overview: 'Overview',
|
||||||
|
files: 'Files',
|
||||||
|
trackers: 'Trackers',
|
||||||
|
peers: 'Peers',
|
||||||
|
transfer: 'Transfer',
|
||||||
|
options: 'Options',
|
||||||
|
advanced: 'Advanced',
|
||||||
|
},
|
||||||
queueId: 'Queue',
|
queueId: 'Queue',
|
||||||
queuePosition: 'Position {{position}}',
|
queuePosition: 'Position {{position}}',
|
||||||
resumable: 'Resumable',
|
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.',
|
transferSettings: 'Transfer settings can be changed after stopping or pausing. Current transfers keep their existing backend options.',
|
||||||
download: 'Download',
|
download: 'Download',
|
||||||
url: 'URL',
|
url: 'URL',
|
||||||
|
urlShowMore: 'Show full address',
|
||||||
|
urlShowLess: 'Show less',
|
||||||
fileName: 'File name',
|
fileName: 'File name',
|
||||||
saveLocation: 'Save location',
|
saveLocation: 'Save location',
|
||||||
select: 'Select',
|
select: 'Select',
|
||||||
|
|||||||
@@ -230,6 +230,16 @@ const fa = {
|
|||||||
configuredConcurrency: 'همزمانی پیکربندیشده',
|
configuredConcurrency: 'همزمانی پیکربندیشده',
|
||||||
connectedPeers: 'همتای متصل',
|
connectedPeers: 'همتای متصل',
|
||||||
details: 'جزئیات',
|
details: 'جزئیات',
|
||||||
|
tabs: {
|
||||||
|
label: 'بخشهای ویژگیها',
|
||||||
|
overview: 'نمای کلی',
|
||||||
|
files: 'فایلها',
|
||||||
|
trackers: 'Trackerها',
|
||||||
|
peers: 'همتاها',
|
||||||
|
transfer: 'انتقال',
|
||||||
|
options: 'گزینهها',
|
||||||
|
advanced: 'پیشرفته',
|
||||||
|
},
|
||||||
queueId: 'صف',
|
queueId: 'صف',
|
||||||
queuePosition: 'موقعیت {{position}}',
|
queuePosition: 'موقعیت {{position}}',
|
||||||
resumable: 'قابل ادامه',
|
resumable: 'قابل ادامه',
|
||||||
@@ -406,6 +416,8 @@ const fa = {
|
|||||||
transferSettings: 'تنظیمات انتقال را میتوان پس از توقف تغییر داد. انتقالهای کنونی گزینههای فعلی خود را حفظ میکنند.',
|
transferSettings: 'تنظیمات انتقال را میتوان پس از توقف تغییر داد. انتقالهای کنونی گزینههای فعلی خود را حفظ میکنند.',
|
||||||
download: 'دانلود',
|
download: 'دانلود',
|
||||||
url: 'URL',
|
url: 'URL',
|
||||||
|
urlShowMore: 'نمایش نشانی کامل',
|
||||||
|
urlShowLess: 'نمایش کمتر',
|
||||||
fileName: 'نام فایل',
|
fileName: 'نام فایل',
|
||||||
saveLocation: 'محل ذخیره',
|
saveLocation: 'محل ذخیره',
|
||||||
select: 'انتخاب',
|
select: 'انتخاب',
|
||||||
|
|||||||
@@ -230,6 +230,16 @@ const he = {
|
|||||||
configuredConcurrency: 'מקביליות מוגדרת',
|
configuredConcurrency: 'מקביליות מוגדרת',
|
||||||
connectedPeers: 'עמיתים מחוברים',
|
connectedPeers: 'עמיתים מחוברים',
|
||||||
details: 'פרטים',
|
details: 'פרטים',
|
||||||
|
tabs: {
|
||||||
|
label: 'מקטעי המאפיינים',
|
||||||
|
overview: 'סקירה',
|
||||||
|
files: 'קבצים',
|
||||||
|
trackers: 'עוקבים',
|
||||||
|
peers: 'עמיתים',
|
||||||
|
transfer: 'העברה',
|
||||||
|
options: 'אפשרויות',
|
||||||
|
advanced: 'מתקדם',
|
||||||
|
},
|
||||||
queueId: 'תור',
|
queueId: 'תור',
|
||||||
queuePosition: 'מיקום {{position}}',
|
queuePosition: 'מיקום {{position}}',
|
||||||
resumable: 'ניתן להמשך',
|
resumable: 'ניתן להמשך',
|
||||||
@@ -406,6 +416,8 @@ const he = {
|
|||||||
transferSettings: 'ניתן לשנות את הגדרות ההעברה לאחר עצירה או השהייה. העברות נוכחיות שומרות על אפשרויות המנוע הקיימות שלהן.',
|
transferSettings: 'ניתן לשנות את הגדרות ההעברה לאחר עצירה או השהייה. העברות נוכחיות שומרות על אפשרויות המנוע הקיימות שלהן.',
|
||||||
download: 'הורדה',
|
download: 'הורדה',
|
||||||
url: 'URL',
|
url: 'URL',
|
||||||
|
urlShowMore: 'הצג כתובת מלאה',
|
||||||
|
urlShowLess: 'הצג פחות',
|
||||||
fileName: 'שם קובץ',
|
fileName: 'שם קובץ',
|
||||||
saveLocation: 'מיקום שמירה',
|
saveLocation: 'מיקום שמירה',
|
||||||
select: 'בחירה',
|
select: 'בחירה',
|
||||||
|
|||||||
@@ -230,6 +230,16 @@ const ru = {
|
|||||||
configuredConcurrency: 'Настроенная параллельность',
|
configuredConcurrency: 'Настроенная параллельность',
|
||||||
connectedPeers: 'подключённых пиров',
|
connectedPeers: 'подключённых пиров',
|
||||||
details: 'Подробности',
|
details: 'Подробности',
|
||||||
|
tabs: {
|
||||||
|
label: 'Разделы свойств',
|
||||||
|
overview: 'Обзор',
|
||||||
|
files: 'Файлы',
|
||||||
|
trackers: 'Трекеры',
|
||||||
|
peers: 'Пиры',
|
||||||
|
transfer: 'Передача',
|
||||||
|
options: 'Параметры',
|
||||||
|
advanced: 'Дополнительно',
|
||||||
|
},
|
||||||
queueId: 'Очередь',
|
queueId: 'Очередь',
|
||||||
queuePosition: 'Позиция {{position}}',
|
queuePosition: 'Позиция {{position}}',
|
||||||
resumable: 'Возобновляемая',
|
resumable: 'Возобновляемая',
|
||||||
@@ -406,6 +416,8 @@ const ru = {
|
|||||||
transferSettings: 'Настройки передачи можно изменить после остановки или приостановки. Текущие загрузки сохраняют свои параметры.',
|
transferSettings: 'Настройки передачи можно изменить после остановки или приостановки. Текущие загрузки сохраняют свои параметры.',
|
||||||
download: 'Загрузка',
|
download: 'Загрузка',
|
||||||
url: 'URL',
|
url: 'URL',
|
||||||
|
urlShowMore: 'Показать полный адрес',
|
||||||
|
urlShowLess: 'Свернуть',
|
||||||
fileName: 'Имя файла',
|
fileName: 'Имя файла',
|
||||||
saveLocation: 'Место сохранения',
|
saveLocation: 'Место сохранения',
|
||||||
select: 'Выбрать',
|
select: 'Выбрать',
|
||||||
|
|||||||
@@ -230,6 +230,16 @@ const uk = {
|
|||||||
configuredConcurrency: 'Налаштована паралельність',
|
configuredConcurrency: 'Налаштована паралельність',
|
||||||
connectedPeers: 'підключених пірів',
|
connectedPeers: 'підключених пірів',
|
||||||
details: 'Деталі',
|
details: 'Деталі',
|
||||||
|
tabs: {
|
||||||
|
label: 'Розділи властивостей',
|
||||||
|
overview: 'Огляд',
|
||||||
|
files: 'Файли',
|
||||||
|
trackers: 'Трекери',
|
||||||
|
peers: 'Піри',
|
||||||
|
transfer: 'Передавання',
|
||||||
|
options: 'Параметри',
|
||||||
|
advanced: 'Додатково',
|
||||||
|
},
|
||||||
queueId: 'Черга',
|
queueId: 'Черга',
|
||||||
queuePosition: 'Позиція {{position}}',
|
queuePosition: 'Позиція {{position}}',
|
||||||
resumable: 'Можна продовжити',
|
resumable: 'Можна продовжити',
|
||||||
@@ -406,6 +416,8 @@ const uk = {
|
|||||||
transferSettings: 'Налаштування передачі можна змінити після зупинки або призупинення. Поточні передачі зберігають свої існуючі налаштування бекенду.',
|
transferSettings: 'Налаштування передачі можна змінити після зупинки або призупинення. Поточні передачі зберігають свої існуючі налаштування бекенду.',
|
||||||
download: 'Завантаження',
|
download: 'Завантаження',
|
||||||
url: 'URL',
|
url: 'URL',
|
||||||
|
urlShowMore: 'Показати повну адресу',
|
||||||
|
urlShowLess: 'Згорнути',
|
||||||
fileName: 'Ім\'я файлу',
|
fileName: 'Ім\'я файлу',
|
||||||
saveLocation: 'Місце збереження',
|
saveLocation: 'Місце збереження',
|
||||||
select: 'Вибрати',
|
select: 'Вибрати',
|
||||||
|
|||||||
@@ -230,6 +230,16 @@ const zhCN = {
|
|||||||
configuredConcurrency: '已配置并发数',
|
configuredConcurrency: '已配置并发数',
|
||||||
connectedPeers: '已连接对等端',
|
connectedPeers: '已连接对等端',
|
||||||
details: '详细信息',
|
details: '详细信息',
|
||||||
|
tabs: {
|
||||||
|
label: '属性部分',
|
||||||
|
overview: '概览',
|
||||||
|
files: '文件',
|
||||||
|
trackers: 'Tracker',
|
||||||
|
peers: '对等端',
|
||||||
|
transfer: '传输',
|
||||||
|
options: '选项',
|
||||||
|
advanced: '高级',
|
||||||
|
},
|
||||||
queueId: '队列',
|
queueId: '队列',
|
||||||
queuePosition: '位置 {{position}}',
|
queuePosition: '位置 {{position}}',
|
||||||
resumable: '可续传',
|
resumable: '可续传',
|
||||||
@@ -406,6 +416,8 @@ const zhCN = {
|
|||||||
transferSettings: '停止或暂停后可以更改传输设置。当前的传输会保留其现有的后端选项。',
|
transferSettings: '停止或暂停后可以更改传输设置。当前的传输会保留其现有的后端选项。',
|
||||||
download: '下载',
|
download: '下载',
|
||||||
url: 'URL',
|
url: 'URL',
|
||||||
|
urlShowMore: '显示完整地址',
|
||||||
|
urlShowLess: '收起',
|
||||||
fileName: '文件名',
|
fileName: '文件名',
|
||||||
saveLocation: '保存位置',
|
saveLocation: '保存位置',
|
||||||
select: '选择',
|
select: '选择',
|
||||||
|
|||||||
+191
-8
@@ -574,6 +574,7 @@ html[data-list-density="relaxed"] {
|
|||||||
.properties-window-shell {
|
.properties-window-shell {
|
||||||
--properties-header-surface: hsl(var(--surface-raised));
|
--properties-header-surface: hsl(var(--surface-raised));
|
||||||
--properties-card-surface: hsl(var(--bg-input) / 0.42);
|
--properties-card-surface: hsl(var(--bg-input) / 0.42);
|
||||||
|
position: relative;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid hsl(var(--border-color));
|
border: 1px solid hsl(var(--border-color));
|
||||||
@@ -586,6 +587,7 @@ html[data-list-density="relaxed"] {
|
|||||||
height: 52px;
|
height: 52px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
padding: 0 18px;
|
padding: 0 18px;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
border-bottom: 1px solid hsl(var(--border-color));
|
border-bottom: 1px solid hsl(var(--border-color));
|
||||||
@@ -601,16 +603,12 @@ html[data-list-density="relaxed"] {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.properties-window-shell--controls-left .properties-window-titlebar {
|
.properties-window-shell .properties-window-titlebar {
|
||||||
padding-left: calc(var(--window-control-reveal-offset, 88px) + 18px);
|
padding-inline: calc(var(--properties-window-control-rail-width, 60px) + 22px);
|
||||||
}
|
|
||||||
|
|
||||||
.properties-window-shell--controls-right .properties-window-titlebar {
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding-right: calc(var(--window-control-reveal-offset, 88px) + 18px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.properties-window-header {
|
.properties-window-header {
|
||||||
@@ -850,6 +848,116 @@ html[data-list-density="relaxed"] {
|
|||||||
white-space: nowrap;
|
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 {
|
.properties-window-panel {
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
@@ -901,6 +1009,55 @@ html[data-list-density="relaxed"] {
|
|||||||
vertical-align: middle;
|
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) {
|
@media (max-width: 720px) {
|
||||||
.properties-window-hero-top {
|
.properties-window-hero-top {
|
||||||
align-items: stretch;
|
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) {
|
@media (max-width: 440px) {
|
||||||
.properties-window-header {
|
.properties-window-header {
|
||||||
padding-inline: 14px;
|
padding-inline: 14px;
|
||||||
@@ -950,6 +1121,14 @@ html[data-list-density="relaxed"] {
|
|||||||
.properties-window-progress-fill {
|
.properties-window-progress-fill {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.properties-window-tab {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-window-tab:active {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-icon-button {
|
.app-icon-button {
|
||||||
@@ -2430,7 +2609,7 @@ html[data-list-density="relaxed"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.window-controls {
|
.window-controls {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
left: 22px;
|
left: 22px;
|
||||||
right: auto;
|
right: auto;
|
||||||
@@ -2443,6 +2622,10 @@ html[data-list-density="relaxed"] {
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell--sidebar-right .window-controls {
|
.app-shell--sidebar-right .window-controls {
|
||||||
left: auto;
|
left: auto;
|
||||||
right: 22px;
|
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 { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
getWindowControlRailWidth,
|
||||||
getWindowControlRevealOffset,
|
getWindowControlRevealOffset,
|
||||||
resolveWindowControlSide,
|
resolveWindowControlSide,
|
||||||
resolveWindowControlStyle,
|
resolveWindowControlStyle,
|
||||||
@@ -37,6 +38,13 @@ describe('resolveWindowControlStyle', () => {
|
|||||||
expect(getWindowControlRevealOffset('minimal')).toBe(104);
|
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', () => {
|
it('resolves automatic control placement from the effective document direction', () => {
|
||||||
expect(resolveWindowControlSide('auto', 'ltr')).toBe('left');
|
expect(resolveWindowControlSide('auto', 'ltr')).toBe('left');
|
||||||
expect(resolveWindowControlSide('auto', 'rtl')).toBe('right');
|
expect(resolveWindowControlSide('auto', 'rtl')).toBe('right');
|
||||||
|
|||||||
@@ -14,9 +14,19 @@ const WINDOW_CONTROL_REVEAL_OFFSETS: Record<ResolvedWindowControlStyle, number>
|
|||||||
minimal: 104,
|
minimal: 104,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WINDOW_CONTROL_RAIL_WIDTHS: Record<ResolvedWindowControlStyle, number> = {
|
||||||
|
macos: 60,
|
||||||
|
windows: 138,
|
||||||
|
gnome: 104,
|
||||||
|
minimal: 74,
|
||||||
|
};
|
||||||
|
|
||||||
export const getWindowControlRevealOffset = (style: ResolvedWindowControlStyle): number =>
|
export const getWindowControlRevealOffset = (style: ResolvedWindowControlStyle): number =>
|
||||||
WINDOW_CONTROL_REVEAL_OFFSETS[style];
|
WINDOW_CONTROL_REVEAL_OFFSETS[style];
|
||||||
|
|
||||||
|
export const getWindowControlRailWidth = (style: ResolvedWindowControlStyle): number =>
|
||||||
|
WINDOW_CONTROL_RAIL_WIDTHS[style];
|
||||||
|
|
||||||
export const resolveWindowControlSide = (
|
export const resolveWindowControlSide = (
|
||||||
sidebarPosition: SidebarPosition,
|
sidebarPosition: SidebarPosition,
|
||||||
direction: 'ltr' | 'rtl',
|
direction: 'ltr' | 'rtl',
|
||||||
|
|||||||
Reference in New Issue
Block a user