45 Commits

Author SHA1 Message Date
ahjephson
d8535fa262 Merge branch 'release/1.2.0' 2025-10-20 20:55:26 +01:00
ahjephson
1c6bfed6ee Merge pull request #11 from lantean-code/feature/performance-enhancements
Feature/performance enhancements
2025-10-20 20:53:42 +01:00
ahjephson
281caf8026 Format fix correctly 2025-10-20 20:43:47 +01:00
ahjephson
ff905e7cac Fix tab indicator 2025-10-20 20:42:52 +01:00
ahjephson
cb80dd0d6b Fix tabs issue 2025-10-20 20:01:11 +01:00
ahjephson
9113fb90ee Fix statusbar on ios 2025-10-20 18:44:18 +01:00
ahjephson
d8b4e932d1 Fix tabs in About 2025-10-20 17:40:40 +01:00
ahjephson
3d0d211d10 Update layout to remove hacks and rely only on flexbox 2025-10-20 16:39:20 +01:00
ahjephson
7db4f2f78d Fix final issues with longpress. Update all files to use correct encoding and ran through CodeMaid. 2025-10-20 14:54:31 +01:00
ahjephson
1f606b4449 Try fix issue with text selection on longpress 2025-10-20 13:48:05 +01:00
ahjephson
88d66b4887 Fix longpress issue 2025-10-20 13:44:57 +01:00
ahjephson
2ad7be1073 Remove custom ContextMenu and replace with MudMenu 2025-10-20 13:30:40 +01:00
ahjephson
300e81345c Fix status update performance 2025-10-20 11:03:43 +01:00
ahjephson
9d8d84168e Merge pull request #7 from lantean-code/codex/find-and-fix-a-bug
Fix typo in SaveLocation property
2025-10-20 10:15:48 +01:00
ahjephson
bb66b97f45 Merge branch 'develop' into codex/find-and-fix-a-bug 2025-10-20 10:14:47 +01:00
ahjephson
4824037ba7 Fix connection icon 2025-10-20 10:04:50 +01:00
ahjephson
1f9b631a36 Merge bugfixes in 2025-10-20 09:52:55 +01:00
ahjephson
2c744cd972 Fix issue wtih toolbar 2025-10-19 19:13:09 +01:00
ahjephson
b02bb7cfae Fix issues with toolbar not updating 2025-10-19 19:12:10 +01:00
ahjephson
e4dac8556e Improve torrent list performance 2025-10-19 15:21:22 +01:00
ahjephson
a9a8a4eba8 Improve file list performance. 2025-10-19 14:19:21 +01:00
ahjephson
bb524450f0 Fix slowness issues with FilesTab when torrents with large file lists are being rendered. 2025-10-19 11:06:45 +01:00
ahjephson
d4ac79af00 Merge pull request #10 from lantean-code/feature/bugfixes
- Fixed an issue where the tag wasn't being correctly applied to the filter in qBittorrent 5.1+ (#9)
- Fixed an issue where the category wasn't being applied to the filter correctly (#9)
- Fixed invalid ValueChanged for "Default Torrent Management Mode"
- Fixed a crash where TimeSpan.FromSeconds was crashing
- Fixed an invalid icon to appear when Paused/Stopped
2025-10-18 16:18:10 +01:00
ahjephson
7370d73c59 Fix minor display issues 2025-10-18 16:01:53 +01:00
ahjephson
8796cc0f24 Fix #9 and bug related to invalid TimeSpan in duration 2025-10-18 15:37:04 +01:00
ahjephson
b24ae440d4 Merge pull request #8 from ehaughee/develop
Fix MaxRatio to allow float values
2025-10-02 15:01:10 +01:00
Eric Haughee
bb90ce5216 Fix MaxRatio to allow float values 2025-09-20 18:29:18 -07:00
ahjephson
4eaa46b2b3 Fix property name in SaveLocation 2025-06-03 09:02:47 +01:00
ahjephson
1cf9f97187 Merge tag '1.1.0' into develop
1.1.0
2025-05-30 15:46:03 +01:00
ahjephson
4f9129fd46 Merge branch 'release/1.1.0' 2025-05-30 15:45:32 +01:00
ahjephson
9a9d2c2ee2 Update packages 2025-05-30 15:43:22 +01:00
ahjephson
736bc46745 Merge pull request #2 from lantean-code/feature/fix-statuses
Fix Paused/Stopped Duplicate
2025-05-30 14:19:34 +01:00
ahjephson
23ae19c4c7 Update readme.md 2025-05-20 13:35:59 +01:00
ahjephson
603470eb30 Merge pull request #3 from lantean-code/feature/fix-relative-resources
Fix Reverse Proxy Issue
2025-05-20 13:24:53 +01:00
ahjephson
27c2406340 Fixes #1 2025-04-22 14:08:55 +01:00
ahjephson
4578dcc11f FIx issue with duplicate paused/stopped status lists when handling v4/5 differences 2025-04-22 14:03:33 +01:00
ahjephson
3215fa3936 Merge tag '1.0.2' into develop
1.0.2
2025-03-22 13:52:41 +00:00
ahjephson
78e62f31d0 Merge branch 'hotfix/1.0.2' 2025-03-22 13:52:33 +00:00
ahjephson
e23842fcb0 Fix invalid exception being caught. 2025-03-22 13:51:44 +00:00
ahjephson
411c7f87cc Merge tag '1.0.1' into develop
1.0.1
2025-02-10 08:57:20 +00:00
ahjephson
4098f8f5a9 Merge branch 'hotfix/1.0.1' 2025-02-10 08:57:01 +00:00
ahjephson
12f81c5978 Fix issue with TorrentActions treating actions as all downloaded. 2025-02-10 08:55:46 +00:00
ahjephson
717738d720 Update readme.md 2025-02-07 13:24:25 +00:00
ahjephson
885c34c8cf Update readme.md 2025-02-07 13:10:15 +00:00
ahjephson
ef3c68a6aa Merge tag '1.0.0' into develop
1.0.0
2025-02-07 13:02:16 +00:00
87 changed files with 2874 additions and 1441 deletions

1
.gitignore vendored
View File

@@ -361,3 +361,4 @@ MigrationBackup/
# Fody - auto-generated XML schema # Fody - auto-generated XML schema
FodyWeavers.xsd FodyWeavers.xsd
/output

View File

@@ -4,24 +4,20 @@
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject> <IsTestProject>true</IsTestProject>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentAssertions" Version="7.1.0" AllowedVersions="[5.0.0,7.*.*)" /> <PackageReference Include="AwesomeAssertions" Version="9.2.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="MudBlazor" Version="8.2.0" />
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" /> <PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="System.Net.Http" Version="4.3.4" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,4 +1,4 @@
using Lantean.QBitTorrentClient; using Lantean.QBitTorrentClient;
using Lantean.QBitTorrentClient.Models; using Lantean.QBitTorrentClient.Models;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Text.Json; using System.Text.Json;
@@ -21,7 +21,7 @@ namespace Lantean.QBTMud.Test
Test2(a => a.Name); Test2(a => a.Name);
} }
private void Test2(Expression<Func<TestClass, object>> expr) private void Test2(Expression<Func<TestClass, object?>> expr)
{ {
var body = expr.Body; var body = expr.Body;
} }
@@ -38,7 +38,7 @@ namespace Lantean.QBTMud.Test
var l = Expression.Lambda<Func<TestClass, object>>(convertExpression, expression); var l = Expression.Lambda<Func<TestClass, object>>(convertExpression, expression);
Expression<Func<TestClass, object>> expr2 = a => a.Name; Expression<Func<TestClass, object?>> expr2 = a => a.Name;
var x = l.Compile(); var x = l.Compile();
var res = (long)x(new TestClass { Name = "Name", Value = 12 }); var res = (long)x(new TestClass { Name = "Name", Value = 12 });
@@ -58,9 +58,9 @@ namespace Lantean.QBTMud.Test
public class TestClass public class TestClass
{ {
public string Name { get; set; } public string? Name { get; set; }
public string Description { get; set; } public string? Description { get; set; }
public long Value { get; set; } public long Value { get; set; }
} }

View File

@@ -14,7 +14,7 @@ namespace Lantean.QBTMud.Components.Dialogs
protected IDialogService DialogService { get; set; } = default!; protected IDialogService DialogService { get; set; } = default!;
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
protected HashSet<string> Tags { get; } = []; protected HashSet<string> Tags { get; } = [];

View File

@@ -8,7 +8,7 @@ namespace Lantean.QBTMud.Components.Dialogs
public partial class AddTorrentFileDialog public partial class AddTorrentFileDialog
{ {
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
protected IReadOnlyList<IBrowserFile> Files { get; set; } = []; protected IReadOnlyList<IBrowserFile> Files { get; set; } = [];

View File

@@ -18,7 +18,7 @@ namespace Lantean.QBTMud.Components.Dialogs
protected IKeyboardService KeyboardService { get; set; } = default!; protected IKeyboardService KeyboardService { get; set; } = default!;
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter] [Parameter]
public string? Url { get; set; } public string? Url { get; set; }

View File

@@ -1,7 +1,6 @@
using Lantean.QBitTorrentClient; using Lantean.QBitTorrentClient;
using Lantean.QBTMud.Models; using Lantean.QBTMud.Models;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace Lantean.QBTMud.Components.Dialogs namespace Lantean.QBTMud.Components.Dialogs
{ {

View File

@@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs
public partial class AddTrackerDialog public partial class AddTrackerDialog
{ {
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
protected HashSet<string> Trackers { get; } = []; protected HashSet<string> Trackers { get; } = [];

View File

@@ -10,7 +10,7 @@ namespace Lantean.QBTMud.Components.Dialogs
private string _savePath = string.Empty; private string _savePath = string.Empty;
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
[Inject] [Inject]
protected IApiClient ApiClient { get; set; } = default!; protected IApiClient ApiClient { get; set; } = default!;

View File

@@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs
public partial class ConfirmDialog public partial class ConfirmDialog
{ {
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter] [Parameter]
public string Content { get; set; } = default!; public string Content { get; set; } = default!;

View File

@@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs
public partial class DeleteDialog public partial class DeleteDialog
{ {
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter] [Parameter]
public int Count { get; set; } public int Count { get; set; }

View File

@@ -6,7 +6,7 @@ namespace Lantean.QBTMud.Components.Dialogs
public partial class ExceptionDialog public partial class ExceptionDialog
{ {
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter] [Parameter]
public Exception? Exception { get; set; } public Exception? Exception { get; set; }

View File

@@ -11,7 +11,7 @@ namespace Lantean.QBTMud.Components.Dialogs
private static readonly IReadOnlyList<PropertyInfo> _properties = typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public); private static readonly IReadOnlyList<PropertyInfo> _properties = typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public);
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
protected IReadOnlyList<PropertyInfo> Columns => _properties; protected IReadOnlyList<PropertyInfo> Columns => _properties;

View File

@@ -14,7 +14,7 @@ namespace Lantean.QBTMud.Components.Dialogs
protected IDialogService DialogService { get; set; } = default!; protected IDialogService DialogService { get; set; } = default!;
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter] [Parameter]
public IEnumerable<string> Hashes { get; set; } = []; public IEnumerable<string> Hashes { get; set; } = [];

View File

@@ -14,7 +14,7 @@ namespace Lantean.QBTMud.Components.Dialogs
protected IDialogService DialogService { get; set; } = default!; protected IDialogService DialogService { get; set; } = default!;
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter] [Parameter]
public IEnumerable<string> Hashes { get; set; } = []; public IEnumerable<string> Hashes { get; set; } = [];

View File

@@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs
public partial class MultipleFieldDialog public partial class MultipleFieldDialog
{ {
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter] [Parameter]
public string Label { get; set; } = default!; public string Label { get; set; } = default!;

View File

@@ -8,7 +8,7 @@ namespace Lantean.QBTMud.Components.Dialogs
public partial class NumericFieldDialog<T> where T : struct, INumber<T> public partial class NumericFieldDialog<T> where T : struct, INumber<T>
{ {
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter] [Parameter]
public string? Label { get; set; } public string? Label { get; set; }

View File

@@ -30,7 +30,7 @@ namespace Lantean.QBTMud.Components.Dialogs
protected ILocalStorageService LocalStorage { get; set; } = default!; protected ILocalStorageService LocalStorage { get; set; } = default!;
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter] [Parameter]
public string? Hash { get; set; } public string? Hash { get; set; }
@@ -426,7 +426,6 @@ namespace Lantean.QBTMud.Components.Dialogs
{ {
await LocalStorage.RemoveItemAsync(_preferencesStorageKey); await LocalStorage.RemoveItemAsync(_preferencesStorageKey);
} }
} }
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()

View File

@@ -10,7 +10,7 @@ namespace Lantean.QBTMud.Components.Dialogs
private readonly List<string> _unsavedRuleNames = []; private readonly List<string> _unsavedRuleNames = [];
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
[Inject] [Inject]
protected IDialogService DialogService { get; set; } = default!; protected IDialogService DialogService { get; set; } = default!;

View File

@@ -8,7 +8,7 @@ namespace Lantean.QBTMud.Components.Dialogs
public partial class ShareRatioDialog public partial class ShareRatioDialog
{ {
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter] [Parameter]
public string? Label { get; set; } public string? Label { get; set; }

View File

@@ -8,7 +8,7 @@ namespace Lantean.QBTMud.Components.Dialogs
public partial class SliderFieldDialog<T> where T : struct, INumber<T> public partial class SliderFieldDialog<T> where T : struct, INumber<T>
{ {
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter] [Parameter]
public string? Label { get; set; } public string? Label { get; set; }

View File

@@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs
public partial class StringFieldDialog public partial class StringFieldDialog
{ {
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter] [Parameter]
public string? Label { get; set; } public string? Label { get; set; }

View File

@@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs
public partial class SubMenuDialog public partial class SubMenuDialog
{ {
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter] [Parameter]
public UIAction? ParentAction { get; set; } public UIAction? ParentAction { get; set; }

View File

@@ -7,7 +7,7 @@ namespace Lantean.QBTMud.Components.Dialogs
public partial class TorrentOptionsDialog public partial class TorrentOptionsDialog
{ {
[CascadingParameter] [CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = default!; private IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter] [Parameter]
[EditorRequired] [EditorRequired]

View File

@@ -1,46 +1,49 @@
<ContextMenu @ref="ContextMenu" Dense="true"> <MudMenu @ref="ContextMenu" Dense="true" PositionAtCursor="true" ListClass="unselectable" PopoverClass="unselectable">
<MudMenuItem Icon="@Icons.Material.Filled.DriveFileRenameOutline" OnClick="RenameFileContextMenu">Rename</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.DriveFileRenameOutline" OnClick="RenameFileContextMenu">Rename</MudMenuItem>
</ContextMenu> </MudMenu>
<div style="overflow-x: auto; white-space: nowrap; width: 100%;"> <div class="content-panel">
<MudToolBar Gutters="false" Dense="true"> <div class="content-panel__toolbar content-panel__toolbar--scroll">
<MudIconButton Icon="@Icons.Material.Filled.DriveFileRenameOutline" OnClick="RenameFileToolbar" title="Rename" /> <MudToolBar Gutters="false" Dense="true">
<MudDivider Vertical="true" /> <MudIconButton Icon="@Icons.Material.Filled.DriveFileRenameOutline" OnClick="RenameFileToolbar" title="Rename" />
<MudIconButton Icon="@Icons.Material.Outlined.ViewColumn" Color="Color.Inherit" OnClick="ColumnOptions" title="Choose Columns" /> <MudDivider Vertical="true" />
<MudDivider Vertical="true" /> <MudIconButton Icon="@Icons.Material.Outlined.ViewColumn" Color="Color.Inherit" OnClick="ColumnOptions" title="Choose Columns" />
<MudMenu Icon="@Icons.Material.Outlined.FileDownloadOff" Label="Do Not Download" AnchorOrigin="Origin.BottomLeft" TransformOrigin="Origin.TopLeft" title="Do Not Download"> <MudDivider Vertical="true" />
<MudMenuItem OnClick="DoNotDownloadLessThan100PercentAvailability">Less Than 100% Availability</MudMenuItem> <MudMenu Icon="@Icons.Material.Outlined.FileDownloadOff" Label="Do Not Download" AnchorOrigin="Origin.BottomLeft" TransformOrigin="Origin.TopLeft" title="Do Not Download">
<MudMenuItem OnClick="DoNotDownloadLessThan80PercentAvailability">Less than 80% Availability</MudMenuItem> <MudMenuItem OnClick="DoNotDownloadLessThan100PercentAvailability">Less Than 100% Availability</MudMenuItem>
<MudMenuItem OnClick="DoNotDownloadCurrentlyFilteredFiles">Currently Filtered Files</MudMenuItem> <MudMenuItem OnClick="DoNotDownloadLessThan80PercentAvailability">Less than 80% Availability</MudMenuItem>
</MudMenu> <MudMenuItem OnClick="DoNotDownloadCurrentlyFilteredFiles">Currently Filtered Files</MudMenuItem>
<MudMenu Icon="@Icons.Material.Outlined.FileDownload" Label="Normal Priority" AnchorOrigin="Origin.BottomLeft" TransformOrigin="Origin.TopLeft" title="Download"> </MudMenu>
<MudMenuItem OnClick="NormalPriorityLessThan100PercentAvailability">Less Than 100% Availability</MudMenuItem> <MudMenu Icon="@Icons.Material.Outlined.FileDownload" Label="Normal Priority" AnchorOrigin="Origin.BottomLeft" TransformOrigin="Origin.TopLeft" title="Download">
<MudMenuItem OnClick="NormalPriorityLessThan80PercentAvailability">Less than 80% Availability</MudMenuItem> <MudMenuItem OnClick="NormalPriorityLessThan100PercentAvailability">Less Than 100% Availability</MudMenuItem>
<MudMenuItem OnClick="NormalPriorityCurrentlyFilteredFiles">Currently Filtered Files</MudMenuItem> <MudMenuItem OnClick="NormalPriorityLessThan80PercentAvailability">Less than 80% Availability</MudMenuItem>
</MudMenu> <MudMenuItem OnClick="NormalPriorityCurrentlyFilteredFiles">Currently Filtered Files</MudMenuItem>
<MudIconButton Icon="@Icons.Material.Outlined.FilterList" OnClick="ShowFilterDialog" title="Filter" /> </MudMenu>
<MudIconButton Icon="@Icons.Material.Outlined.FilterListOff" OnClick="RemoveFilter" title="Remove Filter" /> <MudIconButton Icon="@Icons.Material.Outlined.FilterList" OnClick="ShowFilterDialog" title="Filter" />
<MudSpacer /> <MudIconButton Icon="@Icons.Material.Outlined.FilterListOff" OnClick="RemoveFilter" title="Remove Filter" />
<MudTextField T="string" Value="SearchText" ValueChanged="SearchTextChanged" Immediate="true" DebounceInterval="500" Placeholder="Filter file list" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"></MudTextField> <MudSpacer />
</MudToolBar> <MudTextField T="string" Value="SearchText" ValueChanged="SearchTextChanged" Immediate="true" DebounceInterval="500" Placeholder="Filter file list" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"></MudTextField>
</MudToolBar>
</div>
<div class="content-panel__body">
<DynamicTable
@ref="Table"
T="ContentItem"
ColumnDefinitions="Columns"
Items="Files"
MultiSelection="false"
SelectOnRowClick="true"
PreSorted="true"
SelectedItemChanged="SelectedItemChanged"
SortColumnChanged="SortColumnChanged"
SortDirectionChanged="SortDirectionChanged"
OnTableDataContextMenu="TableDataContextMenu"
OnTableDataLongPress="TableDataLongPress"
Class="file-list content-panel__table"
/>
</div>
</div> </div>
<DynamicTable
@ref="Table"
T="ContentItem"
ColumnDefinitions="Columns"
Items="Files"
MultiSelection="false"
SelectOnRowClick="true"
PreSorted="true"
SelectedItemChanged="SelectedItemChanged"
SortColumnChanged="SortColumnChanged"
SortDirectionChanged="SortDirectionChanged"
OnTableDataContextMenu="TableDataContextMenu"
OnTableDataLongPress="TableDataLongPress"
Class="file-list"
/>
@code { @code {
private RenderFragment<RowContext<ContentItem>> NameColumn private RenderFragment<RowContext<ContentItem>> NameColumn
{ {

View File

@@ -20,6 +20,9 @@ namespace Lantean.QBTMud.Components
private readonly CancellationTokenSource _timerCancellationToken = new(); private readonly CancellationTokenSource _timerCancellationToken = new();
private bool _disposedValue; private bool _disposedValue;
private static readonly ReadOnlyCollection<ContentItem> EmptyContentItems = new ReadOnlyCollection<ContentItem>(Array.Empty<ContentItem>());
private ReadOnlyCollection<ContentItem> _visibleFiles = EmptyContentItems;
private bool _filesDirty = true;
private List<PropertyFilterDefinition<ContentItem>>? _filterDefinitions; private List<PropertyFilterDefinition<ContentItem>>? _filterDefinitions;
private readonly Dictionary<string, RenderFragment<RowContext<ContentItem>>> _columnRenderFragments = []; private readonly Dictionary<string, RenderFragment<RowContext<ContentItem>>> _columnRenderFragments = [];
@@ -65,7 +68,7 @@ namespace Lantean.QBTMud.Components
private DynamicTable<ContentItem>? Table { get; set; } private DynamicTable<ContentItem>? Table { get; set; }
private ContextMenu? ContextMenu { get; set; } private MudMenu? ContextMenu { get; set; }
public FilesTab() public FilesTab()
{ {
@@ -102,6 +105,7 @@ namespace Lantean.QBTMud.Components
if (_filterDefinitions is null) if (_filterDefinitions is null)
{ {
Filters = null; Filters = null;
MarkFilesDirty();
return; return;
} }
@@ -113,11 +117,13 @@ namespace Lantean.QBTMud.Components
} }
Filters = filters; Filters = filters;
MarkFilesDirty();
} }
protected void RemoveFilter() protected void RemoveFilter()
{ {
Filters = null; Filters = null;
MarkFilesDirty();
} }
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
@@ -157,6 +163,7 @@ namespace Lantean.QBTMud.Components
protected void SearchTextChanged(string value) protected void SearchTextChanged(string value)
{ {
SearchText = value; SearchText = value;
MarkFilesDirty();
} }
protected Task TableDataContextMenu(TableDataContextMenuEventArgs<ContentItem> eventArgs) protected Task TableDataContextMenu(TableDataContextMenuEventArgs<ContentItem> eventArgs)
@@ -178,7 +185,9 @@ namespace Lantean.QBTMud.Components
return; return;
} }
await ContextMenu.OpenMenuAsync(eventArgs); var normalizedEventArgs = eventArgs.NormalizeForContextMenu();
await ContextMenu.OpenMenuAsync(normalizedEventArgs);
} }
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -197,6 +206,7 @@ namespace Lantean.QBTMud.Components
{ {
while (!_timerCancellationToken.IsCancellationRequested && await timer.WaitForNextTickAsync()) while (!_timerCancellationToken.IsCancellationRequested && await timer.WaitForNextTickAsync())
{ {
var hasUpdates = false;
if (Active && Hash is not null) if (Active && Hash is not null)
{ {
IReadOnlyList<QBitTorrentClient.Models.FileData> files; IReadOnlyList<QBitTorrentClient.Models.FileData> files;
@@ -213,14 +223,20 @@ namespace Lantean.QBTMud.Components
if (FileList is null) if (FileList is null)
{ {
FileList = DataManager.CreateContentsList(files); FileList = DataManager.CreateContentsList(files);
hasUpdates = true;
} }
else else
{ {
DataManager.MergeContentsList(files, FileList); hasUpdates = DataManager.MergeContentsList(files, FileList);
} }
} }
await InvokeAsync(StateHasChanged); if (hasUpdates)
{
MarkFilesDirty();
PruneSelectionIfMissing();
await InvokeAsync(StateHasChanged);
}
} }
} }
} }
@@ -246,6 +262,8 @@ namespace Lantean.QBTMud.Components
var contents = await ApiClient.GetTorrentContents(Hash); var contents = await ApiClient.GetTorrentContents(Hash);
FileList = DataManager.CreateContentsList(contents); FileList = DataManager.CreateContentsList(contents);
MarkFilesDirty();
PruneSelectionIfMissing();
var expandedNodes = await LocalStorage.GetItemAsync<HashSet<string>>($"{_expandedNodesStorageKey}.{Hash}"); var expandedNodes = await LocalStorage.GetItemAsync<HashSet<string>>($"{_expandedNodesStorageKey}.{Hash}");
if (expandedNodes is not null) if (expandedNodes is not null)
@@ -256,6 +274,8 @@ namespace Lantean.QBTMud.Components
{ {
ExpandedNodes.Clear(); ExpandedNodes.Clear();
} }
MarkFilesDirty();
} }
protected async Task PriorityValueChanged(ContentItem contentItem, Priority priority) protected async Task PriorityValueChanged(ContentItem contentItem, Priority priority)
@@ -320,11 +340,13 @@ namespace Lantean.QBTMud.Components
protected void SortColumnChanged(string sortColumn) protected void SortColumnChanged(string sortColumn)
{ {
_sortColumn = sortColumn; _sortColumn = sortColumn;
MarkFilesDirty();
} }
protected void SortDirectionChanged(SortDirection sortDirection) protected void SortDirectionChanged(SortDirection sortDirection)
{ {
_sortDirection = sortDirection; _sortDirection = sortDirection;
MarkFilesDirty();
} }
protected void SelectedItemChanged(ContentItem item) protected void SelectedItemChanged(ContentItem item)
@@ -343,6 +365,7 @@ namespace Lantean.QBTMud.Components
ExpandedNodes.Add(contentItem.Name); ExpandedNodes.Add(contentItem.Name);
} }
MarkFilesDirty();
await LocalStorage.SetItemAsync($"{_expandedNodesStorageKey}.{Hash}", ExpandedNodes); await LocalStorage.SetItemAsync($"{_expandedNodesStorageKey}.{Hash}", ExpandedNodes);
} }
@@ -368,44 +391,6 @@ namespace Lantean.QBTMud.Components
return FileList!.Values.Where(f => f.Name.StartsWith(contentItem.Name + Extensions.DirectorySeparator) && !f.IsFolder); return FileList!.Values.Where(f => f.Name.StartsWith(contentItem.Name + Extensions.DirectorySeparator) && !f.IsFolder);
} }
private IEnumerable<ContentItem> GetChildren(ContentItem folder, int level)
{
level++;
var descendantsKey = folder.GetDescendantsKey(level);
foreach (var item in FileList!.Values.Where(f => f.Name.StartsWith(descendantsKey) && f.Level == level).OrderByDirection(_sortDirection, GetSortSelector()))
{
if (item.IsFolder)
{
var descendants = GetChildren(item, level);
// if the filter returns some results then show folder item
if (descendants.Any())
{
yield return item;
}
// if the folder is not expanded - don't return children
if (!ExpandedNodes.Contains(item.Name))
{
continue;
}
// then show children
foreach (var descendant in descendants)
{
yield return descendant;
}
}
else
{
if (FilterContentItem(item))
{
yield return item;
}
}
}
}
private bool FilterContentItem(ContentItem item) private bool FilterContentItem(ContentItem item)
{ {
if (Filters is not null) if (Filters is not null)
@@ -429,38 +414,130 @@ namespace Lantean.QBTMud.Components
} }
private ReadOnlyCollection<ContentItem> GetFiles() private ReadOnlyCollection<ContentItem> GetFiles()
{
if (!_filesDirty)
{
return _visibleFiles;
}
_visibleFiles = BuildVisibleFiles();
_filesDirty = false;
return _visibleFiles;
}
private ReadOnlyCollection<ContentItem> BuildVisibleFiles()
{ {
if (FileList is null || FileList.Values.Count == 0) if (FileList is null || FileList.Values.Count == 0)
{ {
return new ReadOnlyCollection<ContentItem>([]); return EmptyContentItems;
} }
var maxLevel = FileList.Values.Max(f => f.Level); var lookup = BuildChildrenLookup();
// this is a flat file structure if (!lookup.TryGetValue(string.Empty, out var roots))
if (maxLevel == 0)
{ {
return FileList.Values.Where(FilterContentItem).OrderByDirection(_sortDirection, GetSortSelector()).ToList().AsReadOnly(); return EmptyContentItems;
} }
var list = new List<ContentItem>(); var sortSelector = GetSortSelector();
var orderedRoots = roots.OrderByDirection(_sortDirection, sortSelector).ToList();
var result = new List<ContentItem>(FileList.Values.Count);
var rootItems = FileList.Values.Where(c => c.Level == 0).OrderByDirection(_sortDirection, GetSortSelector()).ToList(); foreach (var item in orderedRoots)
foreach (var item in rootItems)
{ {
list.Add(item); if (item.IsFolder)
if (item.IsFolder && ExpandedNodes.Contains(item.Name))
{ {
var level = 0; result.Add(item);
var descendants = GetChildren(item, level);
foreach (var descendant in descendants) if (!ExpandedNodes.Contains(item.Name))
{ {
list.Add(descendant); continue;
}
var descendants = GetVisibleDescendants(item, lookup, sortSelector);
result.AddRange(descendants);
}
else
{
if (FilterContentItem(item))
{
result.Add(item);
} }
} }
} }
return list.AsReadOnly(); return new ReadOnlyCollection<ContentItem>(result);
}
private Dictionary<string, List<ContentItem>> BuildChildrenLookup()
{
var lookup = new Dictionary<string, List<ContentItem>>(FileList!.Count);
foreach (var item in FileList!.Values)
{
var parentPath = item.Level == 0 ? string.Empty : item.Name.GetDirectoryPath();
if (!lookup.TryGetValue(parentPath, out var children))
{
children = [];
lookup[parentPath] = children;
}
children.Add(item);
}
return lookup;
}
private List<ContentItem> GetVisibleDescendants(ContentItem folder, Dictionary<string, List<ContentItem>> lookup, Func<ContentItem, object?> sortSelector)
{
if (!lookup.TryGetValue(folder.Name, out var children))
{
return [];
}
var orderedChildren = children.OrderByDirection(_sortDirection, sortSelector).ToList();
var visible = new List<ContentItem>();
foreach (var child in orderedChildren)
{
if (child.IsFolder)
{
var descendants = GetVisibleDescendants(child, lookup, sortSelector);
if (descendants.Count != 0)
{
visible.Add(child);
if (ExpandedNodes.Contains(child.Name))
{
visible.AddRange(descendants);
}
}
}
else if (FilterContentItem(child))
{
visible.Add(child);
}
}
return visible;
}
private void MarkFilesDirty()
{
_filesDirty = true;
}
private void PruneSelectionIfMissing()
{
if (SelectedItem is not null && (FileList is null || !FileList.ContainsKey(SelectedItem.Name)))
{
SelectedItem = null;
}
if (ContextMenuItem is not null && (FileList is null || !FileList.ContainsKey(ContextMenuItem.Name)))
{
ContextMenuItem = null;
}
} }
protected async Task DoNotDownloadLessThan100PercentAvailability() protected async Task DoNotDownloadLessThan100PercentAvailability()

View File

@@ -1,8 +1,8 @@
<ContextMenu @ref="StatusContextMenu" Dense="true" AdjustmentY="-60"> <MudMenu @ref="StatusContextMenu" Dense="true" PositionAtCursor="true" ListClass="unselectable" PopoverClass="unselectable">
@TorrentControls(_statusType) @TorrentControls(_statusType)
</ContextMenu> </MudMenu>
<ContextMenu @ref="CategoryContextMenu" Dense="true" AdjustmentY="-60"> <MudMenu @ref="CategoryContextMenu" Dense="true" PositionAtCursor="true" ListClass="unselectable" PopoverClass="unselectable">
<MudMenuItem Icon="@Icons.Material.Outlined.AddCircle" IconColor="Color.Info" OnClick="AddCategory">Add category</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Outlined.AddCircle" IconColor="Color.Info" OnClick="AddCategory">Add category</MudMenuItem>
@if (IsCategoryTarget) @if (IsCategoryTarget)
{ {
@@ -12,9 +12,9 @@
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="RemoveUnusedCategories">Remove unused categories</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="RemoveUnusedCategories">Remove unused categories</MudMenuItem>
<MudDivider /> <MudDivider />
@TorrentControls(_categoryType) @TorrentControls(_categoryType)
</ContextMenu> </MudMenu>
<ContextMenu @ref="TagContextMenu" Dense="true" AdjustmentY="-60"> <MudMenu @ref="TagContextMenu" Dense="true" PositionAtCursor="true" ListClass="unselectable" PopoverClass="unselectable">
<MudMenuItem Icon="@Icons.Material.Outlined.AddCircle" IconColor="Color.Info" OnClick="AddTag">Add tag</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Outlined.AddCircle" IconColor="Color.Info" OnClick="AddTag">Add tag</MudMenuItem>
@if (IsTagTarget) @if (IsTagTarget)
{ {
@@ -23,13 +23,13 @@
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="RemoveUnusedTags">Remove unused tags</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="RemoveUnusedTags">Remove unused tags</MudMenuItem>
<MudDivider /> <MudDivider />
@TorrentControls(_tagType) @TorrentControls(_tagType)
</ContextMenu> </MudMenu>
<ContextMenu @ref="TrackerContextMenu" Dense="true" AdjustmentY="-60"> <MudMenu @ref="TrackerContextMenu" Dense="true" PositionAtCursor="true" ListClass="unselectable" PopoverClass="unselectable">
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="RemoveUnusedCategories">Remove tracker</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="RemoveUnusedCategories">Remove tracker</MudMenuItem>
<MudDivider /> <MudDivider />
@TorrentControls(_trackerType) @TorrentControls(_trackerType)
</ContextMenu> </MudMenu>
<MudNavMenu Dense="true"> <MudNavMenu Dense="true">
<MudNavGroup Title="Status" @bind-Expanded="_statusExpanded"> <MudNavGroup Title="Status" @bind-Expanded="_statusExpanded">

View File

@@ -1,6 +1,5 @@
using Blazored.LocalStorage; using Blazored.LocalStorage;
using Lantean.QBitTorrentClient; using Lantean.QBitTorrentClient;
using Lantean.QBTMud.Components.UI;
using Lantean.QBTMud.Helpers; using Lantean.QBTMud.Helpers;
using Lantean.QBTMud.Models; using Lantean.QBTMud.Models;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
@@ -69,13 +68,13 @@ namespace Lantean.QBTMud.Components
protected Dictionary<string, int> Statuses => GetStatuses(); protected Dictionary<string, int> Statuses => GetStatuses();
protected ContextMenu? StatusContextMenu { get; set; } protected MudMenu? StatusContextMenu { get; set; }
protected ContextMenu? CategoryContextMenu { get; set; } protected MudMenu? CategoryContextMenu { get; set; }
protected ContextMenu? TagContextMenu { get; set; } protected MudMenu? TagContextMenu { get; set; }
protected ContextMenu? TrackerContextMenu { get; set; } protected MudMenu? TrackerContextMenu { get; set; }
protected string? ContextMenuStatus { get; set; } protected string? ContextMenuStatus { get; set; }
@@ -154,7 +153,9 @@ namespace Lantean.QBTMud.Components
ContextMenuStatus = value; ContextMenuStatus = value;
return StatusContextMenu.OpenMenuAsync(args); var normalizedArgs = args.NormalizeForContextMenu();
return StatusContextMenu.OpenMenuAsync(normalizedArgs);
} }
protected async Task CategoryValueChanged(string value) protected async Task CategoryValueChanged(string value)
@@ -192,7 +193,9 @@ namespace Lantean.QBTMud.Components
IsCategoryTarget = value != FilterHelper.CATEGORY_ALL && value != FilterHelper.CATEGORY_UNCATEGORIZED; IsCategoryTarget = value != FilterHelper.CATEGORY_ALL && value != FilterHelper.CATEGORY_UNCATEGORIZED;
ContextMenuCategory = value; ContextMenuCategory = value;
return CategoryContextMenu.OpenMenuAsync(args); var normalizedArgs = args.NormalizeForContextMenu();
return CategoryContextMenu.OpenMenuAsync(normalizedArgs);
} }
protected async Task TagValueChanged(string value) protected async Task TagValueChanged(string value)
@@ -230,7 +233,9 @@ namespace Lantean.QBTMud.Components
IsTagTarget = value != FilterHelper.TAG_ALL && value != FilterHelper.TAG_UNTAGGED; IsTagTarget = value != FilterHelper.TAG_ALL && value != FilterHelper.TAG_UNTAGGED;
ContextMenuTag = value; ContextMenuTag = value;
return TagContextMenu.OpenMenuAsync(args); var normalizedArgs = args.NormalizeForContextMenu();
return TagContextMenu.OpenMenuAsync(normalizedArgs);
} }
protected async Task TrackerValueChanged(string value) protected async Task TrackerValueChanged(string value)
@@ -267,7 +272,9 @@ namespace Lantean.QBTMud.Components
ContextMenuTracker = value; ContextMenuTracker = value;
return TrackerContextMenu.OpenMenuAsync(args); var normalizedArgs = args.NormalizeForContextMenu();
return TrackerContextMenu.OpenMenuAsync(normalizedArgs);
} }
protected async Task AddCategory() protected async Task AddCategory()

View File

@@ -92,7 +92,9 @@
<FieldSwitch Label="When ratio reaches" Value="MaxRatioEnabled" ValueChanged="MaxRatioEnabledChanged" /> <FieldSwitch Label="When ratio reaches" Value="MaxRatioEnabled" ValueChanged="MaxRatioEnabledChanged" />
</MudItem> </MudItem>
<MudItem xs="9"> <MudItem xs="9">
<MudNumericField T="int" Label="" Value="MaxRatio" ValueChanged="MaxRatioChanged" Disabled="@(!MaxRatioEnabled)" Min="0" Max="9998" Variant="Variant.Outlined" Validation="MaxRatioValidation" /> <MudNumericField T="float" Label="" Value="MaxRatio" ValueChanged="MaxRatioChanged"
Disabled="@(!MaxRatioEnabled)" Min="0" Max="9998" Variant="Variant.Outlined"
Validation="MaxRatioValidation" />
</MudItem> </MudItem>
<MudItem xs="3"> <MudItem xs="3">
<FieldSwitch Label="When total seeding time reaches" Value="MaxSeedingTimeEnabled" ValueChanged="MaxSeedingTimeEnabledChanged" /> <FieldSwitch Label="When total seeding time reaches" Value="MaxSeedingTimeEnabled" ValueChanged="MaxSeedingTimeEnabledChanged" />

View File

@@ -17,7 +17,7 @@
protected int SlowTorrentUlRateThreshold { get; private set; } protected int SlowTorrentUlRateThreshold { get; private set; }
protected int SlowTorrentInactiveTimer { get; private set; } protected int SlowTorrentInactiveTimer { get; private set; }
protected bool MaxRatioEnabled { get; private set; } protected bool MaxRatioEnabled { get; private set; }
protected int MaxRatio { get; private set; } protected float MaxRatio { get; private set; }
protected bool MaxSeedingTimeEnabled { get; private set; } protected bool MaxSeedingTimeEnabled { get; private set; }
protected int MaxSeedingTime { get; private set; } protected int MaxSeedingTime { get; private set; }
protected int MaxRatioAct { get; private set; } protected int MaxRatioAct { get; private set; }
@@ -275,7 +275,7 @@
await PreferencesChanged.InvokeAsync(UpdatePreferences); await PreferencesChanged.InvokeAsync(UpdatePreferences);
} }
protected async Task MaxRatioChanged(int value) protected async Task MaxRatioChanged(float value)
{ {
MaxRatio = value; MaxRatio = value;
UpdatePreferences.MaxRatio = value; UpdatePreferences.MaxRatio = value;

View File

@@ -62,7 +62,7 @@
<MudCardContent Class="pt-0"> <MudCardContent Class="pt-0">
<MudGrid> <MudGrid>
<MudItem xs="12"> <MudItem xs="12">
<MudSelect T="bool" Label="Default Torrent Management Mode" Value="AutoTmmEnabled" ValueChanged="AutoDeleteModeChanged" Variant="Variant.Outlined"> <MudSelect T="bool" Label="Default Torrent Management Mode" Value="AutoTmmEnabled" ValueChanged="AutoTmmEnabledChanged" Variant="Variant.Outlined">
<MudSelectItem Value="false">Manual</MudSelectItem> <MudSelectItem Value="false">Manual</MudSelectItem>
<MudSelectItem Value="true">Automatic</MudSelectItem> <MudSelectItem Value="true">Automatic</MudSelectItem>
</MudSelect> </MudSelect>

View File

@@ -1,24 +1,30 @@
<ContextMenu @ref="ContextMenu" Dense="true"> <MudMenu @ref="ContextMenu" Dense="true" PositionAtCursor="true" ListClass="unselectable" PopoverClass="unselectable">
<MudMenuItem Icon="@Icons.Material.Filled.AddCircle" IconColor="Color.Info" OnClick="AddPeer">Add peer</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.AddCircle" IconColor="Color.Info" OnClick="AddPeer">Add peer</MudMenuItem>
@if (ContextMenuItem is not null) @if (ContextMenuItem is not null)
{ {
<MudMenuItem Icon="@Icons.Material.Filled.DisabledByDefault" IconColor="Color.Info" OnClick="BanPeerContextMenu">Ban peer</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.DisabledByDefault" IconColor="Color.Info" OnClick="BanPeerContextMenu">Ban peer</MudMenuItem>
} }
</ContextMenu> </MudMenu>
<MudToolBar Gutters="false" Dense="true"> <div class="content-panel">
<MudIconButton Icon="@Icons.Material.Filled.AddCircle" Color="Color.Info" OnClick="AddPeer">Add peer</MudIconButton> <div class="content-panel__toolbar">
<MudIconButton Icon="@Icons.Material.Filled.DisabledByDefault" Color="Color.Error" OnClick="BanPeerToolbar" Disabled="@(SelectedItem is null)">Ban peer</MudIconButton> <MudToolBar Gutters="false" Dense="true">
<MudDivider Vertical="true" /> <MudIconButton Icon="@Icons.Material.Filled.AddCircle" Color="Color.Info" OnClick="AddPeer">Add peer</MudIconButton>
<MudIconButton Icon="@Icons.Material.Outlined.ViewColumn" Color="Color.Inherit" OnClick="ColumnOptions" title="Choose Columns" /> <MudIconButton Icon="@Icons.Material.Filled.DisabledByDefault" Color="Color.Error" OnClick="BanPeerToolbar" Disabled="@(SelectedItem is null)">Ban peer</MudIconButton>
</MudToolBar> <MudDivider Vertical="true" />
<MudIconButton Icon="@Icons.Material.Outlined.ViewColumn" Color="Color.Inherit" OnClick="ColumnOptions" title="Choose Columns" />
</MudToolBar>
</div>
<DynamicTable T="Peer" <div class="content-panel__body">
ColumnDefinitions="Columns" <DynamicTable T="Peer"
Items="Peers" ColumnDefinitions="Columns"
MultiSelection="false" Items="Peers"
SelectOnRowClick="true" MultiSelection="false"
OnTableDataLongPress="TableDataLongPress" SelectOnRowClick="true"
OnTableDataContextMenu="TableDataContextMenu" OnTableDataLongPress="TableDataLongPress"
SelectedItemChanged="SelectedItemChanged" OnTableDataContextMenu="TableDataContextMenu"
Class="details-list" /> SelectedItemChanged="SelectedItemChanged"
Class="details-list content-panel__table" />
</div>
</div>

View File

@@ -52,7 +52,7 @@ namespace Lantean.QBTMud.Components
protected Peer? SelectedItem { get; set; } protected Peer? SelectedItem { get; set; }
protected ContextMenu? ContextMenu { get; set; } protected MudMenu? ContextMenu { get; set; }
protected DynamicTable<Peer>? Table { get; set; } protected DynamicTable<Peer>? Table { get; set; }
@@ -153,7 +153,9 @@ namespace Lantean.QBTMud.Components
return; return;
} }
await ContextMenu.ToggleMenuAsync(eventArgs); var normalizedEventArgs = eventArgs.NormalizeForContextMenu();
await ContextMenu.OpenMenuAsync(normalizedEventArgs);
} }
protected void SelectedItemChanged(Peer peer) protected void SelectedItemChanged(Peer peer)

View File

@@ -12,10 +12,7 @@ namespace Lantean.QBTMud.Components
{ {
public partial class TorrentActions : IAsyncDisposable public partial class TorrentActions : IAsyncDisposable
{ {
private const int _defaultVersion = 5;
private bool _disposedValue; private bool _disposedValue;
private int? _version;
private List<UIAction>? _actions; private List<UIAction>? _actions;
@@ -74,30 +71,7 @@ namespace Lantean.QBTMud.Components
protected bool OverlayVisible { get; set; } protected bool OverlayVisible { get; set; }
protected int MajorVersion protected int MajorVersion => VersionHelper.GetMajorVersion(Version);
{
get
{
if (_version is not null)
{
return _version.Value;
}
if (string.IsNullOrEmpty(Version))
{
return _defaultVersion;
}
if (!System.Version.TryParse(Version.Replace("v", ""), out var version))
{
return _defaultVersion;
}
_version = version.Major;
return _version.Value;
}
}
protected override void OnInitialized() protected override void OnInitialized()
{ {
@@ -441,7 +415,7 @@ namespace Lantean.QBTMud.Components
thereAreFirstLastPiecePrio = true; thereAreFirstLastPiecePrio = true;
} }
if (torrent.Progress > 0.999999) // not downloaded if (torrent.Progress < 0.999999) // not downloaded
{ {
allAreDownloaded = false; allAreDownloaded = false;
} }

View File

@@ -1,4 +1,4 @@
<ContextMenu @ref="ContextMenu" Dense="true"> <MudMenu @ref="ContextMenu" Dense="true" PositionAtCursor="true" ListClass="unselectable" PopoverClass="unselectable">
<MudMenuItem Icon="@Icons.Material.Filled.AddCircle" IconColor="Color.Info" OnClick="AddTracker">Add trackers</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.AddCircle" IconColor="Color.Info" OnClick="AddTracker">Add trackers</MudMenuItem>
@if (ContextMenuItem is not null) @if (ContextMenuItem is not null)
{ {
@@ -6,27 +6,33 @@
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="RemoveTrackerContextMenu">Remove tracker</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="RemoveTrackerContextMenu">Remove tracker</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.FolderCopy" IconColor="Color.Info" OnClick="CopyTrackerUrlContextMenu">Copy tracker url</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Filled.FolderCopy" IconColor="Color.Info" OnClick="CopyTrackerUrlContextMenu">Copy tracker url</MudMenuItem>
} }
</ContextMenu> </MudMenu>
<MudToolBar Gutters="false" Dense="true"> <div class="content-panel">
<MudIconButton Icon="@Icons.Material.Filled.AddCircle" Color="Color.Info" OnClick="AddTracker">Add trackers</MudIconButton> <div class="content-panel__toolbar">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Info" OnClick="EditTrackerToolbar" Disabled="@(SelectedItem is null)">Edit tracker URL</MudIconButton> <MudToolBar Gutters="false" Dense="true">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="RemoveTrackerToolbar" Disabled="@(SelectedItem is null)">Remove tracker</MudIconButton> <MudIconButton Icon="@Icons.Material.Filled.AddCircle" Color="Color.Info" OnClick="AddTracker">Add trackers</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.FolderCopy" Color="Color.Info" OnClick="CopyTrackerUrlToolbar" Disabled="@(SelectedItem is null)">Copy tracker url</MudIconButton> <MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Info" OnClick="EditTrackerToolbar" Disabled="@(SelectedItem is null)">Edit tracker URL</MudIconButton>
<MudDivider Vertical="true" /> <MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="RemoveTrackerToolbar" Disabled="@(SelectedItem is null)">Remove tracker</MudIconButton>
<MudIconButton Icon="@Icons.Material.Outlined.ViewColumn" Color="Color.Inherit" OnClick="ColumnOptions" title="Choose Columns" /> <MudIconButton Icon="@Icons.Material.Filled.FolderCopy" Color="Color.Info" OnClick="CopyTrackerUrlToolbar" Disabled="@(SelectedItem is null)">Copy tracker url</MudIconButton>
</MudToolBar> <MudDivider Vertical="true" />
<MudIconButton Icon="@Icons.Material.Outlined.ViewColumn" Color="Color.Inherit" OnClick="ColumnOptions" title="Choose Columns" />
</MudToolBar>
</div>
<DynamicTable @ref="Table" <div class="content-panel__body">
T="Lantean.QBitTorrentClient.Models.TorrentTracker" <DynamicTable @ref="Table"
ColumnDefinitions="Columns" T="Lantean.QBitTorrentClient.Models.TorrentTracker"
Items="Trackers" ColumnDefinitions="Columns"
MultiSelection="false" Items="Trackers"
SelectOnRowClick="false" MultiSelection="false"
PreSorted="true" SelectOnRowClick="false"
SortDirectionChanged="SortDirectionChanged" PreSorted="true"
SortColumnChanged="SortColumnChanged" SortDirectionChanged="SortDirectionChanged"
OnTableDataLongPress="TableDataLongPress" SortColumnChanged="SortColumnChanged"
OnTableDataContextMenu="TableDataContextMenu" OnTableDataLongPress="TableDataLongPress"
SelectedItemChanged="SelectedItemChanged" OnTableDataContextMenu="TableDataContextMenu"
Class="file-list" /> SelectedItemChanged="SelectedItemChanged"
Class="file-list content-panel__table" />
</div>
</div>

View File

@@ -52,7 +52,7 @@ namespace Lantean.QBTMud.Components
protected TorrentTracker? SelectedItem { get; set; } protected TorrentTracker? SelectedItem { get; set; }
protected ContextMenu? ContextMenu { get; set; } protected MudMenu? ContextMenu { get; set; }
protected DynamicTable<TorrentTracker>? Table { get; set; } protected DynamicTable<TorrentTracker>? Table { get; set; }
@@ -148,7 +148,9 @@ namespace Lantean.QBTMud.Components
return; return;
} }
await ContextMenu.ToggleMenuAsync(eventArgs); var normalizedEventArgs = eventArgs.NormalizeForContextMenu();
await ContextMenu.OpenMenuAsync(normalizedEventArgs);
} }
protected void SelectedItemChanged(TorrentTracker torrentTracker) protected void SelectedItemChanged(TorrentTracker torrentTracker)

View File

@@ -1,26 +0,0 @@
@inherits MudComponentBase
<MudMenu @ref="FakeMenu" Style="display: none" OpenChanged="FakeOpenChanged"></MudMenu>
@* The portal has to include the cascading values inside, because it's not able to teletransport the cascade *@
<MudPopover tracker="@Id"
Open="@_open"
Class="unselectable"
MaxHeight="@MaxHeight"
AnchorOrigin="@AnchorOrigin"
TransformOrigin="@TransformOrigin"
RelativeWidth="@RelativeWidth"
OverflowBehavior="OverflowBehavior.FlipAlways"
Style="@_popoverStyle"
@ontouchend:preventDefault>
<CascadingValue Value="@(FakeMenu)">
@if (_showChildren)
{
<MudList T="object" Class="unselectable" Dense="@Dense">
@ChildContent
</MudList>
}
</CascadingValue>
</MudPopover>
<MudOverlay Visible="@(_open)" LockScroll="@LockScroll" AutoClose="true" OnClosed="@CloseMenuAsync" />

View File

@@ -1,290 +0,0 @@
using Lantean.QBTMud.Interop;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
using MudBlazor;
using MudBlazor.Utilities;
namespace Lantean.QBTMud.Components.UI
{
public partial class ContextMenu : MudComponentBase
{
private bool _open;
private bool _showChildren;
private string? _popoverStyle;
private string? _id;
private double _x;
private double _y;
private bool _isResized = false;
private const double _diff = 64;
private string Id
{
get
{
_id ??= Guid.NewGuid().ToString();
return _id;
}
}
[Inject]
public IJSRuntime JSRuntime { get; set; } = default!;
[Inject]
public IPopoverService PopoverService { get; set; } = default!;
/// <summary>
/// If true, compact vertical padding will be applied to all menu items.
/// </summary>
[Parameter]
[Category(CategoryTypes.Menu.PopupAppearance)]
public bool Dense { get; set; }
/// <summary>
/// Set to true if you want to prevent page from scrolling when the menu is open
/// </summary>
[Parameter]
[Category(CategoryTypes.Menu.PopupAppearance)]
public bool LockScroll { get; set; }
/// <summary>
/// If true, the list menu will be same width as the parent.
/// </summary>
[Parameter]
[Category(CategoryTypes.Menu.PopupAppearance)]
public DropdownWidth RelativeWidth { get; set; }
/// <summary>
/// Sets the max height the menu can have when open.
/// </summary>
[Parameter]
[Category(CategoryTypes.Menu.PopupAppearance)]
public int? MaxHeight { get; set; }
/// <summary>
/// Set the anchor origin point to determine where the popover will open from.
/// </summary>
[Parameter]
[Category(CategoryTypes.Menu.PopupAppearance)]
public Origin AnchorOrigin { get; set; } = Origin.TopLeft;
/// <summary>
/// Sets the transform origin point for the popover.
/// </summary>
[Parameter]
[Category(CategoryTypes.Menu.PopupAppearance)]
public Origin TransformOrigin { get; set; } = Origin.TopLeft;
/// <summary>
/// If true, menu will be disabled.
/// </summary>
[Parameter]
[Category(CategoryTypes.Menu.Behavior)]
public bool Disabled { get; set; }
/// <summary>
/// Gets or sets whether to show a ripple effect when the user clicks the button. Default is true.
/// </summary>
[Parameter]
[Category(CategoryTypes.Menu.Appearance)]
public bool Ripple { get; set; } = true;
/// <summary>
/// Determines whether the component has a drop-shadow. Default is true
/// </summary>
[Parameter]
[Category(CategoryTypes.Menu.Appearance)]
public bool DropShadow { get; set; } = true;
/// <summary>
/// Add menu items here
/// </summary>
[Parameter]
[Category(CategoryTypes.Menu.PopupBehavior)]
public RenderFragment? ChildContent { get; set; }
/// <summary>
/// Fired when the menu <see cref="Open"/> property changes.
/// </summary>
[Parameter]
[Category(CategoryTypes.Menu.PopupBehavior)]
public EventCallback<bool> OpenChanged { get; set; }
[Parameter]
public int AdjustmentX { get; set; }
[Parameter]
public int AdjustmentY { get; set; }
protected MudMenu? FakeMenu { get; set; }
protected void FakeOpenChanged(bool value)
{
if (!value)
{
_open = false;
}
StateHasChanged();
}
/// <summary>
/// Opens the menu.
/// </summary>
/// <param name="args">
/// The arguments of the calling mouse/pointer event.
/// </param>
public async Task OpenMenuAsync(EventArgs args)
{
if (Disabled)
{
return;
}
// long press on iOS triggers selection, so clear it
await JSRuntime.ClearSelection();
if (args is not LongPressEventArgs)
{
_showChildren = true;
}
_open = true;
_isResized = false;
StateHasChanged();
var (x, y) = GetPositionFromArgs(args);
_x = x;
_y = y;
SetPopoverStyle(x, y);
StateHasChanged();
await OpenChanged.InvokeAsync(_open);
// long press on iOS triggers selection, so clear it
await JSRuntime.ClearSelection();
if (args is LongPressEventArgs)
{
await Task.Delay(1000);
_showChildren = true;
}
}
/// <summary>
/// Closes the menu.
/// </summary>
public Task CloseMenuAsync()
{
_open = false;
_popoverStyle = null;
StateHasChanged();
return OpenChanged.InvokeAsync(_open);
}
private void SetPopoverStyle(double x, double y)
{
_popoverStyle = $"margin-top: {y.ToPx()}; margin-left: {x.ToPx()};";
}
/// <summary>
/// Toggle the visibility of the menu.
/// </summary>
public async Task ToggleMenuAsync(EventArgs args)
{
if (Disabled)
{
return;
}
if (_open)
{
await CloseMenuAsync();
}
else
{
await OpenMenuAsync(args);
}
}
protected override Task OnAfterRenderAsync(bool firstRender)
{
if (!_isResized)
{
//await DeterminePosition();
}
return Task.CompletedTask;
}
//private async Task DeterminePosition()
//{
// var mainContentSize = await JSRuntime.GetInnerDimensions(".mud-main-content");
// double? contextMenuHeight = null;
// double? contextMenuWidth = null;
// var popoverHolder = PopoverService.ActivePopovers.FirstOrDefault(p => p.UserAttributes.ContainsKey("tracker") && (string?)p.UserAttributes["tracker"] == Id);
// var popoverSize = await JSRuntime.GetBoundingClientRect($"#popovercontent-{popoverHolder?.Id}");
// if (popoverSize.Height > 0)
// {
// contextMenuHeight = popoverSize.Height;
// contextMenuWidth = popoverSize.Width;
// }
// else
// {
// return;
// }
// // the bottom position of the popover will be rendered off screen
// if (_y - _diff + contextMenuHeight.Value >= mainContentSize.Height)
// {
// // adjust the top of the context menu
// var overshoot = Math.Abs(mainContentSize.Height - (_y - _diff + contextMenuHeight.Value));
// _y -= overshoot;
// if (_y - _diff + contextMenuHeight >= mainContentSize.Height)
// {
// MaxHeight = (int)(mainContentSize.Height - _y + _diff);
// }
// }
// if (_x + contextMenuWidth.Value > mainContentSize.Width)
// {
// var overshoot = Math.Abs(mainContentSize.Width - (_x + contextMenuWidth.Value));
// _x -= overshoot;
// }
// SetPopoverStyle(_x, _y);
// _isResized = true;
// await InvokeAsync(StateHasChanged);
//}
private (double x, double y) GetPositionFromArgs(EventArgs eventArgs)
{
double x, y;
if (eventArgs is MouseEventArgs mouseEventArgs)
{
x = mouseEventArgs.ClientX;
y = mouseEventArgs.ClientY;
}
else if (eventArgs is LongPressEventArgs longPressEventArgs)
{
x = longPressEventArgs.ClientX;
y = longPressEventArgs.ClientY;
}
else
{
throw new NotSupportedException("Invalid eventArgs type.");
}
return (x + AdjustmentX, y + AdjustmentY);
}
}
}

View File

@@ -1,5 +1,5 @@
<div class="@Classname"> <div class="@Classname">
<div @onclick="this.AsNonRenderingEventHandler<MouseEventArgs>(OnClickHandler)" class="@LinkClassname" @onlongpress="OnLongPressInternal" @oncontextmenu="OnContextMenuInternal" @oncontextmenu:preventDefault> <div @onclick="this.AsNonRenderingEventHandler<MouseEventArgs>(OnClickHandler)" class="@LinkClassname" @onlongpress="OnLongPressInternal" @onlongpress:preventDefault @oncontextmenu="OnContextMenuInternal" @oncontextmenu:preventDefault>
@if (!string.IsNullOrEmpty(Icon)) @if (!string.IsNullOrEmpty(Icon))
{ {
<MudIcon Icon="@Icon" Color="@IconColor" Class="@IconClassname" /> <MudIcon Icon="@Icon" Color="@IconColor" Class="@IconClassname" />

View File

@@ -59,6 +59,7 @@ namespace Lantean.QBTMud.Components.UI
new CssBuilder("mud-nav-link") new CssBuilder("mud-nav-link")
.AddClass($"mud-nav-link-disabled", Disabled) .AddClass($"mud-nav-link-disabled", Disabled)
.AddClass("active", Active) .AddClass("active", Active)
.AddClass("unselectable", OnLongPress.HasDelegate || OnContextMenu.HasDelegate)
.Build(); .Build();
protected string IconClassname => protected string IconClassname =>

View File

@@ -81,6 +81,8 @@ namespace Lantean.QBTMud.Components.UI
protected HashSet<string> SelectedColumns { get; set; } = []; protected HashSet<string> SelectedColumns { get; set; } = [];
private static readonly IReadOnlyList<ColumnDefinition<T>> EmptyColumns = Array.Empty<ColumnDefinition<T>>();
private Dictionary<string, int?> _columnWidths = []; private Dictionary<string, int?> _columnWidths = [];
private Dictionary<string, int> _columnOrder = []; private Dictionary<string, int> _columnOrder = [];
@@ -89,8 +91,16 @@ namespace Lantean.QBTMud.Components.UI
private SortDirection _sortDirection; private SortDirection _sortDirection;
private DateTimeOffset? _suppressRowClickUntil;
private readonly Dictionary<string, TdExtended> _tds = []; private readonly Dictionary<string, TdExtended> _tds = [];
private IReadOnlyList<ColumnDefinition<T>> _visibleColumns = EmptyColumns;
private bool _columnsDirty = true;
private IEnumerable<ColumnDefinition<T>>? _lastColumnDefinitions;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
HashSet<string> selectedColumns; HashSet<string> selectedColumns;
@@ -109,6 +119,13 @@ namespace Lantean.QBTMud.Components.UI
SelectedColumns = selectedColumns; SelectedColumns = selectedColumns;
await SelectedColumnsChanged.InvokeAsync(SelectedColumns); await SelectedColumnsChanged.InvokeAsync(SelectedColumns);
} }
else
{
SelectedColumns = selectedColumns;
}
_lastColumnDefinitions = ColumnDefinitions;
MarkColumnsDirty();
string? sortColumn; string? sortColumn;
SortDirection sortDirection; SortDirection sortDirection;
@@ -137,11 +154,24 @@ namespace Lantean.QBTMud.Components.UI
await SortDirectionChanged.InvokeAsync(_sortDirection); await SortDirectionChanged.InvokeAsync(_sortDirection);
} }
MarkColumnsDirty();
var storedColumnsWidths = await LocalStorage.GetItemAsync<Dictionary<string, int?>>(_columnWidthsStorageKey); var storedColumnsWidths = await LocalStorage.GetItemAsync<Dictionary<string, int?>>(_columnWidthsStorageKey);
if (storedColumnsWidths is not null) if (storedColumnsWidths is not null)
{ {
_columnWidths = storedColumnsWidths; _columnWidths = storedColumnsWidths;
} }
MarkColumnsDirty();
}
protected override void OnParametersSet()
{
base.OnParametersSet();
if (!ReferenceEquals(_lastColumnDefinitions, ColumnDefinitions))
{
_lastColumnDefinitions = ColumnDefinitions;
MarkColumnsDirty();
}
} }
private IEnumerable<T>? GetOrderedItems() private IEnumerable<T>? GetOrderedItems()
@@ -165,39 +195,74 @@ namespace Lantean.QBTMud.Components.UI
return Items.OrderByDirection(_sortDirection, sortSelector); return Items.OrderByDirection(_sortDirection, sortSelector);
} }
protected IEnumerable<ColumnDefinition<T>> GetColumns() protected IReadOnlyList<ColumnDefinition<T>> GetColumns()
{ {
var filteredColumns = ColumnDefinitions.Where(c => SelectedColumns.Contains(c.Id)).Where(ColumnFilter); if (!_columnsDirty)
if (_columnOrder.Count == 0)
{ {
foreach (var column in filteredColumns) return _visibleColumns;
{
if (_columnWidths.TryGetValue(column.Id, out var value))
{
column.Width = value;
}
yield return column;
}
yield break;
} }
var columnDictionary = filteredColumns.ToDictionary(c => c.Id); _visibleColumns = BuildVisibleColumns();
foreach (var columnId in _columnOrder.OrderBy(c => c.Value).Select(c => c.Key)) _columnsDirty = false;
return _visibleColumns;
}
private IReadOnlyList<ColumnDefinition<T>> BuildVisibleColumns()
{
var filteredColumns = ColumnDefinitions
.Where(c => SelectedColumns.Contains(c.Id))
.Where(ColumnFilter)
.ToList();
if (filteredColumns.Count == 0)
{ {
if (!columnDictionary.TryGetValue(columnId, out var column)) return EmptyColumns;
}
List<ColumnDefinition<T>> orderedColumns;
if (_columnOrder.Count == 0)
{
orderedColumns = filteredColumns;
}
else
{
var orderLookup = _columnOrder.OrderBy(entry => entry.Value).ToList();
var columnDictionary = filteredColumns.ToDictionary(c => c.Id);
orderedColumns = new List<ColumnDefinition<T>>(filteredColumns.Count);
foreach (var (columnId, _) in orderLookup)
{ {
continue; if (!columnDictionary.TryGetValue(columnId, out var column))
{
continue;
}
orderedColumns.Add(column);
} }
if (orderedColumns.Count != filteredColumns.Count)
{
var existingIds = new HashSet<string>(orderedColumns.Select(c => c.Id));
foreach (var column in filteredColumns)
{
if (existingIds.Add(column.Id))
{
orderedColumns.Add(column);
}
}
}
}
foreach (var column in orderedColumns)
{
if (_columnWidths.TryGetValue(column.Id, out var value)) if (_columnWidths.TryGetValue(column.Id, out var value))
{ {
column.Width = value; column.Width = value;
} }
yield return column;
} }
return orderedColumns;
} }
private async Task SetSort(string columnId, SortDirection sortDirection) private async Task SetSort(string columnId, SortDirection sortDirection)
@@ -223,6 +288,17 @@ namespace Lantean.QBTMud.Components.UI
protected async Task OnRowClickInternal(TableRowClickEventArgs<T> eventArgs) protected async Task OnRowClickInternal(TableRowClickEventArgs<T> eventArgs)
{ {
if (_suppressRowClickUntil is not null)
{
if (DateTimeOffset.UtcNow <= _suppressRowClickUntil.Value)
{
_suppressRowClickUntil = null;
return;
}
_suppressRowClickUntil = null;
}
if (eventArgs.Item is null) if (eventArgs.Item is null)
{ {
return; return;
@@ -298,6 +374,7 @@ namespace Lantean.QBTMud.Components.UI
protected Task OnLongPressInternal(LongPressEventArgs eventArgs, string columnId, T item) protected Task OnLongPressInternal(LongPressEventArgs eventArgs, string columnId, T item)
{ {
_suppressRowClickUntil = DateTimeOffset.UtcNow.AddMilliseconds(500);
var data = _tds[columnId]; var data = _tds[columnId];
return OnTableDataLongPress.InvokeAsync(new TableDataLongPressEventArgs<T>(eventArgs, data, item)); return OnTableDataLongPress.InvokeAsync(new TableDataLongPressEventArgs<T>(eventArgs, data, item));
} }
@@ -316,18 +393,21 @@ namespace Lantean.QBTMud.Components.UI
SelectedColumns = result.SelectedColumns; SelectedColumns = result.SelectedColumns;
await LocalStorage.SetItemAsync(_columnSelectionStorageKey, SelectedColumns); await LocalStorage.SetItemAsync(_columnSelectionStorageKey, SelectedColumns);
await SelectedColumnsChanged.InvokeAsync(SelectedColumns); await SelectedColumnsChanged.InvokeAsync(SelectedColumns);
MarkColumnsDirty();
} }
if (!DictionaryEqual(_columnWidths, result.ColumnWidths)) if (!DictionaryEqual(_columnWidths, result.ColumnWidths))
{ {
_columnWidths = result.ColumnWidths; _columnWidths = result.ColumnWidths;
await LocalStorage.SetItemAsync(_columnWidthsStorageKey, _columnWidths); await LocalStorage.SetItemAsync(_columnWidthsStorageKey, _columnWidths);
MarkColumnsDirty();
} }
if (!DictionaryEqual(_columnOrder, result.ColumnOrder)) if (!DictionaryEqual(_columnOrder, result.ColumnOrder))
{ {
_columnOrder = result.ColumnOrder; _columnOrder = result.ColumnOrder;
await LocalStorage.SetItemAsync(_columnOrderStorageKey, _columnOrder); await LocalStorage.SetItemAsync(_columnOrderStorageKey, _columnOrder);
MarkColumnsDirty();
} }
} }
@@ -368,17 +448,34 @@ namespace Lantean.QBTMud.Components.UI
if (column.Width.HasValue) if (column.Width.HasValue)
{ {
className = $"overflow-cell {className}"; className = string.IsNullOrWhiteSpace(className)
? "overflow-cell"
: $"overflow-cell {className}";
} }
if (OnTableDataContextMenu.HasDelegate) if (OnTableDataContextMenu.HasDelegate)
{ {
className = $"no-default-context-menu {className}"; className = string.IsNullOrWhiteSpace(className)
? "no-default-context-menu"
: $"no-default-context-menu {className}";
}
if (OnTableDataLongPress.HasDelegate)
{
className = string.IsNullOrWhiteSpace(className)
? "unselectable"
: $"unselectable {className}";
} }
return className; return className;
} }
private void MarkColumnsDirty()
{
_columnsDirty = true;
_visibleColumns = EmptyColumns;
}
private sealed record SortData private sealed record SortData
{ {
public SortData(string sortColumn, SortDirection sortDirection) public SortData(string sortColumn, SortDirection sortDirection)

View File

@@ -1,5 +1,5 @@
@inherits MudTd @inherits MudTd
<td data-label="@DataLabel" style="@Style" class="@Classname" @attributes="@UserAttributes" @onlongpress="OnLongPressInternal" @oncontextmenu="OnContextMenuInternal" @oncontextmenu:preventDefault> <td data-label="@DataLabel" style="@Style" class="@Classname" @attributes="@UserAttributes" @onlongpress="OnLongPressInternal" @onlongpress:preventDefault @oncontextmenu="OnContextMenuInternal" @oncontextmenu:preventDefault>
@ChildContent @ChildContent
</td> </td>

View File

@@ -1,6 +1,10 @@
<DynamicTable T="Lantean.QBitTorrentClient.Models.WebSeed" <div class="content-panel">
ColumnDefinitions="Columns" <div class="content-panel__body">
Items="WebSeeds" <DynamicTable T="Lantean.QBitTorrentClient.Models.WebSeed"
MultiSelection="false" ColumnDefinitions="Columns"
SelectOnRowClick="false" Items="WebSeeds"
Class="details-list" /> MultiSelection="false"
SelectOnRowClick="false"
Class="details-list content-panel__table" />
</div>
</div>

View File

@@ -19,28 +19,28 @@ namespace Lantean.QBTMud.Helpers
{ {
if (seconds is null) if (seconds is null)
{ {
return ""; return string.Empty;
} }
if (seconds == 8640000) const long InfiniteEtaSentinelSeconds = 8_640_000; // ~100 days, used by qBittorrent for "infinite" ETA.
var value = seconds.Value;
if (value >= long.MaxValue || value >= TimeSpan.MaxValue.TotalSeconds || value == InfiniteEtaSentinelSeconds)
{ {
return "∞"; return "∞";
} }
if (seconds < 60) if (value <= 0)
{ {
return "< 1m"; return "< 1m";
} }
TimeSpan time; var time = TimeSpan.FromSeconds(value);
try if (time.TotalMinutes < 1)
{ {
time = TimeSpan.FromSeconds(seconds.Value); return "< 1m";
}
catch (OverflowException)
{
return "∞";
} }
var sb = new StringBuilder(); var sb = new StringBuilder();
if (prefix is not null) if (prefix is not null)
{ {
@@ -129,7 +129,7 @@ namespace Lantean.QBTMud.Helpers
return ""; return "";
} }
return Size(size); return Size(size, prefix, suffix);
} }
/// <summary> /// <summary>

View File

@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Components.Web;
namespace Lantean.QBTMud.Helpers
{
public static class EventArgsExtensions
{
public static EventArgs NormalizeForContextMenu(this EventArgs eventArgs)
{
ArgumentNullException.ThrowIfNull(eventArgs);
if (eventArgs is LongPressEventArgs longPressEventArgs)
{
return longPressEventArgs.ToMouseEventArgs();
}
return eventArgs;
}
public static MouseEventArgs ToMouseEventArgs(this LongPressEventArgs longPressEventArgs)
{
ArgumentNullException.ThrowIfNull(longPressEventArgs);
return new MouseEventArgs
{
Button = 2,
Buttons = 2,
ClientX = longPressEventArgs.ClientX,
ClientY = longPressEventArgs.ClientY,
OffsetX = longPressEventArgs.OffsetX,
OffsetY = longPressEventArgs.OffsetY,
PageX = longPressEventArgs.PageX,
PageY = longPressEventArgs.PageY,
ScreenX = longPressEventArgs.ScreenX,
ScreenY = longPressEventArgs.ScreenY,
Type = longPressEventArgs.Type ?? "contextmenu",
Detail = -1,
};
}
}
}

View File

@@ -119,34 +119,35 @@ namespace Lantean.QBTMud.Helpers
switch (category) switch (category)
{ {
case CATEGORY_ALL: case CATEGORY_ALL:
break; return true;
case CATEGORY_UNCATEGORIZED: case CATEGORY_UNCATEGORIZED:
if (!string.IsNullOrEmpty(torrent.Category)) if (!string.IsNullOrEmpty(torrent.Category))
{ {
return false; return false;
} }
break;
return true;
default: default:
if (string.IsNullOrEmpty(torrent.Category))
{
return false;
}
if (!useSubcategories) if (!useSubcategories)
{ {
if (torrent.Category != category) return string.Equals(torrent.Category, category, StringComparison.Ordinal);
{
return false;
}
else
{
if (!torrent.Category.StartsWith(category))
{
return false;
}
}
} }
break;
}
return true; if (string.Equals(torrent.Category, category, StringComparison.Ordinal))
{
return true;
}
var prefix = string.Concat(category, "/");
return torrent.Category.StartsWith(prefix, StringComparison.Ordinal);
}
} }
public static bool FilterTag(Torrent torrent, string tag) public static bool FilterTag(Torrent torrent, string tag)
@@ -207,7 +208,7 @@ namespace Lantean.QBTMud.Helpers
break; break;
case Status.Paused: case Status.Paused:
if (!state.Contains("paused") || !state.Contains("stopped")) if (!state.Contains("paused") && !state.Contains("stopped"))
{ {
return false; return false;
} }

View File

@@ -0,0 +1,33 @@
namespace Lantean.QBTMud.Helpers
{
internal static class VersionHelper
{
private static int? _version;
private const int _defaultVersion = 5;
public static int DefaultVersion => _defaultVersion;
public static int GetMajorVersion(string? version)
{
if (_version is not null)
{
return _version.Value;
}
if (string.IsNullOrEmpty(version))
{
return _defaultVersion;
}
if (!Version.TryParse(version?.Replace("v", ""), out var theVersion))
{
return _defaultVersion;
}
_version = theVersion.Major;
return _version.Value;
}
}
}

View File

@@ -4,21 +4,19 @@
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<CompressionEnabled>false</CompressionEnabled> <CompressionEnabled>false</CompressionEnabled>
<LangVersion>12</LangVersion> <LangVersion>12</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" /> <PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
<PackageReference Include="ByteSize" Version="2.1.2" /> <PackageReference Include="ByteSize" Version="2.1.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.1" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.1" PrivateAssets="all" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.10" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.1" /> <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" />
<PackageReference Include="MudBlazor" Version="8.2.0" /> <PackageReference Include="MudBlazor" Version="8.13.0" />
<PackageReference Include="MudBlazor.ThemeManager" Version="3.0.0" /> <PackageReference Include="MudBlazor.ThemeManager" Version="3.0.0" />
<!-- added to fix vuln in dependency -->
<PackageReference Include="System.Text.Json" Version="9.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,9 +1,11 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@layout LoggedInLayout @layout LoggedInLayout
<MudDrawer Open="DrawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2" Overlay="false"> <div class="app-shell__body">
<TorrentsListNav Torrents="Torrents" SelectedTorrent="@SelectedTorrent" SortDirection="SortDirection" SortColumn="@SortColumn" /> <MudDrawer Open="DrawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2" Overlay="false" Class="app-shell__sidebar">
</MudDrawer> <TorrentsListNav Torrents="Torrents" SelectedTorrent="@SelectedTorrent" SortDirection="SortDirection" SortColumn="@SortColumn" />
<MudMainContent> </MudDrawer>
@Body <MudMainContent Class="app-shell__main">
</MudMainContent> @Body
</MudMainContent>
</div>

View File

@@ -1,11 +1,13 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@layout LoggedInLayout @layout LoggedInLayout
<MudDrawer Open="DrawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2" Overlay="false"> <div class="app-shell__body">
<FiltersNav CategoryChanged="CategoryChanged" StatusChanged="StatusChanged" TagChanged="TagChanged" TrackerChanged="TrackerChanged" /> <MudDrawer Open="DrawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2" Overlay="false" Class="app-shell__sidebar">
</MudDrawer> <FiltersNav CategoryChanged="CategoryChanged" StatusChanged="StatusChanged" TagChanged="TagChanged" TrackerChanged="TrackerChanged" />
<MudMainContent> </MudDrawer>
<CascadingValue Value="SearchTermChanged" Name="SearchTermChanged"> <MudMainContent Class="app-shell__main">
@Body <CascadingValue Value="SearchTermChanged" Name="SearchTermChanged">
</CascadingValue> @Body
</MudMainContent> </CascadingValue>
</MudMainContent>
</div>

View File

@@ -10,20 +10,53 @@
} }
<CascadingValue Value="Torrents"> <CascadingValue Value="Torrents">
<CascadingValue Value="MainData"> <CascadingValue Value="_torrentsVersion" Name="TorrentsVersion">
<CascadingValue Value="Preferences"> <CascadingValue Value="MainData">
<CascadingValue Value="SortColumnChanged" Name="SortColumnChanged"> <CascadingValue Value="Preferences">
<CascadingValue Value="SortColumn" Name="SortColumn"> <CascadingValue Value="SortColumnChanged" Name="SortColumnChanged">
<CascadingValue Value="SortDirectionChanged" Name="SortDirectionChanged"> <CascadingValue Value="SortColumn" Name="SortColumn">
<CascadingValue Value="SortDirection" Name="SortDirection"> <CascadingValue Value="SortDirectionChanged" Name="SortDirectionChanged">
<CascadingValue Value="CategoryChanged" Name="CategoryChanged"> <CascadingValue Value="SortDirection" Name="SortDirection">
<CascadingValue Value="StatusChanged" Name="StatusChanged"> <CascadingValue Value="CategoryChanged" Name="CategoryChanged">
<CascadingValue Value="TagChanged" Name="TagChanged"> <CascadingValue Value="StatusChanged" Name="StatusChanged">
<CascadingValue Value="TrackerChanged" Name="TrackerChanged"> <CascadingValue Value="TagChanged" Name="TagChanged">
<CascadingValue Value="SearchTermChanged" Name="SearchTermChanged"> <CascadingValue Value="TrackerChanged" Name="TrackerChanged">
<CascadingValue Value="@(MainData?.LostConnection ?? false)" Name="LostConnection"> <CascadingValue Value="SearchTermChanged" Name="SearchTermChanged">
<CascadingValue Value="Version" Name="Version"> <CascadingValue Value="@(MainData?.LostConnection ?? false)" Name="LostConnection">
@Body <CascadingValue Value="Version" Name="Version">
<div class="app-shell">
@Body
<MudAppBar Bottom="true" Elevation="0" Dense="true" Class="app-shell__status-bar">
@if (MainData?.LostConnection == true)
{
<MudText Class="mx-2 mb-1 d-none d-sm-flex" Color="Color.Error">qBittorrent client is not reachable</MudText>
}
<MudSpacer />
<MudText Class="mx-2 mb-1 d-none d-sm-flex">@DisplayHelpers.Size(MainData?.ServerState.FreeSpaceOnDisk, "Free space: ")</MudText>
<MudDivider Vertical="true" Class="d-none d-sm-flex" />
<MudText Class="mx-2 mb-1 d-none d-sm-flex">DHT @(MainData?.ServerState.DHTNodes ?? 0) nodes</MudText>
<MudDivider Vertical="true" Class="d-none d-sm-flex" />
@{
var (icon, colour) = GetConnectionIcon(MainData?.ServerState.ConnectionStatus);
}
<MudIcon Class="mx-1 mb-1" Icon="@icon" Color="@colour" Title="@MainData?.ServerState.ConnectionStatus" />
<MudDivider Vertical="true" Class="" />
<MudIcon Class="mx-1 mb-1" Icon="@Icons.Material.Outlined.Speed" Color="@((MainData?.ServerState.UseAltSpeedLimits ?? false) ? Color.Error : Color.Success)" />
<MudDivider Vertical="true" Class="" />
<MudIcon Class="ml-1 mb-1" Icon="@Icons.Material.Filled.KeyboardDoubleArrowDown" Color="Color.Success" />
<MudText Class="mr-1 mb-1">
@DisplayHelpers.Size(MainData?.ServerState.DownloadInfoSpeed, null, "/s")
@DisplayHelpers.Size(MainData?.ServerState.DownloadInfoData, "(", ")")
</MudText>
<MudDivider Vertical="true" />
<MudIcon Class="ml-1 mb-1" Icon="@Icons.Material.Filled.KeyboardDoubleArrowUp" Color="Color.Info" />
<MudText Class="mr-1 mb-1">
@DisplayHelpers.Size(MainData?.ServerState.UploadInfoSpeed, null, "/s")
@DisplayHelpers.Size(MainData?.ServerState.UploadInfoData, "(", ")")
</MudText>
</MudAppBar>
</div>
</CascadingValue>
</CascadingValue> </CascadingValue>
</CascadingValue> </CascadingValue>
</CascadingValue> </CascadingValue>
@@ -36,34 +69,5 @@
</CascadingValue> </CascadingValue>
</CascadingValue> </CascadingValue>
</CascadingValue> </CascadingValue>
<MudAppBar Bottom="true" Fixed="true" Elevation="0" Dense="true" Style="background-color: var(--mud-palette-dark-lighten); z-index: 900">
@if (MainData?.LostConnection == true)
{
<MudText Class="mx-2 mb-1 d-none d-sm-flex" Color="Color.Error">qBittorrent client is not reachable</MudText>
}
<MudSpacer />
<MudText Class="mx-2 mb-1 d-none d-sm-flex">@DisplayHelpers.Size(MainData?.ServerState.FreeSpaceOnDisk, "Free space: ")</MudText>
<MudDivider Vertical="true" Class="d-none d-sm-flex" />
<MudText Class="mx-2 mb-1 d-none d-sm-flex">DHT @(MainData?.ServerState.DHTNodes ?? 0) nodes</MudText>
<MudDivider Vertical="true" Class="d-none d-sm-flex" />
@{
var (icon, colour) = GetConnectionIcon(MainData?.ServerState.ConnectionStatus);
}
<MudIcon Class="mx-1 mb-1" Icon="@icon" Color="@colour" Title="MainData?.ServerState.ConnectionStatus" />
<MudDivider Vertical="true" Class="" />
<MudIcon Class="mx-1 mb-1" Icon="@Icons.Material.Outlined.Speed" Color="@((MainData?.ServerState.UseAltSpeedLimits ?? false) ? Color.Error : Color.Success)" />
<MudDivider Vertical="true" Class="" />
<MudIcon Class="ml-1 mb-1" Icon="@Icons.Material.Filled.KeyboardDoubleArrowDown" Color="Color.Success" />
<MudText Class="mr-1 mb-1">
@DisplayHelpers.Size(MainData?.ServerState.DownloadInfoSpeed, null, "/s")
@DisplayHelpers.Size(MainData?.ServerState.DownloadInfoData, "(", ")")
</MudText>
<MudDivider Vertical="true" />
<MudIcon Class="ml-1 mb-1" Icon="@Icons.Material.Filled.KeyboardDoubleArrowUp" Color="Color.Info" />
<MudText Class="mr-1 mb-1">
@DisplayHelpers.Size(MainData?.ServerState.UploadInfoSpeed, null, "/s")
@DisplayHelpers.Size(MainData?.ServerState.UploadInfoData, "(", ")")
</MudText>
</MudAppBar>
</CascadingValue> </CascadingValue>
</CascadingValue> </CascadingValue>

View File

@@ -52,22 +52,36 @@ namespace Lantean.QBTMud.Layout
protected string? SearchText { get; set; } protected string? SearchText { get; set; }
protected IEnumerable<Torrent> Torrents => GetTorrents(); protected IReadOnlyList<Torrent> Torrents => GetTorrents();
protected bool IsAuthenticated { get; set; } protected bool IsAuthenticated { get; set; }
protected bool LostConnection { get; set; } protected bool LostConnection { get; set; }
private List<Torrent> GetTorrents() private IReadOnlyList<Torrent> _visibleTorrents = Array.Empty<Torrent>();
private bool _torrentsDirty = true;
private int _torrentsVersion;
private IReadOnlyList<Torrent> GetTorrents()
{ {
if (!_torrentsDirty)
{
return _visibleTorrents;
}
if (MainData is null) if (MainData is null)
{ {
return []; _visibleTorrents = Array.Empty<Torrent>();
_torrentsDirty = false;
return _visibleTorrents;
} }
var filterState = new FilterState(Category, Status, Tag, Tracker, MainData.ServerState.UseSubcategories, SearchText); var filterState = new FilterState(Category, Status, Tag, Tracker, MainData.ServerState.UseSubcategories, SearchText);
_visibleTorrents = MainData.Torrents.Values.Filter(filterState).ToList();
_torrentsDirty = false;
return MainData.Torrents.Values.Filter(filterState).ToList(); return _visibleTorrents;
} }
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -83,7 +97,8 @@ namespace Lantean.QBTMud.Layout
Preferences = await ApiClient.GetApplicationPreferences(); Preferences = await ApiClient.GetApplicationPreferences();
Version = await ApiClient.GetApplicationVersion(); Version = await ApiClient.GetApplicationVersion();
var data = await ApiClient.GetMainData(_requestId); var data = await ApiClient.GetMainData(_requestId);
MainData = DataManager.CreateMainData(data); MainData = DataManager.CreateMainData(data, Version);
MarkTorrentsDirty();
_requestId = data.ResponseId; _requestId = data.ResponseId;
_refreshInterval = MainData.ServerState.RefreshInterval; _refreshInterval = MainData.ServerState.RefreshInterval;
@@ -126,32 +141,51 @@ namespace Lantean.QBTMud.Layout
return; return;
} }
var shouldRender = false;
if (MainData is null || data.FullUpdate) if (MainData is null || data.FullUpdate)
{ {
MainData = DataManager.CreateMainData(data); MainData = DataManager.CreateMainData(data, Version);
MarkTorrentsDirty();
shouldRender = true;
} }
else else
{ {
DataManager.MergeMainData(data, MainData); var dataChanged = DataManager.MergeMainData(data, MainData, out var filterChanged);
if (filterChanged)
{
MarkTorrentsDirty();
}
else if (dataChanged)
{
IncrementTorrentsVersion();
}
shouldRender = dataChanged;
} }
_refreshInterval = MainData.ServerState.RefreshInterval; if (MainData is not null)
{
_refreshInterval = MainData.ServerState.RefreshInterval;
}
_requestId = data.ResponseId; _requestId = data.ResponseId;
await InvokeAsync(StateHasChanged); if (shouldRender)
{
await InvokeAsync(StateHasChanged);
}
} }
} }
} }
} }
protected EventCallback<string> CategoryChanged => EventCallback.Factory.Create<string>(this, category => Category = category); protected EventCallback<string> CategoryChanged => EventCallback.Factory.Create<string>(this, OnCategoryChanged);
protected EventCallback<Status> StatusChanged => EventCallback.Factory.Create<Status>(this, status => Status = status); protected EventCallback<Status> StatusChanged => EventCallback.Factory.Create<Status>(this, OnStatusChanged);
protected EventCallback<string> TagChanged => EventCallback.Factory.Create<string>(this, tag => Tag = tag); protected EventCallback<string> TagChanged => EventCallback.Factory.Create<string>(this, OnTagChanged);
protected EventCallback<string> TrackerChanged => EventCallback.Factory.Create<string>(this, tracker => Tracker = tracker); protected EventCallback<string> TrackerChanged => EventCallback.Factory.Create<string>(this, OnTrackerChanged);
protected EventCallback<string> SearchTermChanged => EventCallback.Factory.Create<string>(this, term => SearchText = term); protected EventCallback<string> SearchTermChanged => EventCallback.Factory.Create<string>(this, OnSearchTermChanged);
protected EventCallback<string> SortColumnChanged => EventCallback.Factory.Create<string>(this, columnId => SortColumn = columnId); protected EventCallback<string> SortColumnChanged => EventCallback.Factory.Create<string>(this, columnId => SortColumn = columnId);
@@ -159,12 +193,81 @@ namespace Lantean.QBTMud.Layout
protected static (string, Color) GetConnectionIcon(string? status) protected static (string, Color) GetConnectionIcon(string? status)
{ {
if (status is null) return status switch
{ {
return (Icons.Material.Outlined.SignalWifiOff, Color.Warning); "firewalled" => (Icons.Material.Outlined.SignalWifiStatusbarConnectedNoInternet4, Color.Warning),
"connected" => (Icons.Material.Outlined.SignalWifi4Bar, Color.Success),
_ => (Icons.Material.Outlined.SignalWifiOff, Color.Error),
};
}
private void OnCategoryChanged(string category)
{
if (Category == category)
{
return;
} }
return (Icons.Material.Outlined.SignalWifi4Bar, Color.Success); Category = category;
MarkTorrentsDirty();
}
private void OnStatusChanged(Status status)
{
if (Status == status)
{
return;
}
Status = status;
MarkTorrentsDirty();
}
private void OnTagChanged(string tag)
{
if (Tag == tag)
{
return;
}
Tag = tag;
MarkTorrentsDirty();
}
private void OnTrackerChanged(string tracker)
{
if (Tracker == tracker)
{
return;
}
Tracker = tracker;
MarkTorrentsDirty();
}
private void OnSearchTermChanged(string term)
{
if (SearchText == term)
{
return;
}
SearchText = term;
MarkTorrentsDirty();
}
private void MarkTorrentsDirty()
{
_torrentsDirty = true;
IncrementTorrentsVersion();
}
private void IncrementTorrentsVersion()
{
unchecked
{
_torrentsVersion++;
}
} }
protected virtual void Dispose(bool disposing) protected virtual void Dispose(bool disposing)

View File

@@ -13,9 +13,6 @@ namespace Lantean.QBTMud.Layout
private bool _disposedValue; private bool _disposedValue;
[Inject]
protected NavigationManager NavigationManager { get; set; } = default!;
[Inject] [Inject]
private IBrowserViewportService BrowserViewportService { get; set; } = default!; private IBrowserViewportService BrowserViewportService { get; set; } = default!;
@@ -78,13 +75,13 @@ namespace Lantean.QBTMud.Layout
{ {
IsDarkMode = isDarkMode.Value; IsDarkMode = isDarkMode.Value;
} }
await MudThemeProvider.WatchSystemPreference(OnSystemPreferenceChanged); await MudThemeProvider.WatchSystemDarkModeAsync(OnSystemDarkModeChanged);
await BrowserViewportService.SubscribeAsync(this, fireImmediately: true); await BrowserViewportService.SubscribeAsync(this, fireImmediately: true);
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
} }
protected Task OnSystemPreferenceChanged(bool value) protected Task OnSystemDarkModeChanged(bool value)
{ {
IsDarkMode = value; IsDarkMode = value;
return Task.CompletedTask; return Task.CompletedTask;

View File

@@ -1,11 +1,13 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@layout LoggedInLayout @layout LoggedInLayout
<MudDrawer Open="DrawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2" Overlay="false"> <div class="app-shell__body">
<MudNavMenu> <MudDrawer Open="DrawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2" Overlay="false" Class="app-shell__sidebar">
<ApplicationActions IsMenu="false" Preferences="Preferences" /> <MudNavMenu>
</MudNavMenu> <ApplicationActions IsMenu="false" Preferences="Preferences" />
</MudDrawer> </MudNavMenu>
<MudMainContent> </MudDrawer>
@Body <MudMainContent Class="app-shell__main">
</MudMainContent> @Body
</MudMainContent>
</div>

View File

@@ -11,7 +11,8 @@
Dictionary<string, HashSet<string>> tagState, Dictionary<string, HashSet<string>> tagState,
Dictionary<string, HashSet<string>> categoriesState, Dictionary<string, HashSet<string>> categoriesState,
Dictionary<string, HashSet<string>> statusState, Dictionary<string, HashSet<string>> statusState,
Dictionary<string, HashSet<string>> trackersState) Dictionary<string, HashSet<string>> trackersState,
int majorVersion)
{ {
Torrents = torrents.ToDictionary(); Torrents = torrents.ToDictionary();
Tags = tags.ToHashSet(); Tags = tags.ToHashSet();
@@ -22,6 +23,7 @@
CategoriesState = categoriesState; CategoriesState = categoriesState;
StatusState = statusState; StatusState = statusState;
TrackersState = trackersState; TrackersState = trackersState;
MajorVersion = majorVersion;
} }
public Dictionary<string, Torrent> Torrents { get; } public Dictionary<string, Torrent> Torrents { get; }
@@ -36,5 +38,6 @@
public Dictionary<string, HashSet<string>> TrackersState { get; } public Dictionary<string, HashSet<string>> TrackersState { get; }
public string? SelectedTorrentHash { get; set; } public string? SelectedTorrentHash { get; set; }
public bool LostConnection { get; set; } public bool LostConnection { get; set; }
public int MajorVersion { get; }
} }
} }

View File

@@ -8,6 +8,7 @@
Completed, Completed,
Resumed, Resumed,
Paused, Paused,
Stopped,
Active, Active,
Inactive, Inactive,
Stalled, Stalled,
@@ -15,6 +16,5 @@
StalledDownloading, StalledDownloading,
Checking, Checking,
Errored, Errored,
Stopped
} }
} }

View File

