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

@ -238,6 +238,15 @@ impl HttpApi {
)
}),
)
.route(
"/assets/index.css",
get(|| async {
(
[("Content-Type", "text/css")],
include_str!("../webui/dist/assets/index.css"),
)
}),
)
.route(
"/assets/logo.svg",
get(|| async {

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64mm"
height="64mm"
@ -13,7 +12,8 @@
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
@ -31,25 +31,34 @@
inkscape:window-x="0"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" /><defs
id="defs1"><inkscape:perspective
inkscape:current-layer="layer1" />
<defs
id="defs1">
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="3.1042448 : 18.147022 : 1"
inkscape:vp_y="0 : 999.99994 : 0"
inkscape:vp_z="303.94612 : 54.05812 : 1"
inkscape:persp3d-origin="105 : -134 : 1"
id="perspective4" /></defs><g
id="perspective4" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-56.674541,-85.911432)"><path
transform="translate(-56.674541,-85.911432)">
<path
style="fill:#0d6efd;fill-opacity:1;stroke-width:0.610041"
d="m 81.603814,145.5382 -7.433116,-4.36986 -6.018097,-3.54529 -6.018099,-3.54529 -0.09405,-0.35761 -0.09406,-0.3576 v -15.31979 -15.31979 l 0.09451,-0.35939 0.09451,-0.35938 6.017643,-3.544433 6.017641,-3.544428 7.432805,-4.369167 7.432803,-4.369166 0.240089,0.09331 0.240083,0.09331 13.292394,7.826122 13.29241,7.826122 0.093,0.35355 0.093,0.35355 v 15.31979 15.31979 l -0.0928,0.35267 -0.0928,0.35266 -13.29682,7.82802 -13.296819,7.82803 -0.235583,0.0921 -0.235588,0.0921 z m 19.758596,-5.88315 12.13185,-7.15306 v -14.45996 -14.45994 l -5.11526,-3.01603 -5.11526,-3.016035 -7.017048,-4.136312 -7.017053,-4.13631 h -0.112738 -0.11273 l -7.097035,4.182089 -7.09703,4.18209 -5.035336,2.971548 -5.035335,2.97155 v 14.45761 14.45762 l 5.275113,3.11051 5.275113,3.11051 6.793709,4.02813 6.793702,4.02812 0.176743,0.0155 0.176742,0.0155 z"
id="path15"
sodipodi:nodetypes="cccccccccccccccccccccccccccccccccccccccccccccccccccc" /><path
sodipodi:nodetypes="cccccccccccccccccccccccccccccccccccccccccccccccccccc" />
<path
style="fill:#000000"
id="path1"
d="" /><path
d="" />
<path
d="m 84.161856,133.76725 -4.567369,-2.5483 -0.700367,0.28585 -0.700376,0.28584 -0.495731,0.11427 -0.495731,0.11426 -3.168791,-1.75603 -3.168782,-1.75603 -0.515321,-0.57002 -0.515323,-0.57002 0.0161,-3.59449 0.0161,-3.59448 0.164041,-0.41611 0.164042,-0.41611 1.480342,-0.85565 1.480346,-0.85565 0.06276,-3.67919 0.06276,-3.67921 0.39653,-0.35884 0.396521,-0.35886 3.663275,-2.04111 3.663267,-2.04112 0.114523,-1.62785 0.114506,-1.62786 0.229029,-0.21764 0.229038,-0.21765 3.341459,-1.854383 3.341467,-1.854382 h 0.301682 0.301682 l 2.476951,1.380374 2.476959,1.380381 1.151673,0.57845 1.151674,0.57845 0.02969,0.27424 0.02969,0.27423 0.02753,1.46272 0.02753,1.46273 3.663258,2.04521 3.66327,2.04521 0.40205,0.36386 0.40206,0.36385 v 3.60098 3.60097 l 0.28629,0.23408 0.28628,0.23408 1.43146,0.72479 1.43145,0.72479 v 3.9806 3.98059 l -0.51533,0.56995 -0.51532,0.56994 -3.19072,1.75191 -3.19072,1.75191 -0.47379,-0.11007 -0.473797,-0.11007 -0.699654,-0.28558 -0.699656,-0.28558 -4.589745,2.5527 -4.589746,2.55269 -0.321886,-0.004 -0.321886,-0.004 z m 8.681258,-1.65766 3.426793,-1.91645 0.06536,-0.19784 0.06535,-0.19785 -1.315884,-0.68709 -1.315885,-0.6871 -0.401264,-0.40081 -0.401265,-0.4008 v -3.79409 -3.79409 l 0.221568,-0.41401 0.221567,-0.414 3.411065,-1.86126 3.411061,-1.86126 h 0.38456 0.38455 l 0.91435,0.46646 0.91435,0.46646 -0.0646,-2.66249 -0.0646,-2.66248 -2.829977,-1.58148 -2.829984,-1.58149 -0.204696,0.12651 -0.204688,0.12651 v 1.11999 1.11999 l -0.744359,0.54975 -0.744358,0.54977 -2.773906,1.52318 -2.773906,1.52319 h -0.526565 -0.526565 l -3.34145,-1.85945 -3.341467,-1.85945 -0.168271,-0.2591 -0.16827,-0.25909 -0.0035,-1.07439 -0.0035,-1.07439 -0.204688,-0.12651 -0.204697,-0.12651 -2.82999,1.58149 -2.829982,1.58148 -0.06458,2.66248 -0.06458,2.66249 0.914351,-0.46646 0.914343,-0.46646 h 0.378551 0.37855 l 3.638639,1.97495 3.63864,1.97495 v 4.0944 4.09441 l -0.401265,0.4008 -0.401264,0.40081 -1.315885,0.6871 -1.315884,0.68709 0.06535,0.19785 0.06536,0.19784 3.426792,1.91645 3.426785,1.91644 h 0.343552 0.343544 z m -16.416873,-5.4431 -0.06475,-2.55838 -2.046125,-1.10985 -2.046124,-1.10984 -0.195677,0.19567 -0.195685,0.19568 0.06596,2.2656 0.06596,2.2656 2.06129,1.18713 2.06129,1.18713 0.179282,0.0198 0.179273,0.0198 z m 4.515881,1.34369 2.061291,-1.19456 0.06527,-2.48747 0.06527,-2.48748 -0.408821,0.15589 -0.408848,0.15589 -1.889514,1.02885 -1.889514,1.02885 v 2.50736 2.50736 l 0.171777,-0.01 0.171767,-0.01 z m 18.666131,-1.29267 v -2.50736 l -1.889509,-1.02885 -1.889522,-1.02885 -0.40883,-0.15589 -0.408839,-0.15589 0.06527,2.48748 0.06527,2.48747 2.061291,1.19456 2.061288,1.19456 0.171777,0.01 0.171774,0.01 z m 4.466127,1.28059 2.06129,-1.18713 0.0661,-2.2656 0.066,-2.2656 -0.19568,-0.19568 -0.19568,-0.19567 -2.04613,1.10984 -2.04612,1.10985 -0.0647,2.55838 -0.0648,2.55838 0.17927,-0.0198 0.17927,-0.0198 z m -24.281888,-6.71835 1.960418,-1.07415 -0.07566,-0.22693 -0.07566,-0.22691 -2.049423,-1.09443 -2.049431,-1.09442 -2.116604,1.14523 -2.116613,1.14523 v 0.17781 0.17782 l 2.004029,1.06709 2.004037,1.06711 0.277236,0.005 0.277237,0.005 z m 23.056528,4.3e-4 2.03643,-1.07372 -0.0708,-0.21245 -0.0708,-0.21244 -2.09332,-1.13015 -2.09332,-1.13016 -2.077692,1.17124 -2.077685,1.17123 v 0.17384 0.17386 l 1.889522,1.0551 1.889505,1.0551 0.31588,0.0161 0.31587,0.0161 z m -14.742004,-11.90808 0.06492,-2.2946 -0.293917,-0.23052 -0.293926,-0.2305 -2.011741,-1.08631 -2.01175,-1.08629 0.06501,2.57478 0.06501,2.57476 1.946776,1.09773 1.946767,1.09773 0.229038,-0.0611 0.229029,-0.0611 z m 4.401245,1.31989 1.946767,-1.09857 0.06501,-2.57476 0.06501,-2.57478 -2.01175,1.08629 -2.011741,1.08631 -0.286291,0.22417 -0.286291,0.22418 v 2.22411 2.2241 l 0.152689,0.15269 0.152689,0.15269 0.133602,-0.0139 0.133602,-0.0139 z m -1.021015,-6.79999 1.952048,-1.05546 -0.224347,-0.26147 -0.224337,-0.26149 -1.958921,-1.03065 -1.958911,-1.03064 -1.958912,1.03064 -1.958912,1.03065 -0.224303,0.26142 -0.224277,0.26141 1.896915,1.04261 1.896906,1.04261 0.517494,0.0129 0.517501,0.0129 z"
style="fill:#0d6efd;fill-opacity:1;stroke-width:0.865634"
id="path1-8" /></g></svg>
id="path1-8" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64mm"
height="64mm"
@ -13,7 +12,8 @@
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
@ -31,25 +31,34 @@
inkscape:window-x="0"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" /><defs
id="defs1"><inkscape:perspective
inkscape:current-layer="layer1" />
<defs
id="defs1">
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="3.1042448 : 18.147022 : 1"
inkscape:vp_y="0 : 999.99994 : 0"
inkscape:vp_z="303.94612 : 54.05812 : 1"
inkscape:persp3d-origin="105 : -134 : 1"
id="perspective4" /></defs><g
id="perspective4" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-56.674541,-85.911432)"><path
transform="translate(-56.674541,-85.911432)">
<path
style="fill:#0d6efd;fill-opacity:1;stroke-width:0.610041"
d="m 81.603814,145.5382 -7.433116,-4.36986 -6.018097,-3.54529 -6.018099,-3.54529 -0.09405,-0.35761 -0.09406,-0.3576 v -15.31979 -15.31979 l 0.09451,-0.35939 0.09451,-0.35938 6.017643,-3.544433 6.017641,-3.544428 7.432805,-4.369167 7.432803,-4.369166 0.240089,0.09331 0.240083,0.09331 13.292394,7.826122 13.29241,7.826122 0.093,0.35355 0.093,0.35355 v 15.31979 15.31979 l -0.0928,0.35267 -0.0928,0.35266 -13.29682,7.82802 -13.296819,7.82803 -0.235583,0.0921 -0.235588,0.0921 z m 19.758596,-5.88315 12.13185,-7.15306 v -14.45996 -14.45994 l -5.11526,-3.01603 -5.11526,-3.016035 -7.017048,-4.136312 -7.017053,-4.13631 h -0.112738 -0.11273 l -7.097035,4.182089 -7.09703,4.18209 -5.035336,2.971548 -5.035335,2.97155 v 14.45761 14.45762 l 5.275113,3.11051 5.275113,3.11051 6.793709,4.02813 6.793702,4.02812 0.176743,0.0155 0.176742,0.0155 z"
id="path15"
sodipodi:nodetypes="cccccccccccccccccccccccccccccccccccccccccccccccccccc" /><path
sodipodi:nodetypes="cccccccccccccccccccccccccccccccccccccccccccccccccccc" />
<path
style="fill:#000000"
id="path1"
d="" /><path
d="" />
<path
d="m 84.161856,133.76725 -4.567369,-2.5483 -0.700367,0.28585 -0.700376,0.28584 -0.495731,0.11427 -0.495731,0.11426 -3.168791,-1.75603 -3.168782,-1.75603 -0.515321,-0.57002 -0.515323,-0.57002 0.0161,-3.59449 0.0161,-3.59448 0.164041,-0.41611 0.164042,-0.41611 1.480342,-0.85565 1.480346,-0.85565 0.06276,-3.67919 0.06276,-3.67921 0.39653,-0.35884 0.396521,-0.35886 3.663275,-2.04111 3.663267,-2.04112 0.114523,-1.62785 0.114506,-1.62786 0.229029,-0.21764 0.229038,-0.21765 3.341459,-1.854383 3.341467,-1.854382 h 0.301682 0.301682 l 2.476951,1.380374 2.476959,1.380381 1.151673,0.57845 1.151674,0.57845 0.02969,0.27424 0.02969,0.27423 0.02753,1.46272 0.02753,1.46273 3.663258,2.04521 3.66327,2.04521 0.40205,0.36386 0.40206,0.36385 v 3.60098 3.60097 l 0.28629,0.23408 0.28628,0.23408 1.43146,0.72479 1.43145,0.72479 v 3.9806 3.98059 l -0.51533,0.56995 -0.51532,0.56994 -3.19072,1.75191 -3.19072,1.75191 -0.47379,-0.11007 -0.473797,-0.11007 -0.699654,-0.28558 -0.699656,-0.28558 -4.589745,2.5527 -4.589746,2.55269 -0.321886,-0.004 -0.321886,-0.004 z m 8.681258,-1.65766 3.426793,-1.91645 0.06536,-0.19784 0.06535,-0.19785 -1.315884,-0.68709 -1.315885,-0.6871 -0.401264,-0.40081 -0.401265,-0.4008 v -3.79409 -3.79409 l 0.221568,-0.41401 0.221567,-0.414 3.411065,-1.86126 3.411061,-1.86126 h 0.38456 0.38455 l 0.91435,0.46646 0.91435,0.46646 -0.0646,-2.66249 -0.0646,-2.66248 -2.829977,-1.58148 -2.829984,-1.58149 -0.204696,0.12651 -0.204688,0.12651 v 1.11999 1.11999 l -0.744359,0.54975 -0.744358,0.54977 -2.773906,1.52318 -2.773906,1.52319 h -0.526565 -0.526565 l -3.34145,-1.85945 -3.341467,-1.85945 -0.168271,-0.2591 -0.16827,-0.25909 -0.0035,-1.07439 -0.0035,-1.07439 -0.204688,-0.12651 -0.204697,-0.12651 -2.82999,1.58149 -2.829982,1.58148 -0.06458,2.66248 -0.06458,2.66249 0.914351,-0.46646 0.914343,-0.46646 h 0.378551 0.37855 l 3.638639,1.97495 3.63864,1.97495 v 4.0944 4.09441 l -0.401265,0.4008 -0.401264,0.40081 -1.315885,0.6871 -1.315884,0.68709 0.06535,0.19785 0.06536,0.19784 3.426792,1.91645 3.426785,1.91644 h 0.343552 0.343544 z m -16.416873,-5.4431 -0.06475,-2.55838 -2.046125,-1.10985 -2.046124,-1.10984 -0.195677,0.19567 -0.195685,0.19568 0.06596,2.2656 0.06596,2.2656 2.06129,1.18713 2.06129,1.18713 0.179282,0.0198 0.179273,0.0198 z m 4.515881,1.34369 2.061291,-1.19456 0.06527,-2.48747 0.06527,-2.48748 -0.408821,0.15589 -0.408848,0.15589 -1.889514,1.02885 -1.889514,1.02885 v 2.50736 2.50736 l 0.171777,-0.01 0.171767,-0.01 z m 18.666131,-1.29267 v -2.50736 l -1.889509,-1.02885 -1.889522,-1.02885 -0.40883,-0.15589 -0.408839,-0.15589 0.06527,2.48748 0.06527,2.48747 2.061291,1.19456 2.061288,1.19456 0.171777,0.01 0.171774,0.01 z m 4.466127,1.28059 2.06129,-1.18713 0.0661,-2.2656 0.066,-2.2656 -0.19568,-0.19568 -0.19568,-0.19567 -2.04613,1.10984 -2.04612,1.10985 -0.0647,2.55838 -0.0648,2.55838 0.17927,-0.0198 0.17927,-0.0198 z m -24.281888,-6.71835 1.960418,-1.07415 -0.07566,-0.22693 -0.07566,-0.22691 -2.049423,-1.09443 -2.049431,-1.09442 -2.116604,1.14523 -2.116613,1.14523 v 0.17781 0.17782 l 2.004029,1.06709 2.004037,1.06711 0.277236,0.005 0.277237,0.005 z m 23.056528,4.3e-4 2.03643,-1.07372 -0.0708,-0.21245 -0.0708,-0.21244 -2.09332,-1.13015 -2.09332,-1.13016 -2.077692,1.17124 -2.077685,1.17123 v 0.17384 0.17386 l 1.889522,1.0551 1.889505,1.0551 0.31588,0.0161 0.31587,0.0161 z m -14.742004,-11.90808 0.06492,-2.2946 -0.293917,-0.23052 -0.293926,-0.2305 -2.011741,-1.08631 -2.01175,-1.08629 0.06501,2.57478 0.06501,2.57476 1.946776,1.09773 1.946767,1.09773 0.229038,-0.0611 0.229029,-0.0611 z m 4.401245,1.31989 1.946767,-1.09857 0.06501,-2.57476 0.06501,-2.57478 -2.01175,1.08629 -2.011741,1.08631 -0.286291,0.22417 -0.286291,0.22418 v 2.22411 2.2241 l 0.152689,0.15269 0.152689,0.15269 0.133602,-0.0139 0.133602,-0.0139 z m -1.021015,-6.79999 1.952048,-1.05546 -0.224347,-0.26147 -0.224337,-0.26149 -1.958921,-1.03065 -1.958911,-1.03064 -1.958912,1.03064 -1.958912,1.03065 -0.224303,0.26142 -0.224277,0.26141 1.896915,1.04261 1.896906,1.04261 0.517494,0.0129 0.517501,0.0129 z"
style="fill:#0d6efd;fill-opacity:1;stroke-width:0.865634"
id="path1-8" /></g></svg>
id="path1-8" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -5,14 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>rqbit web</title>
<link rel="icon" type="image/svg+xml" href="assets/logo.svg" />
<!-- Include Bootstrap CSS -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM"
crossorigin="anonymous"
/>
<script type="module" crossorigin src="assets/index.js"></script>
<link rel="stylesheet" href="assets/index.css">
</head>
<body>

View File

@ -1,10 +1,17 @@
{
"assets/logo.svg": {
"file": "assets/logo-083ce41b.svg",
"file": "assets/logo-22bc8ae6.svg",
"src": "assets/logo.svg"
},
"index.css": {
"file": "assets/index-bc066ae5.css",
"src": "index.css"
},
"index.html": {
"file": "assets/index-af1222d3.js",
"css": [
"assets/index-bc066ae5.css"
],
"file": "assets/index-9e7b65dd.js",
"isEntry": true,
"src": "index.html"
}

View File

@ -5,13 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>rqbit web</title>
<link rel="icon" type="image/svg+xml" href="assets/logo.svg" />
<!-- Include Bootstrap CSS -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM"
crossorigin="anonymous"
/>
<link rel="stylesheet" href="src/globals.css" />
</head>
<body>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -8,10 +8,9 @@
"preview": "vite preview"
},
"dependencies": {
"@vitejs/plugin-react": "^4.2.1",
"@restart/ui": "^1.6.6",
"lodash.debounce": "^4.0.8",
"react": "^18.2.0",
"react-bootstrap": "^2.9.1",
"react-dom": "^18.2.0",
"react-icons": "^4.12.0"
},
@ -19,8 +18,13 @@
"@types/lodash.debounce": "^4.0.9",
"@types/react": "^18.2.38",
"@types/react-dom": "^18.2.16",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"prettier": "3.1.0",
"tailwindcss": "^3.3.6",
"typescript": "^5.3.2",
"vite": "^4.5.1"
"vite": "^4.5.1",
"vite-plugin-svgr": "^4.2.0"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

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

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -6,7 +6,7 @@ export function customSetInterval(
asyncCallback: () => Promise<number>,
initialInterval: number
): () => void {
let timeoutId: number;
let timeoutId: any;
let currentInterval: number = initialInterval;
const executeCallback = async () => {

View File

@ -2,7 +2,7 @@ export function loopUntilSuccess<T>(
callback: () => Promise<T>,
interval: number
): () => void {
let timeoutId: number;
let timeoutId: any;
const executeCallback = async () => {
let retry = await callback().then(

View File

@ -4,6 +4,7 @@ import { RqbitWebUI } from "./rqbit-web";
import { customSetInterval } from "./helper/customSetInterval";
import { APIContext } from "./context";
import { API } from "./http-api";
import "./globals.css";
const RootWithVersion = () => {
let [title, setTitle] = useState<string>("rqbit web UI");

View File

@ -0,0 +1,35 @@
import { type ReactNode, createContext, useContext, useState } from "react";
interface ModalContextProps {
isOpen: boolean;
openModal: () => void;
closeModal: () => void;
}
const ModalContext = createContext<ModalContextProps | undefined>(undefined);
export const ModalProvider = ({ children }: { children: ReactNode }) => {
const [isOpen, setIsOpen] = useState(false);
const openModal = () => {
setIsOpen(true);
};
const closeModal = () => {
setIsOpen(false);
};
return (
<ModalContext.Provider value={{ isOpen, openModal, closeModal }}>
{children}
</ModalContext.Provider>
);
};
export const useModal = () => {
const context = useContext(ModalContext);
if (!context) {
throw new Error("useModal must be used within a ModalProvider");
}
return context;
};

View File

@ -3,9 +3,10 @@ import { TorrentId, ErrorDetails as ApiErrorDetails } from "./api-types";
import { AppContext, APIContext } from "./context";
import { RootContent } from "./components/RootContent";
import { customSetInterval } from "./helper/customSetInterval";
import { IconButton } from "./components/IconButton";
import { IconButton } from "./components/buttons/IconButton";
import { BsBodyText } from "react-icons/bs";
import { LogStreamModal } from "./components/LogStreamModal";
import { LogStreamModal } from "./components/modal/LogStreamModal";
import { Header } from "./components/Header";
export interface ErrorWithLabel {
text: string;
@ -65,8 +66,17 @@ export const RqbitWebUI = (props: {
return (
<AppContext.Provider value={context}>
<div className="text-center">
<h1 className="mt-3 mb-4">{props.title}</h1>
<Header title={props.title} />
<div className="relative">
{/* Menu buttons */}
<div className="absolute top-0 start-0 pl-2 z-10">
{props.menuButtons &&
props.menuButtons.map((b, i) => <span key={i}>{b}</span>)}
<IconButton onClick={() => setLogsOpened(true)}>
<BsBodyText />
</IconButton>
</div>
<RootContent
closeableError={closeableError}
otherError={otherError}
@ -75,15 +85,6 @@ export const RqbitWebUI = (props: {
/>
</div>
{/* Menu buttons */}
<div className="position-absolute top-0 start-0 p-1">
{props.menuButtons &&
props.menuButtons.map((b, i) => <span key={i}>{b}</span>)}
<IconButton onClick={() => setLogsOpened(true)}>
<BsBodyText />
</IconButton>
</div>
<LogStreamModal show={logsOpened} onClose={() => setLogsOpened(false)} />
</AppContext.Provider>
);

View File

@ -0,0 +1,25 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
fadeIn: {
from: { opacity: 0 },
to: { opacity: 1 },
},
fadeOut: {
from: { opacity: 1 },
to: { opacity: 0 },
},
},
animation: {
'fade-in': 'fadeIn 0.3s ease-in-out',
'fade-out': 'fadeOut 0.3s ease-in-out',
},
},
plugins: [],
}

View File

@ -1,9 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [react(), svgr()],
server: {
port: 3031,
},

View File

@ -6,7 +6,7 @@
<title>rqbit web 4.0.0-beta.0</title>
<link rel="icon" type="image/svg+xml" href="assets/logo.svg" />
<!-- Include Bootstrap CSS -->
<link rel="stylesheet" href="/src/styles/bootstrap.min.css" />
<!-- <link rel="stylesheet" href="/src/styles/bootstrap.min.css" /> -->
</head>
<body>

4477
desktop/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,12 +10,11 @@
},
"dependencies": {
"@tauri-apps/api": "^1.5.1",
"rqbit-webui": "file:../crates/librqbit/webui",
"lodash.debounce": "^4.0.8",
"react": "^18.2.0",
"react-bootstrap": "^2.9.1",
"react-dom": "^18.2.0",
"react-icons": "^4.12.0"
"react-icons": "^4.12.0",
"rqbit-webui": "file:../crates/librqbit/webui"
},
"devDependencies": {
"@tauri-apps/cli": ">=2.0.0-alpha.16",
@ -23,7 +22,11 @@
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react": "^4.0.3",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.0.2",
"vite": "^4.5.1"
"vite": "^4.5.1",
"vite-plugin-svgr": "^4.2.0"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -1,9 +1,15 @@
import React, { useState } from "react";
import React, { ReactNode, useState } from "react";
import { RqbitDesktopConfig } from "./configuration";
import { Button, Form, Modal, Row, Tab, Tabs } from "react-bootstrap";
import { ErrorComponent } from "rqbit-webui/src/components/ErrorComponent";
import { invokeAPI } from "./api";
import { ErrorDetails } from "rqbit-webui/src/api-types";
import { FormCheckbox } from "rqbit-webui/src/components/forms/FormCheckbox";
import { FormInput as FI } from "rqbit-webui/src/components/forms/FormInput";
import { ModalBody } from "rqbit-webui/src/components/modal/ModalBody";
import { Modal } from "rqbit-webui/src/components/modal/Modal";
import { Fieldset } from "rqbit-webui/src/components/forms/Fieldset";
import { ModalFooter } from "rqbit-webui/src/components/modal/ModalFooter";
import { Button } from "rqbit-webui/src/components/buttons/Button";
const FormCheck: React.FC<{
label: string;
@ -14,19 +20,14 @@ const FormCheck: React.FC<{
help?: string;
}> = ({ label, name, checked, onChange, disabled, help }) => {
return (
<Form.Group as={Row} controlId={name} className="mb-3">
<Form.Label className="col-4">{label}</Form.Label>
<div className="col-8">
<Form.Check
type="switch"
name={name}
checked={checked}
onChange={onChange}
disabled={disabled}
/>
</div>
{help && <div className="form-text">{help}</div>}
</Form.Group>
<FormCheckbox
label={label}
name={name}
checked={checked}
onChange={onChange}
disabled={disabled}
help={help}
/>
);
};
@ -40,22 +41,47 @@ const FormInput: React.FC<{
help?: string;
}> = ({ label, name, value, inputType, onChange, disabled, help }) => {
return (
<Form.Group as={Row} controlId={name} className="mb-3">
<Form.Label className="col-4 col-form-label">{label}</Form.Label>
<div className="col-8">
<Form.Control
type={inputType}
name={name}
value={value}
onChange={onChange}
disabled={disabled}
/>
</div>
{help && <div className="form-text">{help}</div>}
</Form.Group>
<FI
inputType={inputType}
name={name}
value={value as string}
onChange={onChange}
disabled={disabled}
label={label}
help={help}
/>
);
};
type TAB =
| "Home"
| "DHT"
| "Session"
| "Peer options"
| "HTTP API"
| "TCP Listen";
const TABS: readonly TAB[] = [
"Home",
"DHT",
"Session",
"TCP Listen",
"Peer options",
"HTTP API",
] as const;
const Tab: React.FC<{
name: TAB;
currentTab: TAB;
children: ReactNode;
}> = ({ name, currentTab, children }) => {
const show = name === currentTab;
if (!show) {
return;
}
return <div>{children}</div>;
};
export const ConfigModal: React.FC<{
show: boolean;
handleStartReconfigure: () => void;
@ -74,6 +100,8 @@ export const ConfigModal: React.FC<{
let [config, setConfig] = useState<RqbitDesktopConfig>(initialConfig);
let [loading, setLoading] = useState<boolean>(false);
let [tab, setTab] = useState<TAB>("Home");
const [error, setError] = useState<any | null>(null);
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
@ -143,27 +171,46 @@ export const ConfigModal: React.FC<{
};
return (
<Modal show={show} size="xl" onHide={handleCancel}>
<Modal.Header closeButton>
<Modal.Title>Configure Rqbit desktop</Modal.Title>
</Modal.Header>
<Modal.Body>
<Modal
title="Configure Rqbit desktop"
isOpen={show}
onClose={handleCancel}
className="max-w-4xl"
>
<ModalBody>
<ErrorComponent error={error}></ErrorComponent>
<Tabs defaultActiveKey="home" id="rqbit-config" className="mb-3">
<Tab className="mb-3" eventKey="home" title="Home">
<FormInput
label="Default download folder"
name="default_download_location"
value={config.default_download_location}
inputType="text"
onChange={handleInputChange}
help="Where to download torrents by default. You can override this per torrent."
/>
</Tab>
<div className="flex border-b mb-4">
{TABS.map((t, i) => {
const isActive = t === tab;
let classNames = "text-slate-300";
if (isActive) {
classNames = "text-slate-800 border-b-2 border-blue-800";
}
return (
<button
key={i}
className={`p-2 ${classNames}`}
onClick={() => setTab(t)}
>
{t}
</button>
);
})}
</div>
<Tab className="mb-3" eventKey="dht" title="DHT">
<legend>DHT config</legend>
<Tab name="Home" currentTab={tab}>
<FormInput
label="Default download folder"
name="default_download_location"
value={config.default_download_location}
inputType="text"
onChange={handleInputChange}
help="Where to download torrents by default. You can override this per torrent."
/>
</Tab>
<Tab name="DHT" currentTab={tab}>
<Fieldset label="DHT config">
<FormCheck
label="Enable DHT"
name="dht.disable"
@ -190,11 +237,11 @@ export const ConfigModal: React.FC<{
onChange={handleInputChange}
help="The filename to store DHT state into"
/>
</Tab>
<Tab className="mb-3" eventKey="tcp_listen" title="TCP">
<legend>TCP Listener config</legend>
</Fieldset>
</Tab>
<Tab name="TCP Listen" currentTab={tab}>
<Fieldset label="TCP Listener config">
<FormCheck
label="Listen on TCP"
name="tcp_listen.disable"
@ -230,11 +277,11 @@ export const ConfigModal: React.FC<{
onChange={handleInputChange}
help="The max port to try to listen on."
/>
</Tab>
<Tab className="mb-3" eventKey="session_persistence" title="Session">
<legend>Session persistence</legend>
</Fieldset>
</Tab>
<Tab name="Session" currentTab={tab}>
<Fieldset label="Session persistence">
<FormCheck
label="Enable persistence"
name="persistence.disable"
@ -251,11 +298,11 @@ export const ConfigModal: React.FC<{
onChange={handleInputChange}
disabled={config.persistence.disable}
/>
</Tab>
<Tab className="mb-3" eventKey="peer_opts" title="Peer options">
<legend>Peer connection options</legend>
</Fieldset>
</Tab>
<Tab name="Peer options" currentTab={tab}>
<Fieldset label="Peer connection options">
<FormInput
label="Connect timeout (seconds)"
inputType="number"
@ -273,11 +320,11 @@ export const ConfigModal: React.FC<{
onChange={handleInputChange}
help="Peer socket read/write timeout."
/>
</Tab>
<Tab className="mb-3" eventKey="http_api" title="HTTP API">
<legend>HTTP API config</legend>
</Fieldset>
</Tab>
<Tab name="HTTP API" currentTab={tab}>
<Fieldset label="HTTP API config">
<FormCheck
label="Enable HTTP API"
name="http_api.disable"
@ -313,10 +360,10 @@ export const ConfigModal: React.FC<{
onChange={handleInputChange}
help={`You'll access the API at http://${config.http_api.listen_addr}`}
/>
</Tab>
</Tabs>
</Modal.Body>
<Modal.Footer>
</Fieldset>
</Tab>
</ModalBody>
<ModalFooter>
{!!handleCancel && (
<Button variant="secondary" onClick={handleCancel}>
Cancel
@ -328,7 +375,7 @@ export const ConfigModal: React.FC<{
<Button variant="primary" onClick={handleOkClick} disabled={loading}>
OK
</Button>
</Modal.Footer>
</ModalFooter>
</Modal>
);
};

View File

@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import { invoke } from "@tauri-apps/api";
import { CurrentDesktopState, RqbitDesktopConfig } from "./configuration";
import { RqbitDesktop } from "./rqbit-desktop";
import "./styles/index.css";
async function get_version(): Promise<string> {
return invoke<string>("get_version");

View File

@ -2,7 +2,7 @@ import { useState } from "react";
import { RqbitWebUI } from "rqbit-webui/src/rqbit-web";
import { CurrentDesktopState, RqbitDesktopConfig } from "./configuration";
import { ConfigModal } from "./configure";
import { IconButton } from "rqbit-webui/src/components/IconButton";
import { IconButton } from "rqbit-webui/src/components/buttons/IconButton";
import { BsSliders2 } from "react-icons/bs";
import { APIContext } from "rqbit-webui/src/context";
import { makeAPI } from "./api";
@ -20,7 +20,6 @@ export const RqbitDesktop: React.FC<{
const configButton = (
<IconButton
className="p-3 text-primary"
onClick={() => {
setConfigurationOpened(true);
}}
@ -33,7 +32,7 @@ export const RqbitDesktop: React.FC<{
<APIContext.Provider value={makeAPI(config)}>
{configured && (
<RqbitWebUI
title={`Rqbit Desktop v${version}`}
title={`Rqbit Desktop - v${version}`}
menuButtons={[configButton]}
></RqbitWebUI>
)}

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./src/**/*.{js,ts,jsx,tsx,mdx}",
"../crates/librqbit/webui/src/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {},
},
plugins: [],
};

View File

@ -1,9 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
// https://vitejs.dev/config/
export default defineConfig(async () => ({
plugins: [react()],
plugins: [react(), svgr()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
@ -13,5 +14,5 @@ export default defineConfig(async () => ({
server: {
port: 1420,
strictPort: true,
}
},
}));