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": [
"assets/index-d46108e9.css"
],
"file": "assets/index-1ee4d2cc.js",
"file": "assets/index-87e26627.js",
"isEntry": true,
"src": "index.html"
}

View file

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

View file

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

View file

@ -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 (

View file

@ -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>

View file

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

View file

@ -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>

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 { 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.");
},

View file

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