@@ -1,6 +1,4 @@
using Lantean.QBitTorrentClient.Models; namespace Lantean.QBTMud.Models
namespace Lantean.QBTMud.Models
{ {
public record TorrentOptions public record TorrentOptions
{ {

View File

@@ -1,18 +1,22 @@
@page "/about" @page "/about"
@layout OtherLayout @layout OtherLayout
<MudToolBar Gutters="false" Dense="true"> <div class="content-panel">
@if (!DrawerOpen) <div class="content-panel__toolbar">
{ <MudToolBar Gutters="false" Dense="true">
<MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" /> @if (!DrawerOpen)
<MudDivider Vertical="true" /> {
} <MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" />
<MudText Class="px-5 no-wrap">About</MudText> <MudDivider Vertical="true" />
</MudToolBar> }
<MudText Class="px-5 no-wrap">About</MudText>
</MudToolBar>
</div>
<MudTabs Elevation="2" ApplyEffectsToContainer="true"> <div class="content-panel__body">
<MudTabPanel Text="About"> <MudTabs Elevation="2" ApplyEffectsToContainer="true">
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-3"> <MudTabPanel Text="About">
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-3 content-panel__container options-tab-contents">
<MudGrid Class="mt-0 mb-4"> <MudGrid Class="mt-0 mb-4">
<MudItem xs="12" sm="3" md="2" lg="2" xl="1" Class="d-flex justify-center"> <MudItem xs="12" sm="3" md="2" lg="2" xl="1" Class="d-flex justify-center">
<MudImage Src="images/mascot.png" Alt="Mascot" Class="ma-6" <MudImage Src="images/mascot.png" Alt="Mascot" Class="ma-6"
@@ -60,7 +64,7 @@
</MudContainer> </MudContainer>
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="Authors"> <MudTabPanel Text="Authors">
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-3"> <MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-3 options-tab-contents">
<MudText Typo="Typo.h5" Class="py-1">Current maintainer</MudText> <MudText Typo="Typo.h5" Class="py-1">Current maintainer</MudText>
<MudGrid Class="mt-0 mb-4"> <MudGrid Class="mt-0 mb-4">
@@ -108,7 +112,7 @@
</MudContainer> </MudContainer>
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="Special Thanks"> <MudTabPanel Text="Special Thanks">
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-3"> <MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-3 options-tab-contents">
<MudText Typo="Typo.body1" Class="py-1">I would first like to thank sourceforge.net for hosting qBittorrent project and for their support.</MudText> <MudText Typo="Typo.body1" Class="py-1">I would first like to thank sourceforge.net for hosting qBittorrent project and for their support.</MudText>
<MudText Typo="Typo.body1" Class="py-1">I am pleased that people from all over the world are contributing to qBittorrent: Ishan Arora (India), Arnaud Demaizière (France) and Stephanos Antaris (Greece). Their help is greatly appreciated</MudText> <MudText Typo="Typo.body1" Class="py-1">I am pleased that people from all over the world are contributing to qBittorrent: Ishan Arora (India), Arnaud Demaizière (France) and Stephanos Antaris (Greece). Their help is greatly appreciated</MudText>
<MudText Typo="Typo.body1" Class="py-1">I also want to thank Στέφανος Αντάρης (santaris@csd.auth.gr) and Mirco Chinelli (infinity89@fastwebmail.it) for working on Mac OS X packaging.</MudText> <MudText Typo="Typo.body1" Class="py-1">I also want to thank Στέφανος Αντάρης (santaris@csd.auth.gr) and Mirco Chinelli (infinity89@fastwebmail.it) for working on Mac OS X packaging.</MudText>
@@ -118,7 +122,7 @@
</MudContainer> </MudContainer>
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="Translators"> <MudTabPanel Text="Translators">
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-3"> <MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-3 options-tab-contents">
<MudText Typo="Typo.body1" Class="py-1"> <MudText Typo="Typo.body1" Class="py-1">
I would like to thank the people who volunteered to Circle qBittorrent.<br> I would like to thank the people who volunteered to Circle qBittorrent.<br>
Most of them Circled via <MudLink Target="https://www.transifex.com/sledgehammer999/qbittorrent/" Href="https://www.transifex.com/sledgehammer999/qbittorrent/">Transifex</MudLink> and some of them are mentioned below:<br> Most of them Circled via <MudLink Target="https://www.transifex.com/sledgehammer999/qbittorrent/" Href="https://www.transifex.com/sledgehammer999/qbittorrent/">Transifex</MudLink> and some of them are mentioned below:<br>
@@ -168,7 +172,7 @@
</MudContainer> </MudContainer>
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="Licence"> <MudTabPanel Text="Licence">
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-3"> <MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-3 options-tab-contents">
<MudText Typo="Typo.body1" Class="py-1"> <MudText Typo="Typo.body1" Class="py-1">
The qBittorrent source code is licensed under the GNU General Public License, version 2 or (at your option) any later version (GPLv2+). The qBittorrent source code is licensed under the GNU General Public License, version 2 or (at your option) any later version (GPLv2+).
However, this binary distribution is licensed under GNU General Public License, version 3 or (at your option) any later version (GPLv3+), However, this binary distribution is licensed under GNU General Public License, version 3 or (at your option) any later version (GPLv3+),
@@ -1061,7 +1065,7 @@
</MudContainer> </MudContainer>
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="Software Used"> <MudTabPanel Text="Software Used">
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-3 mb-3"> <MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-3 mb-3 options-tab-contents">
<MudText Typo="Typo.body1" Class="py-1">qBittorrent was built with the following libraries:</MudText> <MudText Typo="Typo.body1" Class="py-1">qBittorrent was built with the following libraries:</MudText>
<MudGrid Class="mt-1 mb-4"> <MudGrid Class="mt-1 mb-4">
@@ -1105,3 +1109,5 @@
</MudContainer> </MudContainer>
</MudTabPanel> </MudTabPanel>
</MudTabs> </MudTabs>
</div>
</div>

View File

@@ -1,36 +1,41 @@
@page "/blocks" @page "/blocks"
@layout OtherLayout @layout OtherLayout
<MudToolBar Gutters="false" Dense="true"> <div class="content-panel">
@if (!DrawerOpen) <div class="content-panel__toolbar">
{ <MudToolBar Gutters="false" Dense="true">
<MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" /> @if (!DrawerOpen)
<MudDivider Vertical="true" /> {
} <MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" />
<MudDivider Vertical="true" /> <MudDivider Vertical="true" />
<MudText Class="pl-5 no-wrap">Blocked IPs</MudText> }
</MudToolBar> <MudDivider Vertical="true" />
<MudText Class="pl-5 no-wrap">Blocked IPs</MudText>
</MudToolBar>
</div>
<div class="content-panel__body">
<MudCard Elevation="1" Class="ml-4 mr-4 mb-4">
<MudCardContent>
<EditForm Model="Model" OnSubmit="Submit">
<MudGrid>
<MudItem md="10">
<MudTextField T="string" Label="Criteria" @bind-Value="Model.Criteria" Variant="Variant.Outlined" />
</MudItem>
<MudItem md="2">
<MudButton ButtonType="ButtonType.Submit" FullWidth="true" Color="Color.Primary" EndIcon="@Icons.Material.Filled.Search" Variant="Variant.Filled" Class="mt-6">Filter</MudButton>
</MudItem>
</MudGrid>
</EditForm>
</MudCardContent>
</MudCard>
<MudCard Elevation="1" Class="ml-4 mr-4 mb-4"> <DynamicTable @ref="Table"
<MudCardContent> T="Lantean.QBitTorrentClient.Models.PeerLog"
<EditForm Model="Model" OnSubmit="Submit"> ColumnDefinitions="Columns"
<MudGrid> Items="Results"
<MudItem md="10"> MultiSelection="false"
<MudTextField T="string" Label="Criteria" @bind-Value="Model.Criteria" Variant="Variant.Outlined" /> SelectOnRowClick="false"
</MudItem> RowClassFunc="RowClass"
<MudItem md="2"> Class="search-list content-panel__table" />
<MudButton ButtonType="ButtonType.Submit" FullWidth="true" Color="Color.Primary" EndIcon="@Icons.Material.Filled.Search" Variant="Variant.Filled" Class="mt-6">Filter</MudButton> </div>
</MudItem> </div>
</MudGrid>
</EditForm>
</MudCardContent>
</MudCard>
<DynamicTable @ref="Table"
T="Lantean.QBitTorrentClient.Models.PeerLog"
ColumnDefinitions="Columns"
Items="Results"
MultiSelection="false"
SelectOnRowClick="false"
RowClassFunc="RowClass"
Class="search-list" />

View File

@@ -1,24 +1,30 @@
@page "/categories" @page "/categories"
@layout OtherLayout @layout OtherLayout
<MudToolBar Gutters="false" Dense="true"> <div class="content-panel">
@if (!DrawerOpen) <div class="content-panel__toolbar">
{ <MudToolBar Gutters="false" Dense="true">
<MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" /> @if (!DrawerOpen)
<MudDivider Vertical="true" /> {
} <MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" />
<MudText Class="px-5 no-wrap">Categories</MudText> <MudDivider Vertical="true" />
<MudDivider Vertical="true" /> }
<MudIconButton Icon="@Icons.Material.Filled.PlaylistAdd" OnClick="AddCategory" title="Add Category" /> <MudText Class="px-5 no-wrap">Categories</MudText>
</MudToolBar> <MudDivider Vertical="true" />
<MudIconButton Icon="@Icons.Material.Filled.PlaylistAdd" OnClick="AddCategory" title="Add Category" />
</MudToolBar>
</div>
<DynamicTable @ref="Table" <div class="content-panel__body">
T="Category" <DynamicTable @ref="Table"
ColumnDefinitions="Columns" T="Category"
Items="Results" ColumnDefinitions="Columns"
MultiSelection="false" Items="Results"
SelectOnRowClick="false" MultiSelection="false"
Class="details-list" /> SelectOnRowClick="false"
Class="details-list content-panel__table" />
</div>
</div>
@code { @code {
private RenderFragment<RowContext<Category>> ActionsColumn private RenderFragment<RowContext<Category>> ActionsColumn

View File

@@ -1,41 +1,45 @@
@page "/details/{hash}" @page "/details/{hash}"
@layout DetailsLayout @layout DetailsLayout
<div style="overflow-x: auto; white-space: nowrap; width: 100%;"> <div class="content-panel">
<MudToolBar Gutters="false" Dense="true"> <div class="content-panel__toolbar content-panel__toolbar--scroll">
@if (!DrawerOpen) <MudToolBar Gutters="false" Dense="true">
{ @if (!DrawerOpen)
<MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" /> {
<MudDivider Vertical="true" /> <MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" />
} <MudDivider Vertical="true" />
@if (Hash is not null) }
{ @if (Hash is not null)
<TorrentActions RenderType="RenderType.InitialIconsOnly" Hashes="@([Hash])" Torrents="MainData.Torrents" Preferences="Preferences" /> {
} <TorrentActions RenderType="RenderType.InitialIconsOnly" Hashes="@([Hash])" Torrents="MainData.Torrents" Preferences="Preferences" />
<MudDivider Vertical="true" /> }
<MudText Class="pl-5 no-wrap">@Name</MudText> <MudDivider Vertical="true" />
</MudToolBar> <MudText Class="pl-5 no-wrap">@Name</MudText>
</div> </MudToolBar>
</div>
@if (ShowTabs) <div class="content-panel__body">
{ @if (ShowTabs)
<CascadingValue Value="RefreshInterval" Name="RefreshInterval"> {
<MudTabs Elevation="2" ApplyEffectsToContainer="true" @bind-ActivePanelIndex="ActiveTab" KeepPanelsAlive="true" Border="true"> <CascadingValue Value="RefreshInterval" Name="RefreshInterval">
<MudTabPanel Text="General"> <MudTabs Elevation="2" ApplyEffectsToContainer="true" @bind-ActivePanelIndex="ActiveTab" KeepPanelsAlive="true" Border="true">
<GeneralTab Hash="@Hash" Active="@(ActiveTab == 0)" /> <MudTabPanel Text="General">
</MudTabPanel> <GeneralTab Hash="@Hash" Active="@(ActiveTab == 0)" />
<MudTabPanel Text="Trackers"> </MudTabPanel>
<TrackersTab Hash="@Hash" Active="@(ActiveTab == 1)" /> <MudTabPanel Text="Trackers">
</MudTabPanel> <TrackersTab Hash="@Hash" Active="@(ActiveTab == 1)" />
<MudTabPanel Text="Peers"> </MudTabPanel>
<PeersTab Hash="@Hash" Active="@(ActiveTab == 2)" /> <MudTabPanel Text="Peers">
</MudTabPanel> <PeersTab Hash="@Hash" Active="@(ActiveTab == 2)" />
<MudTabPanel Text="HTTP Sources"> </MudTabPanel>
<WebSeedsTab Hash="@Hash" Active="@(ActiveTab == 3)" /> <MudTabPanel Text="HTTP Sources">
</MudTabPanel> <WebSeedsTab Hash="@Hash" Active="@(ActiveTab == 3)" />
<MudTabPanel Text="Content"> </MudTabPanel>
<FilesTab Hash="@Hash" Active="@(ActiveTab == 4)" /> <MudTabPanel Text="Content">
</MudTabPanel> <FilesTab Hash="@Hash" Active="@(ActiveTab == 4)" />
</MudTabs> </MudTabPanel>
</CascadingValue> </MudTabs>
} </CascadingValue>
}
</div>
</div>

View File

@@ -1,44 +1,49 @@
@page "/log" @page "/log"
@layout OtherLayout @layout OtherLayout
<MudToolBar Gutters="false" Dense="true"> <div class="content-panel">
@if (!DrawerOpen) <div class="content-panel__toolbar">
{ <MudToolBar Gutters="false" Dense="true">
<MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" /> @if (!DrawerOpen)
<MudDivider Vertical="true" /> {
} <MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" />
<MudDivider Vertical="true" /> <MudDivider Vertical="true" />
<MudText Class="pl-5 no-wrap">Execution Log</MudText> }
</MudToolBar> <MudDivider Vertical="true" />
<MudText Class="pl-5 no-wrap">Execution Log</MudText>
</MudToolBar>
</div>
<div class="content-panel__body">
<MudCard Elevation="1" Class="ml-4 mr-4 mb-4">
<MudCardContent>
<EditForm Model="Model" OnSubmit="Submit">
<MudGrid>
<MudItem md="7">
<MudTextField T="string" Label="Criteria" @bind-Value="Model.Criteria" Variant="Variant.Outlined" />
</MudItem>
<MudItem md="3">
<MudSelect @ref="CategoryMudSelect" T="string" Label="Categories" SelectedValues="Model.SelectedTypes" SelectedValuesChanged="SelectedValuesChanged" Variant="Variant.Outlined" MultiSelection="true" MultiSelectionTextFunc="GenerateSelectedText" SelectAll="true">
<MudSelectItem Value="@("Normal")">Normal</MudSelectItem>
<MudSelectItem Value="@("Info")">Info</MudSelectItem>
<MudSelectItem Value="@("Warning")">Warning</MudSelectItem>
<MudSelectItem Value="@("Critical")">Critical</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem md="2">
<MudButton ButtonType="ButtonType.Submit" FullWidth="true" Color="Color.Primary" EndIcon="@Icons.Material.Filled.Search" Variant="Variant.Filled" Class="mt-6">Filter</MudButton>
</MudItem>
</MudGrid>
</EditForm>
</MudCardContent>
</MudCard>
<MudCard Elevation="1" Class="ml-4 mr-4 mb-4"> <DynamicTable @ref="Table"
<MudCardContent> T="Lantean.QBitTorrentClient.Models.Log"
<EditForm Model="Model" OnSubmit="Submit"> ColumnDefinitions="Columns"
<MudGrid> Items="Results"
<MudItem md="7"> MultiSelection="false"
<MudTextField T="string" Label="Criteria" @bind-Value="Model.Criteria" Variant="Variant.Outlined" /> SelectOnRowClick="false"
</MudItem> RowClassFunc="RowClass"
<MudItem md="3"> Class="search-list content-panel__table" />
<MudSelect @ref="CategoryMudSelect" T="string" Label="Categories" SelectedValues="Model.SelectedTypes" SelectedValuesChanged="SelectedValuesChanged" Variant="Variant.Outlined" MultiSelection="true" MultiSelectionTextFunc="GenerateSelectedText" SelectAll="true"> </div>
<MudSelectItem Value="@("Normal")">Normal</MudSelectItem> </div>
<MudSelectItem Value="@("Info")">Info</MudSelectItem>
<MudSelectItem Value="@("Warning")">Warning</MudSelectItem>
<MudSelectItem Value="@("Critical")">Critical</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem md="2">
<MudButton ButtonType="ButtonType.Submit" FullWidth="true" Color="Color.Primary" EndIcon="@Icons.Material.Filled.Search" Variant="Variant.Filled" Class="mt-6">Filter</MudButton>
</MudItem>
</MudGrid>
</EditForm>
</MudCardContent>
</MudCard>
<DynamicTable @ref="Table"
T="Lantean.QBitTorrentClient.Models.Log"
ColumnDefinitions="Columns"
Items="Results"
MultiSelection="false"
SelectOnRowClick="false"
RowClassFunc="RowClass"
Class="search-list" />

View File

@@ -3,41 +3,63 @@
<NavigationLock ConfirmExternalNavigation="@(UpdatePreferences is not null)" OnBeforeInternalNavigation="ValidateExit" /> <NavigationLock ConfirmExternalNavigation="@(UpdatePreferences is not null)" OnBeforeInternalNavigation="ValidateExit" />
<MudToolBar Gutters="false" Dense="true"> <div class="content-panel">
@if (!DrawerOpen) <div class="content-panel__toolbar">
{ <MudToolBar Gutters="false" Dense="true">
<MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" /> @if (!DrawerOpen)
<MudDivider Vertical="true" /> {
} <MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" />
<MudText Class="px-5 no-wrap">Settings</MudText> <MudDivider Vertical="true" />
<MudDivider Vertical="true" /> }
<MudIconButton Icon="@Icons.Material.Outlined.Save" OnClick="Save" Disabled="@(LostConnection || UpdatePreferences is null)" /> <MudText Class="px-5 no-wrap">Settings</MudText>
<MudIconButton Icon="@Icons.Material.Outlined.Undo" OnClick="Undo" Disabled="@(LostConnection || UpdatePreferences is null)" /> <MudDivider Vertical="true" />
</MudToolBar> <MudIconButton Icon="@Icons.Material.Outlined.Save" OnClick="Save" Disabled="@(LostConnection || UpdatePreferences is null)" />
<MudIconButton Icon="@Icons.Material.Outlined.Undo" OnClick="Undo" Disabled="@(LostConnection || UpdatePreferences is null)" />
</MudToolBar>
</div>
<MudTabs Elevation="2" ApplyEffectsToContainer="true" @bind-ActivePanelIndex="ActiveTab" Border="true"> <div class="content-panel__body">
<MudTabPanel Text="Behaviour"> <MudTabs Elevation="2" ApplyEffectsToContainer="true" @bind-ActivePanelIndex="ActiveTab" Border="true">
<BehaviourOptions @ref="BehaviourOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> <MudTabPanel Text="Behaviour">
</MudTabPanel> <div class="options-tab-contents">
<MudTabPanel Text="Downloads"> <BehaviourOptions @ref="BehaviourOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" />
<DownloadsOptions @ref="DownloadsOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> </div>
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="Connection"> <MudTabPanel Text="Downloads">
<ConnectionOptions @ref="ConnectionOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> <div class="options-tab-contents">
</MudTabPanel> <DownloadsOptions @ref="DownloadsOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" />
<MudTabPanel Text="Speed"> </div>
<SpeedOptions @ref="SpeedOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> </MudTabPanel>
</MudTabPanel> <MudTabPanel Text="Connection">
<MudTabPanel Text="BitTorrent"> <div class="options-tab-contents">
<BitTorrentOptions @ref="BitTorrentOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> <ConnectionOptions @ref="ConnectionOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" />
</MudTabPanel> </div>
<MudTabPanel Text="RSS"> </MudTabPanel>
<RSSOptions @ref="RSSOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> <MudTabPanel Text="Speed">
</MudTabPanel> <div class="options-tab-contents">
<MudTabPanel Text="Web UI"> <SpeedOptions @ref="SpeedOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" />
<WebUIOptions @ref="WebUIOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> </div>
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="Advanced"> <MudTabPanel Text="BitTorrent">
<AdvancedOptions @ref="AdvancedOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> <div class="options-tab-contents">
</MudTabPanel> <BitTorrentOptions @ref="BitTorrentOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" />
</MudTabs> </div>
</MudTabPanel>
<MudTabPanel Text="RSS">
<div class="options-tab-contents">
<RSSOptions @ref="RSSOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" />
</div>
</MudTabPanel>
<MudTabPanel Text="Web UI">
<div class="options-tab-contents">
<WebUIOptions @ref="WebUIOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" />
</div>
</MudTabPanel>
<MudTabPanel Text="Advanced">
<div class="options-tab-contents">
<AdvancedOptions @ref="AdvancedOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" />
</div>
</MudTabPanel>
</MudTabs>
</div>
</div>

View File

@@ -1,73 +1,79 @@
@page "/rss" @page "/rss"
@layout OtherLayout @layout OtherLayout
<MudToolBar Gutters="false" Dense="true"> <div class="content-panel">
@if (!DrawerOpen) <div class="content-panel__toolbar">
{ <MudToolBar Gutters="false" Dense="true">
<MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" /> @if (!DrawerOpen)
<MudDivider Vertical="true" />
}
<MudText Class="px-5 no-wrap">RSS</MudText>
<MudDivider Vertical="true" />
<MudIconButton Icon="@Icons.Material.Outlined.Subscriptions" OnClick="NewSubscription" title="New subscription" />
<MudIconButton Icon="@Icons.Material.Outlined.MarkEmailRead" OnClick="MarkAsRead" Disabled="@(SelectedFeed is null)" title="Mark items read" />
<MudIconButton Icon="@Icons.Material.Outlined.Update" OnClick="UpdateAll" title="Update all" />
<MudDivider Vertical="true" />
<MudIconButton Icon="@Icons.Material.Outlined.DownloadForOffline" OnClick="EditDownloadRules" title="Edit auto downloading rules" />
</MudToolBar>
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge">
<MudGrid Class="rss-contents">
<MudItem xs="4" Style="height: 100%">
<MudList T="string" SelectionMode="SelectionMode.SingleSelection" SelectedValue="SelectedFeed" SelectedValueChanged="SelectedFeedChanged" Dense>
<MudListItem Icon="@Icons.Material.Filled.MarkEmailUnread" Text="@($"Unread ({UnreadCount})")" Value="@("unread")" />
@foreach (var (key, feed) in Feeds)
{
<MudListItem Icon="@(feed.IsLoading ? Icons.Material.Filled.Sync : Icons.Material.Filled.Wifi)" Class="@(feed.IsLoading ? "spin-animation" : "")" Text="@($"{feed.Title} ({feed.UnreadCount})")" Value="@key" />
}
</MudList>
</MudItem>
<MudItem xs="4" Style="height: 100%; overflow: auto">
@if (Articles.Count > 0)
{ {
<MudList T="string" SelectionMode="SelectionMode.SingleSelection" SelectedValue="SelectedArticle" SelectedValueChanged="SelectedArticleChanged" Dense> <MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" />
@foreach (var article in Articles) <MudDivider Vertical="true" />
}
<MudText Class="px-5 no-wrap">RSS</MudText>
<MudDivider Vertical="true" />
<MudIconButton Icon="@Icons.Material.Outlined.Subscriptions" OnClick="NewSubscription" title="New subscription" />
<MudIconButton Icon="@Icons.Material.Outlined.MarkEmailRead" OnClick="MarkAsRead" Disabled="@(SelectedFeed is null)" title="Mark items read" />
<MudIconButton Icon="@Icons.Material.Outlined.Update" OnClick="UpdateAll" title="Update all" />
<MudDivider Vertical="true" />
<MudIconButton Icon="@Icons.Material.Outlined.DownloadForOffline" OnClick="EditDownloadRules" title="Edit auto downloading rules" />
</MudToolBar>
</div>
<div class="content-panel__body">
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="content-panel__container">
<MudGrid Class="rss-contents">
<MudItem xs="4" Style="height: 100%">
<MudList T="string" SelectionMode="SelectionMode.SingleSelection" SelectedValue="SelectedFeed" SelectedValueChanged="SelectedFeedChanged" Dense>
<MudListItem Icon="@Icons.Material.Filled.MarkEmailUnread" Text="@($"Unread ({UnreadCount})")" Value="@("unread")" />
@foreach (var (key, feed) in Feeds)
{
<MudListItem Icon="@(feed.IsLoading ? Icons.Material.Filled.Sync : Icons.Material.Filled.Wifi)" Class="@(feed.IsLoading ? "spin-animation" : "")" Text="@($"{feed.Title} ({feed.UnreadCount})")" Value="@key" />
}
</MudList>
</MudItem>
<MudItem xs="4" Style="height: 100%; overflow: auto">
@if (Articles.Count > 0)
{ {
<MudListItem Text="@article.Title" Value="article.Id" Icon="@Icons.Material.Filled.Check" IconColor="@(article.IsRead ? Color.Success : Color.Transparent)" /> <MudList T="string" SelectionMode="SelectionMode.SingleSelection" SelectedValue="SelectedArticle" SelectedValueChanged="SelectedArticleChanged" Dense>
@foreach (var article in Articles)
{
<MudListItem Text="@article.Title" Value="article.Id" Icon="@Icons.Material.Filled.Check" IconColor="@(article.IsRead ? Color.Success : Color.Transparent)" />
}
</MudList>
} }
</MudList> else
} {
else <MudSkeleton SkeletonType="SkeletonType.Rectangle" Height="100%" Animation="Animation.False" Width="100%" />
{ }
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Height="100%" Animation="Animation.False" Width="100%" /> </MudItem>
} <MudItem xs="4" Style="height: 100%">
</MudItem> @if (Article is not null)
<MudItem xs="4" Style="height: 100%"> {
@if (Article is not null) <MudCard>
{ <MudCardHeader>
<MudCard> <CardHeaderContent>
<MudCardHeader> <MudText Typo="Typo.h6" Style="overflow-wrap: anywhere">@Article.Title</MudText>
<CardHeaderContent> </CardHeaderContent>
<MudText Typo="Typo.h6" Style="overflow-wrap: anywhere">@Article.Title</MudText> <CardHeaderActions>
</CardHeaderContent> <MudMenu Icon="@Icons.Material.Filled.MoreVert" Dense>
<CardHeaderActions> <MudMenuItem Icon="@Icons.Material.Filled.Download" OnClick="c => DownloadItem(Article.TorrentURL)" title="Download">Download</MudMenuItem>
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Dense> <MudMenuItem Icon="@Icons.Material.Filled.Link" Href="@Article.TorrentURL" Target="@Article.TorrentURL" title="Download">Open torrent URL</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Download" OnClick="c => DownloadItem(Article.TorrentURL)" title="Download">Download</MudMenuItem> </MudMenu>
<MudMenuItem Icon="@Icons.Material.Filled.Link" Href="@Article.TorrentURL" Target="@Article.TorrentURL" title="Download">Open torrent URL</MudMenuItem> </CardHeaderActions>
</MudMenu> </MudCardHeader>
</CardHeaderActions>
</MudCardHeader>
<MudCardContent> <MudCardContent>
<MudText Typo="Typo.subtitle2">@Article.Date</MudText> <MudText Typo="Typo.subtitle2">@Article.Date</MudText>
<MudText Typo="Typo.body1">@Article.Description</MudText> <MudText Typo="Typo.body1">@Article.Description</MudText>
</MudCardContent> </MudCardContent>
</MudCard> </MudCard>
} }
else else
{ {
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Height="100%" Animation="Animation.False" Width="100%" /> <MudSkeleton SkeletonType="SkeletonType.Rectangle" Height="100%" Animation="Animation.False" Width="100%" />
} }
</MudItem> </MudItem>
</MudGrid> </MudGrid>
</MudContainer> </MudContainer>
</div>
</div>

View File

@@ -1,62 +1,67 @@
@page "/search" @page "/search"
@layout OtherLayout @layout OtherLayout
<MudToolBar Gutters="false" Dense="true"> <div class="content-panel">
@if (!DrawerOpen) <div class="content-panel__toolbar">
{ <MudToolBar Gutters="false" Dense="true">
<MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" /> @if (!DrawerOpen)
<MudDivider Vertical="true" /> {
} <MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" />
<MudDivider Vertical="true" /> <MudDivider Vertical="true" />
<MudText Class="pl-5 no-wrap">Search</MudText> }
</MudToolBar> <MudDivider Vertical="true" />
<MudText Class="pl-5 no-wrap">Search</MudText>
</MudToolBar>
</div>
<div class="content-panel__body">
<MudCard Elevation="1" Class="ml-4 mr-4 mb-4">
<MudCardContent>
<EditForm Model="Model" OnValidSubmit="DoSearch">
<MudGrid>
<MudItem xs="12" md="4">
<MudTextField T="string" Label="Criteria" @bind-Value="Model.SearchText" Variant="Variant.Outlined" />
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" Label="Categories" @bind-Value="Model.SelectedCategory" Variant="Variant.Outlined">
@foreach (var (value, name) in Categories)
{
<MudSelectItem Value="value">@name</MudSelectItem>
if (value == "all")
{
<MudDivider />
}
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" Label="Plugins" @bind-Value="Model.SelectedPlugin" Variant="Variant.Outlined">
<MudSelectItem Value="@("all")">All</MudSelectItem>
@if (Plugins.Count > 0)
{
<MudDivider />
<MudCard Elevation="1" Class="ml-4 mr-4 mb-4"> }
<MudCardContent> @foreach (var (value, name) in Plugins)
<EditForm Model="Model" OnValidSubmit="DoSearch"> {
<MudGrid> <MudSelectItem Value="value">@name</MudSelectItem>
<MudItem xs="12" md="4"> }
<MudTextField T="string" Label="Criteria" @bind-Value="Model.SearchText" Variant="Variant.Outlined" /> </MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" md="3"> <MudItem xs="12" md="2">
<MudSelect T="string" Label="Categories" @bind-Value="Model.SelectedCategory" Variant="Variant.Outlined"> <MudButton ButtonType="ButtonType.Submit" FullWidth="true" Color="Color.Primary" EndIcon="@Icons.Material.Filled.Search" Variant="Variant.Filled" Class="mt-6">@(_searchId is null ? "Search" : "Stop")</MudButton>
@foreach (var (value, name) in Categories) </MudItem>
{
<MudSelectItem Value="value">@name</MudSelectItem>
if (value == "all")
{
<MudDivider />
}
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" Label="Plugins" @bind-Value="Model.SelectedPlugin" Variant="Variant.Outlined">
<MudSelectItem Value="@("all")">All</MudSelectItem>
@if (Plugins.Count > 0)
{
<MudDivider />
} </MudGrid>
@foreach (var (value, name) in Plugins) </EditForm>
{ </MudCardContent>
<MudSelectItem Value="value">@name</MudSelectItem> </MudCard>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="2">
<MudButton ButtonType="ButtonType.Submit" FullWidth="true" Color="Color.Primary" EndIcon="@Icons.Material.Filled.Search" Variant="Variant.Filled" Class="mt-6">@(_searchId is null ? "Search" : "Stop")</MudButton>
</MudItem>
</MudGrid> <DynamicTable @ref="Table"
</EditForm> T="Lantean.QBitTorrentClient.Models.SearchResult"
</MudCardContent> ColumnDefinitions="Columns"
</MudCard> Items="Results"
MultiSelection="false"
<DynamicTable @ref="Table" SelectOnRowClick="false"
T="Lantean.QBitTorrentClient.Models.SearchResult" Class="search-list content-panel__table" />
ColumnDefinitions="Columns" </div>
Items="Results" </div>
MultiSelection="false"
SelectOnRowClick="false"
Class="search-list" />

View File

@@ -1,62 +1,68 @@
@page "/statistics" @page "/statistics"
@layout OtherLayout @layout OtherLayout
<MudToolBar Gutters="false" Dense="true"> <div class="content-panel">
@if (!DrawerOpen) <div class="content-panel__toolbar">
{ <MudToolBar Gutters="false" Dense="true">
<MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" /> @if (!DrawerOpen)
<MudDivider Vertical="true" /> {
} <MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" />
<MudDivider Vertical="true" /> <MudDivider Vertical="true" />
<MudText Class="pl-5 no-wrap">Statistics</MudText> }
</MudToolBar> <MudDivider Vertical="true" />
<MudText Class="pl-5 no-wrap">Statistics</MudText>
</MudToolBar>
</div>
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="details-tab-contents"> <div class="content-panel__body">
<MudText Typo="Typo.subtitle2" Class="pt-6">User statistics</MudText> <MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="details-tab-contents content-panel__container">
<MudGrid> <MudText Typo="Typo.subtitle2" Class="pt-6">User statistics</MudText>
<MudItem xs="12"> <MudGrid>
<MudField Label="All-time uploaded">@DisplayHelpers.Size(ServerState?.AllTimeUploaded)</MudField> <MudItem xs="12">
</MudItem> <MudField Label="All-time uploaded">@DisplayHelpers.Size(ServerState?.AllTimeUploaded)</MudField>
<MudItem xs="12"> </MudItem>
<MudField Label="All-time downloaded">@DisplayHelpers.Size(ServerState?.AllTimeDownloaded)</MudField> <MudItem xs="12">
</MudItem> <MudField Label="All-time downloaded">@DisplayHelpers.Size(ServerState?.AllTimeDownloaded)</MudField>
<MudItem xs="12"> </MudItem>
<MudField Label="All-time share ratio">@DisplayHelpers.EmptyIfNull(ServerState?.GlobalRatio, format: "0.00")</MudField> <MudItem xs="12">
</MudItem> <MudField Label="All-time share ratio">@DisplayHelpers.EmptyIfNull(ServerState?.GlobalRatio, format: "0.00")</MudField>
<MudItem xs="12"> </MudItem>
<MudField Label="Session waste">@DisplayHelpers.Size(ServerState?.TotalWastedSession)</MudField> <MudItem xs="12">
</MudItem> <MudField Label="Session waste">@DisplayHelpers.Size(ServerState?.TotalWastedSession)</MudField>
<MudItem xs="12"> </MudItem>
<MudField Label="Connected peers">@DisplayHelpers.EmptyIfNull(ServerState?.TotalPeerConnections)</MudField> <MudItem xs="12">
</MudItem> <MudField Label="Connected peers">@DisplayHelpers.EmptyIfNull(ServerState?.TotalPeerConnections)</MudField>
</MudGrid> </MudItem>
</MudGrid>
<MudText Typo="Typo.subtitle2" Class="pt-6">Cache statistics</MudText> <MudText Typo="Typo.subtitle2" Class="pt-6">Cache statistics</MudText>
<MudGrid> <MudGrid>
<MudItem xs="12"> <MudItem xs="12">
<MudField Label="Read cache hits">@DisplayHelpers.Percentage(ServerState?.ReadCacheHits)</MudField> <MudField Label="Read cache hits">@DisplayHelpers.Percentage(ServerState?.ReadCacheHits)</MudField>
</MudItem> </MudItem>
<MudItem xs="12"> <MudItem xs="12">
<MudField Label="Total buffer size">@DisplayHelpers.Size(ServerState?.TotalBuffersSize)</MudField> <MudField Label="Total buffer size">@DisplayHelpers.Size(ServerState?.TotalBuffersSize)</MudField>
</MudItem> </MudItem>
</MudGrid> </MudGrid>
<MudText Typo="Typo.subtitle2" Class="pt-6">Performance statistics</MudText> <MudText Typo="Typo.subtitle2" Class="pt-6">Performance statistics</MudText>
<MudGrid> <MudGrid>
<MudItem xs="12"> <MudItem xs="12">
<MudField Label="Write cache overload">@DisplayHelpers.Percentage(ServerState?.WriteCacheOverload)</MudField> <MudField Label="Write cache overload">@DisplayHelpers.Percentage(ServerState?.WriteCacheOverload)</MudField>
</MudItem> </MudItem>
<MudItem xs="12"> <MudItem xs="12">
<MudField Label="Read cache overload">@DisplayHelpers.Percentage(ServerState?.ReadCacheOverload)</MudField> <MudField Label="Read cache overload">@DisplayHelpers.Percentage(ServerState?.ReadCacheOverload)</MudField>
</MudItem> </MudItem>
<MudItem xs="12"> <MudItem xs="12">
<MudField Label="Queued I/O jobs">@DisplayHelpers.EmptyIfNull(ServerState?.QueuedIOJobs)</MudField> <MudField Label="Queued I/O jobs">@DisplayHelpers.EmptyIfNull(ServerState?.QueuedIOJobs)</MudField>
</MudItem> </MudItem>
<MudItem xs="12"> <MudItem xs="12">
<MudField Label="Average time in queue">@DisplayHelpers.EmptyIfNull(ServerState?.AverageTimeQueue, suffix: "ms")</MudField> <MudField Label="Average time in queue">@DisplayHelpers.EmptyIfNull(ServerState?.AverageTimeQueue, suffix: "ms")</MudField>
</MudItem> </MudItem>
<MudItem xs="12"> <MudItem xs="12">
<MudField Label="Total queued size">@DisplayHelpers.Size(ServerState?.TotalQueuedSize)</MudField> <MudField Label="Total queued size">@DisplayHelpers.Size(ServerState?.TotalQueuedSize)</MudField>
</MudItem> </MudItem>
</MudGrid> </MudGrid>
</MudContainer> </MudContainer>
</div>
</div>

View File

@@ -1,24 +1,30 @@
@page "/tags" @page "/tags"
@layout OtherLayout @layout OtherLayout
<MudToolBar Gutters="false" Dense="true"> <div class="content-panel">
@if (!DrawerOpen) <div class="content-panel__toolbar">
{ <MudToolBar Gutters="false" Dense="true">
<MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" /> @if (!DrawerOpen)
<MudDivider Vertical="true" /> {
} <MudIconButton Icon="@Icons.Material.Outlined.NavigateBefore" OnClick="NavigateBack" title="Back to torrent list" />
<MudText Class="px-5 no-wrap">Tags</MudText> <MudDivider Vertical="true" />
<MudDivider Vertical="true" /> }
<MudIconButton Icon="@Icons.Material.Filled.NewLabel" OnClick="AddTag" title="Add Tag" /> <MudText Class="px-5 no-wrap">Tags</MudText>
</MudToolBar> <MudDivider Vertical="true" />
<MudIconButton Icon="@Icons.Material.Filled.NewLabel" OnClick="AddTag" title="Add Tag" />
</MudToolBar>
</div>
<DynamicTable @ref="Table" <div class="content-panel__body">
T="string" <DynamicTable @ref="Table"
ColumnDefinitions="Columns" T="string"
Items="Results" ColumnDefinitions="Columns"
MultiSelection="false" Items="Results"
SelectOnRowClick="false" MultiSelection="false"
Class="details-list" /> SelectOnRowClick="false"
Class="details-list content-panel__table" />
</div>
</div>
@code { @code {
private RenderFragment<RowContext<string>> ActionsColumn private RenderFragment<RowContext<string>> ActionsColumn

View File

@@ -1,44 +1,47 @@
@page "/" @page "/"
@layout ListLayout @layout ListLayout
<ContextMenu @ref="ContextMenu" Dense="true" RelativeWidth="DropdownWidth.Ignore" AdjustmentX="-242" AdjustmentY="0"> <MudMenu @ref="ContextMenu" Dense="true" RelativeWidth="DropdownWidth.Ignore" PositionAtCursor="true" ListClass="unselectable" PopoverClass="unselectable">
<MudMenuItem Icon="@Icons.Material.Outlined.Info" IconColor="Color.Inherit" OnClick="ShowTorrentContextMenu">View torrent details</MudMenuItem> <MudMenuItem Icon="@Icons.Material.Outlined.Info" IconColor="Color.Inherit" OnClick="ShowTorrentContextMenu">View torrent details</MudMenuItem>
<MudDivider /> <MudDivider />
<TorrentActions RenderType="RenderType.MenuItems" Hashes="GetContextMenuTargetHashes()" PrimaryHash="@(ContextMenuItem?.Hash)" Torrents="MainData.Torrents" Preferences="Preferences" /> <TorrentActions RenderType="RenderType.MenuItems" Hashes="GetContextMenuTargetHashes()" PrimaryHash="@(ContextMenuItem?.Hash)" Torrents="MainData.Torrents" Preferences="Preferences" />
</ContextMenu> </MudMenu>
<div style="overflow-x: auto; white-space: nowrap; width: 100%;"> <div class="content-panel">
<MudToolBar Gutters="false" Dense="true"> <div class="content-panel__toolbar content-panel__toolbar--scroll">
<MudIconButton Icon="@Icons.Material.Outlined.AddLink" OnClick="AddTorrentLink" title="Add torrent link" /> <MudToolBar Gutters="false" Dense="true">
<MudIconButton Icon="@Icons.Material.Outlined.AddCircle" OnClick="AddTorrentFile" title="Add torrent file" /> <MudIconButton Icon="@Icons.Material.Outlined.AddLink" OnClick="AddTorrentLink" title="Add torrent link" />
<MudDivider Vertical="true" /> <MudIconButton Icon="@Icons.Material.Outlined.AddCircle" OnClick="AddTorrentFile" title="Add torrent file" />
<TorrentActions RenderType="RenderType.InitialIconsOnly" Hashes="GetSelectedTorrentsHashes()" Torrents="MainData.Torrents" Preferences="Preferences" /> <MudDivider Vertical="true" />
<MudDivider Vertical="true" /> <TorrentActions RenderType="RenderType.InitialIconsOnly" Hashes="GetSelectedTorrentsHashes()" Torrents="MainData.Torrents" Preferences="Preferences" />
<MudIconButton Icon="@Icons.Material.Outlined.Info" Color="Color.Inherit" Disabled="@(!ToolbarButtonsEnabled)" OnClick="ShowTorrentToolbar" title="View torrent details" /> <MudDivider Vertical="true" />
<MudIconButton Icon="@Icons.Material.Outlined.ViewColumn" Color="Color.Inherit" OnClick="ColumnOptions" title="Choose Columns" /> <MudIconButton Icon="@Icons.Material.Outlined.Info" Color="Color.Inherit" Disabled="@(!ToolbarButtonsEnabled)" OnClick="ShowTorrentToolbar" title="View torrent details" />
<MudSpacer /> <MudIconButton Icon="@Icons.Material.Outlined.ViewColumn" Color="Color.Inherit" OnClick="ColumnOptions" title="Choose Columns" />
<MudTextField Value="SearchText" TextChanged="SearchTextChanged" Immediate="true" DebounceInterval="1000" Placeholder="Filter torrent list" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"></MudTextField> <MudSpacer />
</MudToolBar> <MudTextField Value="SearchText" TextChanged="SearchTextChanged" Immediate="true" DebounceInterval="1000" Placeholder="Filter torrent list" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"></MudTextField>
</MudToolBar>
</div>
<div class="content-panel__body">
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="ma-0 pa-0 content-panel__container">
<DynamicTable
@ref="Table"
T="Torrent"
Class="torrent-list content-panel__table"
ColumnDefinitions="Columns"
Items="Torrents"
OnRowClick="RowClick"
MultiSelection="true"
SelectOnRowClick="true"
SelectedItemsChanged="SelectedItemsChanged"
SortColumnChanged="SortColumnChangedHandler"
SortDirectionChanged="SortDirectionChangedHandler"
OnTableDataContextMenu="TableDataContextMenu"
OnTableDataLongPress="TableDataLongPress"
/>
</MudContainer>
</div>
</div> </div>
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="ma-0 pa-0">
<DynamicTable
@ref="Table"
T="Torrent"
Class="torrent-list"
ColumnDefinitions="Columns"
Items="Torrents"
OnRowClick="RowClick"
MultiSelection="true"
SelectOnRowClick="true"
SelectedItemsChanged="SelectedItemsChanged"
SortColumnChanged="SortColumnChangedHandler"
SortDirectionChanged="SortDirectionChangedHandler"
OnTableDataContextMenu="TableDataContextMenu"
OnTableDataLongPress="TableDataLongPress"
/>
</MudContainer>
@code { @code {
private static RenderFragment<RowContext<Torrent>> ProgressBarColumn private static RenderFragment<RowContext<Torrent>> ProgressBarColumn
{ {

View File

@@ -35,11 +35,17 @@ namespace Lantean.QBTMud.Pages
public QBitTorrentClient.Models.Preferences? Preferences { get; set; } public QBitTorrentClient.Models.Preferences? Preferences { get; set; }
[CascadingParameter] [CascadingParameter]
public IEnumerable<Torrent>? Torrents { get; set; } public IReadOnlyList<Torrent>? Torrents { get; set; }
[CascadingParameter] [CascadingParameter]
public MainData MainData { get; set; } = default!; public MainData MainData { get; set; } = default!;
[CascadingParameter(Name = "LostConnection")]
public bool LostConnection { get; set; }
[CascadingParameter(Name = "TorrentsVersion")]
public int TorrentsVersion { get; set; }
[CascadingParameter(Name = "SearchTermChanged")] [CascadingParameter(Name = "SearchTermChanged")]
public EventCallback<string> SearchTermChanged { get; set; } public EventCallback<string> SearchTermChanged { get; set; }
@@ -56,13 +62,23 @@ namespace Lantean.QBTMud.Pages
protected HashSet<Torrent> SelectedItems { get; set; } = []; protected HashSet<Torrent> SelectedItems { get; set; } = [];
protected bool ToolbarButtonsEnabled => SelectedItems.Count > 0; protected bool ToolbarButtonsEnabled => _toolbarButtonsEnabled;
protected DynamicTable<Torrent>? Table { get; set; } protected DynamicTable<Torrent>? Table { get; set; }
protected Torrent? ContextMenuItem { get; set; } protected Torrent? ContextMenuItem { get; set; }
protected ContextMenu? ContextMenu { get; set; } protected MudMenu? ContextMenu { get; set; }
private object? _lastRenderedTorrents;
private QBitTorrentClient.Models.Preferences? _lastPreferences;
private bool _lastLostConnection;
private bool _hasRendered;
private int _lastSelectionCount;
private int _lastTorrentsVersion = -1;
private bool _pendingSelectionChange;
private bool _toolbarButtonsEnabled;
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
@@ -73,9 +89,81 @@ namespace Lantean.QBTMud.Pages
} }
} }
protected override bool ShouldRender()
{
if (!_hasRendered)
{
_hasRendered = true;
_lastRenderedTorrents = Torrents;
_lastPreferences = Preferences;
_lastLostConnection = LostConnection;
_lastTorrentsVersion = TorrentsVersion;
_lastSelectionCount = SelectedItems.Count;
_toolbarButtonsEnabled = _lastSelectionCount > 0;
return true;
}
if (_pendingSelectionChange)
{
_pendingSelectionChange = false;
_lastSelectionCount = SelectedItems.Count;
_toolbarButtonsEnabled = _lastSelectionCount > 0;
return true;
}
if (_lastTorrentsVersion != TorrentsVersion)
{
_lastTorrentsVersion = TorrentsVersion;
_lastRenderedTorrents = Torrents;
_lastPreferences = Preferences;
_lastLostConnection = LostConnection;
_lastSelectionCount = SelectedItems.Count;
_toolbarButtonsEnabled = _lastSelectionCount > 0;
return true;
}
if (!ReferenceEquals(_lastRenderedTorrents, Torrents))
{
_lastRenderedTorrents = Torrents;
_lastPreferences = Preferences;
_lastLostConnection = LostConnection;
_lastSelectionCount = SelectedItems.Count;
_toolbarButtonsEnabled = _lastSelectionCount > 0;
return true;
}
if (!ReferenceEquals(_lastPreferences, Preferences))
{
_lastPreferences = Preferences;
_lastSelectionCount = SelectedItems.Count;
_toolbarButtonsEnabled = _lastSelectionCount > 0;
return true;
}
if (_lastLostConnection != LostConnection)
{
_lastLostConnection = LostConnection;
_lastSelectionCount = SelectedItems.Count;
_toolbarButtonsEnabled = _lastSelectionCount > 0;
return true;
}
if (_lastSelectionCount != SelectedItems.Count)
{
_lastSelectionCount = SelectedItems.Count;
_toolbarButtonsEnabled = _lastSelectionCount > 0;
return true;
}
return false;
}
protected void SelectedItemsChanged(HashSet<Torrent> selectedItems) protected void SelectedItemsChanged(HashSet<Torrent> selectedItems)
{ {
SelectedItems = selectedItems; SelectedItems = selectedItems;
_toolbarButtonsEnabled = SelectedItems.Count > 0;
_pendingSelectionChange = true;
InvokeAsync(StateHasChanged);
} }
protected async Task SortDirectionChangedHandler(SortDirection sortDirection) protected async Task SortDirectionChangedHandler(SortDirection sortDirection)
@@ -185,7 +273,9 @@ namespace Lantean.QBTMud.Pages
return; return;
} }
await ContextMenu.ToggleMenuAsync(eventArgs); var normalizedEventArgs = eventArgs.NormalizeForContextMenu();
await ContextMenu.OpenMenuAsync(normalizedEventArgs);
} }
protected IEnumerable<ColumnDefinition<Torrent>> Columns => ColumnsDefinitions.Where(c => c.Id != "#" || Preferences?.QueueingEnabled == true); protected IEnumerable<ColumnDefinition<Torrent>> Columns => ColumnsDefinitions.Where(c => c.Id != "#" || Preferences?.QueueingEnabled == true);

View File

@@ -1,4 +1,4 @@
using Blazored.LocalStorage; using Blazored.LocalStorage;
using Lantean.QBitTorrentClient; using Lantean.QBitTorrentClient;
using Lantean.QBTMud.Services; using Lantean.QBTMud.Services;
using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web;

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,11 @@ namespace Lantean.QBTMud.Services
{ {
public interface IDataManager public interface IDataManager
{ {
MainData CreateMainData(QBitTorrentClient.Models.MainData mainData); MainData CreateMainData(QBitTorrentClient.Models.MainData mainData, string version);
Torrent CreateTorrent(string hash, QBitTorrentClient.Models.Torrent torrent); Torrent CreateTorrent(string hash, QBitTorrentClient.Models.Torrent torrent);
void MergeMainData(QBitTorrentClient.Models.MainData mainData, MainData torrentList); bool MergeMainData(QBitTorrentClient.Models.MainData mainData, MainData torrentList, out bool filterChanged);
PeerList CreatePeerList(QBitTorrentClient.Models.TorrentPeers torrentPeers); PeerList CreatePeerList(QBitTorrentClient.Models.TorrentPeers torrentPeers);
@@ -16,7 +16,7 @@ namespace Lantean.QBTMud.Services
Dictionary<string, ContentItem> CreateContentsList(IReadOnlyList<QBitTorrentClient.Models.FileData> files); Dictionary<string, ContentItem> CreateContentsList(IReadOnlyList<QBitTorrentClient.Models.FileData> files);
void MergeContentsList(IReadOnlyList<QBitTorrentClient.Models.FileData> files, Dictionary<string, ContentItem> contents); bool MergeContentsList(IReadOnlyList<QBitTorrentClient.Models.FileData> files, Dictionary<string, ContentItem> contents);
QBitTorrentClient.Models.UpdatePreferences MergePreferences(QBitTorrentClient.Models.UpdatePreferences? original, QBitTorrentClient.Models.UpdatePreferences changed); QBitTorrentClient.Models.UpdatePreferences MergePreferences(QBitTorrentClient.Models.UpdatePreferences? original, QBitTorrentClient.Models.UpdatePreferences changed);

View File

@@ -65,15 +65,11 @@ code {
} }
.mud-appbar.mud-appbar-fixed-bottom { .mud-appbar.mud-appbar-fixed-bottom {
height: 35px; height: calc(var(--app-status-bar-height) + env(safe-area-inset-bottom, 0px));
}
.mud-main-content {
padding-bottom: 35px;
} }
.mud-drawer-fixed.mud-drawer-mini.mud-drawer-clipped-always, .mud-drawer-fixed.mud-drawer-persistent:not(.mud-drawer-clipped-never), .mud-drawer-fixed.mud-drawer-responsive.mud-drawer-clipped-always, .mud-drawer-fixed.mud-drawer-temporary.mud-drawer-clipped-always { .mud-drawer-fixed.mud-drawer-mini.mud-drawer-clipped-always, .mud-drawer-fixed.mud-drawer-persistent:not(.mud-drawer-clipped-never), .mud-drawer-fixed.mud-drawer-responsive.mud-drawer-clipped-always, .mud-drawer-fixed.mud-drawer-temporary.mud-drawer-clipped-always {
height: calc(100% - var(--mud-appbar-height) - 35px); height: calc(100% - var(--mud-appbar-height) - (var(--app-status-bar-height) + env(safe-area-inset-bottom, 0px)));
} }
.w-100 { .w-100 {
@@ -154,25 +150,91 @@ code {
margin-right: 5px; margin-right: 5px;
} }
.torrent-list .mud-table-container { /*. Layout helpers */
height: calc(100vh - 160px); .content-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
} }
.file-list .mud-table-container { .content-panel__toolbar {
height: calc(100vh - 245px); flex: 0 0 auto;
} }
.details-list .mud-table-container { .content-panel__toolbar--scroll {
height: calc(100vh - 200px); overflow-x: auto;
white-space: nowrap;
} }
.details-tab-contents { .content-panel__body {
height: calc(100vh - 200px); flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.content-panel__container {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.content-panel__table {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.content-panel__table .mud-table-container {
flex: 1 1 auto;
height: 100%;
}
.content-panel__body > .mud-tabs {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
padding-top: 0;
margin-top: 0;
}
.content-panel__body > .mud-tabs .mud-tabs-tabbar {
margin-bottom: 0;
}
.content-panel__body > .mud-tabs .mud-tabs-panels {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
padding-top: 0;
margin-top: -1px;
border-top: none;
}
.content-panel__body .mud-tabs .mud-tabs-panels .mud-tab-panel {
overflow: auto; overflow: auto;
} }
.torrent-list .mud-table-container,
.file-list .mud-table-container,
.details-list .mud-table-container,
.search-list .mud-table-container { .search-list .mud-table-container {
height: calc(100vh - 260px); height: 100%;
}
.details-tab-contents,
.options-tab-contents,
.rss-contents {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
} }
tr.log-normal td { tr.log-normal td {
@@ -220,10 +282,6 @@ td .folder-button {
padding: 6px 16px 6px 16px !important; padding: 6px 16px 6px 16px !important;
} }
.rss-contents {
height: calc(100vh - 149px);
}
@keyframes spin { @keyframes spin {
0% { 0% {
transform: rotate(0deg); transform: rotate(0deg);
@@ -256,3 +314,116 @@ td .folder-button {
.mud-popover .mud-divider:last-child { .mud-popover .mud-divider:last-child {
display: none; display: none;
} }
:root {
--app-viewport-height: 100vh;
--app-status-bar-height: 35px;
}
@supports (height: 100svh) {
:root {
--app-viewport-height: 100svh;
}
}
@supports ((height: 100dvh) and (not (height: 100svh))) {
:root {
--app-viewport-height: 100dvh;
}
}
html,
body {
height: var(--app-viewport-height);
min-height: var(--app-viewport-height);
}
body {
margin: 0;
overflow: hidden;
overscroll-behavior: none;
}
#app,
.mud-layout {
height: 100%;
min-height: 100%;
}
.app-shell {
display: flex;
flex-direction: column;
height: var(--app-viewport-height);
min-height: var(--app-viewport-height);
overflow: hidden;
}
.app-shell__body {
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.app-shell__sidebar {
flex: 0 0 auto;
}
.app-shell__main {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
padding: var(--mud-appbar-height) 0 calc(var(--app-status-bar-height) + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
.app-shell__status-bar.mud-appbar {
flex: 0 0 calc(var(--app-status-bar-height) + env(safe-area-inset-bottom, 0px));
height: calc(var(--app-status-bar-height) + env(safe-area-inset-bottom, 0px));
width: 100%;
background-color: var(--mud-palette-dark-lighten);
align-items: center;
justify-content: flex-start;
box-sizing: border-box;
}
.app-shell__status-bar .mud-toolbar {
width: 100%;
height: 100%;
padding-bottom: env(safe-area-inset-bottom, 0px);
background-color: inherit;
box-sizing: border-box;
}
@supports (-webkit-touch-callout: none) {
:root {
--app-viewport-height: -webkit-fill-available;
}
html,
body {
height: -webkit-fill-available;
min-height: -webkit-fill-available;
}
.app-shell {
height: -webkit-fill-available;
min-height: -webkit-fill-available;
}
}
/* Tab bar gap fix */
.content-panel__body > .mud-tabs .mud-tabs-tabbar {
margin-bottom: 0;
padding-bottom: 0;
border-bottom-width: 0;
}
.content-panel__body > .mud-tabs .mud-tabs-tabbar .mud-tabs-wrapper {
margin-bottom: -1px;
}
.content-panel__body > .mud-tabs .mud-tabs-tabbar .mud-tabs-slider {
bottom: 0;
}

View File

@@ -9,12 +9,12 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,200..1000;1,6..12,200..1000&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,200..1000;1,6..12,200..1000&display=swap" rel="stylesheet">
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" /> <link href="./_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<link rel="stylesheet" href="css/app.css" /> <link rel="stylesheet" href="./css/app.css" />
<link rel="icon" type="image/png" href="images/qbittorrent32.png" /> <link rel="icon" type="image/png" href="images/qbittorrent32.png" />
<link rel="icon" href="images/qbittorrent-tray.svg"> <link rel="icon" href="./images/qbittorrent-tray.svg">
<link rel="mask-icon" href="images/qbittorrent-tray.svg" color="#000000"> <link rel="mask-icon" href="./images/qbittorrent-tray.svg" color="#000000">
<link rel="apple-touch-icon" href="images/qbittorrent32.png"> <link rel="apple-touch-icon" href="./images/qbittorrent32.png">
</head> </head>
<body> <body>
@@ -31,10 +31,10 @@
<a href="" class="reload">Reload</a> <a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a> <a class="dismiss">🗙</a>
</div> </div>
<script src="_framework/blazor.webassembly.js"></script> <script src="./_framework/blazor.webassembly.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script> <script src="./_content/MudBlazor/MudBlazor.min.js"></script>
<script src="js/piecesbar.js"></script> <script src="./js/piecesbar.js"></script>
<script src="js/interop.js"></script> <script src="./js/interop.js"></script>
</body> </body>
</html> </html>

View File

@@ -5,4 +5,4 @@
// * @author John Doherty <www.johndoherty.info> // * @author John Doherty <www.johndoherty.info>
// * @license MIT // * @license MIT
// */ // */
!function (e, t) { "use strict"; var n = null, a = "PointerEvent" in e || e.navigator && "msPointerEnabled" in e.navigator, i = "ontouchstart" in e || navigator.MaxTouchPoints > 0 || navigator.msMaxTouchPoints > 0, o = a ? "pointerdown" : i ? "touchstart" : "mousedown", r = a ? "pointerup" : i ? "touchend" : "mouseup", m = a ? "pointermove" : i ? "touchmove" : "mousemove", u = a ? "pointerleave" : i ? "touchleave" : "mouseleave", s = 0, c = 0, l = 10, v = 10; function f(e) { p(), e = function (e) { if (void 0 !== e.changedTouches) return e.changedTouches[0]; return e }(e), this.dispatchEvent(new CustomEvent("longpress", { bubbles: !0, cancelable: !0, detail: { clientX: e.clientX, clientY: e.clientY, offsetX: e.offsetX, offsetY: e.offsetY, pageX: e.pageX, pageY: e.pageY }, clientX: e.clientX, clientY: e.clientY, offsetX: e.offsetX, offsetY: e.offsetY, pageX: e.pageX, pageY: e.pageY, screenX: e.screenX, screenY: e.screenY })) || t.addEventListener("click", function e(n) { t.removeEventListener("click", e, !0), function (e) { e.stopImmediatePropagation(), e.preventDefault(), e.stopPropagation() }(n) }, !0) } function d(a) { p(a); var i = a.target, o = parseInt(function (e, n, a) { for (; e && e !== t.documentElement;) { var i = e.getAttribute(n); if (i) return i; e = e.parentNode } return a }(i, "data-long-press-delay", "400"), 10); n = function (t, n) { if (!(e.requestAnimationFrame || e.webkitRequestAnimationFrame || e.mozRequestAnimationFrame && e.mozCancelRequestAnimationFrame || e.oRequestAnimationFrame || e.msRequestAnimationFrame)) return e.setTimeout(t, n); var a = (new Date).getTime(), i = {}, o = function () { (new Date).getTime() - a >= n ? t.call() : i.value = requestAnimFrame(o) }; return i.value = requestAnimFrame(o), i }(f.bind(i, a), o) } function p(t) { var a; (a = n) && (e.cancelAnimationFrame ? e.cancelAnimationFrame(a.value) : e.webkitCancelAnimationFrame ? e.webkitCancelAnimationFrame(a.value) : e.webkitCancelRequestAnimationFrame ? e.webkitCancelRequestAnimationFrame(a.value) : e.mozCancelRequestAnimationFrame ? e.mozCancelRequestAnimationFrame(a.value) : e.oCancelRequestAnimationFrame ? e.oCancelRequestAnimationFrame(a.value) : e.msCancelRequestAnimationFrame ? e.msCancelRequestAnimationFrame(a.value) : clearTimeout(a)), n = null } "function" != typeof e.CustomEvent && (e.CustomEvent = function (e, n) { n = n || { bubbles: !1, cancelable: !1, detail: void 0 }; var a = t.createEvent("CustomEvent"); return a.initCustomEvent(e, n.bubbles, n.cancelable, n.detail), a }, e.CustomEvent.prototype = e.Event.prototype), e.requestAnimFrame = e.requestAnimationFrame || e.webkitRequestAnimationFrame || e.mozRequestAnimationFrame || e.oRequestAnimationFrame || e.msRequestAnimationFrame || function (t) { e.setTimeout(t, 1e3 / 60) }, t.addEventListener(r, p, !0), t.addEventListener(u, p, !0), t.addEventListener(m, function (e) { var t = Math.abs(s - e.clientX), n = Math.abs(c - e.clientY); (t >= l || n >= v) && p() }, !0), t.addEventListener("wheel", p, !0), t.addEventListener("scroll", p, !0), t.addEventListener(o, function (e) { s = e.clientX, c = e.clientY, d(e) }, !0) }(window, document); !function (e, t) { "use strict"; var n = null, a = "PointerEvent" in e || e.navigator && "msPointerEnabled" in e.navigator, i = "ontouchstart" in e || navigator.MaxTouchPoints > 0 || navigator.msMaxTouchPoints > 0, o = a ? "pointerdown" : i ? "touchstart" : "mousedown", r = a ? "pointerup" : i ? "touchend" : "mouseup", m = a ? "pointermove" : i ? "touchmove" : "mousemove", u = a ? "pointerleave" : i ? "touchleave" : "mouseleave", s = 0, c = 0, l = 10, v = 10; function f(e) { p(), e = function (e) { if (void 0 !== e.changedTouches) return e.changedTouches[0]; return e }(e); var n = new CustomEvent("longpress", { bubbles: !0, cancelable: !0, detail: { clientX: e.clientX, clientY: e.clientY, offsetX: e.offsetX, offsetY: e.offsetY, pageX: e.pageX, pageY: e.pageY }, clientX: e.clientX, clientY: e.clientY, offsetX: e.offsetX, offsetY: e.offsetY, pageX: e.pageX, pageY: e.pageY, screenX: e.screenX, screenY: e.screenY }); n.__longPress = !0, this.dispatchEvent(n) || t.addEventListener("click", function e(n) { t.removeEventListener("click", e, !0), function (e) { e.stopImmediatePropagation(), e.preventDefault(), e.stopPropagation() }(n) }, !0) } function d(a) { p(a); var i = a.target, o = parseInt(function (e, n, a) { for (; e && e !== t.documentElement;) { var i = e.getAttribute(n); if (i) return i; e = e.parentNode } return a }(i, "data-long-press-delay", "400"), 10); n = function (t, n) { if (!(e.requestAnimationFrame || e.webkitRequestAnimationFrame || e.mozRequestAnimationFrame && e.mozCancelRequestAnimationFrame || e.oRequestAnimationFrame || e.msRequestAnimationFrame)) return e.setTimeout(t, n); var a = (new Date).getTime(), i = {}, o = function () { (new Date).getTime() - a >= n ? t.call() : i.value = requestAnimFrame(o) }; return i.value = requestAnimFrame(o), i }(f.bind(i, a), o) } function p(t) { var a; (a = n) && (e.cancelAnimationFrame ? e.cancelAnimationFrame(a.value) : e.webkitCancelAnimationFrame ? e.webkitCancelAnimationFrame(a.value) : e.webkitCancelRequestAnimationFrame ? e.webkitCancelRequestAnimationFrame(a.value) : e.mozCancelRequestAnimationFrame ? e.mozCancelRequestAnimationFrame(a.value) : e.oCancelRequestAnimationFrame ? e.oCancelRequestAnimationFrame(a.value) : e.msCancelRequestAnimationFrame ? e.msCancelRequestAnimationFrame(a.value) : clearTimeout(a)), n = null } "function" != typeof e.CustomEvent && (e.CustomEvent = function (e, n) { n = n || { bubbles: !1, cancelable: !1, detail: void 0 }; var a = t.createEvent("CustomEvent"); return a.initCustomEvent(e, n.bubbles, n.cancelable, n.detail), a }, e.CustomEvent.prototype = e.Event.prototype), e.requestAnimFrame = e.requestAnimationFrame || e.webkitRequestAnimationFrame || e.mozRequestAnimationFrame || e.oRequestAnimationFrame || e.msRequestAnimationFrame || function (t) { e.setTimeout(t, 1e3 / 60) }, t.addEventListener(r, p, !0), t.addEventListener(u, p, !0), t.addEventListener(m, function (e) { var t = Math.abs(s - e.clientX), n = Math.abs(c - e.clientY); (t >= l || n >= v) && p() }, !0), t.addEventListener("wheel", p, !0), t.addEventListener("scroll", p, !0), t.addEventListener(o, function (e) { s = e.clientX, c = e.clientY, d(e) }, !0) }(window, document);

View File

@@ -27,7 +27,7 @@ namespace Lantean.QBitTorrentClient.Converters
{ {
writer.WriteNumberValue(0); writer.WriteNumberValue(0);
} }
else if (value.IsDefaltFolder) else if (value.IsDefaultFolder)
{ {
writer.WriteNumberValue(1); writer.WriteNumberValue(1);
} }

View File

@@ -4,7 +4,7 @@
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@@ -112,7 +112,7 @@ namespace Lantean.QBitTorrentClient.Models
int maxConnecPerTorrent, int maxConnecPerTorrent,
int maxInactiveSeedingTime, int maxInactiveSeedingTime,
bool maxInactiveSeedingTimeEnabled, bool maxInactiveSeedingTimeEnabled,
int maxRatio, float maxRatio,
int maxRatioAct, int maxRatioAct,
bool maxRatioEnabled, bool maxRatioEnabled,
int maxSeedingTime, int maxSeedingTime,
@@ -745,7 +745,7 @@ namespace Lantean.QBitTorrentClient.Models
public bool MaxInactiveSeedingTimeEnabled { get; } public bool MaxInactiveSeedingTimeEnabled { get; }
[JsonPropertyName("max_ratio")] [JsonPropertyName("max_ratio")]
public int MaxRatio { get; } public float MaxRatio { get; }
[JsonPropertyName("max_ratio_act")] [JsonPropertyName("max_ratio_act")]
public int MaxRatioAct { get; } public int MaxRatioAct { get; }

View File

@@ -4,7 +4,7 @@
{ {
public bool IsWatchedFolder { get; set; } public bool IsWatchedFolder { get; set; }
public bool IsDefaltFolder { get; set; } public bool IsDefaultFolder { get; set; }
public string? SavePath { get; set; } public string? SavePath { get; set; }
@@ -23,7 +23,7 @@
{ {
return new SaveLocation return new SaveLocation
{ {
IsDefaltFolder = true IsDefaultFolder = true
}; };
} }
} }
@@ -40,7 +40,7 @@
{ {
return new SaveLocation return new SaveLocation
{ {
IsDefaltFolder = true IsDefaultFolder = true
}; };
} }
else else
@@ -61,7 +61,7 @@
{ {
return 0; return 0;
} }
else if (IsDefaltFolder) else if (IsDefaultFolder)
{ {
return 1; return 1;
} }

View File

@@ -14,7 +14,7 @@ namespace Lantean.QBitTorrentClient.Models
long downloadLimit, long downloadLimit,
long downloadSpeed, long downloadSpeed,
long downloadSpeedAverage, long downloadSpeedAverage,
int estimatedTimeOfArrival, long estimatedTimeOfArrival,
long lastSeen, long lastSeen,
int connections, int connections,
int connectionsLimit, int connectionsLimit,
@@ -104,7 +104,7 @@ namespace Lantean.QBitTorrentClient.Models
public long DownloadSpeedAverage { get; } public long DownloadSpeedAverage { get; }
[JsonPropertyName("eta")] [JsonPropertyName("eta")]
public int EstimatedTimeOfArrival { get; } public long EstimatedTimeOfArrival { get; }
[JsonPropertyName("last_seen")] [JsonPropertyName("last_seen")]
public long LastSeen { get; } public long LastSeen { get; }

View File

@@ -323,7 +323,7 @@ namespace Lantean.QBitTorrentClient.Models
public bool? MaxInactiveSeedingTimeEnabled { get; set; } public bool? MaxInactiveSeedingTimeEnabled { get; set; }
[JsonPropertyName("max_ratio")] [JsonPropertyName("max_ratio")]
public int? MaxRatio { get; set; } public float? MaxRatio { get; set; }
[JsonPropertyName("max_ratio_act")] [JsonPropertyName("max_ratio_act")]
public int? MaxRatioAct { get; set; } public int? MaxRatioAct { get; set; }

5
global.json Normal file
View File

@@ -0,0 +1,5 @@
{
"sdk": {
"version": "9.0.306"
}
}

View File

@@ -1,14 +1,84 @@
# qbt-mud # qbtmud
## To-Do qbtmud is a drop-in replacement for qBittorrent's default WebUI, implementing all of its functionality with a modern and user-friendly interface.
- Rename multiple files dialog ## Features
- ~~RSS feeds and dialogs~~
- ~~About~~ qbtmud replicates all core features of the qBittorrent WebUI, including:
- ~~Context menu for files list/trackers list/peers list~~
- ~~Tag management page~~ - **Torrent Management** Add, remove, and control torrents.
- ~~Category management page~~ - **Tracker Control** View and manage trackers.
- ~~Update all tables to use DynamicTable~~ - **Peer Management** Monitor and manage peers connected to torrents.
- ~~Log~~ - **File Prioritization** Select and prioritize specific files within a torrent.
- ~~Blocks~~ - **Speed Limits** Set global and per-torrent speed limits.
- ~~Search~~ - **RSS Integration** Subscribe to RSS feeds for automated torrent downloads.
- **Search Functionality** Integrated torrent search.
- **Sequential Downloading** Download files in order for media streaming.
- **Super Seeding Mode** Efficiently distribute torrents as an initial seeder.
- **IP Filtering** Improve security by filtering specific IP addresses.
- **IPv6 Support** Full support for IPv6 networks.
- **Bandwidth Scheduler** Schedule bandwidth limits.
- **WebUI Access** Remotely manage torrents through the WebUI.
![image](https://github.com/user-attachments/assets/c4e383fd-bff0-4367-b6de-79e19a632f11)
![image](https://github.com/user-attachments/assets/4ff56ed6-cc11-42cd-a070-23f086fd8821)
![image](https://github.com/user-attachments/assets/e321c5a2-ccf1-4205-828d-7ed7adade7dd)
For a detailed explanation of these features, refer to the [qBittorrent Options Guide](https://github.com/qbittorrent/qBittorrent/wiki/Explanation-of-Options-in-qBittorrent).
---
## Installation
To install qbtmud without building from source:
### 1. Download the Latest Release
- Go to the [qbtmud Releases](https://github.com/lantean-code/qbtmud/releases) page.
- Download the latest release archive for your operating system.
### 2. Extract the Archive
- Extract the contents of the downloaded archive to a directory of your choice.
### 3. Configure qBittorrent to Use qbtmud
- Open qBittorrent and navigate to `Tools` > `Options` > `Web UI`.
- Enable the option **"Use alternative WebUI"**.
- Set the **"Root Folder"** to the directory where you extracted qbtmud.
- Click **OK** to save the settings.
### 4. Access qbtmud
- Open your web browser and go to `http://localhost:8080` (or the port configured in qBittorrent).
For more detailed instructions, refer to the [Alternate WebUI Usage Guide](https://github.com/qbittorrent/qBittorrent/wiki/Alternate-WebUI-usage).
---
## Building from Source
To build qbtmud from source, you need to have the **.NET 9.0 SDK** installed on your system.
### 1. Clone the Repository
```sh
git clone https://github.com/lantean-code/qbtmud.git
cd qbtmud
```
### 2. Restore Dependencies
```sh
dotnet restore
```
### 3. Build and Publish the Application
```sh
dotnet publish --configuration Release
```
This will output the Web UI files to `Lantean.QBTMud\bin\Release\net9.0\publish\wwwroot`.
### 4. Configure qBittorrent to Use qbtmud
Follow the same steps as in the **Installation** section to set qbtmud as your WebUI.
### 5. Run qbtmud
Navigate to the directory containing the built files and run the application using the appropriate command for your OS.
By following these steps, you can set up qbtmud to manage your qBittorrent server with an improved web interface, offering better functionality and usability.