Possibility to change selected files after the fact
This commit is contained in:
parent
feae1789a9
commit
86d9d2c5f0
11 changed files with 186 additions and 52 deletions
20
crates/librqbit/webui/dist/assets/index.js
vendored
20
crates/librqbit/webui/dist/assets/index.js
vendored
File diff suppressed because one or more lines are too long
2
crates/librqbit/webui/dist/manifest.json
vendored
2
crates/librqbit/webui/dist/manifest.json
vendored
|
|
@ -11,7 +11,7 @@
|
|||
"css": [
|
||||
"assets/index-d46108e9.css"
|
||||
],
|
||||
"file": "assets/index-1ee4d2cc.js",
|
||||
"file": "assets/index-87e26627.js",
|
||||
"isEntry": true,
|
||||
"src": "index.html"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,10 +167,11 @@ export interface RqbitAPI {
|
|||
getTorrentStats: (index: number) => Promise<TorrentStats>;
|
||||
uploadTorrent: (
|
||||
data: string | File,
|
||||
opts?: AddTorrentOptions
|
||||
opts?: AddTorrentOptions,
|
||||
) => Promise<AddTorrentResponse>;
|
||||
|
||||
pause: (index: number) => Promise<void>;
|
||||
updateOnlyFiles: (index: number, files: number[]) => Promise<void>;
|
||||
start: (index: number) => Promise<void>;
|
||||
forget: (index: number) => Promise<void>;
|
||||
delete: (index: number) => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { AddTorrentResponse, TorrentFile } from "../api-types";
|
||||
import { TorrentDetails } from "../api-types";
|
||||
import { FormCheckbox } from "./forms/FormCheckbox";
|
||||
import { CiSquarePlus, CiSquareMinus } from "react-icons/ci";
|
||||
import { IconButton } from "./buttons/IconButton";
|
||||
|
|
@ -19,12 +19,12 @@ type FileTree = {
|
|||
files: TorrentFileForCheckbox[];
|
||||
};
|
||||
|
||||
const newFileTree = (listTorrentResponse: AddTorrentResponse): FileTree => {
|
||||
const newFileTree = (torrentDetails: TorrentDetails): FileTree => {
|
||||
const newFileTreeInner = (
|
||||
name: string,
|
||||
id: string,
|
||||
files: TorrentFileForCheckbox[],
|
||||
depth: number
|
||||
depth: number,
|
||||
): FileTree => {
|
||||
let directFiles: TorrentFileForCheckbox[] = [];
|
||||
let groups: FileTree[] = [];
|
||||
|
|
@ -59,7 +59,7 @@ const newFileTree = (listTorrentResponse: AddTorrentResponse): FileTree => {
|
|||
return newFileTreeInner(
|
||||
"",
|
||||
"filetree-root",
|
||||
listTorrentResponse.details.files.map((file, id) => {
|
||||
torrentDetails.files.map((file, id) => {
|
||||
return {
|
||||
id,
|
||||
filename: file.components[file.components.length - 1],
|
||||
|
|
@ -67,13 +67,13 @@ const newFileTree = (listTorrentResponse: AddTorrentResponse): FileTree => {
|
|||
length: file.length,
|
||||
};
|
||||
}),
|
||||
0
|
||||
0,
|
||||
);
|
||||
};
|
||||
|
||||
const FileTreeComponent: React.FC<{
|
||||
tree: FileTree;
|
||||
listTorrentResponse: AddTorrentResponse;
|
||||
torrentDetails: TorrentDetails;
|
||||
selectedFiles: Set<number>;
|
||||
setSelectedFiles: React.Dispatch<React.SetStateAction<Set<number>>>;
|
||||
initialExpanded: boolean;
|
||||
|
|
@ -82,7 +82,7 @@ const FileTreeComponent: React.FC<{
|
|||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
initialExpanded,
|
||||
listTorrentResponse,
|
||||
torrentDetails,
|
||||
}) => {
|
||||
let [expanded, setExpanded] = useState(initialExpanded);
|
||||
let children = useMemo(() => {
|
||||
|
|
@ -125,7 +125,7 @@ const FileTreeComponent: React.FC<{
|
|||
const getTotalSelectedBytes = () => {
|
||||
return children
|
||||
.filter((c) => selectedFiles.has(c))
|
||||
.map((c) => listTorrentResponse.details.files[c].length)
|
||||
.map((c) => torrentDetails.files[c].length)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
};
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ const FileTreeComponent: React.FC<{
|
|||
label={`${
|
||||
tree.name ? tree.name + ", " : ""
|
||||
} ${getTotalSelectedFiles()} files, ${formatBytes(
|
||||
getTotalSelectedBytes()
|
||||
getTotalSelectedBytes(),
|
||||
)}`}
|
||||
name={tree.id}
|
||||
onChange={handleToggleTree}
|
||||
|
|
@ -150,7 +150,7 @@ const FileTreeComponent: React.FC<{
|
|||
<div className="pl-5" hidden={!expanded}>
|
||||
{tree.dirs.map((dir) => (
|
||||
<FileTreeComponent
|
||||
listTorrentResponse={listTorrentResponse}
|
||||
torrentDetails={torrentDetails}
|
||||
key={dir.name}
|
||||
tree={dir}
|
||||
selectedFiles={selectedFiles}
|
||||
|
|
@ -175,19 +175,16 @@ const FileTreeComponent: React.FC<{
|
|||
};
|
||||
|
||||
export const FileListInput: React.FC<{
|
||||
listTorrentResponse: AddTorrentResponse;
|
||||
torrentDetails: TorrentDetails;
|
||||
selectedFiles: Set<number>;
|
||||
setSelectedFiles: React.Dispatch<React.SetStateAction<Set<number>>>;
|
||||
}> = ({ listTorrentResponse, selectedFiles, setSelectedFiles }) => {
|
||||
let fileTree = useMemo(
|
||||
() => newFileTree(listTorrentResponse),
|
||||
[listTorrentResponse]
|
||||
);
|
||||
}> = ({ torrentDetails, selectedFiles, setSelectedFiles }) => {
|
||||
let fileTree = useMemo(() => newFileTree(torrentDetails), [torrentDetails]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileTreeComponent
|
||||
listTorrentResponse={listTorrentResponse}
|
||||
torrentDetails={torrentDetails}
|
||||
tree={fileTree}
|
||||
selectedFiles={selectedFiles}
|
||||
setSelectedFiles={setSelectedFiles}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const Torrent: React.FC<{
|
|||
const [detailsResponse, updateDetailsResponse] =
|
||||
useState<TorrentDetails | null>(null);
|
||||
const [statsResponse, updateStatsResponse] = useState<TorrentStats | null>(
|
||||
null
|
||||
null,
|
||||
);
|
||||
const [forceStatsRefresh, setForceStatsRefresh] = useState(0);
|
||||
const API = useContext(APIContext);
|
||||
|
|
@ -27,14 +27,12 @@ export const Torrent: React.FC<{
|
|||
setForceStatsRefresh(forceStatsRefresh + 1);
|
||||
};
|
||||
|
||||
// Update details once.
|
||||
// Update details once then when asked for.
|
||||
useEffect(() => {
|
||||
if (detailsResponse === null) {
|
||||
return loopUntilSuccess(async () => {
|
||||
await API.getTorrentDetails(torrent.id).then(updateDetailsResponse);
|
||||
}, 1000);
|
||||
}
|
||||
}, [detailsResponse]);
|
||||
return loopUntilSuccess(async () => {
|
||||
await API.getTorrentDetails(torrent.id).then(updateDetailsResponse);
|
||||
}, 1000);
|
||||
}, [forceStatsRefresh]);
|
||||
|
||||
// Update stats once then forever.
|
||||
useEffect(
|
||||
|
|
@ -61,10 +59,10 @@ export const Torrent: React.FC<{
|
|||
},
|
||||
() => {
|
||||
return errorInterval;
|
||||
}
|
||||
},
|
||||
);
|
||||
}, 0),
|
||||
[forceStatsRefresh]
|
||||
[forceStatsRefresh],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -105,7 +105,11 @@ export const TorrentRow: React.FC<{
|
|||
{/* Actions */}
|
||||
{statsResponse && (
|
||||
<div className="">
|
||||
<TorrentActions id={id} statsResponse={statsResponse} />
|
||||
<TorrentActions
|
||||
id={id}
|
||||
detailsResponse={detailsResponse}
|
||||
statsResponse={statsResponse}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,28 @@
|
|||
import { useContext, useState } from "react";
|
||||
import { TorrentStats } from "../../api-types";
|
||||
import { TorrentDetails, TorrentStats } from "../../api-types";
|
||||
import { APIContext, RefreshTorrentStatsContext } from "../../context";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { DeleteTorrentModal } from "../modal/DeleteTorrentModal";
|
||||
import { FaPause, FaPlay, FaTrash } from "react-icons/fa";
|
||||
import { TorrentSettingsModal } from "../modal/TorrentSettingsModal";
|
||||
import { FaCog, FaPause, FaPlay, FaTrash } from "react-icons/fa";
|
||||
import { useErrorStore } from "../../stores/errorStore";
|
||||
|
||||
export const TorrentActions: React.FC<{
|
||||
id: number;
|
||||
detailsResponse: TorrentDetails | null;
|
||||
statsResponse: TorrentStats;
|
||||
}> = ({ id, statsResponse }) => {
|
||||
}> = ({ id, detailsResponse, statsResponse }) => {
|
||||
let state = statsResponse.state;
|
||||
|
||||
let [disabled, setDisabled] = useState<boolean>(false);
|
||||
let [deleting, setDeleting] = useState<boolean>(false);
|
||||
let [configuring, setConfiguring] = useState<boolean>(false);
|
||||
|
||||
let refreshCtx = useContext(RefreshTorrentStatsContext);
|
||||
|
||||
const canPause = state == "live";
|
||||
const canUnpause = state == "paused" || state == "error";
|
||||
const canConfigure = state == "paused" || state == "live";
|
||||
|
||||
const setCloseableError = useErrorStore((state) => state.setCloseableError);
|
||||
|
||||
|
|
@ -36,7 +40,7 @@ export const TorrentActions: React.FC<{
|
|||
text: `Error starting torrent id=${id}`,
|
||||
details: e,
|
||||
});
|
||||
}
|
||||
},
|
||||
)
|
||||
.finally(() => setDisabled(false));
|
||||
};
|
||||
|
|
@ -53,11 +57,15 @@ export const TorrentActions: React.FC<{
|
|||
text: `Error pausing torrent id=${id}`,
|
||||
details: e,
|
||||
});
|
||||
}
|
||||
},
|
||||
)
|
||||
.finally(() => setDisabled(false));
|
||||
};
|
||||
|
||||
const openConfigureModal = () => {
|
||||
setConfiguring(true);
|
||||
};
|
||||
|
||||
const startDeleting = () => {
|
||||
setDisabled(true);
|
||||
setDeleting(true);
|
||||
|
|
@ -80,10 +88,23 @@ export const TorrentActions: React.FC<{
|
|||
<FaPause className="hover:text-amber-500" />
|
||||
</IconButton>
|
||||
)}
|
||||
{canConfigure && (
|
||||
<IconButton onClick={openConfigureModal} disabled={disabled}>
|
||||
<FaCog className="hover:text-green-600" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton onClick={startDeleting} disabled={disabled}>
|
||||
<FaTrash className="hover:text-red-500" />
|
||||
</IconButton>
|
||||
<DeleteTorrentModal id={id} show={deleting} onHide={cancelDeleting} />
|
||||
{detailsResponse && configuring && (
|
||||
<TorrentSettingsModal
|
||||
id={id}
|
||||
show={configuring}
|
||||
details={detailsResponse}
|
||||
onHide={() => setConfiguring(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export const FileSelectionModal = (props: {
|
|||
|
||||
useEffect(() => {
|
||||
setSelectedFiles(
|
||||
new Set(listTorrentResponse?.details.files.map((_, i) => i))
|
||||
new Set(listTorrentResponse?.details.files.map((_, i) => i)),
|
||||
);
|
||||
setOutputFolder(listTorrentResponse?.output_folder || "");
|
||||
}, [listTorrentResponse]);
|
||||
|
|
@ -79,7 +79,7 @@ export const FileSelectionModal = (props: {
|
|||
},
|
||||
(e) => {
|
||||
setUploadError({ text: "Error starting torrent", details: e });
|
||||
}
|
||||
},
|
||||
)
|
||||
.finally(() => setUploading(false));
|
||||
};
|
||||
|
|
@ -104,7 +104,7 @@ export const FileSelectionModal = (props: {
|
|||
<FileListInput
|
||||
selectedFiles={selectedFiles}
|
||||
setSelectedFiles={setSelectedFiles}
|
||||
listTorrentResponse={listTorrentResponse}
|
||||
torrentDetails={listTorrentResponse.details}
|
||||
/>
|
||||
</Fieldset>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import React, { useContext, useState } from "react";
|
||||
import {
|
||||
AddTorrentResponse,
|
||||
ErrorDetails,
|
||||
TorrentDetails,
|
||||
} from "../../api-types";
|
||||
import { FileListInput } from "../FileListInput";
|
||||
import { Modal } from "./Modal";
|
||||
import { ModalBody } from "./ModalBody";
|
||||
import { ModalFooter } from "./ModalFooter";
|
||||
import { Button } from "../buttons/Button";
|
||||
import { Spinner } from "../Spinner";
|
||||
import { APIContext, RefreshTorrentStatsContext } from "../../context";
|
||||
import { ErrorComponent } from "../ErrorComponent";
|
||||
import { ErrorWithLabel } from "../../stores/errorStore";
|
||||
|
||||
export const TorrentSettingsModal: React.FC<{
|
||||
id: number;
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
details: TorrentDetails;
|
||||
}> = ({ id, show, onHide, details }) => {
|
||||
let initialSelectedFiles = new Set<number>();
|
||||
|
||||
let refreshCtx = useContext(RefreshTorrentStatsContext);
|
||||
|
||||
details.files.forEach((f, i) => {
|
||||
if (f.included) {
|
||||
initialSelectedFiles.add(i);
|
||||
}
|
||||
});
|
||||
|
||||
const API = useContext(APIContext);
|
||||
|
||||
const [selectedFiles, setSelectedFiles] =
|
||||
useState<Set<number>>(initialSelectedFiles);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<ErrorWithLabel | null>(null);
|
||||
|
||||
const close = () => {
|
||||
setSelectedFiles(initialSelectedFiles);
|
||||
onHide();
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setSaving(true);
|
||||
API.updateOnlyFiles(id, Array.from(selectedFiles)).then(
|
||||
() => {
|
||||
setSaving(false);
|
||||
refreshCtx.refresh();
|
||||
close();
|
||||
setError(null);
|
||||
},
|
||||
(e) => {
|
||||
setSaving(false);
|
||||
setError({
|
||||
text: "Error configuring torrent",
|
||||
details: e as ErrorDetails,
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={show} onClose={close} title="Configure torrent">
|
||||
<ModalBody>
|
||||
<ErrorComponent error={error}></ErrorComponent>
|
||||
<FileListInput
|
||||
torrentDetails={details}
|
||||
selectedFiles={selectedFiles}
|
||||
setSelectedFiles={setSelectedFiles}
|
||||
/>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{saving && <Spinner />}
|
||||
<Button onClick={close} variant="cancel">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="primary"
|
||||
disabled={saving || selectedFiles.size == 0}
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { createContext } from "react";
|
||||
import { RqbitAPI } from "./api-types";
|
||||
import { ContextType } from "./rqbit-web";
|
||||
|
||||
export const APIContext = createContext<RqbitAPI>({
|
||||
listTorrents: () => {
|
||||
|
|
@ -15,6 +14,9 @@ export const APIContext = createContext<RqbitAPI>({
|
|||
uploadTorrent: () => {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
updateOnlyFiles: () => {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
pause: () => {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -16,17 +16,26 @@ const apiUrl =
|
|||
const makeRequest = async (
|
||||
method: string,
|
||||
path: string,
|
||||
data?: any
|
||||
data?: any,
|
||||
isJson?: boolean,
|
||||
): Promise<any> => {
|
||||
console.log(method, path);
|
||||
const url = apiUrl + path;
|
||||
const options: RequestInit = {
|
||||
let options: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: data,
|
||||
};
|
||||
if (isJson) {
|
||||
options.headers = {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
options.body = JSON.stringify(data);
|
||||
} else {
|
||||
options.body = data;
|
||||
}
|
||||
|
||||
let error: ErrorDetails = {
|
||||
method: method,
|
||||
|
|
@ -100,6 +109,18 @@ export const API: RqbitAPI & { getVersion: () => Promise<string> } = {
|
|||
return makeRequest("POST", url, data);
|
||||
},
|
||||
|
||||
updateOnlyFiles: (index: number, files: number[]): Promise<void> => {
|
||||
let url = `/torrents/${index}/update_only_files`;
|
||||
return makeRequest(
|
||||
"POST",
|
||||
url,
|
||||
{
|
||||
only_files: files,
|
||||
},
|
||||
true,
|
||||
);
|
||||
},
|
||||
|
||||
pause: (index: number): Promise<void> => {
|
||||
return makeRequest("POST", `/torrents/${index}/pause`);
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue