Rewrite all styles to tailwind CSS from Bootstrap by @arccik (#58)
* add tailwindcss * add header component with logo and add torrent buttons * remove bootstrap from few files replace it with tailwindcss classes, add card which diplay all nessesarry information about torrent and current state * Add modal component and reorganize components folder * add useModal hook to render modal though react portal, remove UrlPromptModal and replace it with useModal. * add taliwindcss to Desctop app * removed bootstrap from deleteTorrentModal replace it with useModal * replacing bootstrap with useModal * saving * Saving * Header and cards now look good * Modals still broken... * still doesnt work * Finally it scrolls * Continuing to fix bugs * Continuing to fix bugs * Aler * Getting better * Desktop doesnt work with tailwind somehow * Desktop now works with tailwind * Styles fully work * (De)select all buttons * fix alert styles * Animate progress bar * Progress bar + error colors * Fix error message * Torrent status icon (#56) * add statusIcon component to display icon of the torrent status * change props name and remove isDownloading variable * Tweak styles for icon * Tweak styles * Update styles --------- Co-authored-by: Artur Lozovski <arccik@gmail.com>
This commit is contained in:
parent
911bf3a0d5
commit
50fc7f2f01
62 changed files with 7454 additions and 1776 deletions
|
|
@ -1,11 +0,0 @@
|
|||
import { MagnetInput } from "./MagnetInput";
|
||||
import { FileInput } from "./FileInput";
|
||||
|
||||
export const Buttons = () => {
|
||||
return (
|
||||
<div id="buttons-container" className="mt-3">
|
||||
<MagnetInput />
|
||||
<FileInput />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { Col } from "react-bootstrap";
|
||||
|
||||
export const Column: React.FC<{
|
||||
label: string;
|
||||
size?: number;
|
||||
children?: any;
|
||||
}> = ({ size, label, children }) => (
|
||||
<Col md={size || 1} className="py-3">
|
||||
<div className="fw-bold">{label}</div>
|
||||
{children}
|
||||
</Col>
|
||||
);
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import { useContext, useState } from "react";
|
||||
import { Button, Modal, Form, Spinner } from "react-bootstrap";
|
||||
import { AppContext, APIContext } from "../context";
|
||||
import { ErrorWithLabel } from "../rqbit-web";
|
||||
import { ErrorComponent } from "./ErrorComponent";
|
||||
|
||||
export const DeleteTorrentModal: React.FC<{
|
||||
id: number;
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
}> = ({ id, show, onHide }) => {
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
const [deleteFiles, setDeleteFiles] = useState(false);
|
||||
const [error, setError] = useState<ErrorWithLabel | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const ctx = useContext(AppContext);
|
||||
const API = useContext(APIContext);
|
||||
|
||||
const close = () => {
|
||||
setDeleteFiles(false);
|
||||
setError(null);
|
||||
setDeleting(false);
|
||||
onHide();
|
||||
};
|
||||
|
||||
const deleteTorrent = () => {
|
||||
setDeleting(true);
|
||||
|
||||
const call = deleteFiles ? API.delete : API.forget;
|
||||
|
||||
call(id)
|
||||
.then(() => {
|
||||
ctx.refreshTorrents();
|
||||
close();
|
||||
})
|
||||
.catch((e) => {
|
||||
setError({
|
||||
text: `Error deleting torrent id=${id}`,
|
||||
details: e,
|
||||
});
|
||||
setDeleting(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={show} onHide={close}>
|
||||
<Modal.Header closeButton>Delete torrent</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form>
|
||||
<Form.Group controlId="delete-torrent">
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
label="Also delete files"
|
||||
checked={deleteFiles}
|
||||
onChange={() => setDeleteFiles(!deleteFiles)}
|
||||
></Form.Check>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
{error && <ErrorComponent error={error} />}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
{deleting && <Spinner />}
|
||||
<Button variant="primary" onClick={deleteTorrent} disabled={deleting}>
|
||||
OK
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,6 +1,26 @@
|
|||
import { Alert } from "react-bootstrap";
|
||||
import { BsX } from "react-icons/bs";
|
||||
import { ErrorWithLabel } from "../rqbit-web";
|
||||
|
||||
const AlertDanger: React.FC<{
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
}> = ({ title, children, onClose }) => {
|
||||
return (
|
||||
<div className="bg-red-200 p-3 rounded-md mb-3">
|
||||
<div className="flex justify-between mb-2">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
{onClose && (
|
||||
<button onClick={onClose}>
|
||||
<BsX />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ErrorComponent = (props: {
|
||||
error: ErrorWithLabel | null;
|
||||
remove?: () => void;
|
||||
|
|
@ -12,14 +32,11 @@ export const ErrorComponent = (props: {
|
|||
}
|
||||
|
||||
return (
|
||||
<Alert variant="danger" onClose={remove} dismissible={remove != null}>
|
||||
<Alert.Heading>{error.text}</Alert.Heading>
|
||||
<AlertDanger onClose={remove} title={error.text}>
|
||||
{error.details?.statusText && (
|
||||
<p>
|
||||
<strong>{error.details?.statusText}</strong>
|
||||
</p>
|
||||
<div className="pb-2 text-md">{error.details?.statusText}</div>
|
||||
)}
|
||||
<pre>{error.details?.text}</pre>
|
||||
</Alert>
|
||||
<div className="whitespace-pre text-sm">{error.details?.text}</div>
|
||||
</AlertDanger>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
26
crates/librqbit/webui/src/components/Header.tsx
Normal file
26
crates/librqbit/webui/src/components/Header.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { FileInput } from "./buttons/FileInput";
|
||||
import { MagnetInput } from "./buttons/MagnetInput";
|
||||
|
||||
// @ts-ignore
|
||||
import Logo from "../../assets/logo.svg?react";
|
||||
|
||||
export const Header = ({ title }: { title: string }) => {
|
||||
const [name, version] = title.split("-");
|
||||
return (
|
||||
<header className="bg-slate-50 drop-shadow-lg flex flex-wrap justify-center lg:justify-between items-center mb-3">
|
||||
<div className="flex flex-nowrap items-center justify-between m-2">
|
||||
<Logo className="w-10 h-10 p-1" alt="logo" />
|
||||
<h1 className="flex items-center">
|
||||
<div className="text-3xl">{name}</div>
|
||||
<div className="bg-blue-100 text-blue-800 text-xl font-semibold me-2 px-2.5 py-0.5 rounded ms-2">
|
||||
{version}
|
||||
</div>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 m-2">
|
||||
<MagnetInput className="flex-grow justify-center" />
|
||||
<FileInput className="flex-grow justify-center" />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
|
@ -29,7 +29,7 @@ const SpanFields: React.FC<{ span: Span }> = ({ span }) => {
|
|||
|
||||
const LogSpan: React.FC<{ span: Span }> = ({ span }) => (
|
||||
<>
|
||||
<span className="fw-bold">{span.name}</span>
|
||||
<span className="font-bold">{span.name}</span>
|
||||
<SpanFields span={span} />
|
||||
</>
|
||||
);
|
||||
|
|
@ -37,7 +37,7 @@ const LogSpan: React.FC<{ span: Span }> = ({ span }) => (
|
|||
const Fields: React.FC<{ fields: JSONLogLine["fields"] }> = ({ fields }) => (
|
||||
<span
|
||||
className={`m-1 ${
|
||||
fields.message.match(/error|fail/g) ? "text-danger" : "text-muted"
|
||||
fields.message.match(/error|fail/g) ? "text-red-500" : "text-slate-500"
|
||||
}`}
|
||||
>
|
||||
{fields.message}
|
||||
|
|
@ -45,7 +45,7 @@ const Fields: React.FC<{ fields: JSONLogLine["fields"] }> = ({ fields }) => (
|
|||
.filter(([key, value]) => key != "message")
|
||||
.map(([key, value]) => (
|
||||
<span className="m-1" key={key}>
|
||||
<span className="fst-italic fw-bold">{key}</span>={value}
|
||||
<span className="italic font-bold">{key}</span>={value}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
|
|
@ -58,21 +58,21 @@ export const LogLine: React.FC<{ line: JSONLogLine }> = React.memo(
|
|||
const classNameByLevel = (level: string) => {
|
||||
switch (level) {
|
||||
case "DEBUG":
|
||||
return "text-primary";
|
||||
return "text-blue-500";
|
||||
case "INFO":
|
||||
return "text-success";
|
||||
return "text-green-500";
|
||||
case "WARN":
|
||||
return "text-warning";
|
||||
return "text-amber-500";
|
||||
case "ERROR":
|
||||
return "text-danger";
|
||||
return "text-red-500";
|
||||
default:
|
||||
return "text-muted";
|
||||
return "text-slate-500";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<p className="font-monospace m-0 text-break" style={{ fontSize: "10px" }}>
|
||||
<span className="m-1">{parsed.timestamp}</span>
|
||||
<p className="font-mono m-0 text-break text-[10px]">
|
||||
<span className="m-1 text-slate-500">{parsed.timestamp}</span>
|
||||
<span className={`m-1 ${classNameByLevel(parsed.level)}`}>
|
||||
{parsed.level}
|
||||
</span>
|
||||
|
|
@ -80,7 +80,7 @@ export const LogLine: React.FC<{ line: JSONLogLine }> = React.memo(
|
|||
<span className="m-1">
|
||||
{parsed.spans?.map((span, i) => <LogSpan key={i} span={span} />)}
|
||||
</span>
|
||||
<span className="m-1 text-muted">{parsed.target}</span>
|
||||
<span className="m-1 text-slate-500">{parsed.target}</span>
|
||||
<Fields fields={parsed.fields} />
|
||||
</p>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,12 @@
|
|||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ErrorWithLabel } from "../rqbit-web";
|
||||
import { ErrorComponent } from "./ErrorComponent";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { loopUntilSuccess } from "../helper/loopUntilSuccess";
|
||||
import debounce from "lodash.debounce";
|
||||
import { LogLine } from "./LogLine";
|
||||
import { JSONLogLine } from "../api-types";
|
||||
import { Form } from "./forms/Form";
|
||||
import { FormInput } from "./forms/FormInput";
|
||||
|
||||
interface LogStreamProps {
|
||||
url: string;
|
||||
|
|
@ -200,15 +195,12 @@ export const LogStream: React.FC<LogStreamProps> = ({ url, maxLines }) => {
|
|||
Showing last {maxL} logs since this window was opened
|
||||
</div>
|
||||
<Form>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={filter}
|
||||
name="filter"
|
||||
placeholder="Enter filter (regex)"
|
||||
onChange={(e) => handleFilterChange(e.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<FormInput
|
||||
value={filter}
|
||||
name="filter"
|
||||
placeholder="Enter filter (regex)"
|
||||
onChange={(e) => handleFilterChange(e.target.value)}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
{logLines.map((line) => (
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { UploadButton } from "./UploadButton";
|
||||
import { UrlPromptModal } from "./UrlPromptModal";
|
||||
|
||||
export const MagnetInput = () => {
|
||||
let [magnet, setMagnet] = useState<string | null>(null);
|
||||
|
||||
let [showModal, setShowModal] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UploadButton
|
||||
variant="primary"
|
||||
buttonText="Add Torrent from Magnet / URL"
|
||||
onClick={() => {
|
||||
setShowModal(true);
|
||||
}}
|
||||
data={magnet}
|
||||
resetData={() => setMagnet(null)}
|
||||
/>
|
||||
|
||||
<UrlPromptModal
|
||||
show={showModal}
|
||||
setUrl={(url) => {
|
||||
setShowModal(false);
|
||||
setMagnet(url);
|
||||
}}
|
||||
cancel={() => {
|
||||
setShowModal(false);
|
||||
setMagnet(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
27
crates/librqbit/webui/src/components/ProgressBar.tsx
Normal file
27
crates/librqbit/webui/src/components/ProgressBar.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
type Props = {
|
||||
now: number;
|
||||
label?: string | null;
|
||||
variant?: "warn" | "info" | "success" | "error";
|
||||
};
|
||||
|
||||
export const ProgressBar = ({ now, variant, label }: Props) => {
|
||||
const progressLabel = label ?? `${now.toFixed(2)}%`;
|
||||
|
||||
const variantClassName = {
|
||||
warn: "bg-yellow-500",
|
||||
info: "bg-blue-500 text-white",
|
||||
success: "bg-green-700 text-white",
|
||||
error: "bg-red-500 text-white",
|
||||
}[variant ?? "info"];
|
||||
|
||||
return (
|
||||
<div className={"w-full bg-gray-200 rounded-full"}>
|
||||
<div
|
||||
className={`text-xs bg-blue-500 font-medium transition-all text-center p-0.5 leading-none rounded-full ${variantClassName}`}
|
||||
style={{ width: `${now}%` }}
|
||||
>
|
||||
{progressLabel}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
import { useContext, useState } from "react";
|
||||
import { Container } from "react-bootstrap";
|
||||
import { useContext } from "react";
|
||||
import { TorrentId, ErrorDetails as ApiErrorDetails } from "../api-types";
|
||||
import { APIContext, AppContext } from "../context";
|
||||
import { AppContext } from "../context";
|
||||
import { TorrentsList } from "./TorrentsList";
|
||||
import { ErrorComponent } from "./ErrorComponent";
|
||||
import { Buttons } from "./Buttons";
|
||||
|
||||
export const RootContent = (props: {
|
||||
closeableError: ApiErrorDetails | null;
|
||||
|
|
@ -14,14 +12,13 @@ export const RootContent = (props: {
|
|||
}) => {
|
||||
let ctx = useContext(AppContext);
|
||||
return (
|
||||
<Container>
|
||||
<div className="container mx-auto">
|
||||
<ErrorComponent
|
||||
error={props.closeableError}
|
||||
remove={() => ctx.setCloseableError(null)}
|
||||
/>
|
||||
<ErrorComponent error={props.otherError} />
|
||||
<TorrentsList torrents={props.torrents} loading={props.torrentsLoading} />
|
||||
<Buttons />
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ export const Speed: React.FC<{ statsResponse: TorrentStats }> = ({
|
|||
↑ {statsResponse.live.upload_speed?.human_readable}
|
||||
{statsResponse.live.snapshot.uploaded_bytes > 0 && (
|
||||
<span>
|
||||
{" "}
|
||||
({formatBytes(statsResponse.live.snapshot.uploaded_bytes)})
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
23
crates/librqbit/webui/src/components/Spinner.tsx
Normal file
23
crates/librqbit/webui/src/components/Spinner.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export const Spinner = () => {
|
||||
return (
|
||||
<div role="status">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="inline w-8 h-8 text-gray-200 animate-spin fill-blue-600"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
24
crates/librqbit/webui/src/components/StatusIcon.tsx
Normal file
24
crates/librqbit/webui/src/components/StatusIcon.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import {
|
||||
MdCheck,
|
||||
MdCheckCircle,
|
||||
MdDownload,
|
||||
MdError,
|
||||
MdOutlineMotionPhotosPaused,
|
||||
MdOutlineUpload,
|
||||
} from "react-icons/md";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
finished: boolean;
|
||||
live: boolean;
|
||||
error: boolean;
|
||||
};
|
||||
|
||||
export const StatusIcon = ({ className, finished, live, error }: Props) => {
|
||||
const isSeeding = finished && live;
|
||||
if (error) return <MdError className={className} color="red" />;
|
||||
if (isSeeding) return <MdOutlineUpload className={className} color="green" />;
|
||||
if (finished) return <MdCheckCircle className={className} color="green" />;
|
||||
if (live) return <MdDownload className={`text-blue-500 ${className}`} />;
|
||||
else return <MdOutlineMotionPhotosPaused className={className} />;
|
||||
};
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
import { ProgressBar, Row, Spinner } from "react-bootstrap";
|
||||
import { GoClock, GoFile, GoPeople } from "react-icons/go";
|
||||
import {
|
||||
TorrentDetails,
|
||||
TorrentStats,
|
||||
STATE_INITIALIZING,
|
||||
STATE_LIVE,
|
||||
STATE_PAUSED,
|
||||
} from "../api-types";
|
||||
import { TorrentActions } from "./TorrentActions";
|
||||
import { TorrentActions } from "./buttons/TorrentActions";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
import { Speed } from "./Speed";
|
||||
import { Column } from "./Column";
|
||||
import { formatBytes } from "../helper/formatBytes";
|
||||
import { getLargestFileName } from "../helper/getLargestFileName";
|
||||
import { getCompletionETA } from "../helper/getCompletionETA";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
|
||||
export const TorrentRow: React.FC<{
|
||||
id: number;
|
||||
|
|
@ -19,21 +19,11 @@ export const TorrentRow: React.FC<{
|
|||
statsResponse: TorrentStats | null;
|
||||
}> = ({ id, detailsResponse, statsResponse }) => {
|
||||
const state = statsResponse?.state ?? "";
|
||||
const error = statsResponse?.error;
|
||||
const error = statsResponse?.error ?? null;
|
||||
const totalBytes = statsResponse?.total_bytes ?? 1;
|
||||
const progressBytes = statsResponse?.progress_bytes ?? 0;
|
||||
const finished = statsResponse?.finished || false;
|
||||
const progressPercentage = error ? 100 : (progressBytes / totalBytes) * 100;
|
||||
const isAnimated =
|
||||
(state == STATE_INITIALIZING || state == STATE_LIVE) && !finished;
|
||||
const progressLabel = error ? "Error" : `${progressPercentage.toFixed(2)}%`;
|
||||
const progressBarVariant = error
|
||||
? "danger"
|
||||
: finished
|
||||
? "success"
|
||||
: state == STATE_INITIALIZING
|
||||
? "warning"
|
||||
: "primary";
|
||||
|
||||
const formatPeersString = () => {
|
||||
let peer_stats = statsResponse?.live?.snapshot.peer_stats;
|
||||
|
|
@ -43,64 +33,81 @@ export const TorrentRow: React.FC<{
|
|||
return `${peer_stats.live} / ${peer_stats.seen}`;
|
||||
};
|
||||
|
||||
let classNames = [];
|
||||
|
||||
if (error) {
|
||||
classNames.push("bg-warning");
|
||||
} else {
|
||||
if (id % 2 == 0) {
|
||||
classNames.push("bg-light");
|
||||
}
|
||||
}
|
||||
const statusIcon = (className: string) => {
|
||||
return (
|
||||
<StatusIcon
|
||||
className={className}
|
||||
error={!!error}
|
||||
live={!!statsResponse?.live}
|
||||
finished={finished}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Row className={classNames.join(" ")}>
|
||||
<Column size={3} label="Name">
|
||||
{detailsResponse ? (
|
||||
<>
|
||||
<div className="text-truncate">
|
||||
<section className="flex flex-col sm:flex-row items-center gap-2 border p-2 border-gray-200 rounded-xl shadow-xs hover:drop-shadow-sm">
|
||||
{/* Icon */}
|
||||
<div className="hidden md:block">{statusIcon("w-10 h-10")}</div>
|
||||
{/* Name, progress, stats */}
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
{detailsResponse && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="md:hidden">{statusIcon("w-5 h-5")}</div>
|
||||
<div className="text-left text-lg text-gray-900 text-ellipsis break-all">
|
||||
{getLargestFileName(detailsResponse)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-danger">
|
||||
<strong>Error:</strong> {error}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</Column>
|
||||
{statsResponse ? (
|
||||
<>
|
||||
<Column label="Size">{`${formatBytes(totalBytes)} `}</Column>
|
||||
<Column
|
||||
size={2}
|
||||
label={state == STATE_PAUSED ? "Progress" : "Progress"}
|
||||
>
|
||||
<ProgressBar
|
||||
now={progressPercentage}
|
||||
label={progressLabel}
|
||||
animated={isAnimated}
|
||||
variant={progressBarVariant}
|
||||
/>
|
||||
</Column>
|
||||
<Column size={2} label="Speed">
|
||||
<Speed statsResponse={statsResponse} />
|
||||
</Column>
|
||||
<Column label="ETA">{getCompletionETA(statsResponse)}</Column>
|
||||
<Column size={2} label="Live / Seen">
|
||||
{formatPeersString()}
|
||||
</Column>
|
||||
<Column label="Actions">
|
||||
<TorrentActions id={id} statsResponse={statsResponse} />
|
||||
</Column>
|
||||
</>
|
||||
) : (
|
||||
<Column label="Loading stats" size={8}>
|
||||
<Spinner />
|
||||
</Column>
|
||||
{error ? (
|
||||
<p className="text-red-500 text-sm">
|
||||
<strong>Error:</strong> {error}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<ProgressBar
|
||||
now={progressPercentage}
|
||||
label={error}
|
||||
variant={
|
||||
state == STATE_INITIALIZING
|
||||
? "warn"
|
||||
: finished
|
||||
? "success"
|
||||
: "info"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 sm:flex-wrap items-center text-sm text-nowrap font-medium text-gray-500">
|
||||
<div className="flex gap-2 items-center">
|
||||
<GoPeople /> {formatPeersString().toString()}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<GoFile />
|
||||
<div>
|
||||
{formatBytes(progressBytes)}/{formatBytes(totalBytes)}
|
||||
</div>
|
||||
</div>
|
||||
{statsResponse && (
|
||||
<>
|
||||
<div className="flex gap-2 items-center">
|
||||
<GoClock />
|
||||
{getCompletionETA(statsResponse)}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Speed statsResponse={statsResponse} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Actions */}
|
||||
{statsResponse && (
|
||||
<div className="">
|
||||
<TorrentActions id={id} statsResponse={statsResponse} />
|
||||
</div>
|
||||
)}
|
||||
</Row>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Spinner } from "react-bootstrap";
|
||||
import { TorrentId } from "../api-types";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { Torrent } from "./Torrent";
|
||||
|
||||
export const TorrentsList = (props: {
|
||||
|
|
@ -17,12 +17,12 @@ export const TorrentsList = (props: {
|
|||
if (props.torrents.length === 0) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<p>No existing torrents found. Add them through buttons below.</p>
|
||||
<p>No existing torrents found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ fontSize: "smaller" }}>
|
||||
<div className="flex flex-col gap-2 mx-2">
|
||||
{props.torrents.map((t: TorrentId) => (
|
||||
<Torrent id={t.id} key={t.id} torrent={t} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { Button, Modal, Form } from "react-bootstrap";
|
||||
|
||||
export const UrlPromptModal: React.FC<{
|
||||
show: boolean;
|
||||
setUrl: (_: string) => void;
|
||||
cancel: () => void;
|
||||
}> = ({ show, setUrl, cancel }) => {
|
||||
let [inputValue, setInputValue] = useState("");
|
||||
return (
|
||||
<Modal show={show} onHide={cancel} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Add torrent</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form>
|
||||
<Form.Group className="mb-3" controlId="url">
|
||||
<Form.Label>Enter magnet or HTTP(S) URL to the .torrent</Form.Label>
|
||||
<Form.Control
|
||||
value={inputValue}
|
||||
placeholder="magnet:?xt=urn:btih:..."
|
||||
onChange={(u) => {
|
||||
setInputValue(u.target.value);
|
||||
}}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setUrl(inputValue);
|
||||
setInputValue("");
|
||||
}}
|
||||
disabled={inputValue.length == 0}
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={cancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
31
crates/librqbit/webui/src/components/buttons/Button.tsx
Normal file
31
crates/librqbit/webui/src/components/buttons/Button.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { ReactNode } from "react";
|
||||
|
||||
export const Button: React.FC<{
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
variant?: "cancel" | "primary" | "secondary" | "danger" | "none";
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
}> = ({ onClick, children, className, disabled, variant }) => {
|
||||
let variantClassNames = {
|
||||
secondary:
|
||||
"hover:bg-blue-600 transition-colors duration-100 hover:text-white",
|
||||
danger:
|
||||
"bg-red-500 text-white border-green-50 hover:border-red-700 hover:bg-red-600",
|
||||
primary: "bg-blue-400 text-white hover:bg-blue-600",
|
||||
cancel: "bg-slate-50 hover:bg-slate-200",
|
||||
none: "",
|
||||
}[variant ?? "secondary"];
|
||||
return (
|
||||
<button
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClick(e);
|
||||
}}
|
||||
className={`flex inline-flex items-center gap-1 border rounded-lg border disabled:cursor-not-allowed px-2 py-1 transition-colors duration-300 ${variantClassNames} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { RefObject, useRef, useState } from "react";
|
||||
import { UploadButton } from "./UploadButton";
|
||||
import { CgFileAdd } from "react-icons/cg";
|
||||
|
||||
export const FileInput = () => {
|
||||
export const FileInput = ({ className }: { className?: string }) => {
|
||||
const inputRef = useRef<HTMLInputElement>() as RefObject<HTMLInputElement>;
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
|
|
@ -35,15 +36,17 @@ export const FileInput = () => {
|
|||
ref={inputRef}
|
||||
accept=".torrent"
|
||||
onChange={onFileChange}
|
||||
className="d-none"
|
||||
hidden
|
||||
/>
|
||||
<UploadButton
|
||||
variant="secondary"
|
||||
buttonText="Upload .torrent File"
|
||||
onClick={onClick}
|
||||
data={file}
|
||||
resetData={reset}
|
||||
/>
|
||||
className={className}
|
||||
>
|
||||
<CgFileAdd color="blue" />
|
||||
<div>Upload .torrent File</div>
|
||||
</UploadButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -19,7 +19,7 @@ export const IconButton: React.FC<{
|
|||
const colorClassName = color ? `text-${color}` : "";
|
||||
return (
|
||||
<a
|
||||
className={`p-1 ${colorClassName} ${className}`}
|
||||
className={`block p-1 ${colorClassName} ${className}`}
|
||||
onClick={onClickStopPropagation}
|
||||
href="#"
|
||||
{...otherProps}
|
||||
65
crates/librqbit/webui/src/components/buttons/MagnetInput.tsx
Normal file
65
crates/librqbit/webui/src/components/buttons/MagnetInput.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { useState } from "react";
|
||||
import { CgLink } from "react-icons/cg";
|
||||
import { UploadButton } from "./UploadButton";
|
||||
import { Modal } from "../modal/Modal";
|
||||
import { Button } from "./Button";
|
||||
import { ModalBody } from "../modal/ModalBody";
|
||||
import { ModalFooter } from "../modal/ModalFooter";
|
||||
import { FormInput } from "../forms/FormInput";
|
||||
|
||||
export const MagnetInput = ({ className }: { className?: string }) => {
|
||||
const [magnet, setMagnet] = useState<string | null>(null);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||
|
||||
const clear = () => {
|
||||
setModalIsOpen(false);
|
||||
setMagnet(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<UploadButton
|
||||
onClick={() => {
|
||||
setModalIsOpen(true);
|
||||
}}
|
||||
data={magnet}
|
||||
className={className}
|
||||
resetData={() => setMagnet(null)}
|
||||
>
|
||||
<CgLink color="blue" />
|
||||
<div>Add Torrent from Magnet / URL</div>
|
||||
</UploadButton>
|
||||
|
||||
<Modal isOpen={modalIsOpen} onClose={clear} title="Add torrent">
|
||||
<ModalBody>
|
||||
<FormInput
|
||||
autoFocus
|
||||
value={inputValue}
|
||||
name="magnet"
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder="magnet:?xt=urn:btih:..."
|
||||
help="Enter magnet or HTTP(S) URL to the .torrent"
|
||||
/>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="cancel" onClick={clear}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!inputValue}
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setMagnet(inputValue);
|
||||
setInputValue("");
|
||||
setModalIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
import { useContext, useState } from "react";
|
||||
import { Row, Col } from "react-bootstrap";
|
||||
import { TorrentStats } from "../api-types";
|
||||
import { AppContext, APIContext, RefreshTorrentStatsContext } from "../context";
|
||||
import { TorrentStats } from "../../api-types";
|
||||
import {
|
||||
AppContext,
|
||||
APIContext,
|
||||
RefreshTorrentStatsContext,
|
||||
} from "../../context";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { DeleteTorrentModal } from "./DeleteTorrentModal";
|
||||
import { BsPauseCircle, BsPlayCircle, BsXCircle } from "react-icons/bs";
|
||||
import { DeleteTorrentModal } from "../modal/DeleteTorrentModal";
|
||||
import { FaPause, FaPlay, FaTrash } from "react-icons/fa";
|
||||
|
||||
export const TorrentActions: React.FC<{
|
||||
id: number;
|
||||
|
|
@ -68,23 +71,21 @@ export const TorrentActions: React.FC<{
|
|||
};
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col>
|
||||
{canUnpause && (
|
||||
<IconButton onClick={unpause} disabled={disabled} color="success">
|
||||
<BsPlayCircle />
|
||||
</IconButton>
|
||||
)}
|
||||
{canPause && (
|
||||
<IconButton onClick={pause} disabled={disabled}>
|
||||
<BsPauseCircle />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton onClick={startDeleting} disabled={disabled} color="danger">
|
||||
<BsXCircle />
|
||||
<div className="flex w-full justify-center gap-2">
|
||||
{canUnpause && (
|
||||
<IconButton onClick={unpause} disabled={disabled}>
|
||||
<FaPlay className="hover:text-green-500 transition-colors duration-300" />
|
||||
</IconButton>
|
||||
<DeleteTorrentModal id={id} show={deleting} onHide={cancelDeleting} />
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
{canPause && (
|
||||
<IconButton onClick={pause} disabled={disabled}>
|
||||
<FaPause className="hover:text-yellow-500 transition-colors duration-300" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton onClick={startDeleting} disabled={disabled}>
|
||||
<FaTrash className="hover:text-red-500 transition-colors duration-500" />
|
||||
</IconButton>
|
||||
<DeleteTorrentModal id={id} show={deleting} onHide={cancelDeleting} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,20 +1,20 @@
|
|||
import { useContext, useEffect, useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { ReactNode, useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
AddTorrentResponse,
|
||||
ErrorDetails as ApiErrorDetails,
|
||||
} from "../api-types";
|
||||
import { APIContext } from "../context";
|
||||
import { ErrorWithLabel } from "../rqbit-web";
|
||||
import { FileSelectionModal } from "./FileSelectionModal";
|
||||
} from "../../api-types";
|
||||
import { APIContext } from "../../context";
|
||||
import { ErrorWithLabel } from "../../rqbit-web";
|
||||
import { FileSelectionModal } from "../modal/FileSelectionModal";
|
||||
import { Button } from "./Button";
|
||||
|
||||
export const UploadButton: React.FC<{
|
||||
buttonText: string;
|
||||
onClick: () => void;
|
||||
data: string | File | null;
|
||||
resetData: () => void;
|
||||
variant: string;
|
||||
}> = ({ buttonText, onClick, data, resetData, variant }) => {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}> = ({ onClick, data, resetData, children, className }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [listTorrentResponse, setListTorrentResponse] =
|
||||
useState<AddTorrentResponse | null>(null);
|
||||
|
|
@ -54,8 +54,8 @@ export const UploadButton: React.FC<{
|
|||
|
||||
return (
|
||||
<>
|
||||
<Button variant={variant} onClick={onClick} className="m-1">
|
||||
{buttonText}
|
||||
<Button onClick={onClick} className={className}>
|
||||
{children}
|
||||
</Button>
|
||||
|
||||
{data && (
|
||||
20
crates/librqbit/webui/src/components/forms/Fieldset.tsx
Normal file
20
crates/librqbit/webui/src/components/forms/Fieldset.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { ReactNode } from "react";
|
||||
|
||||
export const Fieldset = ({
|
||||
children,
|
||||
label,
|
||||
help,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
help?: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<fieldset className={`mb-4 ${className}`}>
|
||||
<label className="text-md font-md mb-3 block">{label}</label>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
5
crates/librqbit/webui/src/components/forms/Form.tsx
Normal file
5
crates/librqbit/webui/src/components/forms/Form.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { ReactNode } from "react";
|
||||
|
||||
export const Form = ({ children }: { children: ReactNode }) => {
|
||||
return <form>{children}</form>;
|
||||
};
|
||||
31
crates/librqbit/webui/src/components/forms/FormCheckbox.tsx
Normal file
31
crates/librqbit/webui/src/components/forms/FormCheckbox.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { ChangeEventHandler } from "react";
|
||||
|
||||
export const FormCheckbox: React.FC<{
|
||||
checked: boolean;
|
||||
label: string;
|
||||
name: string;
|
||||
help?: string;
|
||||
disabled?: boolean;
|
||||
inputType?: "checkbox" | "switch";
|
||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||
}> = ({ checked, name, disabled, onChange, label, help, inputType }) => {
|
||||
return (
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="flex">
|
||||
<input
|
||||
type={inputType || "checkbox"}
|
||||
className="block mt-1"
|
||||
id={name}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm flex flex-col gap-1">
|
||||
<label htmlFor={name}>{label}</label>
|
||||
{help && <div className="text-xs text-slate-500 mb-3">{help}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
41
crates/librqbit/webui/src/components/forms/FormInput.tsx
Normal file
41
crates/librqbit/webui/src/components/forms/FormInput.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { ChangeEventHandler } from "react";
|
||||
|
||||
export const FormInput: React.FC<{
|
||||
value: string;
|
||||
label?: string;
|
||||
autoFocus?: boolean;
|
||||
name: string;
|
||||
inputType?: string;
|
||||
placeholder?: string;
|
||||
help?: string;
|
||||
disabled?: boolean;
|
||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||
}> = ({
|
||||
autoFocus,
|
||||
value,
|
||||
name,
|
||||
disabled,
|
||||
onChange,
|
||||
label,
|
||||
help,
|
||||
inputType,
|
||||
placeholder,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 text-sm mb-6">
|
||||
<label htmlFor={name}>{label}</label>
|
||||
<input
|
||||
autoFocus={autoFocus}
|
||||
type={inputType}
|
||||
className="block border rounded bg-transparent py-1.5 pl-2 text-gray-800 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
id={name}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
{help && <div className="text-xs text-slate-500 mb-3">{help}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { useContext, useState } from "react";
|
||||
import { AppContext, APIContext } from "../../context";
|
||||
import { ErrorWithLabel } from "../../rqbit-web";
|
||||
import { ErrorComponent } from "../ErrorComponent";
|
||||
import { Spinner } from "../Spinner";
|
||||
import { Modal } from "./Modal";
|
||||
import { ModalBody } from "./ModalBody";
|
||||
import { ModalFooter } from "./ModalFooter";
|
||||
import { Button } from "../buttons/Button";
|
||||
|
||||
export const DeleteTorrentModal: React.FC<{
|
||||
id: number;
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
}> = ({ id, show, onHide }) => {
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
const [deleteFiles, setDeleteFiles] = useState(false);
|
||||
const [error, setError] = useState<ErrorWithLabel | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const ctx = useContext(AppContext);
|
||||
const API = useContext(APIContext);
|
||||
|
||||
const close = () => {
|
||||
setDeleteFiles(false);
|
||||
setError(null);
|
||||
setDeleting(false);
|
||||
onHide();
|
||||
};
|
||||
|
||||
const deleteTorrent = () => {
|
||||
setDeleting(true);
|
||||
|
||||
const call = deleteFiles ? API.delete : API.forget;
|
||||
|
||||
call(id)
|
||||
.then(() => {
|
||||
ctx.refreshTorrents();
|
||||
close();
|
||||
})
|
||||
.catch((e) => {
|
||||
setError({
|
||||
text: `Error deleting torrent id=${id}`,
|
||||
details: e,
|
||||
});
|
||||
setDeleting(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={show} onClose={onHide} title="Delete torrent">
|
||||
<ModalBody>
|
||||
<p className="text-gray-700">
|
||||
Are you sure you want to delete the torrent?
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="deleteFiles"
|
||||
className="form-checkbox h-4 w-4 text-blue-500"
|
||||
onChange={() => setDeleteFiles(!deleteFiles)}
|
||||
checked={deleteFiles}
|
||||
placeholder="Also delete files"
|
||||
/>
|
||||
<label htmlFor="deleteFiles" className="ml-2 text-gray-700">
|
||||
Also delete files
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <ErrorComponent error={error} />}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{deleting && <Spinner />}
|
||||
<Button variant="cancel" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={deleteTorrent} disabled={deleting}>
|
||||
Delete Torrent
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,10 +1,18 @@
|
|||
import { useContext, useEffect, useState } from "react";
|
||||
import { Button, Modal, Form, Spinner } from "react-bootstrap";
|
||||
import { AddTorrentResponse, AddTorrentOptions } from "../api-types";
|
||||
import { AppContext, APIContext } from "../context";
|
||||
import { ErrorComponent } from "./ErrorComponent";
|
||||
import { formatBytes } from "../helper/formatBytes";
|
||||
import { ErrorWithLabel } from "../rqbit-web";
|
||||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { AddTorrentResponse, AddTorrentOptions } from "../../api-types";
|
||||
import { AppContext, APIContext } from "../../context";
|
||||
import { ErrorComponent } from "../ErrorComponent";
|
||||
import { formatBytes } from "../../helper/formatBytes";
|
||||
import { ErrorWithLabel } from "../../rqbit-web";
|
||||
import { Spinner } from "../Spinner";
|
||||
import { Modal } from "./Modal";
|
||||
import { ModalBody } from "./ModalBody";
|
||||
import { ModalFooter } from "./ModalFooter";
|
||||
import { Button } from "../buttons/Button";
|
||||
import { FormCheckbox } from "../forms/FormCheckbox";
|
||||
import { Fieldset } from "../forms/Fieldset";
|
||||
import { FormInput } from "../forms/FormInput";
|
||||
import { Form } from "../forms/Form";
|
||||
|
||||
export const FileSelectionModal = (props: {
|
||||
onHide: () => void;
|
||||
|
|
@ -28,14 +36,19 @@ export const FileSelectionModal = (props: {
|
|||
const [outputFolder, setOutputFolder] = useState<string>("");
|
||||
const ctx = useContext(AppContext);
|
||||
const API = useContext(APIContext);
|
||||
// const [Modal, , , closeModal] = useModal({ fullScreen: true });
|
||||
|
||||
useEffect(() => {
|
||||
console.log(listTorrentResponse);
|
||||
const selectAll = () => {
|
||||
setSelectedFiles(
|
||||
listTorrentResponse
|
||||
? listTorrentResponse.details.files.map((_, id) => id)
|
||||
: []
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log(listTorrentResponse);
|
||||
selectAll();
|
||||
setOutputFolder(listTorrentResponse?.output_folder || "");
|
||||
}, [listTorrentResponse]);
|
||||
|
||||
|
|
@ -95,71 +108,67 @@ export const FileSelectionModal = (props: {
|
|||
} else if (listTorrentResponse) {
|
||||
return (
|
||||
<Form>
|
||||
<fieldset className="mb-4">
|
||||
<legend>Pick the files to download</legend>
|
||||
<Fieldset className="mb-4" label="Pick the files to download">
|
||||
<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) => (
|
||||
<Form.Group key={index} controlId={`check-${index}`}>
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
label={`${file.name} (${formatBytes(file.length)})`}
|
||||
checked={selectedFiles.includes(index)}
|
||||
onChange={() => handleToggleFile(index)}
|
||||
></Form.Check>
|
||||
</Form.Group>
|
||||
))}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Options</legend>
|
||||
<Form.Group controlId="output-folder" className="mb-3">
|
||||
<Form.Label>Output folder</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={outputFolder}
|
||||
onChange={(e) => setOutputFolder(e.target.value)}
|
||||
<FormCheckbox
|
||||
key={index}
|
||||
label={`${file.name} (${formatBytes(file.length)})`}
|
||||
checked={selectedFiles.includes(index)}
|
||||
onChange={() => handleToggleFile(index)}
|
||||
name={`check-${index}`}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="unpopular-torrent" className="mb-3">
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
label="Increase timeouts"
|
||||
checked={unpopularTorrent}
|
||||
onChange={() => setUnpopularTorrent(!unpopularTorrent)}
|
||||
></Form.Check>
|
||||
<small id="emailHelp" className="form-text text-muted">
|
||||
This might be useful for unpopular torrents with few peers. It
|
||||
will slow down fast torrents though.
|
||||
</small>
|
||||
</Form.Group>
|
||||
</fieldset>
|
||||
))}
|
||||
</Fieldset>
|
||||
<Fieldset label="Options">
|
||||
<FormInput
|
||||
label="Output folder"
|
||||
name="output_folder"
|
||||
inputType="text"
|
||||
value={outputFolder}
|
||||
onChange={(e) => setOutputFolder(e.target.value)}
|
||||
/>
|
||||
|
||||
<FormCheckbox
|
||||
label="Increase timeouts"
|
||||
checked={unpopularTorrent}
|
||||
onChange={() => setUnpopularTorrent(!unpopularTorrent)}
|
||||
help="This might be useful for unpopular torrents with few peers. It will slow down fast torrents though."
|
||||
name="increase_timeouts"
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show onHide={clear} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Add torrent</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Modal isOpen={true} onClose={clear} title="Add Torrent">
|
||||
<ModalBody>
|
||||
{getBody()}
|
||||
<ErrorComponent error={uploadError} />
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
{uploading && <Spinner />}
|
||||
<Button onClick={clear} variant="cancel">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleUpload}
|
||||
variant="primary"
|
||||
disabled={
|
||||
listTorrentLoading || uploading || selectedFiles.length == 0
|
||||
}
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={clear}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
import { useContext } from "react";
|
||||
import { Button, Modal } from "react-bootstrap";
|
||||
import { APIContext } from "../context";
|
||||
import { ErrorComponent } from "./ErrorComponent";
|
||||
import { LogStream } from "./LogStream";
|
||||
import { APIContext } from "../../context";
|
||||
import { ErrorComponent } from "../ErrorComponent";
|
||||
import { LogStream } from "../LogStream";
|
||||
import { Modal } from "./Modal";
|
||||
import { ModalFooter } from "./ModalFooter";
|
||||
import { ModalBody } from "./ModalBody";
|
||||
import { Button } from "../buttons/Button";
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
|
|
@ -14,11 +17,13 @@ export const LogStreamModal: React.FC<Props> = ({ show, onClose }) => {
|
|||
let logsUrl = api.getStreamLogsUrl();
|
||||
|
||||
return (
|
||||
<Modal size="xl" show={show} onHide={onClose}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>rqbit server logs</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Modal
|
||||
isOpen={show}
|
||||
onClose={onClose}
|
||||
title="rqbit server logs"
|
||||
className="max-w-7xl"
|
||||
>
|
||||
<ModalBody>
|
||||
{logsUrl ? (
|
||||
<LogStream url={logsUrl} />
|
||||
) : (
|
||||
|
|
@ -26,12 +31,12 @@ export const LogStreamModal: React.FC<Props> = ({ show, onClose }) => {
|
|||
error={{ text: "HTTP API not available to stream logs" }}
|
||||
></ErrorComponent>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
59
crates/librqbit/webui/src/components/modal/Modal.tsx
Normal file
59
crates/librqbit/webui/src/components/modal/Modal.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// Modal.tsx
|
||||
import React, { type ReactNode } from "react";
|
||||
import RestartModal from "@restart/ui/Modal";
|
||||
import { BsX } from "react-icons/bs";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose?: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ModalHeader: React.FC<{
|
||||
onClose?: () => void;
|
||||
title: string;
|
||||
}> = ({ onClose, title }) => {
|
||||
return (
|
||||
<div className="flex p-3 justify-between items-center border-b">
|
||||
<h2 className="text-xl font-semibold">{title}</h2>
|
||||
{onClose && (
|
||||
<button
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<BsX className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
const renderBackdrop = () => {
|
||||
return <div className="fixed inset-0 bg-black/30 z-[300]"></div>;
|
||||
};
|
||||
return (
|
||||
<RestartModal
|
||||
show={isOpen}
|
||||
onHide={onClose}
|
||||
renderBackdrop={renderBackdrop}
|
||||
className={`fixed z-[301] top-0 left-0 w-full h-full block overflow-x-hidden overflow-y-auto`}
|
||||
>
|
||||
<div
|
||||
className={`bg-white shadow-lg my-8 mx-auto max-w-2xl rounded ${className}`}
|
||||
>
|
||||
<ModalHeader onClose={onClose} title={title} />
|
||||
{children}
|
||||
</div>
|
||||
</RestartModal>
|
||||
);
|
||||
};
|
||||
5
crates/librqbit/webui/src/components/modal/ModalBody.tsx
Normal file
5
crates/librqbit/webui/src/components/modal/ModalBody.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { ReactNode } from "react";
|
||||
|
||||
export const ModalBody = ({ children }: { children: ReactNode }) => {
|
||||
return <div className="p-3 border-b">{children}</div>;
|
||||
};
|
||||
13
crates/librqbit/webui/src/components/modal/ModalFooter.tsx
Normal file
13
crates/librqbit/webui/src/components/modal/ModalFooter.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { ReactNode } from "react";
|
||||
|
||||
export const ModalFooter = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className={`p-3 flex justify-end gap-2 ${className}`}>{children}</div>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue