Merge branch 'release/1.2.0'

This commit is contained in:
ahjephson
2025-10-20 20:55:26 +01:00
83 changed files with 2711 additions and 1370 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

@@ -10,11 +10,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AwesomeAssertions" Version="9.0.0" /> <PackageReference Include="AwesomeAssertions" Version="9.2.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.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.1.0"> <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>

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;

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,8 +1,9 @@
<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">
<div class="content-panel__toolbar content-panel__toolbar--scroll">
<MudToolBar Gutters="false" Dense="true"> <MudToolBar Gutters="false" Dense="true">
<MudIconButton Icon="@Icons.Material.Filled.DriveFileRenameOutline" OnClick="RenameFileToolbar" title="Rename" /> <MudIconButton Icon="@Icons.Material.Filled.DriveFileRenameOutline" OnClick="RenameFileToolbar" title="Rename" />
<MudDivider Vertical="true" /> <MudDivider Vertical="true" />
@@ -24,7 +25,7 @@
<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> <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> </MudToolBar>
</div> </div>
<div class="content-panel__body">
<DynamicTable <DynamicTable
@ref="Table" @ref="Table"
T="ContentItem" T="ContentItem"
@@ -38,8 +39,10 @@
SortDirectionChanged="SortDirectionChanged" SortDirectionChanged="SortDirectionChanged"
OnTableDataContextMenu="TableDataContextMenu" OnTableDataContextMenu="TableDataContextMenu"
OnTableDataLongPress="TableDataLongPress" OnTableDataLongPress="TableDataLongPress"
Class="file-list" Class="file-list content-panel__table"
/> />
</div>
</div>
@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,17 +223,23 @@ 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);
} }
} }
if (hasUpdates)
{
MarkFilesDirty();
PruneSelectionIfMissing();
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
} }
} }
}
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
@@ -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)
{
result.Add(item);
if (item.IsFolder && ExpandedNodes.Contains(item.Name)) if (!ExpandedNodes.Contains(item.Name))
{ {
var level = 0; continue;
var descendants = GetChildren(item, level); }
foreach (var descendant in descendants)
var descendants = GetVisibleDescendants(item, lookup, sortSelector);
result.AddRange(descendants);
}
else
{ {
list.Add(descendant); 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,18 +1,22 @@
<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>
<div class="content-panel">
<div class="content-panel__toolbar">
<MudToolBar Gutters="false" Dense="true"> <MudToolBar Gutters="false" Dense="true">
<MudIconButton Icon="@Icons.Material.Filled.AddCircle" Color="Color.Info" OnClick="AddPeer">Add peer</MudIconButton> <MudIconButton Icon="@Icons.Material.Filled.AddCircle" Color="Color.Info" OnClick="AddPeer">Add peer</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.DisabledByDefault" Color="Color.Error" OnClick="BanPeerToolbar" Disabled="@(SelectedItem is null)">Ban peer</MudIconButton> <MudIconButton Icon="@Icons.Material.Filled.DisabledByDefault" Color="Color.Error" OnClick="BanPeerToolbar" Disabled="@(SelectedItem is null)">Ban peer</MudIconButton>
<MudDivider Vertical="true" /> <MudDivider Vertical="true" />
<MudIconButton Icon="@Icons.Material.Outlined.ViewColumn" Color="Color.Inherit" OnClick="ColumnOptions" title="Choose Columns" /> <MudIconButton Icon="@Icons.Material.Outlined.ViewColumn" Color="Color.Inherit" OnClick="ColumnOptions" title="Choose Columns" />
</MudToolBar> </MudToolBar>
</div>
<div class="content-panel__body">
<DynamicTable T="Peer" <DynamicTable T="Peer"
ColumnDefinitions="Columns" ColumnDefinitions="Columns"
Items="Peers" Items="Peers"
@@ -21,4 +25,6 @@
OnTableDataLongPress="TableDataLongPress" OnTableDataLongPress="TableDataLongPress"
OnTableDataContextMenu="TableDataContextMenu" OnTableDataContextMenu="TableDataContextMenu"
SelectedItemChanged="SelectedItemChanged" SelectedItemChanged="SelectedItemChanged"
Class="details-list" /> 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

@@ -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,8 +6,10 @@
<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>
<div class="content-panel">
<div class="content-panel__toolbar">
<MudToolBar Gutters="false" Dense="true"> <MudToolBar Gutters="false" Dense="true">
<MudIconButton Icon="@Icons.Material.Filled.AddCircle" Color="Color.Info" OnClick="AddTracker">Add trackers</MudIconButton> <MudIconButton Icon="@Icons.Material.Filled.AddCircle" Color="Color.Info" OnClick="AddTracker">Add trackers</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Info" OnClick="EditTrackerToolbar" Disabled="@(SelectedItem is null)">Edit tracker URL</MudIconButton> <MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Info" OnClick="EditTrackerToolbar" Disabled="@(SelectedItem is null)">Edit tracker URL</MudIconButton>
@@ -16,7 +18,9 @@
<MudDivider Vertical="true" /> <MudDivider Vertical="true" />
<MudIconButton Icon="@Icons.Material.Outlined.ViewColumn" Color="Color.Inherit" OnClick="ColumnOptions" title="Choose Columns" /> <MudIconButton Icon="@Icons.Material.Outlined.ViewColumn" Color="Color.Inherit" OnClick="ColumnOptions" title="Choose Columns" />
</MudToolBar> </MudToolBar>
</div>
<div class="content-panel__body">
<DynamicTable @ref="Table" <DynamicTable @ref="Table"
T="Lantean.QBitTorrentClient.Models.TorrentTracker" T="Lantean.QBitTorrentClient.Models.TorrentTracker"
ColumnDefinitions="Columns" ColumnDefinitions="Columns"
@@ -29,4 +33,6 @@
OnTableDataLongPress="TableDataLongPress" OnTableDataLongPress="TableDataLongPress"
OnTableDataContextMenu="TableDataContextMenu" OnTableDataContextMenu="TableDataContextMenu"
SelectedItemChanged="SelectedItemChanged" SelectedItemChanged="SelectedItemChanged"
Class="file-list" /> 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)
{
return _visibleColumns;
}
_visibleColumns = BuildVisibleColumns();
_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)
{
return EmptyColumns;
}
List<ColumnDefinition<T>> orderedColumns;
if (_columnOrder.Count == 0) if (_columnOrder.Count == 0)
{ {
foreach (var column in filteredColumns) orderedColumns = filteredColumns;
}
else
{ {
if (_columnWidths.TryGetValue(column.Id, out var value)) var orderLookup = _columnOrder.OrderBy(entry => entry.Value).ToList();
{
column.Width = value;
}
yield return column;
}
yield break;
}
var columnDictionary = filteredColumns.ToDictionary(c => c.Id); var columnDictionary = filteredColumns.ToDictionary(c => c.Id);
foreach (var columnId in _columnOrder.OrderBy(c => c.Value).Select(c => c.Key)) orderedColumns = new List<ColumnDefinition<T>>(filteredColumns.Count);
foreach (var (columnId, _) in orderLookup)
{ {
if (!columnDictionary.TryGetValue(columnId, out var column)) if (!columnDictionary.TryGetValue(columnId, out var column))
{ {
continue; 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">
<div class="content-panel__body">
<DynamicTable T="Lantean.QBitTorrentClient.Models.WebSeed"
ColumnDefinitions="Columns" ColumnDefinitions="Columns"
Items="WebSeeds" Items="WebSeeds"
MultiSelection="false" MultiSelection="false"
SelectOnRowClick="false" SelectOnRowClick="false"
Class="details-list" /> 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
{
return "∞";
} }
var sb = new StringBuilder(); var sb = new StringBuilder();
if (prefix is not null) if (prefix is not null)
{ {

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;
default:
if (!useSubcategories)
{
if (torrent.Category != category)
{
return false;
}
else
{
if (!torrent.Category.StartsWith(category))
{
return false;
}
}
}
break;
}
return true; return true;
default:
if (string.IsNullOrEmpty(torrent.Category))
{
return false;
}
if (!useSubcategories)
{
return string.Equals(torrent.Category, category, StringComparison.Ordinal);
}
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

@@ -1,5 +1,4 @@
 namespace Lantean.QBTMud.Helpers
namespace Lantean.QBTMud.Helpers
{ {
internal static class VersionHelper internal static class VersionHelper
{ {

View File

@@ -12,10 +12,10 @@
<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.5" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.5" PrivateAssets="all" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.10" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.5" /> <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" />
<PackageReference Include="MudBlazor" Version="8.7.0" /> <PackageReference Include="MudBlazor" Version="8.13.0" />
<PackageReference Include="MudBlazor.ThemeManager" Version="3.0.0" /> <PackageReference Include="MudBlazor.ThemeManager" Version="3.0.0" />
</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">
<MudDrawer Open="DrawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2" Overlay="false" Class="app-shell__sidebar">
<TorrentsListNav Torrents="Torrents" SelectedTorrent="@SelectedTorrent" SortDirection="SortDirection" SortColumn="@SortColumn" /> <TorrentsListNav Torrents="Torrents" SelectedTorrent="@SelectedTorrent" SortDirection="SortDirection" SortColumn="@SortColumn" />
</MudDrawer> </MudDrawer>
<MudMainContent> <MudMainContent Class="app-shell__main">
@Body @Body
</MudMainContent> </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">
<MudDrawer Open="DrawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2" Overlay="false" Class="app-shell__sidebar">
<FiltersNav CategoryChanged="CategoryChanged" StatusChanged="StatusChanged" TagChanged="TagChanged" TrackerChanged="TrackerChanged" /> <FiltersNav CategoryChanged="CategoryChanged" StatusChanged="StatusChanged" TagChanged="TagChanged" TrackerChanged="TrackerChanged" />
</MudDrawer> </MudDrawer>
<MudMainContent> <MudMainContent Class="app-shell__main">
<CascadingValue Value="SearchTermChanged" Name="SearchTermChanged"> <CascadingValue Value="SearchTermChanged" Name="SearchTermChanged">
@Body @Body
</CascadingValue> </CascadingValue>
</MudMainContent> </MudMainContent>
</div>

View File

@@ -10,6 +10,7 @@
} }
<CascadingValue Value="Torrents"> <CascadingValue Value="Torrents">
<CascadingValue Value="_torrentsVersion" Name="TorrentsVersion">
<CascadingValue Value="MainData"> <CascadingValue Value="MainData">
<CascadingValue Value="Preferences"> <CascadingValue Value="Preferences">
<CascadingValue Value="SortColumnChanged" Name="SortColumnChanged"> <CascadingValue Value="SortColumnChanged" Name="SortColumnChanged">
@@ -23,20 +24,9 @@
<CascadingValue Value="SearchTermChanged" Name="SearchTermChanged"> <CascadingValue Value="SearchTermChanged" Name="SearchTermChanged">
<CascadingValue Value="@(MainData?.LostConnection ?? false)" Name="LostConnection"> <CascadingValue Value="@(MainData?.LostConnection ?? false)" Name="LostConnection">
<CascadingValue Value="Version" Name="Version"> <CascadingValue Value="Version" Name="Version">
<div class="app-shell">
@Body @Body
</CascadingValue> <MudAppBar Bottom="true" Elevation="0" Dense="true" Class="app-shell__status-bar">
</CascadingValue>
</CascadingValue>
</CascadingValue>
</CascadingValue>
</CascadingValue>
</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) @if (MainData?.LostConnection == true)
{ {
<MudText Class="mx-2 mb-1 d-none d-sm-flex" Color="Color.Error">qBittorrent client is not reachable</MudText> <MudText Class="mx-2 mb-1 d-none d-sm-flex" Color="Color.Error">qBittorrent client is not reachable</MudText>
@@ -49,7 +39,7 @@
@{ @{
var (icon, colour) = GetConnectionIcon(MainData?.ServerState.ConnectionStatus); var (icon, colour) = GetConnectionIcon(MainData?.ServerState.ConnectionStatus);
} }
<MudIcon Class="mx-1 mb-1" Icon="@icon" Color="@colour" Title="MainData?.ServerState.ConnectionStatus" /> <MudIcon Class="mx-1 mb-1" Icon="@icon" Color="@colour" Title="@MainData?.ServerState.ConnectionStatus" />
<MudDivider Vertical="true" Class="" /> <MudDivider Vertical="true" Class="" />
<MudIcon Class="mx-1 mb-1" Icon="@Icons.Material.Outlined.Speed" Color="@((MainData?.ServerState.UseAltSpeedLimits ?? false) ? Color.Error : Color.Success)" /> <MudIcon Class="mx-1 mb-1" Icon="@Icons.Material.Outlined.Speed" Color="@((MainData?.ServerState.UseAltSpeedLimits ?? false) ? Color.Error : Color.Success)" />
<MudDivider Vertical="true" Class="" /> <MudDivider Vertical="true" Class="" />
@@ -65,5 +55,19 @@
@DisplayHelpers.Size(MainData?.ServerState.UploadInfoData, "(", ")") @DisplayHelpers.Size(MainData?.ServerState.UploadInfoData, "(", ")")
</MudText> </MudText>
</MudAppBar> </MudAppBar>
</div>
</CascadingValue>
</CascadingValue>
</CascadingValue>
</CascadingValue>
</CascadingValue>
</CascadingValue>
</CascadingValue>
</CascadingValue>
</CascadingValue>
</CascadingValue>
</CascadingValue>
</CascadingValue>
</CascadingValue>
</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()
@@ -84,6 +98,7 @@ namespace Lantean.QBTMud.Layout
Version = await ApiClient.GetApplicationVersion(); Version = await ApiClient.GetApplicationVersion();
var data = await ApiClient.GetMainData(_requestId); var data = await ApiClient.GetMainData(_requestId);
MainData = DataManager.CreateMainData(data, Version); 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, Version); 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;
} }
if (MainData is not null)
{
_refreshInterval = MainData.ServerState.RefreshInterval; _refreshInterval = MainData.ServerState.RefreshInterval;
}
_requestId = data.ResponseId; _requestId = data.ResponseId;
if (shouldRender)
{
await InvokeAsync(StateHasChanged); 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),
};
} }
return (Icons.Material.Outlined.SignalWifi4Bar, Color.Success); private void OnCategoryChanged(string category)
{
if (Category == category)
{
return;
}
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

@@ -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">
<MudDrawer Open="DrawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2" Overlay="false" Class="app-shell__sidebar">
<MudNavMenu> <MudNavMenu>
<ApplicationActions IsMenu="false" Preferences="Preferences" /> <ApplicationActions IsMenu="false" Preferences="Preferences" />
</MudNavMenu> </MudNavMenu>
</MudDrawer> </MudDrawer>
<MudMainContent> <MudMainContent Class="app-shell__main">
@Body @Body
</MudMainContent> </MudMainContent>
</div>

View File

@@ -16,6 +16,5 @@
StalledDownloading, StalledDownloading,
Checking, Checking,
Errored, Errored,
} }
} }

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,6 +1,8 @@
@page "/about" @page "/about"
@layout OtherLayout @layout OtherLayout
<div class="content-panel">
<div class="content-panel__toolbar">
<MudToolBar Gutters="false" Dense="true"> <MudToolBar Gutters="false" Dense="true">
@if (!DrawerOpen) @if (!DrawerOpen)
{ {
@@ -9,10 +11,12 @@
} }
<MudText Class="px-5 no-wrap">About</MudText> <MudText Class="px-5 no-wrap">About</MudText>
</MudToolBar> </MudToolBar>
</div>
<div class="content-panel__body">
<MudTabs Elevation="2" ApplyEffectsToContainer="true"> <MudTabs Elevation="2" ApplyEffectsToContainer="true">
<MudTabPanel Text="About"> <MudTabPanel Text="About">
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-3"> <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,6 +1,8 @@
@page "/blocks" @page "/blocks"
@layout OtherLayout @layout OtherLayout
<div class="content-panel">
<div class="content-panel__toolbar">
<MudToolBar Gutters="false" Dense="true"> <MudToolBar Gutters="false" Dense="true">
@if (!DrawerOpen) @if (!DrawerOpen)
{ {
@@ -10,7 +12,8 @@
<MudDivider Vertical="true" /> <MudDivider Vertical="true" />
<MudText Class="pl-5 no-wrap">Blocked IPs</MudText> <MudText Class="pl-5 no-wrap">Blocked IPs</MudText>
</MudToolBar> </MudToolBar>
</div>
<div class="content-panel__body">
<MudCard Elevation="1" Class="ml-4 mr-4 mb-4"> <MudCard Elevation="1" Class="ml-4 mr-4 mb-4">
<MudCardContent> <MudCardContent>
<EditForm Model="Model" OnSubmit="Submit"> <EditForm Model="Model" OnSubmit="Submit">
@@ -33,4 +36,6 @@
MultiSelection="false" MultiSelection="false"
SelectOnRowClick="false" SelectOnRowClick="false"
RowClassFunc="RowClass" RowClassFunc="RowClass"
Class="search-list" /> Class="search-list content-panel__table" />
</div>
</div>

View File

@@ -1,6 +1,8 @@
@page "/categories" @page "/categories"
@layout OtherLayout @layout OtherLayout
<div class="content-panel">
<div class="content-panel__toolbar">
<MudToolBar Gutters="false" Dense="true"> <MudToolBar Gutters="false" Dense="true">
@if (!DrawerOpen) @if (!DrawerOpen)
{ {
@@ -11,14 +13,18 @@
<MudDivider Vertical="true" /> <MudDivider Vertical="true" />
<MudIconButton Icon="@Icons.Material.Filled.PlaylistAdd" OnClick="AddCategory" title="Add Category" /> <MudIconButton Icon="@Icons.Material.Filled.PlaylistAdd" OnClick="AddCategory" title="Add Category" />
</MudToolBar> </MudToolBar>
</div>
<div class="content-panel__body">
<DynamicTable @ref="Table" <DynamicTable @ref="Table"
T="Category" T="Category"
ColumnDefinitions="Columns" ColumnDefinitions="Columns"
Items="Results" Items="Results"
MultiSelection="false" MultiSelection="false"
SelectOnRowClick="false" SelectOnRowClick="false"
Class="details-list" /> Class="details-list content-panel__table" />
</div>
</div>
@code { @code {
private RenderFragment<RowContext<Category>> ActionsColumn private RenderFragment<RowContext<Category>> ActionsColumn

View File

@@ -1,7 +1,8 @@
@page "/details/{hash}" @page "/details/{hash}"
@layout DetailsLayout @layout DetailsLayout
<div style="overflow-x: auto; white-space: nowrap; width: 100%;"> <div class="content-panel">
<div class="content-panel__toolbar content-panel__toolbar--scroll">
<MudToolBar Gutters="false" Dense="true"> <MudToolBar Gutters="false" Dense="true">
@if (!DrawerOpen) @if (!DrawerOpen)
{ {
@@ -17,6 +18,7 @@
</MudToolBar> </MudToolBar>
</div> </div>
<div class="content-panel__body">
@if (ShowTabs) @if (ShowTabs)
{ {
<CascadingValue Value="RefreshInterval" Name="RefreshInterval"> <CascadingValue Value="RefreshInterval" Name="RefreshInterval">
@@ -39,3 +41,5 @@
</MudTabs> </MudTabs>
</CascadingValue> </CascadingValue>
} }
</div>
</div>

View File

@@ -1,6 +1,8 @@
@page "/log" @page "/log"
@layout OtherLayout @layout OtherLayout
<div class="content-panel">
<div class="content-panel__toolbar">
<MudToolBar Gutters="false" Dense="true"> <MudToolBar Gutters="false" Dense="true">
@if (!DrawerOpen) @if (!DrawerOpen)
{ {
@@ -10,7 +12,8 @@
<MudDivider Vertical="true" /> <MudDivider Vertical="true" />
<MudText Class="pl-5 no-wrap">Execution Log</MudText> <MudText Class="pl-5 no-wrap">Execution Log</MudText>
</MudToolBar> </MudToolBar>
</div>
<div class="content-panel__body">
<MudCard Elevation="1" Class="ml-4 mr-4 mb-4"> <MudCard Elevation="1" Class="ml-4 mr-4 mb-4">
<MudCardContent> <MudCardContent>
<EditForm Model="Model" OnSubmit="Submit"> <EditForm Model="Model" OnSubmit="Submit">
@@ -41,4 +44,6 @@
MultiSelection="false" MultiSelection="false"
SelectOnRowClick="false" SelectOnRowClick="false"
RowClassFunc="RowClass" RowClassFunc="RowClass"
Class="search-list" /> Class="search-list content-panel__table" />
</div>
</div>

View File

@@ -3,6 +3,8 @@
<NavigationLock ConfirmExternalNavigation="@(UpdatePreferences is not null)" OnBeforeInternalNavigation="ValidateExit" /> <NavigationLock ConfirmExternalNavigation="@(UpdatePreferences is not null)" OnBeforeInternalNavigation="ValidateExit" />
<div class="content-panel">
<div class="content-panel__toolbar">
<MudToolBar Gutters="false" Dense="true"> <MudToolBar Gutters="false" Dense="true">
@if (!DrawerOpen) @if (!DrawerOpen)
{ {
@@ -14,30 +16,50 @@
<MudIconButton Icon="@Icons.Material.Outlined.Save" OnClick="Save" Disabled="@(LostConnection || UpdatePreferences is null)" /> <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)" /> <MudIconButton Icon="@Icons.Material.Outlined.Undo" OnClick="Undo" Disabled="@(LostConnection || UpdatePreferences is null)" />
</MudToolBar> </MudToolBar>
</div>
<div class="content-panel__body">
<MudTabs Elevation="2" ApplyEffectsToContainer="true" @bind-ActivePanelIndex="ActiveTab" Border="true"> <MudTabs Elevation="2" ApplyEffectsToContainer="true" @bind-ActivePanelIndex="ActiveTab" Border="true">
<MudTabPanel Text="Behaviour"> <MudTabPanel Text="Behaviour">
<div class="options-tab-contents">
<BehaviourOptions @ref="BehaviourOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> <BehaviourOptions @ref="BehaviourOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" />
</div>
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="Downloads"> <MudTabPanel Text="Downloads">
<div class="options-tab-contents">
<DownloadsOptions @ref="DownloadsOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> <DownloadsOptions @ref="DownloadsOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" />
</div>
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="Connection"> <MudTabPanel Text="Connection">
<div class="options-tab-contents">
<ConnectionOptions @ref="ConnectionOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> <ConnectionOptions @ref="ConnectionOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" />
</div>
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="Speed"> <MudTabPanel Text="Speed">
<div class="options-tab-contents">
<SpeedOptions @ref="SpeedOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> <SpeedOptions @ref="SpeedOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" />
</div>
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="BitTorrent"> <MudTabPanel Text="BitTorrent">
<div class="options-tab-contents">
<BitTorrentOptions @ref="BitTorrentOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> <BitTorrentOptions @ref="BitTorrentOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" />
</div>
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="RSS"> <MudTabPanel Text="RSS">
<div class="options-tab-contents">
<RSSOptions @ref="RSSOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> <RSSOptions @ref="RSSOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" />
</div>
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="Web UI"> <MudTabPanel Text="Web UI">
<div class="options-tab-contents">
<WebUIOptions @ref="WebUIOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> <WebUIOptions @ref="WebUIOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" />
</div>
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="Advanced"> <MudTabPanel Text="Advanced">
<div class="options-tab-contents">
<AdvancedOptions @ref="AdvancedOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" /> <AdvancedOptions @ref="AdvancedOptions" Preferences="Preferences" UpdatePreferences="@UpdatePreferences" PreferencesChanged="PreferencesChanged" />
</div>
</MudTabPanel> </MudTabPanel>
</MudTabs> </MudTabs>
</div>
</div>

View File

@@ -1,6 +1,8 @@
@page "/rss" @page "/rss"
@layout OtherLayout @layout OtherLayout
<div class="content-panel">
<div class="content-panel__toolbar">
<MudToolBar Gutters="false" Dense="true"> <MudToolBar Gutters="false" Dense="true">
@if (!DrawerOpen) @if (!DrawerOpen)
{ {
@@ -15,8 +17,10 @@
<MudDivider Vertical="true" /> <MudDivider Vertical="true" />
<MudIconButton Icon="@Icons.Material.Outlined.DownloadForOffline" OnClick="EditDownloadRules" title="Edit auto downloading rules" /> <MudIconButton Icon="@Icons.Material.Outlined.DownloadForOffline" OnClick="EditDownloadRules" title="Edit auto downloading rules" />
</MudToolBar> </MudToolBar>
</div>
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge"> <div class="content-panel__body">
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="content-panel__container">
<MudGrid Class="rss-contents"> <MudGrid Class="rss-contents">
<MudItem xs="4" Style="height: 100%"> <MudItem xs="4" Style="height: 100%">
<MudList T="string" SelectionMode="SelectionMode.SingleSelection" SelectedValue="SelectedFeed" SelectedValueChanged="SelectedFeedChanged" Dense> <MudList T="string" SelectionMode="SelectionMode.SingleSelection" SelectedValue="SelectedFeed" SelectedValueChanged="SelectedFeedChanged" Dense>
@@ -71,3 +75,5 @@
</MudItem> </MudItem>
</MudGrid> </MudGrid>
</MudContainer> </MudContainer>
</div>
</div>

View File

@@ -1,6 +1,8 @@
@page "/search" @page "/search"
@layout OtherLayout @layout OtherLayout
<div class="content-panel">
<div class="content-panel__toolbar">
<MudToolBar Gutters="false" Dense="true"> <MudToolBar Gutters="false" Dense="true">
@if (!DrawerOpen) @if (!DrawerOpen)
{ {
@@ -10,7 +12,8 @@
<MudDivider Vertical="true" /> <MudDivider Vertical="true" />
<MudText Class="pl-5 no-wrap">Search</MudText> <MudText Class="pl-5 no-wrap">Search</MudText>
</MudToolBar> </MudToolBar>
</div>
<div class="content-panel__body">
<MudCard Elevation="1" Class="ml-4 mr-4 mb-4"> <MudCard Elevation="1" Class="ml-4 mr-4 mb-4">
<MudCardContent> <MudCardContent>
<EditForm Model="Model" OnValidSubmit="DoSearch"> <EditForm Model="Model" OnValidSubmit="DoSearch">
@@ -59,4 +62,6 @@
Items="Results" Items="Results"
MultiSelection="false" MultiSelection="false"
SelectOnRowClick="false" SelectOnRowClick="false"
Class="search-list" /> Class="search-list content-panel__table" />
</div>
</div>

View File

@@ -1,6 +1,8 @@
@page "/statistics" @page "/statistics"
@layout OtherLayout @layout OtherLayout
<div class="content-panel">
<div class="content-panel__toolbar">
<MudToolBar Gutters="false" Dense="true"> <MudToolBar Gutters="false" Dense="true">
@if (!DrawerOpen) @if (!DrawerOpen)
{ {
@@ -10,8 +12,10 @@
<MudDivider Vertical="true" /> <MudDivider Vertical="true" />
<MudText Class="pl-5 no-wrap">Statistics</MudText> <MudText Class="pl-5 no-wrap">Statistics</MudText>
</MudToolBar> </MudToolBar>
</div>
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="details-tab-contents"> <div class="content-panel__body">
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="details-tab-contents content-panel__container">
<MudText Typo="Typo.subtitle2" Class="pt-6">User statistics</MudText> <MudText Typo="Typo.subtitle2" Class="pt-6">User statistics</MudText>
<MudGrid> <MudGrid>
<MudItem xs="12"> <MudItem xs="12">
@@ -60,3 +64,5 @@
</MudItem> </MudItem>
</MudGrid> </MudGrid>
</MudContainer> </MudContainer>
</div>
</div>

View File

@@ -1,6 +1,8 @@
@page "/tags" @page "/tags"
@layout OtherLayout @layout OtherLayout
<div class="content-panel">
<div class="content-panel__toolbar">
<MudToolBar Gutters="false" Dense="true"> <MudToolBar Gutters="false" Dense="true">
@if (!DrawerOpen) @if (!DrawerOpen)
{ {
@@ -11,14 +13,18 @@
<MudDivider Vertical="true" /> <MudDivider Vertical="true" />
<MudIconButton Icon="@Icons.Material.Filled.NewLabel" OnClick="AddTag" title="Add Tag" /> <MudIconButton Icon="@Icons.Material.Filled.NewLabel" OnClick="AddTag" title="Add Tag" />
</MudToolBar> </MudToolBar>
</div>
<div class="content-panel__body">
<DynamicTable @ref="Table" <DynamicTable @ref="Table"
T="string" T="string"
ColumnDefinitions="Columns" ColumnDefinitions="Columns"
Items="Results" Items="Results"
MultiSelection="false" MultiSelection="false"
SelectOnRowClick="false" SelectOnRowClick="false"
Class="details-list" /> Class="details-list content-panel__table" />
</div>
</div>
@code { @code {
private RenderFragment<RowContext<string>> ActionsColumn private RenderFragment<RowContext<string>> ActionsColumn

View File

@@ -1,13 +1,14 @@
@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">
<div class="content-panel__toolbar content-panel__toolbar--scroll">
<MudToolBar Gutters="false" Dense="true"> <MudToolBar Gutters="false" Dense="true">
<MudIconButton Icon="@Icons.Material.Outlined.AddLink" OnClick="AddTorrentLink" title="Add torrent link" /> <MudIconButton Icon="@Icons.Material.Outlined.AddLink" OnClick="AddTorrentLink" title="Add torrent link" />
<MudIconButton Icon="@Icons.Material.Outlined.AddCircle" OnClick="AddTorrentFile" title="Add torrent file" /> <MudIconButton Icon="@Icons.Material.Outlined.AddCircle" OnClick="AddTorrentFile" title="Add torrent file" />
@@ -20,12 +21,12 @@
<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> <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> </MudToolBar>
</div> </div>
<div class="content-panel__body">
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="ma-0 pa-0"> <MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="ma-0 pa-0 content-panel__container">
<DynamicTable <DynamicTable
@ref="Table" @ref="Table"
T="Torrent" T="Torrent"
Class="torrent-list" Class="torrent-list content-panel__table"
ColumnDefinitions="Columns" ColumnDefinitions="Columns"
Items="Torrents" Items="Torrents"
OnRowClick="RowClick" OnRowClick="RowClick"
@@ -38,6 +39,8 @@
OnTableDataLongPress="TableDataLongPress" OnTableDataLongPress="TableDataLongPress"
/> />
</MudContainer> </MudContainer>
</div>
</div>
@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

@@ -8,7 +8,7 @@ namespace Lantean.QBTMud.Services
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

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

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

@@ -68,11 +68,13 @@ cd qbtmud
dotnet restore dotnet restore
``` ```
### 3. Build the Application ### 3. Build and Publish the Application
```sh ```sh
dotnet build --configuration Release 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 ### 4. Configure qBittorrent to Use qbtmud
Follow the same steps as in the **Installation** section to set qbtmud as your WebUI. Follow the same steps as in the **Installation** section to set qbtmud as your WebUI.