A widget to select files better when there are many

This commit is contained in:
Igor Katson 2023-12-17 11:18:17 +00:00
parent ee307c11c5
commit ccc19f9e1a
No known key found for this signature in database
GPG key ID: B4EC22B66D61A3F5
6 changed files with 229 additions and 54 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,14 +4,14 @@
"src": "assets/logo.svg"
},
"index.css": {
"file": "assets/index-3adfdbb8.css",
"file": "assets/index-87c84a72.css",
"src": "index.css"
},
"index.html": {
"css": [
"assets/index-3adfdbb8.css"
"assets/index-87c84a72.css"
],
"file": "assets/index-1b48a174.js",
"file": "assets/index-5a52a5c4.js",
"isEntry": true,
"src": "index.html"
}

View file

@ -0,0 +1,203 @@
import { useMemo, useState } from "react";
import { AddTorrentResponse } 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";
type TorrentFileForCheckbox = {
id: number;
name: string;
length: number;
};
type FileTree = {
id: string;
name: string;
dirs: FileTree[];
files: TorrentFileForCheckbox[];
};
const splitOnce = (s: string, sep: string): [string, string | undefined] => {
if (s.indexOf(sep) === -1) {
return [s, undefined];
}
return [s.slice(0, s.indexOf(sep)), s.slice(s.indexOf(sep) + 1)];
};
const newFileTree = (listTorrentResponse: AddTorrentResponse): FileTree => {
const separator = "/";
const newFileTreeInner = (
name: string,
id: string,
files: TorrentFileForCheckbox[]
): 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) => {
let [prefix, name] = splitOnce(file.name, separator);
if (name === undefined) {
directFiles.push(file);
return;
}
getGroup(prefix).push({
id: file.id,
name: name,
length: file.length,
});
});
let childId = 0;
for (const [key, value] of Object.entries(groupsByName)) {
groups.push(newFileTreeInner(key, id + "." + childId, value));
childId += 1;
}
return {
name,
id,
dirs: groups,
files: directFiles,
};
};
return newFileTreeInner(
"",
"filetree-root",
listTorrentResponse.details.files.map((data, id) => {
return { id, name: data.name, length: data.length };
})
);
};
const FileTreeComponent: React.FC<{
tree: FileTree;
listTorrentResponse: AddTorrentResponse;
selectedFiles: Set<number>;
setSelectedFiles: React.Dispatch<React.SetStateAction<Set<number>>>;
initialExpanded: boolean;
}> = ({
tree,
selectedFiles,
setSelectedFiles,
initialExpanded,
listTorrentResponse,
}) => {
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) => listTorrentResponse.details.files[c].length)
.reduce((a, b) => a + b, 0);
};
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
listTorrentResponse={listTorrentResponse}
key={dir.name}
tree={dir}
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
initialExpanded={false}
/>
))}
<div className="pl-1">
{tree.files.map((file) => (
<FormCheckbox
checked={selectedFiles.has(file.id)}
key={file.id}
label={`${file.name} (${formatBytes(file.length)})`}
name={`file-${file.id}`}
onChange={() => handleToggleFile(file.id)}
></FormCheckbox>
))}
</div>
</div>
</>
);
};
export const FileListInput: React.FC<{
listTorrentResponse: AddTorrentResponse;
selectedFiles: Set<number>;
setSelectedFiles: React.Dispatch<React.SetStateAction<Set<number>>>;
}> = ({ listTorrentResponse, selectedFiles, setSelectedFiles }) => {
let fileTree = useMemo(
() => newFileTree(listTorrentResponse),
[listTorrentResponse]
);
return (
<>
<FileTreeComponent
listTorrentResponse={listTorrentResponse}
tree={fileTree}
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
initialExpanded={true}
/>
</>
);
};

View file

@ -19,7 +19,7 @@ export const IconButton: React.FC<{
const colorClassName = color ? `text-${color}` : "";
return (
<a
className={`block p-1 text-blue-500 ${colorClassName} ${className}`}
className={`block p-1 text-blue-500 flex items-center justify-center ${colorClassName} ${className}`}
onClick={onClickStopPropagation}
href="#"
{...otherProps}

View file

@ -13,6 +13,7 @@ import { FormCheckbox } from "../forms/FormCheckbox";
import { Fieldset } from "../forms/Fieldset";
import { FormInput } from "../forms/FormInput";
import { Form } from "../forms/Form";
import { FileListInput } from "../FileListInput";
export const FileSelectionModal = (props: {
onHide: () => void;
@ -29,7 +30,7 @@ export const FileSelectionModal = (props: {
data,
} = props;
const [selectedFiles, setSelectedFiles] = useState<number[]>([]);
const [selectedFiles, setSelectedFiles] = useState<Set<number>>(new Set());
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<ErrorWithLabel | null>(null);
const [unpopularTorrent, setUnpopularTorrent] = useState(false);
@ -37,35 +38,21 @@ export const FileSelectionModal = (props: {
const ctx = useContext(AppContext);
const API = useContext(APIContext);
const selectAll = () => {
setSelectedFiles(
listTorrentResponse
? listTorrentResponse.details.files.map((_, id) => id)
: []
);
};
useEffect(() => {
console.log(listTorrentResponse);
selectAll();
setSelectedFiles(
new Set(listTorrentResponse?.details.files.map((_, i) => i))
);
setOutputFolder(listTorrentResponse?.output_folder || "");
}, [listTorrentResponse]);
const clear = () => {
onHide();
setSelectedFiles([]);
setSelectedFiles(new Set());
setUploadError(null);
setUploading(false);
};
const handleToggleFile = (toggledId: number) => {
if (selectedFiles.includes(toggledId)) {
setSelectedFiles(selectedFiles.filter((i) => i !== toggledId));
} else {
setSelectedFiles([...selectedFiles, toggledId]);
}
};
const handleUpload = async () => {
if (!listTorrentResponse) {
return;
@ -76,7 +63,7 @@ export const FileSelectionModal = (props: {
: null;
let opts: AddTorrentOptions = {
overwrite: true,
only_files: selectedFiles,
only_files: Array.from(selectedFiles),
initial_peers: initialPeers,
output_folder: outputFolder,
};
@ -116,24 +103,11 @@ export const FileSelectionModal = (props: {
/>
<Fieldset>
<label className="text-sm mb-2 block">Select files</label>
<div className="mb-3 flex gap-2">
<Button onClick={selectAll} className="text-sm">
Select all
</Button>
<Button onClick={() => setSelectedFiles([])} className="text-sm">
Deselect all
</Button>
</div>
{listTorrentResponse.details.files.map((file, index) => (
<FormCheckbox
key={index}
label={`${file.name} (${formatBytes(file.length)})`}
checked={selectedFiles.includes(index)}
onChange={() => handleToggleFile(index)}
name={`check-${index}`}
/>
))}
<FileListInput
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
listTorrentResponse={listTorrentResponse}
/>
</Fieldset>
{/* <Fieldset label="Options">
@ -163,9 +137,7 @@ export const FileSelectionModal = (props: {
<Button
onClick={handleUpload}
variant="primary"
disabled={
listTorrentLoading || uploading || selectedFiles.length == 0
}
disabled={listTorrentLoading || uploading || selectedFiles.size == 0}
>
OK
</Button>