diff --git a/src/Artemis.UI/Extensions/Bitmap.cs b/src/Artemis.UI/Extensions/Bitmap.cs
index 8c14ee4da..e30bc754f 100644
--- a/src/Artemis.UI/Extensions/Bitmap.cs
+++ b/src/Artemis.UI/Extensions/Bitmap.cs
@@ -1,4 +1,6 @@
+using System;
using System.IO;
+using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using SkiaSharp;
@@ -24,21 +26,23 @@ public class BitmapExtensions
private static Bitmap Resize(SKBitmap source, int size)
{
- int newWidth, newHeight;
- float aspectRatio = (float) source.Width / source.Height;
+ // Get smaller dimension.
+ int minDim = Math.Min(source.Width, source.Height);
- if (aspectRatio > 1)
- {
- newWidth = size;
- newHeight = (int) (size / aspectRatio);
- }
- else
- {
- newWidth = (int) (size * aspectRatio);
- newHeight = size;
- }
+ // Calculate crop rectangle position for center crop.
+ int deltaX = (source.Width - minDim) / 2;
+ int deltaY = (source.Height - minDim) / 2;
+
+ // Create crop rectangle.
+ SKRectI rect = new(deltaX, deltaY, deltaX + minDim, deltaY + minDim);
+
+ // Do the actual cropping of the bitmap.
+ using SKBitmap croppedBitmap = new(minDim, minDim);
+ source.ExtractSubset(croppedBitmap, rect);
+
+ // Resize to the desired size after cropping.
+ using SKBitmap resizedBitmap = croppedBitmap.Resize(new SKImageInfo(size, size), SKFilterQuality.High);
- using SKBitmap resizedBitmap = source.Resize(new SKImageInfo(newWidth, newHeight), SKFilterQuality.High);
return new Bitmap(resizedBitmap.Encode(SKEncodedImageFormat.Png, 100).AsStream());
}
}
\ No newline at end of file
diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml b/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml
index 9af92602f..628846513 100644
--- a/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml
+++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml
@@ -23,8 +23,9 @@
VerticalAlignment="Center"
Margin="0 0 10 0"
Width="80"
- Height="80">
-
+ Height="80"
+ ClipToBounds="True">
+
diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs
index 0691c5b53..22e378a1d 100644
--- a/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs
+++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs
@@ -1,26 +1,34 @@
using System;
using System.Reactive;
+using System.Reactive.Linq;
+using System.Threading;
using System.Threading.Tasks;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Routing;
using Artemis.WebClient.Workshop;
+using Artemis.WebClient.Workshop.Services;
+using Avalonia.Media.Imaging;
using ReactiveUI;
namespace Artemis.UI.Screens.Workshop.Entries;
-public class EntryListViewModel : ViewModelBase
+public class EntryListViewModel : ActivatableViewModelBase
{
private readonly IRouter _router;
+ private readonly ObservableAsPropertyHelper _entryIcon;
- public EntryListViewModel(IGetEntries_Entries_Items entry, IRouter router)
+ public EntryListViewModel(IGetEntries_Entries_Items entry, IRouter router, IWorkshopService workshopService)
{
_router = router;
+
Entry = entry;
+ EntryIcon = workshopService.GetEntryIcon(entry.Id, CancellationToken.None);
NavigateToEntry = ReactiveCommand.CreateFromTask(ExecuteNavigateToEntry);
}
public IGetEntries_Entries_Items Entry { get; }
- public ReactiveCommand NavigateToEntry { get; }
+ public Task EntryIcon { get; }
+ public ReactiveCommand NavigateToEntry { get; }
private async Task ExecuteNavigateToEntry()
{
diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs
index 98e45f00b..340c84ee4 100644
--- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs
+++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs
@@ -13,6 +13,7 @@ using Artemis.UI.Shared.Routing;
using Artemis.UI.Shared.Services;
using Artemis.UI.Shared.Services.Builders;
using Artemis.WebClient.Workshop;
+using DryIoc.ImTools;
using ReactiveUI;
using StrawberryShake;
@@ -20,8 +21,8 @@ namespace Artemis.UI.Screens.Workshop.Profile;
public class ProfileListViewModel : RoutableScreen, IWorkshopViewModel
{
- private readonly IRouter _router;
private readonly INotificationService _notificationService;
+ private readonly Func _getEntryListViewModel;
private readonly IWorkshopClient _workshopClient;
private readonly ObservableAsPropertyHelper _showPagination;
private readonly ObservableAsPropertyHelper _isLoading;
@@ -31,18 +32,22 @@ public class ProfileListViewModel : RoutableScreen getEntryListViewModel)
{
_workshopClient = workshopClient;
- _router = router;
_notificationService = notificationService;
+ _getEntryListViewModel = getEntryListViewModel;
_showPagination = this.WhenAnyValue(vm => vm.TotalPages).Select(t => t > 1).ToProperty(this, vm => vm.ShowPagination);
_isLoading = this.WhenAnyValue(vm => vm.Page, vm => vm.LoadedPage, (p, c) => p != c).ToProperty(this, vm => vm.IsLoading);
CategoriesViewModel = categoriesViewModel;
// Respond to page changes
- this.WhenAnyValue(vm => vm.Page).Skip(1).Subscribe(p => Task.Run(() => _router.Navigate($"workshop/profiles/{p}")));
+ this.WhenAnyValue(vm => vm.Page).Skip(1).Subscribe(p => Task.Run(() => router.Navigate($"workshop/profiles/{p}")));
// Respond to filter changes
this.WhenActivated(d => CategoriesViewModel.WhenAnyValue(vm => vm.CategoryFilters).Skip(1).Subscribe(_ =>
{
@@ -57,7 +62,7 @@ public class ProfileListViewModel : RoutableScreen _showPagination.Value;
public bool IsLoading => _isLoading.Value;
-
+
public CategoriesViewModel CategoriesViewModel { get; }
public List? Entries
@@ -108,10 +113,10 @@ public class ProfileListViewModel : RoutableScreen entries = await _workshopClient.GetEntries.ExecuteAsync(filter, EntriesPerPage * (Page - 1), EntriesPerPage, cancellationToken);
entries.EnsureNoErrors();
-
+
if (entries.Data?.Entries?.Items != null)
{
- Entries = entries.Data.Entries.Items.Select(n => new EntryListViewModel(n, _router)).ToList();
+ Entries = entries.Data.Entries.Items.Select(n => _getEntryListViewModel(n)).ToList();
TotalPages = (int) Math.Ceiling(entries.Data.Entries.TotalCount / (double) EntriesPerPage);
}
else
diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs
index 60d2c92c6..05ef0355a 100644
--- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs
+++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/UploadStepViewModel.cs
@@ -13,12 +13,14 @@ using Artemis.Core;
using Artemis.UI.Shared.Routing;
using System;
using Artemis.UI.Shared.Utilities;
+using Artemis.WebClient.Workshop.Services;
namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps;
public class UploadStepViewModel : SubmissionViewModel
{
private readonly IWorkshopClient _workshopClient;
+ private readonly IWorkshopService _workshopService;
private readonly EntryUploadHandlerFactory _entryUploadHandlerFactory;
private readonly IWindowService _windowService;
private readonly IRouter _router;
@@ -30,9 +32,10 @@ public class UploadStepViewModel : SubmissionViewModel
private Guid? _entryId;
///
- public UploadStepViewModel(IWorkshopClient workshopClient, EntryUploadHandlerFactory entryUploadHandlerFactory, IWindowService windowService, IRouter router)
+ public UploadStepViewModel(IWorkshopClient workshopClient, IWorkshopService workshopService, EntryUploadHandlerFactory entryUploadHandlerFactory, IWindowService windowService, IRouter router)
{
_workshopClient = workshopClient;
+ _workshopService = workshopService;
_entryUploadHandlerFactory = entryUploadHandlerFactory;
_windowService = windowService;
_router = router;
@@ -87,6 +90,10 @@ public class UploadStepViewModel : SubmissionViewModel
if (cancellationToken.IsCancellationRequested)
return;
+
+ // Upload image
+ if (State.Icon != null)
+ await _workshopService.SetEntryIcon(entryId.Value, _progress, State.Icon, cancellationToken);
// Create the workshop entry
try
diff --git a/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs
index 46c4060fc..05f1ecb21 100644
--- a/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs
+++ b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs
@@ -44,6 +44,7 @@ public static class ContainerExtensions
container.Register(Reuse.Singleton);
container.Register(Reuse.Singleton);
+ container.Register(Reuse.Singleton);
container.Register(Reuse.Transient);
container.RegisterMany(workshopAssembly, type => type.IsAssignableTo(), Reuse.Transient);
diff --git a/src/Artemis.WebClient.Workshop/Queries/GetCategories.graphql b/src/Artemis.WebClient.Workshop/Queries/GetCategories.graphql
index d39be92f2..864caa217 100644
--- a/src/Artemis.WebClient.Workshop/Queries/GetCategories.graphql
+++ b/src/Artemis.WebClient.Workshop/Queries/GetCategories.graphql
@@ -1,5 +1,5 @@
query GetCategories {
- categories {
+ categories(order: {name: ASC}) {
id
name
icon
diff --git a/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql b/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql
index c7e639c31..a1879e786 100644
--- a/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql
+++ b/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql
@@ -1,5 +1,5 @@
query GetEntries($filter: EntryFilterInput $skip: Int $take: Int) {
- entries(where: $filter skip: $skip take: $take) {
+ entries(where: $filter skip: $skip take: $take, order: {createdAt: DESC}) {
totalCount
items {
id
diff --git a/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs b/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs
index b159031c1..eb9e4e459 100644
--- a/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs
+++ b/src/Artemis.WebClient.Workshop/Services/AuthenticationService.cs
@@ -26,6 +26,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi
private readonly BehaviorSubject _isLoggedInSubject = new(false);
private AuthenticationToken? _token;
+ private bool _noStoredRefreshToken;
public AuthenticationService(IHttpClientFactory httpClientFactory, IDiscoveryCache discoveryCache, IAuthenticationRepository authenticationRepository)
{
@@ -50,7 +51,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi
private void SetCurrentUser(TokenResponse response)
{
_token = new AuthenticationToken(response);
- _authenticationRepository.SetRefreshToken(_token.RefreshToken);
+ SetStoredRefreshToken(_token.RefreshToken);
JwtSecurityTokenHandler handler = new();
JwtSecurityToken? token = handler.ReadJwtToken(response.IdentityToken);
@@ -80,7 +81,10 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi
if (response.IsError)
{
if (response.Error is OidcConstants.TokenErrors.ExpiredToken or OidcConstants.TokenErrors.InvalidGrant)
+ {
+ SetStoredRefreshToken(null);
return false;
+ }
throw new ArtemisWebClientException("Failed to request refresh token: " + response.Error);
}
@@ -118,8 +122,9 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi
{
// If not logged in, attempt to auto login first
if (!_isLoggedInSubject.Value)
- await AutoLogin();
+ await InternalAutoLogin();
+ // If there is no token, even after an auto-login, there's no bearer to add
if (_token == null)
return null;
@@ -142,14 +147,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi
try
{
- if (!force && _isLoggedInSubject.Value)
- return true;
-
- string? refreshToken = _authenticationRepository.GetRefreshToken();
- if (refreshToken == null)
- return false;
-
- return await UseRefreshToken(refreshToken);
+ return await InternalAutoLogin(force);
}
finally
{
@@ -157,6 +155,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi
}
}
+
///
public async Task Login(CancellationToken cancellationToken)
{
@@ -240,8 +239,36 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi
{
_token = null;
_claims.Clear();
- _authenticationRepository.SetRefreshToken(null);
+ SetStoredRefreshToken(null);
_isLoggedInSubject.OnNext(false);
}
+
+ private async Task InternalAutoLogin(bool force = false)
+ {
+ if (!force && _isLoggedInSubject.Value)
+ return true;
+
+ if (_noStoredRefreshToken)
+ return false;
+
+ string? refreshToken = GetStoredRefreshToken();
+ if (refreshToken == null)
+ return false;
+
+ return await UseRefreshToken(refreshToken);
+ }
+
+ private string? GetStoredRefreshToken()
+ {
+ string? token = _authenticationRepository.GetRefreshToken();
+ _noStoredRefreshToken = token == null;
+ return token;
+ }
+
+ private void SetStoredRefreshToken(string? token)
+ {
+ _authenticationRepository.SetRefreshToken(token);
+ _noStoredRefreshToken = token == null;
+ }
}
\ No newline at end of file
diff --git a/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs
new file mode 100644
index 000000000..34e39b5c8
--- /dev/null
+++ b/src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs
@@ -0,0 +1,101 @@
+using System.Net.Http.Headers;
+using Artemis.UI.Shared.Services.MainWindow;
+using Artemis.UI.Shared.Utilities;
+using Artemis.WebClient.Workshop.UploadHandlers;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+
+namespace Artemis.WebClient.Workshop.Services;
+
+public class WorkshopService : IWorkshopService
+{
+ private readonly Dictionary _entryIconCache = new();
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly SemaphoreSlim _iconCacheLock = new(1);
+
+ public WorkshopService(IHttpClientFactory httpClientFactory, IMainWindowService mainWindowService)
+ {
+ _httpClientFactory = httpClientFactory;
+ mainWindowService.MainWindowClosed += (_, _) => Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ await Task.Delay(1000);
+ ClearCache();
+ });
+ }
+
+ ///
+ public async Task GetEntryIcon(Guid entryId, CancellationToken cancellationToken)
+ {
+ await _iconCacheLock.WaitAsync(cancellationToken);
+ try
+ {
+ if (_entryIconCache.TryGetValue(entryId, out Stream? cachedBitmap))
+ {
+ cachedBitmap.Seek(0, SeekOrigin.Begin);
+ return new Bitmap(cachedBitmap);
+ }
+ }
+ finally
+ {
+ _iconCacheLock.Release();
+ }
+
+ HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME);
+ try
+ {
+ HttpResponseMessage response = await client.GetAsync($"entries/{entryId}/icon", cancellationToken);
+ response.EnsureSuccessStatusCode();
+ Stream data = await response.Content.ReadAsStreamAsync(cancellationToken);
+
+ _entryIconCache[entryId] = data;
+ return new Bitmap(data);
+ }
+ catch (HttpRequestException)
+ {
+ // ignored
+ return null;
+ }
+ }
+
+ public async Task SetEntryIcon(Guid entryId, Progress progress, Stream icon, CancellationToken cancellationToken)
+ {
+ icon.Seek(0, SeekOrigin.Begin);
+
+ // Submit the archive
+ HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME);
+
+ // Construct the request
+ MultipartFormDataContent content = new();
+ ProgressableStreamContent streamContent = new(icon, progress);
+ streamContent.Headers.ContentType = new MediaTypeHeaderValue("image/png");
+ content.Add(streamContent, "file", "file.png");
+
+ // Submit
+ HttpResponseMessage response = await client.PostAsync($"entries/{entryId}/icon", content, cancellationToken);
+ if (!response.IsSuccessStatusCode)
+ return ImageUploadResult.FromFailure($"{response.StatusCode} - {await response.Content.ReadAsStringAsync(cancellationToken)}");
+ return ImageUploadResult.FromSuccess();
+ }
+
+ private void ClearCache()
+ {
+ try
+ {
+ List values = _entryIconCache.Values.ToList();
+ _entryIconCache.Clear();
+ foreach (Stream bitmap in values)
+ bitmap.Dispose();
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine(e);
+ throw;
+ }
+ }
+}
+
+public interface IWorkshopService
+{
+ Task GetEntryIcon(Guid entryId, CancellationToken cancellationToken);
+ Task SetEntryIcon(Guid entryId, Progress progress, Stream icon, CancellationToken cancellationToken);
+}
\ No newline at end of file
diff --git a/src/Artemis.WebClient.Workshop/UploadHandlers/ImageUploadResult.cs b/src/Artemis.WebClient.Workshop/UploadHandlers/ImageUploadResult.cs
new file mode 100644
index 000000000..97f8e861c
--- /dev/null
+++ b/src/Artemis.WebClient.Workshop/UploadHandlers/ImageUploadResult.cs
@@ -0,0 +1,21 @@
+namespace Artemis.WebClient.Workshop.UploadHandlers;
+
+public class ImageUploadResult
+{
+ public bool IsSuccess { get; set; }
+ public string? Message { get; set; }
+
+ public static ImageUploadResult FromFailure(string? message)
+ {
+ return new ImageUploadResult
+ {
+ IsSuccess = false,
+ Message = message
+ };
+ }
+
+ public static ImageUploadResult FromSuccess()
+ {
+ return new ImageUploadResult {IsSuccess = true};
+ }
+}
\ No newline at end of file