client: migrate file tree componenets to FC

This commit is contained in:
Jesse Chan
2021-03-12 11:19:16 +08:00
parent dee07bb527
commit 76092ed734
4 changed files with 188 additions and 293 deletions
@@ -1,12 +1,12 @@
import classnames from 'classnames';
import {Component, ReactNode, ReactText} from 'react';
import {FC, ReactText, useRef, useState} from 'react';
import {Checkbox} from '@client/ui';
import {Checkmark, Clipboard, File as FileIcon} from '@client/ui/icons';
import ConfigStore from '@client/stores/ConfigStore';
import TorrentActions from '@client/actions/TorrentActions';
import type {TorrentContent, TorrentContentSelection, TorrentContentSelectionTree} from '@shared/types/TorrentContent';
import type {TorrentContentSelection, TorrentContentSelectionTree} from '@shared/types/TorrentContent';
import type {TorrentProperties} from '@shared/types/Torrent';
import PriorityMeter from '../PriorityMeter';
@@ -20,177 +20,127 @@ interface DirectoryFilesProps {
onItemSelect: (selection: TorrentContentSelection) => void;
}
interface DirectoryFilesStates {
copiedToClipboard: number | null;
}
const DirectoryFiles: FC<DirectoryFilesProps> = ({depth, items, hash, path, onItemSelect}: DirectoryFilesProps) => {
const [copiedToClipboard, setCopiedToClipboard] = useState<number | null>(null);
const contentPermalinks = useRef<Record<number, string | null>>({});
class DirectoryFiles extends Component<DirectoryFilesProps, DirectoryFilesStates> {
contentPermalinks: Record<number, string | null> = {};
static defaultProps = {
path: [],
items: {},
};
constructor(props: DirectoryFilesProps) {
super(props);
this.state = {
copiedToClipboard: null,
};
if (items == null) {
return null;
}
getCurrentPath(file: TorrentContent): string[] {
const {path} = this.props;
const files = Object.values(items)
.sort((a, b) => a.filename.localeCompare(b.filename))
.map((file) => {
const isSelected = (items && items[file.filename] && items[file.filename].isSelected) || false;
const classes = classnames(
'directory-tree__node file',
'directory-tree__node--file directory-tree__node--selectable',
{
'directory-tree__node--selected': isSelected,
},
);
return [...path, file.filename];
}
getIcon(file: TorrentContent, isSelected: boolean): ReactNode {
return (
<div className="file__checkbox directory-tree__checkbox">
<div
className="directory-tree__checkbox__item
directory-tree__checkbox__item--checkbox">
<Checkbox checked={isSelected} id={`${file.index}`} onClick={() => this.handleFileSelect(file, isSelected)} />
</div>
<div
className="directory-tree__checkbox__item
directory-tree__checkbox__item--icon">
<FileIcon />
</div>
</div>
);
}
handlePriorityChange = (fileIndex: ReactText, priorityLevel: number): void => {
const {hash} = this.props;
TorrentActions.setFilePriority(hash, {
indices: [Number(fileIndex)],
priority: priorityLevel,
});
};
handleFileSelect = (file: TorrentContent, isSelected: boolean): void => {
const {depth, onItemSelect} = this.props;
onItemSelect({
type: 'file',
depth,
path: this.getCurrentPath(file),
select: !isSelected,
});
};
render(): ReactNode {
const {items, hash} = this.props;
if (items == null) {
return null;
}
const files = Object.values(items)
.sort((a, b) => a.filename.localeCompare(b.filename))
.map((file) => {
const {copiedToClipboard} = this.state;
const isSelected = (items && items[file.filename] && items[file.filename].isSelected) || false;
const classes = classnames(
'directory-tree__node file',
'directory-tree__node--file directory-tree__node--selectable',
{
'directory-tree__node--selected': isSelected,
},
);
return (
<div className={classes} key={file.filename} title={file.filename}>
<div className="file__label file__detail">
{this.getIcon(file, isSelected)}
<div className="file__name">
{/* TODO: Add a WebAssembly decoding player if the feature is popular */}
<a
href={`${ConfigStore.baseURI}api/torrents/${hash}/contents/${file.index}/data`}
style={{textDecoration: 'none'}}
target="_blank"
rel="noreferrer">
{file.filename}
</a>
return (
<div className={classes} key={file.filename} title={file.filename}>
<div className="file__label file__detail">
<div className="file__checkbox directory-tree__checkbox">
<div className="directory-tree__checkbox__item directory-tree__checkbox__item--checkbox">
<Checkbox
checked={isSelected}
id={`${file.index}`}
onClick={() =>
onItemSelect({
type: 'file',
depth,
path: [...path, file.filename],
select: !isSelected,
})
}
/>
</div>
<div className="directory-tree__checkbox__item directory-tree__checkbox__item--icon">
<FileIcon />
</div>
</div>
<div className="file__detail file__detail--secondary">
<Size value={file.sizeBytes} precision={1} />
<div className="file__name">
{/* TODO: Add a WebAssembly decoding player if the feature is popular */}
<a
href={`${ConfigStore.baseURI}api/torrents/${hash}/contents/${file.index}/data`}
style={{textDecoration: 'none'}}
target="_blank"
rel="noreferrer">
{file.filename}
</a>
</div>
<div className="file__detail file__detail--secondary">{Math.trunc(file.percentComplete)}%</div>
<div
className="file__detail file__detail--secondary
file__detail--priority">
<PriorityMeter
key={`${file.index}-${file.filename}`}
level={file.priority}
id={file.index}
maxLevel={2}
onChange={this.handlePriorityChange}
priorityType="file"
/>
</div>
{typeof navigator.clipboard?.writeText === 'function' && (
<button
className="file__detail file__detail--secondary file__detail--clipboard"
type="button"
onClick={() => {
const copy = (link: string): void => {
if (link !== '') {
if (typeof navigator.share === 'function') {
navigator
.share({
title: file.filename,
url: link,
})
.then(() => {
this.setState({
copiedToClipboard: file.index,
});
});
} else {
navigator.clipboard.writeText(link).then(() => {
this.setState({
copiedToClipboard: file.index,
});
});
}
}
};
// Safari does not support async operations inside "user gesture" handler.
// Otherwise the write to clipboard will be rejected for "security reasons".
// As such, we cache the token, so next click can be synchronous. Incompatible
// morons make everyone's life hard.
const link = this.contentPermalinks[file.index];
if (link != null) {
copy(link);
} else {
this.contentPermalinks[file.index] = '';
TorrentActions.getTorrentContentsDataPermalink(hash, [file.index]).then(
(url) => {
this.contentPermalinks[file.index] = url;
copy(url);
},
() => {
this.contentPermalinks[file.index] = null;
},
);
}
}}>
{copiedToClipboard === file.index ? <Checkmark /> : <Clipboard />}
</button>
)}
</div>
);
});
<div className="file__detail file__detail--secondary">
<Size value={file.sizeBytes} precision={1} />
</div>
<div className="file__detail file__detail--secondary">{Math.trunc(file.percentComplete)}%</div>
<div
className="file__detail file__detail--secondary
file__detail--priority">
<PriorityMeter
key={`${file.index}-${file.filename}`}
level={file.priority}
id={file.index}
maxLevel={2}
onChange={(fileIndex: ReactText, priorityLevel: number) =>
TorrentActions.setFilePriority(hash, {
indices: [Number(fileIndex)],
priority: priorityLevel,
})
}
priorityType="file"
/>
</div>
{typeof navigator.clipboard?.writeText === 'function' && (
<button
className="file__detail file__detail--secondary file__detail--clipboard"
type="button"
onClick={() => {
const copy = (link: string): void => {
if (link !== '') {
if (typeof navigator.share === 'function') {
navigator
.share({
title: file.filename,
url: link,
})
.then(() => setCopiedToClipboard(file.index));
} else {
navigator.clipboard.writeText(link).then(() => setCopiedToClipboard(file.index));
}
}
};
// Safari does not support async operations inside "user gesture" handler.
// Otherwise the write to clipboard will be rejected for "security reasons".
// As such, we cache the token, so next click can be synchronous. Incompatible
// morons make everyone's life hard.
const link = contentPermalinks.current[file.index];
if (link != null) {
copy(link);
} else {
contentPermalinks.current[file.index] = '';
TorrentActions.getTorrentContentsDataPermalink(hash, [file.index]).then(
(url) => {
contentPermalinks.current[file.index] = url;
copy(url);
},
() => {
contentPermalinks.current[file.index] = null;
},
);
}
}}>
{copiedToClipboard === file.index ? <Checkmark /> : <Clipboard />}
</button>
)}
</div>
);
});
return <div className="directory-tree__node directory-tree__node--file-list">{files}</div>;
}
}
return <div className="directory-tree__node directory-tree__node--file-list">{files}</div>;
};
export default DirectoryFiles;
@@ -9,8 +9,8 @@ import DirectoryFileList from './DirectoryFileList';
import DirectoryTreeNode from './DirectoryTreeNode';
interface DirectoryTreeProps {
depth?: number;
path?: Array<string>;
depth: number;
path: Array<string>;
hash: TorrentProperties['hash'];
itemsTree: TorrentContentSelectionTree;
onItemSelect: (selection: TorrentContentSelection) => void;
@@ -68,9 +68,4 @@ const DirectoryTree: FC<DirectoryTreeProps> = (props: DirectoryTreeProps) => {
return <div className="directory-tree__tree">{directoryNodes.concat(fileList)}</div>;
};
DirectoryTree.defaultProps = {
depth: 0,
path: [],
};
export default DirectoryTree;
@@ -1,5 +1,5 @@
import classnames from 'classnames';
import {Component, ReactNode} from 'react';
import {FC, useState} from 'react';
import {Checkbox} from '@client/ui';
import {FolderClosedSolid, FolderOpenSolid} from '@client/ui/icons';
@@ -22,138 +22,87 @@ interface DirectoryTreeNodeProps {
onItemSelect: (selection: TorrentContentSelection) => void;
}
interface DirectoryTreeNodeStates {
expanded: boolean;
}
const DirectoryTreeNode: FC<DirectoryTreeNodeProps> = ({
depth,
directoryName,
id,
itemsTree,
hash,
path,
isSelected,
onItemSelect,
}: DirectoryTreeNodeProps) => {
const [expanded, setExpanded] = useState<boolean>(false);
const currentPath = [...path, directoryName];
class DirectoryTreeNode extends Component<DirectoryTreeNodeProps, DirectoryTreeNodeStates> {
static defaultProps = {
path: [],
selectedItems: {},
};
constructor(props: DirectoryTreeNodeProps) {
super(props);
this.state = {
expanded: false,
};
}
getCurrentPath(): string[] {
const {path, directoryName} = this.props;
return [...path, directoryName];
}
getIcon(): ReactNode {
const {id, isSelected} = this.props;
const {expanded} = this.state;
let icon = null;
if (expanded) {
icon = <FolderOpenSolid />;
} else {
icon = <FolderClosedSolid />;
}
return (
<div className="file__checkbox directory-tree__checkbox">
<div
className="directory-tree__checkbox__item
return (
<div
className={classnames('directory-tree__branch', `directory-tree__branch--depth-${depth}`, {
'directory-tree__node--selected': isSelected,
})}>
<button
className={classnames(
'directory-tree__node',
'directory-tree__node--selectable directory-tree__node--directory',
{
'is-expanded': expanded,
},
)}
css={{
width: '100%',
textAlign: 'left',
':focus': {
outline: 'none',
WebkitTapHighlightColor: 'transparent',
},
':focus-visible': {
outline: 'dashed',
},
}}
type="button"
onClick={() => setExpanded(!expanded)}
title={directoryName}>
<div className="file__label">
<div className="file__checkbox directory-tree__checkbox">
<div
className="directory-tree__checkbox__item
directory-tree__checkbox__item--checkbox">
<Checkbox checked={isSelected} id={id} onClick={this.handleDirectorySelection} />
</div>
<div
className="directory-tree__checkbox__item
<Checkbox
checked={isSelected}
id={id}
onClick={() =>
onItemSelect({
type: 'directory',
depth,
path: currentPath,
select: !isSelected,
})
}
/>
</div>
<div
className="directory-tree__checkbox__item
directory-tree__checkbox__item--icon">
{icon}
{expanded ? <FolderOpenSolid /> : <FolderClosedSolid />}
</div>
</div>
<div className="file__name">{directoryName}</div>
</div>
</div>
);
}
getSubTree(): ReactNode {
const {depth, itemsTree, hash, onItemSelect} = this.props;
const {expanded} = this.state;
if (expanded) {
return (
</button>
{expanded ? (
<div className="directory-tree__node directory-tree__node--group">
<DirectoryTree
depth={depth}
hash={hash}
key={`${expanded}-${depth}`}
onItemSelect={onItemSelect}
path={this.getCurrentPath()}
path={currentPath}
itemsTree={itemsTree}
/>
</div>
);
}
return null;
}
handleDirectoryClick = (): void => {
this.setState((state) => ({
expanded: !state.expanded,
}));
};
handleDirectorySelection = (): void => {
const {depth, isSelected, onItemSelect} = this.props;
onItemSelect({
type: 'directory',
depth,
path: this.getCurrentPath(),
select: !isSelected,
});
};
render(): ReactNode {
const {depth, directoryName, isSelected} = this.props;
const {expanded} = this.state;
const branchClasses = classnames('directory-tree__branch', `directory-tree__branch--depth-${depth}`, {
'directory-tree__node--selected': isSelected,
});
const directoryClasses = classnames(
'directory-tree__node',
'directory-tree__node--selectable directory-tree__node--directory',
{
'is-expanded': expanded,
},
);
return (
<div className={branchClasses}>
<button
className={directoryClasses}
css={{
width: '100%',
textAlign: 'left',
':focus': {
outline: 'none',
WebkitTapHighlightColor: 'transparent',
},
':focus-visible': {
outline: 'dashed',
},
}}
type="button"
onClick={this.handleDirectoryClick}
title={directoryName}>
<div className="file__label">
{this.getIcon()}
<div className="file__name">{directoryName}</div>
</div>
</button>
{this.getSubTree()}
</div>
);
}
}
) : null}
</div>
);
};
export default DirectoryTreeNode;
@@ -79,6 +79,7 @@ const TorrentContents: FC = observer(() => {
setSelectedIndices(selectionTree.getSelectedItems(newItemsTree));
}}
hash={hash}
path={[]}
itemsTree={itemsTree}
/>
);