feat(12-01): add Base64ToImageSourceConverter, localization keys, and ClientLogoPreview property

- Base64ToImageSourceConverter converts data URI strings to BitmapImage with null-safe error handling
- Registered converter in App.xaml as Base64ToImageConverter global resource
- Added 9 localization keys (EN+FR) for logo UI labels in Settings and Profile dialogs
- Added ClientLogoPreview string property to ProfileManagementViewModel with FormatLogoPreview helper
- Updated OnSelectedProfileChanged, BrowseClientLogoAsync, ClearClientLogoAsync, AutoPullClientLogoAsync
- 17 tests pass (6 converter + 11 profile VM logo tests)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dev
2026-04-08 15:18:38 +02:00
parent 0bc0babaf8
commit 6a4cd8ab56
7 changed files with 201 additions and 0 deletions

View File

@@ -11,6 +11,7 @@
<conv:EnumBoolConverter x:Key="EnumBoolConverter" />
<conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" />
<conv:ListToStringConverter x:Key="ListToStringConverter" />
<conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" />
<Style x:Key="RightAlignStyle" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right" />
</Style>

View File

@@ -384,4 +384,14 @@
<data name="stor.chart.bar" xml:space="preserve"><value>Graphique en barres</value></data>
<data name="stor.chart.toggle" xml:space="preserve"><value>Type de graphique :</value></data>
<data name="stor.chart.nodata" xml:space="preserve"><value>Ex&#233;cutez une analyse pour voir la r&#233;partition par type de fichier.</value></data>
<!-- Phase 12: Logo UI -->
<data name="settings.logo.title" xml:space="preserve"><value>Logo MSP</value></data>
<data name="settings.logo.browse" xml:space="preserve"><value>Importer</value></data>
<data name="settings.logo.clear" xml:space="preserve"><value>Effacer</value></data>
<data name="settings.logo.nopreview" xml:space="preserve"><value>Aucun logo configur&#233;</value></data>
<data name="profile.logo.title" xml:space="preserve"><value>Logo client</value></data>
<data name="profile.logo.browse" xml:space="preserve"><value>Importer</value></data>
<data name="profile.logo.clear" xml:space="preserve"><value>Effacer</value></data>
<data name="profile.logo.autopull" xml:space="preserve"><value>Importer depuis Entra</value></data>
<data name="profile.logo.nopreview" xml:space="preserve"><value>Aucun logo configur&#233;</value></data>
</root>

View File

@@ -384,4 +384,14 @@
<data name="stor.chart.bar" xml:space="preserve"><value>Bar Chart</value></data>
<data name="stor.chart.toggle" xml:space="preserve"><value>Chart View:</value></data>
<data name="stor.chart.nodata" xml:space="preserve"><value>Run a storage scan to see file type breakdown.</value></data>
<!-- Phase 12: Logo UI -->
<data name="settings.logo.title" xml:space="preserve"><value>MSP Logo</value></data>
<data name="settings.logo.browse" xml:space="preserve"><value>Import</value></data>
<data name="settings.logo.clear" xml:space="preserve"><value>Clear</value></data>
<data name="settings.logo.nopreview" xml:space="preserve"><value>No logo configured</value></data>
<data name="profile.logo.title" xml:space="preserve"><value>Client Logo</value></data>
<data name="profile.logo.browse" xml:space="preserve"><value>Import</value></data>
<data name="profile.logo.clear" xml:space="preserve"><value>Clear</value></data>
<data name="profile.logo.autopull" xml:space="preserve"><value>Pull from Entra</value></data>
<data name="profile.logo.nopreview" xml:space="preserve"><value>No logo configured</value></data>
</root>

View File

@@ -32,6 +32,13 @@ public partial class ProfileManagementViewModel : ObservableObject
[ObservableProperty]
private string _validationMessage = string.Empty;
private string? _clientLogoPreview;
public string? ClientLogoPreview
{
get => _clientLogoPreview;
private set { _clientLogoPreview = value; OnPropertyChanged(); }
}
public ObservableCollection<TenantProfile> Profiles { get; } = new();
public IAsyncRelayCommand AddCommand { get; }
@@ -81,6 +88,7 @@ public partial class ProfileManagementViewModel : ObservableObject
partial void OnSelectedProfileChanged(TenantProfile? value)
{
ClientLogoPreview = FormatLogoPreview(value?.ClientLogo);
BrowseClientLogoCommand.NotifyCanExecuteChanged();
ClearClientLogoCommand.NotifyCanExecuteChanged();
AutoPullClientLogoCommand.NotifyCanExecuteChanged();
@@ -88,6 +96,9 @@ public partial class ProfileManagementViewModel : ObservableObject
DeleteCommand.NotifyCanExecuteChanged();
}
private static string? FormatLogoPreview(LogoData? logo)
=> logo is not null ? $"data:{logo.MimeType};base64,{logo.Base64}" : null;
private void NotifyCommandsCanExecuteChanged()
{
AddCommand.NotifyCanExecuteChanged();
@@ -172,6 +183,7 @@ public partial class ProfileManagementViewModel : ObservableObject
{
var logo = await _brandingService.ImportLogoAsync(dialog.FileName);
SelectedProfile.ClientLogo = logo;
ClientLogoPreview = FormatLogoPreview(logo);
await _profileService.UpdateProfileAsync(SelectedProfile);
ValidationMessage = string.Empty;
}
@@ -188,6 +200,7 @@ public partial class ProfileManagementViewModel : ObservableObject
try
{
SelectedProfile.ClientLogo = null;
ClientLogoPreview = null;
await _profileService.UpdateProfileAsync(SelectedProfile);
ValidationMessage = string.Empty;
}
@@ -229,6 +242,7 @@ public partial class ProfileManagementViewModel : ObservableObject
var logo = await _brandingService.ImportLogoFromBytesAsync(bytes);
SelectedProfile.ClientLogo = logo;
ClientLogoPreview = FormatLogoPreview(logo);
await _profileService.UpdateProfileAsync(SelectedProfile);
ValidationMessage = "Client logo pulled from Entra branding.";
}

View File

@@ -0,0 +1,46 @@
using System.Globalization;
using System.IO;
using System.Windows.Data;
using System.Windows.Media.Imaging;
namespace SharepointToolbox.Views.Converters;
/// <summary>
/// Converts a data URI string (e.g. "data:image/png;base64,iVBOR...") to a BitmapImage
/// for use with WPF Image controls. Returns null for null, empty, or malformed input.
/// </summary>
[ValueConversion(typeof(string), typeof(BitmapImage))]
public class Base64ToImageSourceConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not string dataUri || string.IsNullOrEmpty(dataUri))
return null;
try
{
var marker = "base64,";
var idx = dataUri.IndexOf(marker, StringComparison.Ordinal);
if (idx < 0) return null;
var base64 = dataUri[(idx + marker.Length)..];
var bytes = System.Convert.FromBase64String(base64);
var image = new BitmapImage();
using var ms = new MemoryStream(bytes);
image.BeginInit();
image.CacheOption = BitmapCacheOption.OnLoad;
image.StreamSource = ms;
image.EndInit();
image.Freeze();
return image;
}
catch
{
return null;
}
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotImplementedException();
}