273 lines
7.4 KiB
TypeScript
273 lines
7.4 KiB
TypeScript
import { useContext, useMemo, useState } from "react";
|
|
import { TorrentDetails, TorrentStats } from "../api-types";
|
|
import { FormCheckbox } from "./forms/FormCheckbox";
|
|
import { CiSquarePlus, CiSquareMinus } from "react-icons/ci";
|
|
import { IconButton } from "./buttons/IconButton";
|
|
import { formatBytes } from "../helper/formatBytes";
|
|
import { ProgressBar } from "./ProgressBar";
|
|
import sortBy from "lodash.sortby";
|
|
import { APIContext } from "../context";
|
|
|
|
type TorrentFileForCheckbox = {
|
|
id: number;
|
|
filename: string;
|
|
pathComponents: string[];
|
|
length: number;
|
|
have_bytes: number;
|
|
};
|
|
|
|
type FileTree = {
|
|
id: string;
|
|
name: string;
|
|
dirs: FileTree[];
|
|
files: TorrentFileForCheckbox[];
|
|
};
|
|
|
|
const newFileTree = (
|
|
torrentDetails: TorrentDetails,
|
|
stats: TorrentStats | null,
|
|
): FileTree => {
|
|
const newFileTreeInner = (
|
|
name: string,
|
|
id: string,
|
|
files: TorrentFileForCheckbox[],
|
|
depth: number,
|
|
): FileTree => {
|
|
let directFiles: TorrentFileForCheckbox[] = [];
|
|
let groups: FileTree[] = [];
|
|
let groupsByName: { [key: string]: TorrentFileForCheckbox[] } = {};
|
|
|
|
const getGroup = (prefix: string): TorrentFileForCheckbox[] => {
|
|
groupsByName[prefix] = groupsByName[prefix] || [];
|
|
return groupsByName[prefix];
|
|
};
|
|
|
|
files.forEach((file: TorrentFileForCheckbox) => {
|
|
if (depth == file.pathComponents.length - 1) {
|
|
directFiles.push(file);
|
|
return;
|
|
}
|
|
getGroup(file.pathComponents[depth]).push(file);
|
|
});
|
|
|
|
directFiles = sortBy(directFiles, (f) => f.filename);
|
|
|
|
let sortedGroupsByName = sortBy(
|
|
Object.entries(groupsByName),
|
|
([k, _]) => k,
|
|
);
|
|
|
|
let childId = 0;
|
|
for (const [key, value] of sortedGroupsByName) {
|
|
groups.push(newFileTreeInner(key, id + "." + childId, value, depth + 1));
|
|
childId += 1;
|
|
}
|
|
return {
|
|
name,
|
|
id,
|
|
dirs: groups,
|
|
files: directFiles,
|
|
};
|
|
};
|
|
|
|
return newFileTreeInner(
|
|
"",
|
|
"filetree-root",
|
|
torrentDetails.files
|
|
.map((file, id) => {
|
|
if (file.attributes.padding) {
|
|
return null;
|
|
}
|
|
return {
|
|
id,
|
|
filename: file.components[file.components.length - 1],
|
|
pathComponents: file.components,
|
|
length: file.length,
|
|
have_bytes: stats ? (stats.file_progress[id] ?? 0) : 0,
|
|
};
|
|
})
|
|
.filter((f) => f !== null),
|
|
0,
|
|
);
|
|
};
|
|
|
|
const FileTreeComponent: React.FC<{
|
|
torrentId?: number;
|
|
tree: FileTree;
|
|
torrentDetails: TorrentDetails;
|
|
torrentStats: TorrentStats | null;
|
|
selectedFiles: Set<number>;
|
|
setSelectedFiles: (_: Set<number>) => void;
|
|
initialExpanded: boolean;
|
|
showProgressBar?: boolean;
|
|
disabled?: boolean;
|
|
allowStream?: boolean;
|
|
}> = ({
|
|
torrentId,
|
|
tree,
|
|
selectedFiles,
|
|
setSelectedFiles,
|
|
initialExpanded,
|
|
torrentDetails,
|
|
torrentStats,
|
|
showProgressBar,
|
|
disabled,
|
|
allowStream,
|
|
}) => {
|
|
const API = useContext(APIContext);
|
|
let [expanded, setExpanded] = useState(initialExpanded);
|
|
let children = useMemo(() => {
|
|
let getAllChildren = (tree: FileTree): number[] => {
|
|
let children = tree.dirs.flatMap(getAllChildren);
|
|
children.push(...tree.files.map((file) => file.id));
|
|
return children;
|
|
};
|
|
return getAllChildren(tree);
|
|
}, [tree]);
|
|
|
|
const handleToggleTree: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
|
if (e.target.checked) {
|
|
let copy = new Set(selectedFiles);
|
|
children.forEach((c) => copy.add(c));
|
|
setSelectedFiles(copy);
|
|
} else {
|
|
let copy = new Set(selectedFiles);
|
|
children.forEach((c) => copy.delete(c));
|
|
setSelectedFiles(copy);
|
|
}
|
|
};
|
|
|
|
const handleToggleFile = (toggledId: number) => {
|
|
if (selectedFiles.has(toggledId)) {
|
|
let copy = new Set(selectedFiles);
|
|
copy.delete(toggledId);
|
|
setSelectedFiles(copy);
|
|
} else {
|
|
let copy = new Set(selectedFiles);
|
|
copy.add(toggledId);
|
|
setSelectedFiles(copy);
|
|
}
|
|
};
|
|
|
|
const getTotalSelectedFiles = () => {
|
|
return children.filter((c) => selectedFiles.has(c)).length;
|
|
};
|
|
|
|
const getTotalSelectedBytes = () => {
|
|
return children
|
|
.filter((c) => selectedFiles.has(c))
|
|
.map((c) => torrentDetails.files[c].length)
|
|
.reduce((a, b) => a + b, 0);
|
|
};
|
|
|
|
const fileLink = (file: TorrentFileForCheckbox) => {
|
|
if (allowStream && torrentId != null) {
|
|
return API.getTorrentStreamUrl(torrentId, file.id, file.filename);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="flex items-center">
|
|
<IconButton onClick={() => setExpanded(!expanded)}>
|
|
{expanded ? <CiSquareMinus /> : <CiSquarePlus />}
|
|
</IconButton>
|
|
<FormCheckbox
|
|
checked={children.every((c) => selectedFiles.has(c))}
|
|
label={`${
|
|
tree.name ? tree.name + ", " : ""
|
|
} ${getTotalSelectedFiles()} files, ${formatBytes(
|
|
getTotalSelectedBytes(),
|
|
)}`}
|
|
name={tree.id}
|
|
onChange={handleToggleTree}
|
|
></FormCheckbox>
|
|
</div>
|
|
|
|
<div className="pl-5" hidden={!expanded}>
|
|
{tree.dirs.map((dir) => (
|
|
<FileTreeComponent
|
|
torrentId={torrentId}
|
|
torrentDetails={torrentDetails}
|
|
torrentStats={torrentStats}
|
|
key={dir.name}
|
|
tree={dir}
|
|
selectedFiles={selectedFiles}
|
|
setSelectedFiles={setSelectedFiles}
|
|
initialExpanded={false}
|
|
showProgressBar={showProgressBar}
|
|
disabled={disabled}
|
|
allowStream={allowStream}
|
|
/>
|
|
))}
|
|
<div className="pl-1">
|
|
{tree.files.map((file) => (
|
|
<div
|
|
key={file.id}
|
|
className={`${
|
|
showProgressBar
|
|
? "grid grid-cols-1 gap-1 items-start lg:grid-cols-2 mb-2 lg:mb-0"
|
|
: ""
|
|
}`}
|
|
>
|
|
<FormCheckbox
|
|
checked={selectedFiles.has(file.id)}
|
|
label={`${file.filename} (${formatBytes(file.length)})`}
|
|
name={`torrent-${torrentId}-file-${file.id}`}
|
|
disabled={disabled}
|
|
onChange={() => handleToggleFile(file.id)}
|
|
labelLink={fileLink(file)}
|
|
></FormCheckbox>
|
|
{showProgressBar && (
|
|
<ProgressBar
|
|
now={(file.have_bytes / file.length) * 100}
|
|
variant={file.have_bytes == file.length ? "success" : "info"}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export const FileListInput: React.FC<{
|
|
torrentId?: number;
|
|
torrentDetails: TorrentDetails;
|
|
torrentStats: TorrentStats | null;
|
|
selectedFiles: Set<number>;
|
|
setSelectedFiles: (_: Set<number>) => void;
|
|
showProgressBar?: boolean;
|
|
disabled?: boolean;
|
|
allowStream?: boolean;
|
|
}> = ({
|
|
torrentId,
|
|
torrentDetails,
|
|
selectedFiles,
|
|
setSelectedFiles,
|
|
torrentStats,
|
|
showProgressBar,
|
|
disabled,
|
|
allowStream,
|
|
}) => {
|
|
let fileTree = useMemo(
|
|
() => newFileTree(torrentDetails, torrentStats),
|
|
[torrentDetails, torrentStats],
|
|
);
|
|
|
|
return (
|
|
<FileTreeComponent
|
|
torrentId={torrentId}
|
|
torrentDetails={torrentDetails}
|
|
torrentStats={torrentStats}
|
|
tree={fileTree}
|
|
selectedFiles={selectedFiles}
|
|
setSelectedFiles={setSelectedFiles}
|
|
initialExpanded={true}
|
|
showProgressBar={showProgressBar}
|
|
disabled={disabled}
|
|
allowStream={allowStream}
|
|
/>
|
|
);
|
|
};
|