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:
parent
6014bb9e3c
commit
176a28761f
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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 -->
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
query GetCategories {
|
||||
categories {
|
||||
categories(order: {name: ASC}) {
|
||||
id
|
||||
name
|
||||
icon
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
101
src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs
Normal file
101
src/Artemis.WebClient.Workshop/Services/IWorkshopService.cs
Normal 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);
|
||||
}
|
||||
@ -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};
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user