import { MouseEventHandler, StrictMode, createContext, useContext, useEffect, useRef, useState } from 'react'; import ReactDOM from 'react-dom/client'; import { ProgressBar, Button, Container, Row, Col, Alert, Modal, Form, Spinner, Table } from 'react-bootstrap'; import { AddTorrentResponse, TorrentDetails, TorrentFile, TorrentId, TorrentStats, ErrorDetails, API, STATE_INITIALIZING, STATE_LIVE, STATE_PAUSED, STATE_ERROR } from './api'; interface Error { text: string, details?: ErrorDetails, } interface ContextType { setCloseableError: (error: Error) => void, refreshTorrents: () => void, } const AppContext = createContext(null); const RefreshTorrentStatsContext = createContext<{ refresh: () => void }>(null); const IconButton: React.FC<{ className: string, onClick: () => void, disabled?: boolean, color?: string, }> = ({ className, onClick, disabled, color }) => { const onClickStopPropagation = (e) => { e.stopPropagation(); if (disabled) { return; } onClick(); } return } const DeleteTorrentModal = ({ id, show, onHide }) => { if (!show) { return null; } const [deleteFiles, setDeleteFiles] = useState(false); const [error, setError] = useState(null); const [deleting, setDeleting] = useState(false); const ctx = useContext(AppContext); 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 Delete torrent
setDeleteFiles(!deleteFiles)}>
{error && }
{deleting && }
} const TorrentActions: React.FC<{ id: number, statsResponse: TorrentStats }> = ({ id, statsResponse }) => { let state = statsResponse.state; let [disabled, setDisabled] = useState(false); let [deleting, setDeleting] = useState(false); let refreshCtx = useContext(RefreshTorrentStatsContext); const canPause = state == 'live'; const canUnpause = state == 'paused' || state == 'error'; const ctx = useContext(AppContext); const unpause = () => { setDisabled(true); API.start(id).then(() => { refreshCtx.refresh() }, (e) => { ctx.setCloseableError({ text: `Error starting torrent id=${id}`, details: e, }); }).finally(() => setDisabled(false)) }; const pause = () => { setDisabled(true); API.pause(id).then(() => { refreshCtx.refresh() }, (e) => { ctx.setCloseableError({ text: `Error pausing torrent id=${id}`, details: e, }); }).finally(() => setDisabled(false)) }; const startDeleting = () => { setDisabled(true); setDeleting(true); } const cancelDeleting = () => { setDisabled(false); setDeleting(false); } return {canUnpause && } {canPause && } } const TorrentRow: React.FC<{ id: number, detailsResponse: TorrentDetails, statsResponse: TorrentStats }> = ({ id, detailsResponse, statsResponse }) => { const state = statsResponse?.state ?? ""; const error = statsResponse?.error; 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; if (!peer_stats) { return ''; } return `${peer_stats.live} / ${peer_stats.seen}`; } const formatDownloadSpeed = () => { if (finished) { return 'Completed'; } switch (state) { case STATE_PAUSED: return 'Paused'; case STATE_INITIALIZING: return 'Checking files'; case STATE_ERROR: return 'Error'; } return statsResponse.live?.download_speed.human_readable ?? "N/A"; } let classNames = []; if (error) { classNames.push('bg-warning'); } else { if (id % 2 == 0) { classNames.push('bg-light'); } } return ( {detailsResponse ? <>
{getLargestFileName(detailsResponse)}
{error &&

Error: {error}

} : }
{statsResponse ? <> {`${formatBytes(totalBytes)} `} {formatDownloadSpeed()} {getCompletionETA(statsResponse)} {formatPeersString()} : }
); } const Column: React.FC<{ label: string, size?: number, children?: any }> = ({ size, label, children }) => (
{label}
{children} ); const Torrent = ({ id, torrent }) => { const [detailsResponse, updateDetailsResponse] = useState(null); const [statsResponse, updateStatsResponse] = useState(null); const [forceStatsRefresh, setForceStatsRefresh] = useState(0); const forceStatsRefreshCallback = () => { setForceStatsRefresh(forceStatsRefresh + 1); } // Update details once. useEffect(() => { if (detailsResponse === null) { return loopUntilSuccess(async () => { await API.getTorrentDetails(torrent.id).then(updateDetailsResponse); }, 1000); } }, [detailsResponse]); // Update stats once then forever. useEffect(() => customSetInterval((async () => { const errorInterval = 10000; const liveInterval = 1000; const finishedInterval = 10000; const nonLiveInterval = 10000; return API.getTorrentStats(torrent.id).then((stats) => { updateStatsResponse(stats); return stats; }).then((stats) => { if (stats.finished) { return finishedInterval; } if (stats.state == STATE_INITIALIZING || stats.state == STATE_LIVE) { return liveInterval; } return nonLiveInterval; }, (e) => { return errorInterval; }); }), 0), [forceStatsRefresh]); return } const TorrentsList = (props: { torrents: Array, loading: boolean }) => { if (props.torrents === null && props.loading) { return } // The app either just started, or there was an error loading torrents. if (props.torrents === null) { return; } if (props.torrents.length === 0) { return

No existing torrents found. Add them through buttons below.

; } return <> {props.torrents.map((t: TorrentId) => )} ; }; const Root = () => { const [closeableError, setCloseableError] = useState(null); const [otherError, setOtherError] = useState(null); const [torrents, setTorrents] = useState>(null); const [torrentsLoading, setTorrentsLoading] = useState(false); const refreshTorrents = async () => { setTorrentsLoading(true); let torrents = await API.listTorrents().finally(() => setTorrentsLoading(false)); setTorrents(torrents.torrents); }; useEffect(() => { return customSetInterval(async () => refreshTorrents().then(() => { setOtherError(null); return 5000; }, (e) => { setOtherError({ text: 'Error refreshing torrents', details: e }); console.error(e); return 5000; }), 0); }, []); const context: ContextType = { setCloseableError, refreshTorrents, } return

rqbit web 4.0.0-beta.0

} const ErrorDetails = (props: { details: ErrorDetails }) => { let { details } = props; if (!details) { return null; } return <> {details.status && {details.status} {details.statusText}: } {details.text} } const ErrorComponent = (props: { error: Error, remove?: () => void }) => { let { error, remove } = props; if (error == null) { return null; } return ( {error.text} ); }; const UploadButton = ({ buttonText, onClick, data, resetData, variant }) => { const [loading, setLoading] = useState(false); const [fileList, setFileList] = useState([]); const [fileListError, setFileListError] = useState(null); const ctx = useContext(AppContext); const showModal = data !== null || fileListError !== null; // Get the torrent file list if there's data. useEffect(() => { if (data === null) { return; } let t = setTimeout(async () => { setLoading(true); try { const response = await API.uploadTorrent(data, { listOnly: true }); setFileList(response.details.files); } catch (e) { setFileListError({ text: 'Error uploading torrent', details: e }); } finally { setLoading(false); } }, 0); return () => clearTimeout(t); }, [data]); const clear = () => { resetData(); setFileListError(null); setFileList([]); setLoading(false); } return ( <> ); }; const MagnetInput = () => { let [magnet, setMagnet] = useState(null); const onClick = () => { const m = prompt('Enter magnet link or HTTP(s) URL'); setMagnet(m === '' ? null : m); }; return ( setMagnet(null)} /> ); }; const FileInput = () => { const inputRef = useRef(); const [file, setFile] = useState(null); const onFileChange = async () => { const file = inputRef.current.files[0]; setFile(file); }; const reset = () => { inputRef.current.value = ''; setFile(null); } const onClick = () => { inputRef.current.click(); } return ( <> ); }; const FileSelectionModal = (props: { show: boolean, onHide: () => void, fileList: Array, fileListError: Error, fileListLoading: boolean, data: string | File }) => { let { show, onHide, fileList, fileListError, fileListLoading, data } = props; const [selectedFiles, setSelectedFiles] = useState([]); const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const ctx = useContext(AppContext); useEffect(() => { setSelectedFiles(fileList.map((_, id) => id)); }, [fileList]); const clear = () => { onHide(); setSelectedFiles([]); setUploadError(null); setUploading(false); } const handleToggleFile = (toggledId: number) => { if (selectedFiles.includes(toggledId)) { setSelectedFiles(selectedFiles.filter((i) => i !== toggledId)); } else { setSelectedFiles([...selectedFiles, toggledId]); } }; const handleUpload = async () => { setUploading(true); API.uploadTorrent(data, { selectedFiles }).then( () => { onHide(); ctx.refreshTorrents(); }, (e) => { setUploadError({ text: 'Error starting torrent', details: e }); } ).finally(() => setUploading(false)); }; return ( {!!fileListError || Select Files} {fileListLoading ? : fileListError ? :
{fileList.map((file, index) => ( handleToggleFile(index)}> ))}
}
{uploading && }
); }; const Buttons = () => { return (
); }; const RootContent = (props: { closeableError: ErrorDetails, otherError: ErrorDetails, torrents: Array, torrentsLoading: boolean }) => { let ctx = useContext(AppContext); return ctx.setCloseableError(null)} /> }; function formatBytes(bytes: number): string { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } function getLargestFileName(torrentDetails: TorrentDetails): string { const largestFile = torrentDetails.files.filter( (f) => f.included ).reduce( (prev: any, current: any) => (prev.length > current.length) ? prev : current ); return largestFile.name; } function getCompletionETA(stats: TorrentStats): string { let duration = stats?.live?.time_remaining?.duration?.secs; if (duration == null) { return 'N/A'; } return formatSecondsToTime(duration); } function formatSecondsToTime(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const remainingSeconds = seconds % 60; const formatUnit = (value: number, unit: string) => (value > 0 ? `${value}${unit}` : ''); if (hours > 0) { return `${formatUnit(hours, 'h')} ${formatUnit(minutes, 'm')}`.trim(); } else if (minutes > 0) { return `${formatUnit(minutes, 'm')} ${formatUnit(remainingSeconds, 's')}`.trim(); } else { return `${formatUnit(remainingSeconds, 's')}`.trim(); } } // Run a function with initial interval, then run it forever with the interval that the // callback returns. // Returns a callback to clear it. function customSetInterval(asyncCallback: () => Promise, initialInterval: number): () => void { let timeoutId: number; let currentInterval: number = initialInterval; const executeCallback = async () => { currentInterval = await asyncCallback(); if (currentInterval === null || currentInterval === undefined) { throw 'asyncCallback returned null or undefined'; } scheduleNext(); } let scheduleNext = () => { timeoutId = setTimeout(executeCallback, currentInterval); } scheduleNext(); return () => { clearTimeout(timeoutId); }; } function loopUntilSuccess(callback: () => Promise, interval: number): () => void { let timeoutId: number; const executeCallback = async () => { let retry = await callback().then(() => false, () => true); if (retry) { scheduleNext(); } } let scheduleNext = (overrideInterval?: number) => { timeoutId = setTimeout(executeCallback, overrideInterval !== undefined ? overrideInterval : interval); } scheduleNext(0); return () => clearTimeout(timeoutId); } // List all torrents on page load and set up auto-refresh async function init(): Promise { const torrentsContainer = document.getElementById('app'); ReactDOM.createRoot(torrentsContainer).render(); } // Call init function on page load document.addEventListener('DOMContentLoaded', init);