chore: release v2.4
- Add theme system (Dark/Light palettes, ModernTheme, ThemeManager) - Add InputDialog, Spinner common view - Add DuplicatesCsvExportService - Refresh views, dialogs, and view models across tabs - Update localization strings (en/fr) - Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,9 @@
|
||||
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
||||
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.confirm.title]}"
|
||||
Width="450" Height="220" WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource AppBgBrush}"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
TextOptions.TextFormattingMode="Ideal"
|
||||
ResizeMode="NoResize">
|
||||
<Grid Margin="20">
|
||||
<Grid.RowDefinitions>
|
||||
|
||||
@@ -3,13 +3,24 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
||||
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderbrowser.title]}"
|
||||
Width="400" Height="500" WindowStartupLocation="CenterOwner"
|
||||
Width="520" Height="560" WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource AppBgBrush}"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
TextOptions.TextFormattingMode="Ideal"
|
||||
ResizeMode="CanResizeWithGrip">
|
||||
<DockPanel Margin="10">
|
||||
<!-- Status -->
|
||||
<TextBlock x:Name="StatusText" DockPanel.Dock="Top" Margin="0,0,0,10"
|
||||
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderbrowser.loading]}" />
|
||||
|
||||
<!-- Action bar: new folder (destination mode only) -->
|
||||
<StackPanel x:Name="ActionBar" DockPanel.Dock="Top" Orientation="Horizontal"
|
||||
Margin="0,0,0,6" Visibility="Collapsed">
|
||||
<Button x:Name="NewFolderButton"
|
||||
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.new_folder]}"
|
||||
Padding="8,3" Click="NewFolder_Click" IsEnabled="False" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Buttons -->
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
|
||||
HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||
|
||||
@@ -8,13 +8,38 @@ namespace SharepointToolbox.Views.Dialogs;
|
||||
public partial class FolderBrowserDialog : Window
|
||||
{
|
||||
private readonly ClientContext _ctx;
|
||||
private readonly bool _allowFileSelection;
|
||||
private readonly bool _allowFolderCreation;
|
||||
|
||||
public string SelectedLibrary { get; private set; } = string.Empty;
|
||||
public string SelectedFolderPath { get; private set; } = string.Empty;
|
||||
|
||||
public FolderBrowserDialog(ClientContext ctx)
|
||||
/// <summary>
|
||||
/// Library-relative file paths checked by the user. Only populated when
|
||||
/// <paramref name="allowFileSelection"/> was true. Empty if the user picked
|
||||
/// a folder node instead.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SelectedFilePaths { get; private set; } = Array.Empty<string>();
|
||||
|
||||
private readonly List<CheckBox> _fileCheckboxes = new();
|
||||
private readonly List<TreeViewItem> _expandedNodes = new();
|
||||
|
||||
/// <summary>
|
||||
/// Dialog for browsing library folders. Set <paramref name="allowFileSelection"/>
|
||||
/// to show individual files (with sizes) and allow ticking them for targeted
|
||||
/// transfer. Set <paramref name="allowFolderCreation"/> to expose a "New
|
||||
/// Folder" button that creates a folder under the selected node.
|
||||
/// </summary>
|
||||
public FolderBrowserDialog(ClientContext ctx,
|
||||
bool allowFileSelection = false,
|
||||
bool allowFolderCreation = false)
|
||||
{
|
||||
InitializeComponent();
|
||||
_ctx = ctx;
|
||||
_allowFileSelection = allowFileSelection;
|
||||
_allowFolderCreation = allowFolderCreation;
|
||||
if (allowFolderCreation)
|
||||
ActionBar.Visibility = Visibility.Visible;
|
||||
Loaded += OnLoaded;
|
||||
}
|
||||
|
||||
@@ -22,24 +47,19 @@ public partial class FolderBrowserDialog : Window
|
||||
{
|
||||
try
|
||||
{
|
||||
// Load libraries
|
||||
var web = _ctx.Web;
|
||||
var lists = _ctx.LoadQuery(web.Lists
|
||||
.Include(l => l.Title, l => l.Hidden, l => l.BaseType, l => l.RootFolder)
|
||||
.Include(l => l.Title, l => l.Hidden, l => l.BaseType,
|
||||
l => l.RootFolder.ServerRelativeUrl)
|
||||
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary));
|
||||
var progress = new Progress<Core.Models.OperationProgress>();
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
|
||||
|
||||
foreach (var list in lists)
|
||||
{
|
||||
var libNode = new TreeViewItem
|
||||
{
|
||||
Header = list.Title,
|
||||
Tag = new FolderNodeInfo(list.Title, string.Empty),
|
||||
};
|
||||
// Add dummy child for expand arrow
|
||||
libNode.Items.Add(new TreeViewItem { Header = "Loading..." });
|
||||
libNode.Expanded += LibNode_Expanded;
|
||||
var rootUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
|
||||
var libNode = MakeFolderNode(list.Title,
|
||||
new FolderNodeInfo(list.Title, string.Empty, rootUrl));
|
||||
FolderTree.Items.Add(libNode);
|
||||
}
|
||||
|
||||
@@ -51,52 +71,146 @@ public partial class FolderBrowserDialog : Window
|
||||
}
|
||||
}
|
||||
|
||||
private async void LibNode_Expanded(object sender, RoutedEventArgs e)
|
||||
private TreeViewItem MakeFolderNode(string name, FolderNodeInfo info)
|
||||
{
|
||||
var node = new TreeViewItem
|
||||
{
|
||||
Header = name,
|
||||
Tag = info,
|
||||
};
|
||||
// Placeholder child so the expand arrow appears.
|
||||
node.Items.Add(new TreeViewItem { Header = "Loading..." });
|
||||
node.Expanded += FolderNode_Expanded;
|
||||
_expandedNodes.Add(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
private async void FolderNode_Expanded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not TreeViewItem node || node.Tag is not FolderNodeInfo info)
|
||||
return;
|
||||
|
||||
// Only load children once
|
||||
if (node.Items.Count == 1 && node.Items[0] is TreeViewItem dummy && dummy.Header?.ToString() == "Loading...")
|
||||
// Only load children once.
|
||||
if (!(node.Items.Count == 1
|
||||
&& node.Items[0] is TreeViewItem dummy
|
||||
&& dummy.Header?.ToString() == "Loading..."))
|
||||
return;
|
||||
|
||||
node.Items.Clear();
|
||||
try
|
||||
{
|
||||
node.Items.Clear();
|
||||
try
|
||||
var folder = _ctx.Web.GetFolderByServerRelativeUrl(info.ServerRelativeUrl);
|
||||
_ctx.Load(folder, f => f.StorageMetrics.TotalSize,
|
||||
f => f.StorageMetrics.TotalFileCount);
|
||||
var list = _ctx.Web.Lists.GetByTitle(info.LibraryTitle);
|
||||
_ctx.Load(list, l => l.Title);
|
||||
var progress = new Progress<Core.Models.OperationProgress>();
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
|
||||
|
||||
// Annotate the parent node header with total metrics now that they loaded.
|
||||
node.Header = FormatFolderHeader(info.LibraryTitle == info.RelativePath || string.IsNullOrEmpty(info.RelativePath)
|
||||
? (string)node.Header!
|
||||
: System.IO.Path.GetFileName(info.RelativePath),
|
||||
folder.StorageMetrics.TotalFileCount,
|
||||
folder.StorageMetrics.TotalSize);
|
||||
|
||||
// Enumerate direct children via paginated CAML — Folder.Folders /
|
||||
// Folder.Files lazy loading hits the list-view threshold on libraries
|
||||
// above 5,000 items even when only a small folder is being expanded.
|
||||
var subFolders = new List<(string Name, string Url)>();
|
||||
var filesInFolder = new List<(string Name, long Length, string Url)>();
|
||||
|
||||
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
|
||||
_ctx, list, info.ServerRelativeUrl, recursive: false,
|
||||
viewFields: new[] { "FSObjType", "FileLeafRef", "FileRef", "File_x0020_Size" },
|
||||
ct: CancellationToken.None))
|
||||
{
|
||||
var folderUrl = string.IsNullOrEmpty(info.FolderPath)
|
||||
? GetLibraryRootUrl(info.LibraryTitle)
|
||||
: info.FolderPath;
|
||||
var fsType = item["FSObjType"]?.ToString();
|
||||
var name = item["FileLeafRef"]?.ToString() ?? string.Empty;
|
||||
var fileRef = item["FileRef"]?.ToString() ?? string.Empty;
|
||||
|
||||
var folder = _ctx.Web.GetFolderByServerRelativeUrl(folderUrl);
|
||||
_ctx.Load(folder, f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl));
|
||||
var progress = new Progress<Core.Models.OperationProgress>();
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
|
||||
|
||||
foreach (var subFolder in folder.Folders)
|
||||
if (fsType == "1")
|
||||
{
|
||||
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
|
||||
continue;
|
||||
|
||||
var childNode = new TreeViewItem
|
||||
{
|
||||
Header = subFolder.Name,
|
||||
Tag = new FolderNodeInfo(info.LibraryTitle, subFolder.ServerRelativeUrl),
|
||||
};
|
||||
childNode.Items.Add(new TreeViewItem { Header = "Loading..." });
|
||||
childNode.Expanded += LibNode_Expanded;
|
||||
node.Items.Add(childNode);
|
||||
if (name.StartsWith("_") || name == "Forms") continue;
|
||||
subFolders.Add((name, fileRef));
|
||||
}
|
||||
else if (fsType == "0" && _allowFileSelection)
|
||||
{
|
||||
long.TryParse(item["File_x0020_Size"]?.ToString() ?? "0", out long size);
|
||||
filesInFolder.Add((name, size, fileRef));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
// Batch-load StorageMetrics for each subfolder in one round-trip.
|
||||
var metricFolders = subFolders
|
||||
.Select(sf => _ctx.Web.GetFolderByServerRelativeUrl(sf.Url))
|
||||
.ToList();
|
||||
foreach (var mf in metricFolders)
|
||||
_ctx.Load(mf, f => f.StorageMetrics.TotalSize,
|
||||
f => f.StorageMetrics.TotalFileCount);
|
||||
if (metricFolders.Count > 0)
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
|
||||
|
||||
for (int i = 0; i < subFolders.Count; i++)
|
||||
{
|
||||
node.Items.Add(new TreeViewItem { Header = $"Error: {ex.Message}" });
|
||||
var (name, url) = subFolders[i];
|
||||
var childRelative = string.IsNullOrEmpty(info.RelativePath)
|
||||
? name
|
||||
: $"{info.RelativePath}/{name}";
|
||||
|
||||
var childInfo = new FolderNodeInfo(info.LibraryTitle, childRelative, url);
|
||||
|
||||
var childNode = MakeFolderNode(
|
||||
FormatFolderHeader(name,
|
||||
metricFolders[i].StorageMetrics.TotalFileCount,
|
||||
metricFolders[i].StorageMetrics.TotalSize),
|
||||
childInfo);
|
||||
node.Items.Add(childNode);
|
||||
}
|
||||
|
||||
// Files under this folder — only shown when selection is enabled.
|
||||
if (_allowFileSelection)
|
||||
{
|
||||
foreach (var (fileName, fileSize, _) in filesInFolder)
|
||||
{
|
||||
var fileRel = string.IsNullOrEmpty(info.RelativePath)
|
||||
? fileName
|
||||
: $"{info.RelativePath}/{fileName}";
|
||||
|
||||
var cb = new CheckBox
|
||||
{
|
||||
Content = $"{fileName} ({FormatSize(fileSize)})",
|
||||
Tag = new FileNodeInfo(info.LibraryTitle, fileRel),
|
||||
Margin = new Thickness(4, 2, 0, 2),
|
||||
};
|
||||
cb.Checked += FileCheckbox_Toggled;
|
||||
cb.Unchecked += FileCheckbox_Toggled;
|
||||
_fileCheckboxes.Add(cb);
|
||||
|
||||
var fileItem = new TreeViewItem { Header = cb, Focusable = false };
|
||||
node.Items.Add(fileItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
node.Items.Add(new TreeViewItem { Header = $"Error: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
private string GetLibraryRootUrl(string libraryTitle)
|
||||
private static string FormatFolderHeader(string name, long fileCount, long totalBytes)
|
||||
{
|
||||
var uri = new Uri(_ctx.Url);
|
||||
return $"{uri.AbsolutePath.TrimEnd('/')}/{libraryTitle}";
|
||||
if (fileCount <= 0) return name;
|
||||
return $"{name} ({fileCount} files, {FormatSize(totalBytes)})";
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
if (bytes <= 0) return "0 B";
|
||||
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
|
||||
if (bytes >= 1_048_576L) return $"{bytes / 1_048_576.0:F2} MB";
|
||||
if (bytes >= 1024L) return $"{bytes / 1024.0:F2} KB";
|
||||
return $"{bytes} B";
|
||||
}
|
||||
|
||||
private void FolderTree_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
|
||||
@@ -104,13 +218,83 @@ public partial class FolderBrowserDialog : Window
|
||||
if (e.NewValue is TreeViewItem node && node.Tag is FolderNodeInfo info)
|
||||
{
|
||||
SelectedLibrary = info.LibraryTitle;
|
||||
SelectedFolderPath = info.FolderPath;
|
||||
SelectedFolderPath = info.RelativePath;
|
||||
SelectButton.IsEnabled = true;
|
||||
NewFolderButton.IsEnabled = _allowFolderCreation;
|
||||
}
|
||||
else
|
||||
{
|
||||
// File nodes have CheckBox headers, not FolderNodeInfo tags.
|
||||
NewFolderButton.IsEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void FileCheckbox_Toggled(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// Enable "Select" as soon as any file is checked — user can confirm
|
||||
// purely via file selection without also picking a folder node.
|
||||
if (_fileCheckboxes.Any(c => c.IsChecked == true))
|
||||
SelectButton.IsEnabled = true;
|
||||
}
|
||||
|
||||
private async void NewFolder_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (FolderTree.SelectedItem is not TreeViewItem node ||
|
||||
node.Tag is not FolderNodeInfo info)
|
||||
return;
|
||||
|
||||
var dlg = new InputDialog("New folder name:", string.Empty)
|
||||
{
|
||||
Owner = this
|
||||
};
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
var folderName = dlg.ResponseText.Trim();
|
||||
if (string.IsNullOrEmpty(folderName)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var parent = _ctx.Web.GetFolderByServerRelativeUrl(info.ServerRelativeUrl);
|
||||
var created = parent.Folders.Add(folderName);
|
||||
_ctx.Load(created, f => f.ServerRelativeUrl, f => f.Name);
|
||||
var progress = new Progress<Core.Models.OperationProgress>();
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
|
||||
|
||||
var childRelative = string.IsNullOrEmpty(info.RelativePath)
|
||||
? created.Name
|
||||
: $"{info.RelativePath}/{created.Name}";
|
||||
var childInfo = new FolderNodeInfo(
|
||||
info.LibraryTitle, childRelative, created.ServerRelativeUrl);
|
||||
var childNode = MakeFolderNode(created.Name, childInfo);
|
||||
|
||||
// Expand the parent so the fresh folder is visible immediately.
|
||||
node.IsExpanded = true;
|
||||
node.Items.Add(childNode);
|
||||
StatusText.Text = $"Created: {created.ServerRelativeUrl}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusText.Text = $"Error: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void Select_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// Harvest checked files (library-relative paths).
|
||||
SelectedFilePaths = _fileCheckboxes
|
||||
.Where(c => c.IsChecked == true && c.Tag is FileNodeInfo)
|
||||
.Select(c => ((FileNodeInfo)c.Tag!).RelativePath)
|
||||
.ToList();
|
||||
|
||||
// If files were picked but no folder node was selected, borrow the
|
||||
// library from the first file so the caller still has a valid target.
|
||||
if (SelectedFilePaths.Count > 0 && string.IsNullOrEmpty(SelectedLibrary))
|
||||
{
|
||||
var firstTag = (FileNodeInfo)_fileCheckboxes
|
||||
.First(c => c.IsChecked == true && c.Tag is FileNodeInfo).Tag!;
|
||||
SelectedLibrary = firstTag.LibraryTitle;
|
||||
SelectedFolderPath = string.Empty;
|
||||
}
|
||||
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
@@ -121,5 +305,21 @@ public partial class FolderBrowserDialog : Window
|
||||
Close();
|
||||
}
|
||||
|
||||
private record FolderNodeInfo(string LibraryTitle, string FolderPath);
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
Loaded -= OnLoaded;
|
||||
foreach (var node in _expandedNodes)
|
||||
node.Expanded -= FolderNode_Expanded;
|
||||
_expandedNodes.Clear();
|
||||
foreach (var cb in _fileCheckboxes)
|
||||
{
|
||||
cb.Checked -= FileCheckbox_Toggled;
|
||||
cb.Unchecked -= FileCheckbox_Toggled;
|
||||
}
|
||||
_fileCheckboxes.Clear();
|
||||
base.OnClosed(e);
|
||||
}
|
||||
|
||||
private record FolderNodeInfo(string LibraryTitle, string RelativePath, string ServerRelativeUrl);
|
||||
private record FileNodeInfo(string LibraryTitle, string RelativePath);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<Window x:Class="SharepointToolbox.Views.Dialogs.InputDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
||||
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[input.title]}"
|
||||
Width="340" Height="140"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource AppBgBrush}"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
TextOptions.TextFormattingMode="Ideal"
|
||||
ResizeMode="NoResize">
|
||||
<DockPanel Margin="12">
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
|
||||
HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
|
||||
Width="70" IsCancel="True" Margin="0,0,8,0"
|
||||
Click="Cancel_Click" />
|
||||
<Button Content="OK" Width="70" IsDefault="True"
|
||||
Click="Ok_Click" />
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="PromptText" DockPanel.Dock="Top" Margin="0,0,0,6" />
|
||||
<TextBox x:Name="ResponseBox" VerticalContentAlignment="Center" Padding="4" />
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace SharepointToolbox.Views.Dialogs;
|
||||
|
||||
public partial class InputDialog : Window
|
||||
{
|
||||
public string ResponseText => ResponseBox.Text;
|
||||
|
||||
public InputDialog(string prompt, string initialValue)
|
||||
{
|
||||
InitializeComponent();
|
||||
PromptText.Text = prompt;
|
||||
ResponseBox.Text = initialValue ?? string.Empty;
|
||||
Loaded += (_, _) => { ResponseBox.Focus(); ResponseBox.SelectAll(); };
|
||||
}
|
||||
|
||||
private void Ok_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void Cancel_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
DialogResult = false;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,12 @@
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
||||
Title="Manage Profiles" Width="500" Height="750"
|
||||
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profmgmt.title]}"
|
||||
Width="500" Height="750"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||
Background="{DynamicResource AppBgBrush}"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
TextOptions.TextFormattingMode="Ideal"
|
||||
ResizeMode="NoResize">
|
||||
<Window.Resources>
|
||||
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
|
||||
@@ -19,7 +23,7 @@
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Profile list -->
|
||||
<Label Content="Profiles" Grid.Row="0" />
|
||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profmgmt.group]}" Grid.Row="0" />
|
||||
<ListBox Grid.Row="1" Margin="0,0,0,8"
|
||||
ItemsSource="{Binding Profiles}"
|
||||
SelectedItem="{Binding SelectedProfile}"
|
||||
@@ -50,14 +54,14 @@
|
||||
<TextBox Text="{Binding NewClientId, UpdateSourceTrigger=PropertyChanged}"
|
||||
Grid.Row="2" Grid.Column="1" Margin="0,2" />
|
||||
<TextBlock Grid.Row="3" Grid.Column="1" Margin="0,2,0,0"
|
||||
FontSize="11" FontStyle="Italic" Foreground="#666666" TextWrapping="Wrap"
|
||||
FontSize="11" FontStyle="Italic" Foreground="{DynamicResource TextMutedBrush}" TextWrapping="Wrap"
|
||||
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.clientid.hint]}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Client Logo -->
|
||||
<StackPanel Grid.Row="3" Margin="0,8,0,8">
|
||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.title]}" Padding="0,0,0,4" />
|
||||
<Border BorderBrush="#DDDDDD" BorderThickness="1" Padding="8" CornerRadius="4"
|
||||
<Border BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" Padding="8" CornerRadius="4"
|
||||
HorizontalAlignment="Left" MinWidth="200" MinHeight="50">
|
||||
<Grid>
|
||||
<Image Source="{Binding ClientLogoPreview, Converter={StaticResource Base64ToImageConverter}}"
|
||||
@@ -65,7 +69,7 @@
|
||||
Visibility="{Binding ClientLogoPreview, Converter={StaticResource StringToVisibilityConverter}}" />
|
||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.nopreview]}"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||
Foreground="#999999" FontStyle="Italic">
|
||||
Foreground="{DynamicResource TextMutedBrush}" FontStyle="Italic">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Visible" />
|
||||
@@ -87,7 +91,7 @@
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.autopull]}"
|
||||
Command="{Binding AutoPullClientLogoCommand}" Width="130" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding ValidationMessage}" Foreground="#CC0000" FontSize="11" Margin="0,4,0,0"
|
||||
<TextBlock Text="{Binding ValidationMessage}" Foreground="{DynamicResource DangerBrush}" FontSize="11" Margin="0,4,0,0"
|
||||
Visibility="{Binding ValidationMessage, Converter={StaticResource StringToVisibilityConverter}}" />
|
||||
</StackPanel>
|
||||
|
||||
@@ -107,11 +111,11 @@
|
||||
|
||||
<!-- Status text -->
|
||||
<TextBlock Text="{Binding RegistrationStatus}" FontSize="11" Margin="0,2,0,0"
|
||||
Foreground="#006600"
|
||||
Foreground="{DynamicResource SuccessBrush}"
|
||||
Visibility="{Binding RegistrationStatus, Converter={StaticResource StringToVisibilityConverter}}" />
|
||||
|
||||
<!-- Fallback instructions panel -->
|
||||
<Border BorderBrush="#DDDDDD" BorderThickness="1" Padding="8" CornerRadius="4" Margin="0,4,0,0"
|
||||
<Border BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" Padding="8" CornerRadius="4" Margin="0,4,0,0"
|
||||
Visibility="{Binding ShowFallbackInstructions, Converter={StaticResource BooleanToVisibilityConverter}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.step1]}"
|
||||
@@ -134,11 +138,12 @@
|
||||
<StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.add]}"
|
||||
Command="{Binding AddCommand}" Width="60" Margin="4,0" />
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.rename]}"
|
||||
Command="{Binding RenameCommand}" Width="60" Margin="4,0" />
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.save]}"
|
||||
Command="{Binding SaveCommand}" MinWidth="80" Padding="6,0" Margin="4,0" />
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.delete]}"
|
||||
Command="{Binding DeleteCommand}" Width="60" Margin="4,0" />
|
||||
<Button Content="Close" Width="60" Margin="4,0"
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.close]}"
|
||||
Width="60" Margin="4,0"
|
||||
Click="CloseButton_Click" IsCancel="True" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
@@ -1,30 +1,73 @@
|
||||
<Window x:Class="SharepointToolbox.Views.Dialogs.SitePickerDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Select Sites" Width="600" Height="500"
|
||||
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
||||
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.title]}"
|
||||
Width="760" Height="560"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||
Background="{DynamicResource AppBgBrush}"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
TextOptions.TextFormattingMode="Ideal"
|
||||
Loaded="Window_Loaded">
|
||||
<Grid Margin="12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Filter row -->
|
||||
<DockPanel Grid.Row="0" Margin="0,0,0,8">
|
||||
<TextBlock Text="Filter:" VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||
<!-- Text filter row -->
|
||||
<DockPanel Grid.Row="0" Margin="0,0,0,6">
|
||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.filter]}"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||
<TextBox x:Name="FilterBox" TextChanged="FilterBox_TextChanged" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Size + type filter row -->
|
||||
<DockPanel Grid.Row="1" Margin="0,0,0,8" LastChildFill="False">
|
||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.type]}"
|
||||
VerticalAlignment="Center" Margin="0,0,6,0" />
|
||||
<ComboBox x:Name="TypeFilter" Width="170" SelectedIndex="0"
|
||||
SelectionChanged="TypeFilter_SelectionChanged" Margin="0,0,12,0">
|
||||
<ComboBoxItem Tag="All"
|
||||
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.type.all]}" />
|
||||
<ComboBoxItem Tag="TeamSite"
|
||||
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.type.team]}" />
|
||||
<ComboBoxItem Tag="CommunicationSite"
|
||||
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.type.communication]}" />
|
||||
<ComboBoxItem Tag="Classic"
|
||||
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.type.classic]}" />
|
||||
<ComboBoxItem Tag="Unknown"
|
||||
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.type.other]}" />
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.size]}"
|
||||
VerticalAlignment="Center" Margin="0,0,6,0" />
|
||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.size.min]}"
|
||||
VerticalAlignment="Center" Margin="0,0,4,0"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
<TextBox x:Name="MinSizeBox" Width="80" Margin="0,0,8,0"
|
||||
TextChanged="SizeBox_TextChanged" />
|
||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.size.max]}"
|
||||
VerticalAlignment="Center" Margin="0,0,4,0"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
<TextBox x:Name="MaxSizeBox" Width="80"
|
||||
TextChanged="SizeBox_TextChanged" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Site list with checkboxes -->
|
||||
<ListView x:Name="SiteList" Grid.Row="1" Margin="0,0,0,8"
|
||||
<ListView x:Name="SiteList" Grid.Row="2" Margin="0,0,0,8"
|
||||
SelectionMode="Single"
|
||||
BorderThickness="1" BorderBrush="#CCCCCC">
|
||||
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}"
|
||||
GridViewColumnHeader.Click="SiteList_ColumnHeaderClick">
|
||||
<ListView.View>
|
||||
<GridView>
|
||||
<GridViewColumn Header="" Width="32">
|
||||
<GridViewColumn Width="32">
|
||||
<GridViewColumn.Header>
|
||||
<GridViewColumnHeader Tag="IsSelected" Content="" />
|
||||
</GridViewColumn.Header>
|
||||
<GridViewColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
||||
@@ -32,28 +75,56 @@
|
||||
</DataTemplate>
|
||||
</GridViewColumn.CellTemplate>
|
||||
</GridViewColumn>
|
||||
<GridViewColumn Header="Title" Width="200" DisplayMemberBinding="{Binding Title}" />
|
||||
<GridViewColumn Header="URL" Width="320" DisplayMemberBinding="{Binding Url}" />
|
||||
<GridViewColumn Width="200" DisplayMemberBinding="{Binding Title}">
|
||||
<GridViewColumn.Header>
|
||||
<GridViewColumnHeader Tag="Title"
|
||||
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.col.title]}" />
|
||||
</GridViewColumn.Header>
|
||||
</GridViewColumn>
|
||||
<GridViewColumn Width="280" DisplayMemberBinding="{Binding Url}">
|
||||
<GridViewColumn.Header>
|
||||
<GridViewColumnHeader Tag="Url"
|
||||
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.col.url]}" />
|
||||
</GridViewColumn.Header>
|
||||
</GridViewColumn>
|
||||
<GridViewColumn Width="110" DisplayMemberBinding="{Binding KindDisplay}">
|
||||
<GridViewColumn.Header>
|
||||
<GridViewColumnHeader Tag="Kind"
|
||||
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.col.type]}" />
|
||||
</GridViewColumn.Header>
|
||||
</GridViewColumn>
|
||||
<GridViewColumn Width="80" DisplayMemberBinding="{Binding SizeDisplay}">
|
||||
<GridViewColumn.Header>
|
||||
<GridViewColumnHeader Tag="StorageUsedMb"
|
||||
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.col.size]}" />
|
||||
</GridViewColumn.Header>
|
||||
</GridViewColumn>
|
||||
</GridView>
|
||||
</ListView.View>
|
||||
</ListView>
|
||||
|
||||
<!-- Status text -->
|
||||
<TextBlock x:Name="StatusText" Grid.Row="2" Margin="0,0,0,8"
|
||||
Foreground="#555555" FontSize="11" />
|
||||
<TextBlock x:Name="StatusText" Grid.Row="3" Margin="0,0,0,8"
|
||||
Foreground="{DynamicResource TextMutedBrush}" FontSize="11" />
|
||||
|
||||
<!-- Button row -->
|
||||
<DockPanel Grid.Row="3">
|
||||
<Button x:Name="LoadButton" Content="Load Sites" Width="80" Margin="0,0,8,0"
|
||||
<DockPanel Grid.Row="4">
|
||||
<Button x:Name="LoadButton"
|
||||
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.btn.load]}"
|
||||
Width="110" Margin="0,0,8,0"
|
||||
Click="LoadButton_Click" />
|
||||
<Button Content="Select All" Width="80" Margin="0,0,8,0"
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.btn.selectAll]}"
|
||||
Width="120" Margin="0,0,8,0"
|
||||
Click="SelectAll_Click" />
|
||||
<Button Content="Deselect All" Width="80" Margin="0,0,8,0"
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.btn.deselectAll]}"
|
||||
Width="140" Margin="0,0,8,0"
|
||||
Click="DeselectAll_Click" />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" DockPanel.Dock="Right">
|
||||
<Button Content="OK" Width="70" Margin="4,0" IsDefault="True"
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.btn.ok]}"
|
||||
Width="80" Margin="4,0" IsDefault="True"
|
||||
Click="OK_Click" />
|
||||
<Button Content="Cancel" Width="70" Margin="4,0" IsCancel="True" />
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[sitepicker.btn.cancel]}"
|
||||
Width="80" Margin="4,0" IsCancel="True" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
|
||||
@@ -1,52 +1,61 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
using SharepointToolbox.Services;
|
||||
using SharepointToolbox.ViewModels.Dialogs;
|
||||
|
||||
namespace SharepointToolbox.Views.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Dialog for selecting multiple SharePoint sites.
|
||||
/// Loads sites from ISiteListService, shows them in a filterable list with checkboxes.
|
||||
/// Delegates loading and filter/sort logic to <see cref="SitePickerDialogLogic"/>
|
||||
/// so the code-behind only handles WPF plumbing.
|
||||
/// </summary>
|
||||
public partial class SitePickerDialog : Window
|
||||
{
|
||||
private readonly ISiteListService _siteListService;
|
||||
private readonly TenantProfile _profile;
|
||||
private readonly SitePickerDialogLogic _logic;
|
||||
private List<SitePickerItem> _allItems = new();
|
||||
private string _sortColumn = "Url";
|
||||
private ListSortDirection _sortDirection = ListSortDirection.Ascending;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the list of sites the user checked before clicking OK.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SiteInfo> SelectedUrls =>
|
||||
_allItems.Where(i => i.IsSelected).Select(i => new SiteInfo(i.Url, i.Title)).ToList();
|
||||
_allItems.Where(i => i.IsSelected).Select(i => new SiteInfo(i.Url, i.Title)
|
||||
{
|
||||
StorageUsedMb = i.StorageUsedMb,
|
||||
StorageQuotaMb = i.StorageQuotaMb,
|
||||
Template = i.Template
|
||||
}).ToList();
|
||||
|
||||
public SitePickerDialog(ISiteListService siteListService, TenantProfile profile)
|
||||
{
|
||||
InitializeComponent();
|
||||
_siteListService = siteListService;
|
||||
_profile = profile;
|
||||
_logic = new SitePickerDialogLogic(siteListService, profile);
|
||||
}
|
||||
|
||||
private async void Window_Loaded(object sender, RoutedEventArgs e) => await LoadSitesAsync();
|
||||
|
||||
private async Task LoadSitesAsync()
|
||||
{
|
||||
StatusText.Text = "Loading sites...";
|
||||
StatusText.Text = TranslationSource.Instance["sitepicker.status.loading"];
|
||||
LoadButton.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
var sites = await _siteListService.GetSitesAsync(
|
||||
_profile,
|
||||
var items = await _logic.LoadAsync(
|
||||
new Progress<OperationProgress>(),
|
||||
System.Threading.CancellationToken.None);
|
||||
|
||||
_allItems = sites.Select(s => new SitePickerItem(s.Url, s.Title)).ToList();
|
||||
_allItems = items.ToList();
|
||||
ApplyFilter();
|
||||
StatusText.Text = $"{_allItems.Count} sites loaded.";
|
||||
StatusText.Text = string.Format(
|
||||
CultureInfo.CurrentUICulture,
|
||||
TranslationSource.Instance["sitepicker.status.loaded"],
|
||||
_allItems.Count);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
@@ -54,7 +63,10 @@ public partial class SitePickerDialog : Window
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusText.Text = $"Error: {ex.Message}";
|
||||
StatusText.Text = string.Format(
|
||||
CultureInfo.CurrentUICulture,
|
||||
TranslationSource.Instance["sitepicker.status.error"],
|
||||
ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -64,25 +76,66 @@ public partial class SitePickerDialog : Window
|
||||
|
||||
private void ApplyFilter()
|
||||
{
|
||||
var filter = FilterBox.Text.Trim();
|
||||
SiteList.ItemsSource = string.IsNullOrEmpty(filter)
|
||||
? (IEnumerable<SitePickerItem>)_allItems
|
||||
: _allItems.Where(i =>
|
||||
i.Url.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
|
||||
i.Title.Contains(filter, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
var text = FilterBox.Text.Trim();
|
||||
var minMb = SitePickerDialogLogic.ParseLongOrDefault(MinSizeBox.Text, 0);
|
||||
var maxMb = SitePickerDialogLogic.ParseLongOrDefault(MaxSizeBox.Text, long.MaxValue);
|
||||
var kindFilter = (TypeFilter.SelectedItem as ComboBoxItem)?.Tag as string ?? "All";
|
||||
|
||||
var filtered = SitePickerDialogLogic.ApplyFilter(_allItems, text, minMb, maxMb, kindFilter);
|
||||
var sorted = SitePickerDialogLogic.ApplySort(filtered, _sortColumn, _sortDirection);
|
||||
var list = sorted.ToList();
|
||||
|
||||
SiteList.ItemsSource = list;
|
||||
if (_allItems.Count > 0)
|
||||
StatusText.Text = string.Format(
|
||||
CultureInfo.CurrentUICulture,
|
||||
TranslationSource.Instance["sitepicker.status.shown"],
|
||||
list.Count, _allItems.Count);
|
||||
}
|
||||
|
||||
private void SiteList_ColumnHeaderClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (e.OriginalSource is not GridViewColumnHeader header) return;
|
||||
if (header.Role == GridViewColumnHeaderRole.Padding) return;
|
||||
if (header.Tag is not string column || string.IsNullOrEmpty(column)) return;
|
||||
|
||||
if (_sortColumn == column)
|
||||
{
|
||||
_sortDirection = _sortDirection == ListSortDirection.Ascending
|
||||
? ListSortDirection.Descending
|
||||
: ListSortDirection.Ascending;
|
||||
}
|
||||
else
|
||||
{
|
||||
_sortColumn = column;
|
||||
_sortDirection = ListSortDirection.Ascending;
|
||||
}
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
private void FilterBox_TextChanged(object sender, TextChangedEventArgs e) => ApplyFilter();
|
||||
private void SizeBox_TextChanged(object sender, TextChangedEventArgs e) => ApplyFilter();
|
||||
private void TypeFilter_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (!IsLoaded) return;
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
private void SelectAll_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
foreach (var item in _allItems) item.IsSelected = true;
|
||||
if (SiteList.ItemsSource is IEnumerable<SitePickerItem> visible)
|
||||
{
|
||||
foreach (var item in visible) item.IsSelected = true;
|
||||
}
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
private void DeselectAll_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
foreach (var item in _allItems) item.IsSelected = false;
|
||||
if (SiteList.ItemsSource is IEnumerable<SitePickerItem> visible)
|
||||
{
|
||||
foreach (var item in visible) item.IsSelected = false;
|
||||
}
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
@@ -105,6 +158,13 @@ public class SitePickerItem : INotifyPropertyChanged
|
||||
|
||||
public string Url { get; init; } = string.Empty;
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public long StorageUsedMb { get; init; }
|
||||
public long StorageQuotaMb { get; init; }
|
||||
public string Template { get; init; } = string.Empty;
|
||||
|
||||
public SiteKind Kind => SiteKindHelper.FromTemplate(Template);
|
||||
public string KindDisplay => SiteKindHelper.DisplayName(Kind);
|
||||
public string SizeDisplay => FormatSize(StorageUsedMb);
|
||||
|
||||
public bool IsSelected
|
||||
{
|
||||
@@ -119,9 +179,19 @@ public class SitePickerItem : INotifyPropertyChanged
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public SitePickerItem(string url, string title)
|
||||
public SitePickerItem(string url, string title, long storageUsedMb = 0, long storageQuotaMb = 0, string template = "")
|
||||
{
|
||||
Url = url;
|
||||
Title = title;
|
||||
StorageUsedMb = storageUsedMb;
|
||||
StorageQuotaMb = storageQuotaMb;
|
||||
Template = template;
|
||||
}
|
||||
|
||||
private static string FormatSize(long mb)
|
||||
{
|
||||
if (mb <= 0) return "—";
|
||||
if (mb >= 1024) return $"{mb / 1024.0:F1} GB";
|
||||
return $"{mb} MB";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user