feat/estimate #50
12 changed files with 160 additions and 23 deletions
commit
45d3f1f4f8
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -1,5 +1,25 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.0.8-rc12] - unstable
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* [PostIt] Nouveau helper d'image `ImageHelper` pour charger des bitmaps depuis les ressources et depuis le web.
|
||||||
|
* [PostIt] Affichage de l'avatar XS dans la liste des performers d'activites, avec fallback visuel (initiale utilisateur).
|
||||||
|
* [PostIt.Tests] Nouveaux tests autour des URLs avatar et de la source d'autorite.
|
||||||
|
* [contrib] Ajout d'un `README.md` utilitaire pour les symboles/icones.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
* [PostIt] Les avatars ne sont plus relies en string sur `Image.Source`: ils sont telecharges et lies en `Bitmap`.
|
||||||
|
* [Yavsc.Api.Client] `ActivityApiClient` accepte une base d'avatar dediee et construit les URLs avatar depuis l'autorite d'identification.
|
||||||
|
* [PostIt] Le header de `MainPage` n'utilise plus `ScrollViewer`; remplacement par une barre de commandes basee sur `WrapPanel`.
|
||||||
|
* [PostIt] Alignement de la navigation blogs: renommage `PushMainPageAsync` -> `PushBlogsPageAsync` et ajustement de `HomePageViewModel`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
néant
|
||||||
|
|
||||||
## [1.0.8-rc11] - unstable
|
## [1.0.8-rc11] - unstable
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
5
contrib/README.md
Normal file
5
contrib/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Read me
|
||||||
|
|
||||||
|
## Note aux icones
|
||||||
|
|
||||||
|
㝉®🅬⛒⛑🩎🩺🞫🞮🞕🞖🞆🔴🔵🔲🖂🔧🔩🔐🔌💾💼💬💭👿👾🏷🎯🏹🌍🎎💩
|
||||||
|
|
@ -7,6 +7,20 @@ namespace PostIt.Tests;
|
||||||
|
|
||||||
public class ActivitiesPageViewModelTests
|
public class ActivitiesPageViewModelTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ActivityApiClient_uses_avatar_authority_when_provided()
|
||||||
|
{
|
||||||
|
var api = new StubActivityApi();
|
||||||
|
var client = new ActivityApiClient(
|
||||||
|
api,
|
||||||
|
"https://api.pschneider.fr/api/v1/",
|
||||||
|
"https://yavsc.pschneider.fr/");
|
||||||
|
|
||||||
|
var url = client.BuildAvatarXsUrl("paul");
|
||||||
|
|
||||||
|
Assert.Equal("https://yavsc.pschneider.fr/avatars/paul.xs.png", url);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ActivityApiClient_uses_business_absolute_paths()
|
public async Task ActivityApiClient_uses_business_absolute_paths()
|
||||||
{
|
{
|
||||||
|
|
@ -40,6 +54,7 @@ public class ActivitiesPageViewModelTests
|
||||||
Assert.Equal("brush", vm.CurrentActivity?.Code);
|
Assert.Equal("brush", vm.CurrentActivity?.Code);
|
||||||
Assert.Single(vm.Performers);
|
Assert.Single(vm.Performers);
|
||||||
Assert.Equal("Alice", vm.Performers[0].UserName);
|
Assert.Equal("Alice", vm.Performers[0].UserName);
|
||||||
|
Assert.Equal("https://business.example/avatars/Alice.xs.png", vm.Performers[0].AvatarXsUrl);
|
||||||
Assert.True(vm.Performers[0].HasPerformerProfile);
|
Assert.True(vm.Performers[0].HasPerformerProfile);
|
||||||
Assert.True(vm.Performers[0].IsPerformerActive);
|
Assert.True(vm.Performers[0].IsPerformerActive);
|
||||||
Assert.Equal("Actif", vm.Performers[0].PerformerStatusBadgeLabel);
|
Assert.Equal("Actif", vm.Performers[0].PerformerStatusBadgeLabel);
|
||||||
|
|
@ -50,6 +65,7 @@ public class ActivitiesPageViewModelTests
|
||||||
Assert.Equal("brush-pro", vm.CurrentActivity?.Code);
|
Assert.Equal("brush-pro", vm.CurrentActivity?.Code);
|
||||||
Assert.Single(vm.Performers);
|
Assert.Single(vm.Performers);
|
||||||
Assert.Equal("Bob", vm.Performers[0].UserName);
|
Assert.Equal("Bob", vm.Performers[0].UserName);
|
||||||
|
Assert.Equal("https://business.example/avatars/Bob.xs.png", vm.Performers[0].AvatarXsUrl);
|
||||||
Assert.True(vm.Performers[0].HasPerformerProfile);
|
Assert.True(vm.Performers[0].HasPerformerProfile);
|
||||||
Assert.False(vm.Performers[0].IsPerformerActive);
|
Assert.False(vm.Performers[0].IsPerformerActive);
|
||||||
Assert.Equal("Inactif", vm.Performers[0].PerformerStatusBadgeLabel);
|
Assert.Equal("Inactif", vm.Performers[0].PerformerStatusBadgeLabel);
|
||||||
|
|
|
||||||
|
|
@ -135,9 +135,6 @@ private void ConfigureRootView(MainView rootView)
|
||||||
var homePage = provider.GetRequiredService<HomePageViewModel>();
|
var homePage = provider.GetRequiredService<HomePageViewModel>();
|
||||||
var app = (App)Current!;
|
var app = (App)Current!;
|
||||||
await app.PushPageAsync(homePage);
|
await app.PushPageAsync(homePage);
|
||||||
if (!refreshed) return;
|
|
||||||
|
|
||||||
await PushMainPageAsync().ConfigureAwait(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -148,7 +145,7 @@ private void ConfigureRootView(MainView rootView)
|
||||||
/// (interactive login from the banner). Pulled out as a helper so
|
/// (interactive login from the banner). Pulled out as a helper so
|
||||||
/// the two callers can't drift apart.
|
/// the two callers can't drift apart.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async Task PushMainPageAsync()
|
public static async Task PushBlogsPageAsync()
|
||||||
{
|
{
|
||||||
var app = (App)Current!;
|
var app = (App)Current!;
|
||||||
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>();
|
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>();
|
||||||
|
|
|
||||||
34
src/PostIt/PostIt/Helpers/ImageHelper.cs
Normal file
34
src/PostIt/PostIt/Helpers/ImageHelper.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
using Avalonia.Platform;
|
||||||
|
|
||||||
|
namespace PostIt.Helpers;
|
||||||
|
|
||||||
|
public static class ImageHelper
|
||||||
|
{
|
||||||
|
private static readonly HttpClient HttpClient = new();
|
||||||
|
|
||||||
|
public static Bitmap LoadFromResource(Uri resourceUri)
|
||||||
|
{
|
||||||
|
return new Bitmap(AssetLoader.Open(resourceUri));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<Bitmap?> LoadFromWeb(Uri url)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await HttpClient.GetAsync(url).ConfigureAwait(false);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var data = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
|
||||||
|
return new Bitmap(new MemoryStream(data));
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"An error occurred while downloading image '{url}': {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,7 +24,10 @@ public static class ServiceCollectionHelpers
|
||||||
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
||||||
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
||||||
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
||||||
var activityClient = new ActivityApiClient(api, settings.ApiUrl);
|
var activityClient = new ActivityApiClient(
|
||||||
|
api,
|
||||||
|
settings.ApiUrl,
|
||||||
|
settings.Authentication?.Authority);
|
||||||
var billingClient = new BillingApiClient(api, settings.ApiUrl);
|
var billingClient = new BillingApiClient(api, settings.ApiUrl);
|
||||||
var userDirectory = new UserDirectory(userSearchClient);
|
var userDirectory = new UserDirectory(userSearchClient);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -209,8 +209,26 @@ public partial class ActivitiesPageViewModel : ViewModelBase
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var list = await _client.GetUsersAsync(activity.Code);
|
var list = await _client.GetUsersAsync(activity.Code);
|
||||||
Performers = new ObservableCollection<ActivityUserDisplayItem>((list ?? new())
|
var items = (list ?? new())
|
||||||
.Select(ActivityUserDisplayItem.FromDto));
|
.Select(dto => ActivityUserDisplayItem.FromDto(dto, _client.BuildAvatarXsUrl(dto.UserName)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
await Task.WhenAll(items.Select(async item =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(item.AvatarXsUrl))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(item.AvatarXsUrl, UriKind.Absolute, out var avatarUri))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.AvatarImage = await ImageHelper.LoadFromWeb(avatarUri);
|
||||||
|
}));
|
||||||
|
|
||||||
|
Performers = new ObservableCollection<ActivityUserDisplayItem>(items);
|
||||||
SelectedPerformer = null;
|
SelectedPerformer = null;
|
||||||
StatusMessage = $"{activity.Name} · {Performers.Count} utilisateur(s)";
|
StatusMessage = $"{activity.Name} · {Performers.Count} utilisateur(s)";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Yavsc.Abstract.Workflow;
|
using Yavsc.Abstract.Workflow;
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
public sealed class ActivityUserDisplayItem
|
public sealed partial class ActivityUserDisplayItem : ObservableObject
|
||||||
{
|
{
|
||||||
public string PerformerId { get; init; } = string.Empty;
|
public string PerformerId { get; init; } = string.Empty;
|
||||||
|
public string AvatarXsUrl { get; init; } = string.Empty;
|
||||||
public bool HasPerformerProfile { get; init; }
|
public bool HasPerformerProfile { get; init; }
|
||||||
public string PerformerBadgeLabel { get; init; } = "Profil pro";
|
public string PerformerBadgeLabel { get; init; } = "Profil pro";
|
||||||
public bool IsPerformerActive { get; init; }
|
public bool IsPerformerActive { get; init; }
|
||||||
|
|
@ -13,17 +16,25 @@ public sealed class ActivityUserDisplayItem
|
||||||
public string PerformerStatusBadgeBorder { get; init; } = "#C62828";
|
public string PerformerStatusBadgeBorder { get; init; } = "#C62828";
|
||||||
public string PerformerStatusBadgeForeground { get; init; } = "#8E0000";
|
public string PerformerStatusBadgeForeground { get; init; } = "#8E0000";
|
||||||
public string UserName { get; init; } = string.Empty;
|
public string UserName { get; init; } = string.Empty;
|
||||||
|
public string AvatarFallbackLabel { get; init; } = "?";
|
||||||
public string WebSite { get; init; } = string.Empty;
|
public string WebSite { get; init; } = string.Empty;
|
||||||
public int ExtraActivityCount { get; init; }
|
public int ExtraActivityCount { get; init; }
|
||||||
public string ExtraActivityLabel { get; init; } = "Pas d'autre activité";
|
public string ExtraActivityLabel { get; init; } = "Pas d'autre activité";
|
||||||
|
|
||||||
public static ActivityUserDisplayItem FromDto(PerformerActivity dto)
|
[ObservableProperty]
|
||||||
|
public partial Bitmap? AvatarImage { get; set; }
|
||||||
|
|
||||||
|
public static ActivityUserDisplayItem FromDto(PerformerActivity dto, string avatarXsUrl)
|
||||||
{
|
{
|
||||||
return new ActivityUserDisplayItem
|
return new ActivityUserDisplayItem
|
||||||
{
|
{
|
||||||
PerformerId = dto.PerformerId,
|
PerformerId = dto.PerformerId,
|
||||||
|
AvatarXsUrl = avatarXsUrl,
|
||||||
HasPerformerProfile = dto.HasPerformerProfile,
|
HasPerformerProfile = dto.HasPerformerProfile,
|
||||||
UserName = dto.UserName,
|
UserName = dto.UserName,
|
||||||
|
AvatarFallbackLabel = string.IsNullOrWhiteSpace(dto.UserName)
|
||||||
|
? "?"
|
||||||
|
: dto.UserName.Trim()[0].ToString().ToUpperInvariant(),
|
||||||
WebSite = dto.WebSite,
|
WebSite = dto.WebSite,
|
||||||
IsPerformerActive = dto.Active,
|
IsPerformerActive = dto.Active,
|
||||||
PerformerStatusBadgeLabel = dto.Active ? "Actif" : "Inactif",
|
PerformerStatusBadgeLabel = dto.Active ? "Actif" : "Inactif",
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ public class HomePageViewModel : ViewModelBase
|
||||||
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
|
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
|
||||||
|
|
||||||
}
|
}
|
||||||
public IAsyncRelayCommand OpenBlogs { get; } = new AsyncRelayCommand(App.PushMainPageAsync);
|
public IAsyncRelayCommand OpenBlogs { get; } = new AsyncRelayCommand(App.PushBlogsPageAsync);
|
||||||
public IAsyncRelayCommand OpenActivities { get; }
|
public IAsyncRelayCommand OpenActivities { get; }
|
||||||
|
|
||||||
private async Task OpenActivitiesAsync()
|
private async Task OpenActivitiesAsync()
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@
|
||||||
</Grid.Styles>
|
</Grid.Styles>
|
||||||
|
|
||||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8">
|
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8">
|
||||||
<Button Content="Rafraîchir" Command="{Binding RefreshCommand}" />
|
<Button Content="🗘 Rafraîchir" Command="{Binding RefreshCommand}" />
|
||||||
<TextBlock Text="Catalogue des activités" FontWeight="Bold" VerticalAlignment="Center" />
|
<TextBlock Text="📑 Catalogue des activités" FontWeight="Bold" VerticalAlignment="Center" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Grid Grid.Row="1" Margin="0,12,0,12" ColumnDefinitions="*,16,*,16,*">
|
<Grid Grid.Row="1" Margin="0,12,0,12" ColumnDefinitions="*,16,*,16,*">
|
||||||
|
|
@ -76,7 +76,24 @@
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:ActivityUserDisplayItem">
|
<DataTemplate x:DataType="vm:ActivityUserDisplayItem">
|
||||||
<StackPanel Spacing="2" Margin="0,0,0,8">
|
<StackPanel Spacing="2" Margin="0,0,0,8">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||||
|
<Border Width="24"
|
||||||
|
Height="24"
|
||||||
|
CornerRadius="12"
|
||||||
|
ClipToBounds="True"
|
||||||
|
Background="#E9ECEF">
|
||||||
|
<Grid>
|
||||||
|
<TextBlock Text="{Binding AvatarFallbackLabel}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="#495057" />
|
||||||
|
<Image Source="{Binding AvatarImage}"
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Stretch="UniformToFill" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
<TextBlock Text="{Binding UserName}" FontWeight="Bold" />
|
<TextBlock Text="{Binding UserName}" FontWeight="Bold" />
|
||||||
<Border IsVisible="{Binding HasPerformerProfile}"
|
<Border IsVisible="{Binding HasPerformerProfile}"
|
||||||
Classes="user-badge"
|
Classes="user-badge"
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,13 @@
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<StackPanel Grid.Row="0" Spacing="12"
|
<Border Grid.Row="0"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
VerticalAlignment="Top">
|
VerticalAlignment="Top"
|
||||||
|
Padding="0">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<WrapPanel Orientation="Horizontal">
|
||||||
<Button Command="{Binding RefreshAsync}" Content="Refresh" />
|
<Button Command="{Binding RefreshAsync}" Content="🗘 Refresh" />
|
||||||
<Button Command="{Binding SearchAsync}" Content="Filter" />
|
<Button Command="{Binding SearchAsync}" Content="🔍 Filter" />
|
||||||
<Button Command="{Binding SaveAsync}" Content="Save" />
|
<Button Command="{Binding SaveAsync}" Content="Save" />
|
||||||
<Button Command="{Binding DeleteAsync}" Content="Delete" />
|
<Button Command="{Binding DeleteAsync}" Content="Delete" />
|
||||||
<Button x:Name="ManageAclButton"
|
<Button x:Name="ManageAclButton"
|
||||||
|
|
@ -62,8 +62,8 @@
|
||||||
Command="{Binding OpenSignatureDevAsync}"
|
Command="{Binding OpenSignatureDevAsync}"
|
||||||
Content="[DEV] Signature"
|
Content="[DEV] Signature"
|
||||||
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
|
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
|
||||||
</StackPanel>
|
</WrapPanel>
|
||||||
</StackPanel>
|
</Border>
|
||||||
|
|
||||||
<Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8">
|
<Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8">
|
||||||
<ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}"
|
<ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}"
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,18 @@ public sealed class ActivityApiClient
|
||||||
|
|
||||||
private readonly IYavscApiClient _api;
|
private readonly IYavscApiClient _api;
|
||||||
private readonly Uri _baseAddress;
|
private readonly Uri _baseAddress;
|
||||||
|
private readonly Uri _avatarBaseAddress;
|
||||||
|
|
||||||
public ActivityApiClient(IYavscApiClient api, string businessBaseAddress)
|
public ActivityApiClient(IYavscApiClient api, string businessBaseAddress, string? avatarBaseAddress = null)
|
||||||
{
|
{
|
||||||
_api = api ?? throw new ArgumentNullException(nameof(api));
|
_api = api ?? throw new ArgumentNullException(nameof(api));
|
||||||
if (string.IsNullOrEmpty(businessBaseAddress))
|
if (string.IsNullOrEmpty(businessBaseAddress))
|
||||||
throw new ArgumentException("Base address is required.", nameof(businessBaseAddress));
|
throw new ArgumentException("Base address is required.", nameof(businessBaseAddress));
|
||||||
|
|
||||||
_baseAddress = new Uri(businessBaseAddress, UriKind.Absolute);
|
_baseAddress = new Uri(businessBaseAddress, UriKind.Absolute);
|
||||||
|
_avatarBaseAddress = string.IsNullOrWhiteSpace(avatarBaseAddress)
|
||||||
|
? _baseAddress
|
||||||
|
: new Uri(avatarBaseAddress, UriKind.Absolute);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<ActivityInfo>> GetCatalogAsync(
|
public Task<List<ActivityInfo>> GetCatalogAsync(
|
||||||
|
|
@ -59,5 +63,17 @@ public sealed class ActivityApiClient
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
=> GetUsersAsync(activityCode, ct);
|
=> GetUsersAsync(activityCode, ct);
|
||||||
|
|
||||||
|
public string BuildAvatarXsUrl(string? userName)
|
||||||
|
{
|
||||||
|
var siteRoot = new Uri(_avatarBaseAddress, "/");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(userName))
|
||||||
|
{
|
||||||
|
return new Uri(siteRoot, "images/Users/icon_user.xs.png").ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Uri(siteRoot, $"avatars/{Uri.EscapeDataString(userName)}.xs.png").ToString();
|
||||||
|
}
|
||||||
|
|
||||||
private string Absolute(string relativePath) => new Uri(_baseAddress, relativePath).ToString();
|
private string Absolute(string relativePath) => new Uri(_baseAddress, relativePath).ToString();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue