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:
Igor Katson 2023-12-14 10:37:29 +00:00 committed by GitHub
parent 911bf3a0d5
commit 50fc7f2f01
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
62 changed files with 7454 additions and 1776 deletions

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

View 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} />;
};

View file

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

View file

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

View file

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

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

View file

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

View file

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

View 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>
</>
);
};

View file

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

View file

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

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

View file

@ -0,0 +1,5 @@
import { ReactNode } from "react";
export const Form = ({ children }: { children: ReactNode }) => {
return <form>{children}</form>;
};

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

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

View file

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

View file

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

View file

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

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

View file

@ -0,0 +1,5 @@
import { ReactNode } from "react";
export const ModalBody = ({ children }: { children: ReactNode }) => {
return <div className="p-3 border-b">{children}</div>;
};

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