Possibility to change selected files after the fact

This commit is contained in:
Igor Katson 2024-03-30 20:05:12 +00:00
parent feae1789a9
commit 86d9d2c5f0
11 changed files with 186 additions and 52 deletions

File diff suppressed because one or more lines are too long

View file

@ -11,7 +11,7 @@
"css": [ "css": [
"assets/index-d46108e9.css" "assets/index-d46108e9.css"
], ],
"file": "assets/index-1ee4d2cc.js", "file": "assets/index-87e26627.js",
"isEntry": true, "isEntry": true,
"src": "index.html" "src": "index.html"
} }

View file

@ -167,10 +167,11 @@ export interface RqbitAPI {
getTorrentStats: (index: number) => Promise<TorrentStats>; getTorrentStats: (index: number) => Promise<TorrentStats>;
uploadTorrent: ( uploadTorrent: (
data: string | File, data: string | File,
opts?: AddTorrentOptions opts?: AddTorrentOptions,
) => Promise<AddTorrentResponse>; ) => Promise<AddTorrentResponse>;
pause: (index: number) => Promise<void>; pause: (index: number) => Promise<void>;
updateOnlyFiles: (index: number, files: number[]) => Promise<void>;
start: (index: number) => Promise<void>; start: (index: number) => Promise<void>;
forget: (index: number) => Promise<void>; forget: (index: number) => Promise<void>;
delete: (index: number) => Promise<void>; delete: (index: number) => Promise<void>;

View file

@ -1,5 +1,5 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { AddTorrentResponse, TorrentFile } from "../api-types"; import { TorrentDetails } from "../api-types";
import { FormCheckbox } from "./forms/FormCheckbox"; import { FormCheckbox } from "./forms/FormCheckbox";
import { CiSquarePlus, CiSquareMinus } from "react-icons/ci"; import { CiSquarePlus, CiSquareMinus } from "react-icons/ci";
import { IconButton } from "./buttons/IconButton"; import { IconButton } from "./buttons/IconButton";
@ -19,12 +19,12 @@ type FileTree = {
files: TorrentFileForCheckbox[]; files: TorrentFileForCheckbox[];
}; };
const newFileTree = (listTorrentResponse: AddTorrentResponse): FileTree => { const newFileTree = (torrentDetails: TorrentDetails): FileTree => {
const newFileTreeInner = ( const newFileTreeInner = (
name: string, name: string,
id: string, id: string,
files: TorrentFileForCheckbox[], files: TorrentFileForCheckbox[],
depth: number depth: number,
): FileTree => { ): FileTree => {
let directFiles: TorrentFileForCheckbox[] = []; let directFiles: TorrentFileForCheckbox[] = [];
let groups: FileTree[] = []; let groups: FileTree[] = [];
@ -59,7 +59,7 @@ const newFileTree = (listTorrentResponse: AddTorrentResponse): FileTree => {
return newFileTreeInner( return newFileTreeInner(
"", "",
"filetree-root", "filetree-root",
listTorrentResponse.details.files.map((file, id) => { torrentDetails.files.map((file, id) => {
return { return {
id, id,
filename: file.components[file.components.length - 1], filename: file.components[file.components.length - 1],
@ -67,13 +67,13 @@ const newFileTree = (listTorrentResponse: AddTorrentResponse): FileTree => {
length: file.length, length: file.length,
}; };
}), }),
0 0,
); );
}; };
const FileTreeComponent: React.FC<{ const FileTreeComponent: React.FC<{
tree: FileTree; tree: FileTree;
listTorrentResponse: AddTorrentResponse; torrentDetails: TorrentDetails;
selectedFiles: Set<number>; selectedFiles: Set<number>;
setSelectedFiles: React.Dispatch<React.SetStateAction<Set<number>>>; setSelectedFiles: React.Dispatch<React.SetStateAction<Set<number>>>;
initialExpanded: boolean; initialExpanded: boolean;
@ -82,7 +82,7 @@ const FileTreeComponent: React.FC<{
selectedFiles, selectedFiles,
setSelectedFiles, setSelectedFiles,
initialExpanded, initialExpanded,
listTorrentResponse, torrentDetails,
}) => { }) => {
let [expanded, setExpanded] = useState(initialExpanded); let [expanded, setExpanded] = useState(initialExpanded);
let children = useMemo(() => { let children = useMemo(() => {
@ -125,7 +125,7 @@ const FileTreeComponent: React.FC<{
const getTotalSelectedBytes = () => { const getTotalSelectedBytes = () => {
return children return children
.filter((c) => selectedFiles.has(c)) .filter((c) => selectedFiles.has(c))
.map((c) => listTorrentResponse.details.files[c].length) .map((c) => torrentDetails.files[c].length)
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);
}; };
@ -140,7 +140,7 @@ const FileTreeComponent: React.FC<{
label={`${ label={`${
tree.name ? tree.name + ", " : "" tree.name ? tree.name + ", " : ""
} ${getTotalSelectedFiles()} files, ${formatBytes( } ${getTotalSelectedFiles()} files, ${formatBytes(
getTotalSelectedBytes() getTotalSelectedBytes(),
)}`} )}`}
name={tree.id} name={tree.id}
onChange={handleToggleTree} onChange={handleToggleTree}
@ -150,7 +150,7 @@ const FileTreeComponent: React.FC<{
<div className="pl-5" hidden={!expanded}> <div className="pl-5" hidden={!expanded}>
{tree.dirs.map((dir) => ( {tree.dirs.map((dir) => (
<FileTreeComponent <FileTreeComponent
listTorrentResponse={listTorrentResponse} torrentDetails={torrentDetails}
key={dir.name} key={dir.name}
tree={dir} tree={dir}
selectedFiles={selectedFiles} selectedFiles={selectedFiles}
@ -175,19 +175,16 @@ const FileTreeComponent: React.FC<{
}; };
export const FileListInput: React.FC<{ export const FileListInput: React.FC<{
listTorrentResponse: AddTorrentResponse; torrentDetails: TorrentDetails;
selectedFiles: Set<number>; selectedFiles: Set<number>;
setSelectedFiles: React.Dispatch<React.SetStateAction<Set<number>>>; setSelectedFiles: React.Dispatch<React.SetStateAction<Set<number>>>;
}> = ({ listTorrentResponse, selectedFiles, setSelectedFiles }) => { }> = ({ torrentDetails, selectedFiles, setSelectedFiles }) => {
let fileTree = useMemo( let fileTree = useMemo(() => newFileTree(torrentDetails), [torrentDetails]);
() => newFileTree(listTorrentResponse),
[listTorrentResponse]
);
return ( return (
<> <>
<FileTreeComponent <FileTreeComponent
listTorrentResponse={listTorrentResponse} torrentDetails={torrentDetails}
tree={fileTree} tree={fileTree}
selectedFiles={selectedFiles} selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles} setSelectedFiles={setSelectedFiles}

View file

@ -18,7 +18,7 @@ export const Torrent: React.FC<{
const [detailsResponse, updateDetailsResponse] = const [detailsResponse, updateDetailsResponse] =
useState<TorrentDetails | null>(null); useState<TorrentDetails | null>(null);
const [statsResponse, updateStatsResponse] = useState<TorrentStats | null>( const [statsResponse, updateStatsResponse] = useState<TorrentStats | null>(
null null,
); );
const [forceStatsRefresh, setForceStatsRefresh] = useState(0); const [forceStatsRefresh, setForceStatsRefresh] = useState(0);
const API = useContext(APIContext); const API = useContext(APIContext);
@ -27,14 +27,12 @@ export const Torrent: React.FC<{
setForceStatsRefresh(forceStatsRefresh + 1); setForceStatsRefresh(forceStatsRefresh + 1);
}; };
// Update details once. // Update details once then when asked for.
useEffect(() => { useEffect(() => {
if (detailsResponse === null) { return loopUntilSuccess(async () => {
return loopUntilSuccess(async () => { await API.getTorrentDetails(torrent.id).then(updateDetailsResponse);
await API.getTorrentDetails(torrent.id).then(updateDetailsResponse); }, 1000);
}, 1000); }, [forceStatsRefresh]);
}
}, [detailsResponse]);
// Update stats once then forever. // Update stats once then forever.
useEffect( useEffect(
@ -61,10 +59,10 @@ export const Torrent: React.FC<{
}, },
() => { () => {
return errorInterval; return errorInterval;
} },
); );
}, 0), }, 0),
[forceStatsRefresh] [forceStatsRefresh],
); );
return ( return (

View file

@ -105,7 +105,11 @@ export const TorrentRow: React.FC<{
{/* Actions */} {/* Actions */}
{statsResponse && ( {statsResponse && (
<div className=""> <div className="">
<TorrentActions id={id} statsResponse={statsResponse} /> <TorrentActions
id={id}
detailsResponse={detailsResponse}
statsResponse={statsResponse}
/>
</div> </div>
)} )}
</section> </section>

View file

@ -1,24 +1,28 @@
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { TorrentStats } from "../../api-types"; import { TorrentDetails, TorrentStats } from "../../api-types";
import { APIContext, RefreshTorrentStatsContext } from "../../context"; import { APIContext, RefreshTorrentStatsContext } from "../../context";
import { IconButton } from "./IconButton"; import { IconButton } from "./IconButton";
import { DeleteTorrentModal } from "../modal/DeleteTorrentModal"; 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"; import { useErrorStore } from "../../stores/errorStore";
export const TorrentActions: React.FC<{ export const TorrentActions: React.FC<{
id: number; id: number;
detailsResponse: TorrentDetails | null;
statsResponse: TorrentStats; statsResponse: TorrentStats;
}> = ({ id, statsResponse }) => { }> = ({ id, detailsResponse, statsResponse }) => {
let state = statsResponse.state; let state = statsResponse.state;
let [disabled, setDisabled] = useState<boolean>(false); let [disabled, setDisabled] = useState<boolean>(false);
let [deleting, setDeleting] = useState<boolean>(false); let [deleting, setDeleting] = useState<boolean>(false);
let [configuring, setConfiguring] = useState<boolean>(false);
let refreshCtx = useContext(RefreshTorrentStatsContext); let refreshCtx = useContext(RefreshTorrentStatsContext);
const canPause = state == "live"; const canPause = state == "live";
const canUnpause = state == "paused" || state == "error"; const canUnpause = state == "paused" || state == "error";
const canConfigure = state == "paused" || state == "live";
const setCloseableError = useErrorStore((state) => state.setCloseableError); const setCloseableError = useErrorStore((state) => state.setCloseableError);
@ -36,7 +40,7 @@ export const TorrentActions: React.FC<{
text: `Error starting torrent id=${id}`, text: `Error starting torrent id=${id}`,
details: e, details: e,
}); });
} },
) )
.finally(() => setDisabled(false)); .finally(() => setDisabled(false));
}; };
@ -53,11 +57,15 @@ export const TorrentActions: React.FC<{
text: `Error pausing torrent id=${id}`, text: `Error pausing torrent id=${id}`,
details: e, details: e,
}); });
} },
) )
.finally(() => setDisabled(false)); .finally(() => setDisabled(false));
}; };
const openConfigureModal = () => {
setConfiguring(true);
};
const startDeleting = () => { const startDeleting = () => {
setDisabled(true); setDisabled(true);
setDeleting(true); setDeleting(true);
@ -80,10 +88,23 @@ export const TorrentActions: React.FC<{
<FaPause className="hover:text-amber-500" /> <FaPause className="hover:text-amber-500" />
</IconButton> </IconButton>
)} )}
{canConfigure && (
<IconButton onClick={openConfigureModal} disabled={disabled}>
<FaCog className="hover:text-green-600" />
</IconButton>
)}
<IconButton onClick={startDeleting} disabled={disabled}> <IconButton onClick={startDeleting} disabled={disabled}>
<FaTrash className="hover:text-red-500" /> <FaTrash className="hover:text-red-500" />
</IconButton> </IconButton>
<DeleteTorrentModal id={id} show={deleting} onHide={cancelDeleting} /> <DeleteTorrentModal id={id} show={deleting} onHide={cancelDeleting} />
{detailsResponse && configuring && (
<TorrentSettingsModal
id={id}
show={configuring}
details={detailsResponse}
onHide={() => setConfiguring(false)}
/>
)}
</div> </div>
); );
}; };

View file

@ -39,7 +39,7 @@ export const FileSelectionModal = (props: {
useEffect(() => { useEffect(() => {
setSelectedFiles( setSelectedFiles(
new Set(listTorrentResponse?.details.files.map((_, i) => i)) new Set(listTorrentResponse?.details.files.map((_, i) => i)),
); );
setOutputFolder(listTorrentResponse?.output_folder || ""); setOutputFolder(listTorrentResponse?.output_folder || "");
}, [listTorrentResponse]); }, [listTorrentResponse]);
@ -79,7 +79,7 @@ export const FileSelectionModal = (props: {
}, },
(e) => { (e) => {
setUploadError({ text: "Error starting torrent", details: e }); setUploadError({ text: "Error starting torrent", details: e });
} },
) )
.finally(() => setUploading(false)); .finally(() => setUploading(false));
}; };
@ -104,7 +104,7 @@ export const FileSelectionModal = (props: {
<FileListInput <FileListInput
selectedFiles={selectedFiles} selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles} setSelectedFiles={setSelectedFiles}
listTorrentResponse={listTorrentResponse} torrentDetails={listTorrentResponse.details}
/> />
</Fieldset> </Fieldset>

View file

@ -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>
);
};

View file

@ -1,6 +1,5 @@
import { createContext } from "react"; import { createContext } from "react";
import { RqbitAPI } from "./api-types"; import { RqbitAPI } from "./api-types";
import { ContextType } from "./rqbit-web";
export const APIContext = createContext<RqbitAPI>({ export const APIContext = createContext<RqbitAPI>({
listTorrents: () => { listTorrents: () => {
@ -15,6 +14,9 @@ export const APIContext = createContext<RqbitAPI>({
uploadTorrent: () => { uploadTorrent: () => {
throw new Error("Function not implemented."); throw new Error("Function not implemented.");
}, },
updateOnlyFiles: () => {
throw new Error("Function not implemented.");
},
pause: () => { pause: () => {
throw new Error("Function not implemented."); throw new Error("Function not implemented.");
}, },

View file

@ -16,17 +16,26 @@ const apiUrl =
const makeRequest = async ( const makeRequest = async (
method: string, method: string,
path: string, path: string,
data?: any data?: any,
isJson?: boolean,
): Promise<any> => { ): Promise<any> => {
console.log(method, path); console.log(method, path);
const url = apiUrl + path; const url = apiUrl + path;
const options: RequestInit = { let options: RequestInit = {
method, method,
headers: { headers: {
Accept: "application/json", 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 = { let error: ErrorDetails = {
method: method, method: method,
@ -100,6 +109,18 @@ export const API: RqbitAPI & { getVersion: () => Promise<string> } = {
return makeRequest("POST", url, data); 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> => { pause: (index: number): Promise<void> => {
return makeRequest("POST", `/torrents/${index}/pause`); return makeRequest("POST", `/torrents/${index}/pause`);
}, },