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