1
0
mirror of https://github.com/Artemis-RGB/Artemis synced 2025-12-12 13:28:33 +00:00

Submission wizard - Upload entry icon

Entry lists - Show entry icon
This commit is contained in:
Robert 2023-08-16 20:01:28 +02:00
parent 6014bb9e3c
commit 176a28761f
11 changed files with 214 additions and 39 deletions

View File

@ -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());
}
}

View File

@ -23,8 +23,9 @@
VerticalAlignment="Center"
Margin="0 0 10 0"
Width="80"
Height="80">
<avalonia:MaterialIcon Kind="HandOkay" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" Width="70" Height="70" />
Height="80"
ClipToBounds="True">
<Image Source="{CompiledBinding EntryIcon^}" Stretch="UniformToFill"/>
</Border>
<!-- Body -->

View File

@ -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<Bitmap?> _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<Unit,Unit> NavigateToEntry { get; }
public Task<Bitmap?> EntryIcon { get; }
public ReactiveCommand<Unit, Unit> NavigateToEntry { get; }
private async Task ExecuteNavigateToEntry()
{

View File

@ -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<ActivatableViewModelBase, WorkshopListParameters>, IWorkshopViewModel
{
private readonly IRouter _router;
private readonly INotificationService _notificationService;
private readonly Func<IGetEntries_Entries_Items, EntryListViewModel> _getEntryListViewModel;
private readonly IWorkshopClient _workshopClient;
private readonly ObservableAsPropertyHelper<bool> _showPagination;
private readonly ObservableAsPropertyHelper<bool> _isLoading;
@ -31,18 +32,22 @@ public class ProfileListViewModel : RoutableScreen<ActivatableViewModelBase, Wor
private int _totalPages = 1;
private int _entriesPerPage = 10;
public ProfileListViewModel(IWorkshopClient workshopClient, IRouter router, CategoriesViewModel categoriesViewModel, INotificationService notificationService)
public ProfileListViewModel(IWorkshopClient workshopClient,
IRouter router,
CategoriesViewModel categoriesViewModel,
INotificationService notificationService,
Func<IGetEntries_Entries_Items, EntryListViewModel> 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<ActivatableViewModelBase, Wor
public bool ShowPagination => _showPagination.Value;
public bool IsLoading => _isLoading.Value;
public CategoriesViewModel CategoriesViewModel { get; }
public List<EntryListViewModel>? Entries
@ -108,10 +113,10 @@ public class ProfileListViewModel : RoutableScreen<ActivatableViewModelBase, Wor
EntryFilterInput filter = GetFilter();
IOperationResult<IGetEntriesResult> 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

View File

@ -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;
/// <inheritdoc />
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

View File

@ -44,6 +44,7 @@ public static class ContainerExtensions
container.Register<IAuthenticationRepository, AuthenticationRepository>(Reuse.Singleton);
container.Register<IAuthenticationService, AuthenticationService>(Reuse.Singleton);
container.Register<IWorkshopService, WorkshopService>(Reuse.Singleton);
container.Register<EntryUploadHandlerFactory>(Reuse.Transient);
container.RegisterMany(workshopAssembly, type => type.IsAssignableTo<IEntryUploadHandler>(), Reuse.Transient);

View File

@ -1,5 +1,5 @@
query GetCategories {
categories {
categories(order: {name: ASC}) {
id
name
icon

View File

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

View File

@ -26,6 +26,7 @@ internal class AuthenticationService : CorePropertyChanged, IAuthenticationServi
private readonly BehaviorSubject<bool> _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
}
}
/// <inheritdoc />
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<bool> 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;
}
}

View File

@ -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<Guid, Stream> _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();
});
}
/// <inheritdoc />
public async Task<Bitmap?> 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<ImageUploadResult> SetEntryIcon(Guid entryId, Progress<StreamProgress> 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<Stream> values = _entryIconCache.Values.ToList();
_entryIconCache.Clear();
foreach (Stream bitmap in values)
bitmap.Dispose();
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
}
public interface IWorkshopService
{
Task<Bitmap?> GetEntryIcon(Guid entryId, CancellationToken cancellationToken);
Task<ImageUploadResult> SetEntryIcon(Guid entryId, Progress<StreamProgress> progress, Stream icon, CancellationToken cancellationToken);
}

View File

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