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

Workshop - Added profile uploading

Submission wizard - Final upload steps
This commit is contained in:
Robert 2023-08-13 21:11:05 +02:00
parent e52faf1c9f
commit ad4da3032d
32 changed files with 621 additions and 93 deletions

File diff suppressed because one or more lines are too long

View File

@ -14,7 +14,11 @@ public class BitmapExtensions
public static Bitmap LoadAndResize(Stream stream, int size)
{
using SKBitmap source = SKBitmap.Decode(stream);
stream.Seek(0, SeekOrigin.Begin);
using MemoryStream copy = new();
stream.CopyTo(copy);
copy.Seek(0, SeekOrigin.Begin);
using SKBitmap source = SKBitmap.Decode(copy);
return Resize(source, size);
}

View File

@ -6,8 +6,8 @@
xmlns:workshop="clr-namespace:Artemis.WebClient.Workshop;assembly=Artemis.WebClient.Workshop"
xmlns:converters="clr-namespace:Artemis.UI.Shared.Converters;assembly=Artemis.UI.Shared"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.EntryTypeView"
x:DataType="steps:EntryTypeViewModel">
x:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.EntryTypeStepView"
x:DataType="steps:EntryTypeStepViewModel">
<UserControl.Resources>
<converters:EnumToBooleanConverter x:Key="EnumBoolConverter" />
</UserControl.Resources>

View File

@ -2,9 +2,9 @@ using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps;
public partial class EntryTypeView : ReactiveUserControl<EntryTypeViewModel>
public partial class EntryTypeStepView : ReactiveUserControl<EntryTypeStepViewModel>
{
public EntryTypeView()
public EntryTypeStepView()
{
InitializeComponent();
}

View File

@ -6,12 +6,12 @@ using ReactiveUI;
namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps;
public class EntryTypeViewModel : SubmissionViewModel
public class EntryTypeStepViewModel : SubmissionViewModel
{
private EntryType? _selectedEntryType;
/// <inheritdoc />
public EntryTypeViewModel()
public EntryTypeStepViewModel()
{
GoBack = ReactiveCommand.Create(() => State.ChangeScreen<WelcomeStepViewModel>());
Continue = ReactiveCommand.Create(ExecuteContinue, this.WhenAnyValue(vm => vm.SelectedEntryType).Select(e => e != null));

View File

@ -43,7 +43,7 @@ public class LoginStepViewModel : SubmissionViewModel
Claim? emailVerified = _authenticationService.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.EmailVerified);
if (emailVerified?.Value == "true")
State.ChangeScreen<EntryTypeViewModel>();
State.ChangeScreen<EntryTypeStepViewModel>();
else
State.ChangeScreen<ValidateEmailStepViewModel>();
}

View File

@ -62,6 +62,6 @@ public class ProfileAdaptionHintsStepViewModel : SubmissionViewModel
if (Layers.Any(l => l.AdaptionHintCount == 0))
return;
State.ChangeScreen<EntrySpecificationsStepViewModel>();
State.ChangeScreen<SpecificationsStepViewModel>();
}
}

View File

@ -43,7 +43,7 @@ public class ProfileSelectionStepViewModel : SubmissionViewModel
ProfilePreview = profilePreviewViewModel;
GoBack = ReactiveCommand.Create(() => State.ChangeScreen<EntryTypeViewModel>());
GoBack = ReactiveCommand.Create(() => State.ChangeScreen<EntryTypeStepViewModel>());
Continue = ReactiveCommand.Create(ExecuteContinue, this.WhenAnyValue(vm => vm.SelectedProfile).Select(p => p != null));
this.WhenAnyValue(vm => vm.SelectedProfile).Subscribe(p => Update(p));

View File

@ -7,8 +7,8 @@
xmlns:categories="clr-namespace:Artemis.UI.Screens.Workshop.Categories"
xmlns:tagsInput="clr-namespace:Artemis.UI.Shared.TagsInput;assembly=Artemis.UI.Shared"
mc:Ignorable="d" d:DesignWidth="970" d:DesignHeight="625"
x:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.EntrySpecificationsStepView"
x:DataType="steps:EntrySpecificationsStepViewModel">
x:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.SpecificationsStepView"
x:DataType="steps:SpecificationsStepViewModel">
<Grid RowDefinitions="Auto,*">
<StackPanel Grid.Row="0">
<StackPanel.Styles>

View File

@ -5,9 +5,9 @@ using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps;
public partial class EntrySpecificationsStepView : ReactiveUserControl<EntrySpecificationsStepViewModel>
public partial class SpecificationsStepView : ReactiveUserControl<SpecificationsStepViewModel>
{
public EntrySpecificationsStepView()
public SpecificationsStepView()
{
InitializeComponent();
}

View File

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Reactive;
@ -13,7 +12,6 @@ using Artemis.UI.Screens.Workshop.Categories;
using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Profile;
using Artemis.UI.Shared.Services;
using Artemis.WebClient.Workshop;
using Artemis.WebClient.Workshop.Extensions;
using Avalonia.Threading;
using DynamicData;
using DynamicData.Aggregation;
@ -26,18 +24,17 @@ using Bitmap = Avalonia.Media.Imaging.Bitmap;
namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps;
public class EntrySpecificationsStepViewModel : SubmissionViewModel
public class SpecificationsStepViewModel : SubmissionViewModel
{
private readonly IWindowService _windowService;
private ObservableAsPropertyHelper<bool>? _categoriesValid;
private ObservableAsPropertyHelper<bool>? _iconValid;
private string _description = string.Empty;
private Bitmap? _iconBitmap;
private bool _isDirty;
private string _name = string.Empty;
private string _summary = string.Empty;
private Bitmap? _iconBitmap;
public EntrySpecificationsStepViewModel(IWorkshopClient workshopClient, IWindowService windowService)
public SpecificationsStepViewModel(IWorkshopClient workshopClient, IWindowService windowService)
{
_windowService = windowService;
GoBack = ReactiveCommand.Create(ExecuteGoBack);
@ -45,47 +42,18 @@ public class EntrySpecificationsStepViewModel : SubmissionViewModel
SelectIcon = ReactiveCommand.CreateFromTask(ExecuteSelectIcon);
ClearIcon = ReactiveCommand.Create(ExecuteClearIcon);
workshopClient.GetCategories
.Watch(ExecutionStrategy.CacheFirst)
.SelectOperationResult(c => c.Categories)
.ToObservableChangeSet(c => c.Id)
.Transform(c => new CategoryViewModel(c))
.Bind(out ReadOnlyObservableCollection<CategoryViewModel> categoryViewModels)
.Subscribe();
Categories = categoryViewModels;
this.WhenActivated(d =>
{
DisplayName = $"{State.EntryType} Information";
// Basic fields
Name = State.Name;
Summary = State.Summary;
Description = State.Description;
// Categories
foreach (CategoryViewModel categoryViewModel in Categories)
categoryViewModel.IsSelected = State.Categories.Contains(categoryViewModel.Id);
// Load categories
Observable.FromAsync(workshopClient.GetCategories.ExecuteAsync).Subscribe(PopulateCategories).DisposeWith(d);
// Tags
Tags.Clear();
Tags.AddRange(State.Tags);
// Apply the state
ApplyFromState();
// Icon
if (State.Icon != null)
{
State.Icon.Seek(0, SeekOrigin.Begin);
IconBitmap = BitmapExtensions.LoadAndResize(State.Icon, 128);
}
IsDirty = false;
this.ClearValidationRules();
Disposable.Create(() =>
{
IconBitmap?.Dispose();
IconBitmap = null;
}).DisposeWith(d);
Disposable.Create(ExecuteClearIcon).DisposeWith(d);
});
}
@ -94,7 +62,7 @@ public class EntrySpecificationsStepViewModel : SubmissionViewModel
public ReactiveCommand<Unit, Unit> SelectIcon { get; }
public ReactiveCommand<Unit, Unit> ClearIcon { get; }
public ReadOnlyObservableCollection<CategoryViewModel> Categories { get; }
public ObservableCollection<CategoryViewModel> Categories { get; } = new();
public ObservableCollection<string> Tags { get; } = new();
public bool CategoriesValid => _categoriesValid?.Value ?? true;
public bool IconValid => _iconValid?.Value ?? true;
@ -117,12 +85,6 @@ public class EntrySpecificationsStepViewModel : SubmissionViewModel
set => RaiseAndSetIfChanged(ref _description, value);
}
public bool IsDirty
{
get => _isDirty;
set => RaiseAndSetIfChanged(ref _isDirty, value);
}
public Bitmap? IconBitmap
{
get => _iconBitmap;
@ -131,6 +93,9 @@ public class EntrySpecificationsStepViewModel : SubmissionViewModel
private void ExecuteGoBack()
{
// Apply what's there so far
ApplyToState();
switch (State.EntryType)
{
case EntryType.Layout:
@ -147,35 +112,20 @@ public class EntrySpecificationsStepViewModel : SubmissionViewModel
private void ExecuteContinue()
{
if (!IsDirty)
if (!ValidationContext.Validations.Any())
{
SetupDataValidation();
IsDirty = true;
// The ValidationContext seems to update asynchronously, so stop and schedule a retry
SetupDataValidation();
Dispatcher.UIThread.Post(ExecuteContinue);
return;
}
ApplyToState();
if (!ValidationContext.GetIsValid())
return;
State.Name = Name;
State.Summary = Summary;
State.Description = Description;
State.Categories = Categories.Where(c => c.IsSelected).Select(c => c.Id).ToList();
State.Tags = new List<string>(Tags);
State.Icon?.Dispose();
if (IconBitmap != null)
{
State.Icon = new MemoryStream();
IconBitmap.Save(State.Icon);
}
else
{
State.Icon = null;
}
State.ChangeScreen<SubmitStepViewModel>();
}
private async Task ExecuteSelectIcon()
@ -197,6 +147,13 @@ public class EntrySpecificationsStepViewModel : SubmissionViewModel
IconBitmap = null;
}
private void PopulateCategories(IOperationResult<IGetCategoriesResult> result)
{
Categories.Clear();
if (result.Data != null)
Categories.AddRange(result.Data.Categories.Select(c => new CategoryViewModel(c) {IsSelected = State.Categories.Contains(c.Id)}));
}
private void SetupDataValidation()
{
// Hopefully this can be avoided in the future
@ -204,17 +161,56 @@ public class EntrySpecificationsStepViewModel : SubmissionViewModel
this.ValidationRule(vm => vm.Name, s => !string.IsNullOrWhiteSpace(s), "Name is required");
this.ValidationRule(vm => vm.Summary, s => !string.IsNullOrWhiteSpace(s), "Summary is required");
this.ValidationRule(vm => vm.Description, s => !string.IsNullOrWhiteSpace(s), "Description is required");
// These don't use inputs that support validation messages, do so manually
ValidationHelper iconRule = this.ValidationRule(vm => vm.IconBitmap, s => s != null, "Icon required");
ValidationHelper categoriesRule = this.ValidationRule(vm => vm.Categories, Categories.ToObservableChangeSet()
.AutoRefresh(c => c.IsSelected)
.Filter(c => c.IsSelected)
.IsEmpty()
.CombineLatest(this.WhenAnyValue(vm => vm.IsDirty), (empty, dirty) => !dirty || !empty),
ValidationHelper categoriesRule = this.ValidationRule(vm => vm.Categories, Categories.ToObservableChangeSet().AutoRefresh(c => c.IsSelected).Filter(c => c.IsSelected).IsNotEmpty(),
"At least one category must be selected"
);
_iconValid = iconRule.ValidationChanged.Select(c => c.IsValid).ToProperty(this, vm => vm.IconValid);
_categoriesValid = categoriesRule.ValidationChanged.Select(c => c.IsValid).ToProperty(this, vm => vm.CategoriesValid);
}
private void ApplyFromState()
{
// Basic fields
Name = State.Name;
Summary = State.Summary;
Description = State.Description;
// Tags
Tags.Clear();
Tags.AddRange(State.Tags);
// Icon
if (State.Icon != null)
{
State.Icon.Seek(0, SeekOrigin.Begin);
IconBitmap = BitmapExtensions.LoadAndResize(State.Icon, 128);
}
}
private void ApplyToState()
{
// Basic fields
State.Name = Name;
State.Summary = Summary;
State.Description = Description;
// Categories and tasks
State.Categories = Categories.Where(c => c.IsSelected).Select(c => c.Id).ToList();
State.Tags = new List<string>(Tags);
// Icon
State.Icon?.Dispose();
if (IconBitmap != null)
{
State.Icon = new MemoryStream();
IconBitmap.Save(State.Icon);
}
else
{
State.Icon = null;
}
}
}

View File

@ -0,0 +1,71 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:steps="clr-namespace:Artemis.UI.Screens.Workshop.SubmissionWizard.Steps"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.SubmitStepView"
x:DataType="steps:SubmitStepViewModel">
<StackPanel>
<StackPanel.Styles>
<Styles>
<Style Selector="TextBlock">
<Setter Property="TextWrapping" Value="Wrap"></Setter>
</Style>
</Styles>
</StackPanel.Styles>
<TextBlock Theme="{StaticResource TitleTextBlockStyle}">
Ready to submit?
</TextBlock>
<TextBlock>
We have all the information we need, are you ready to submit the following to the workshop?
</TextBlock>
<Border Classes="card" Margin="0 15 0 0" >
<Grid ColumnDefinitions="Auto,*">
<!-- Icon -->
<Border Grid.Column="0"
CornerRadius="12"
Background="{StaticResource ControlStrokeColorOnAccentDefault}"
VerticalAlignment="Center"
Margin="0 0 10 0"
Width="80"
Height="80"
ClipToBounds="True">
<Image Source="{CompiledBinding IconBitmap}" VerticalAlignment="Stretch" HorizontalAlignment="Stretch"></Image>
</Border>
<!-- Body -->
<Grid Grid.Column="1" VerticalAlignment="Stretch" RowDefinitions="Auto,*,Auto">
<TextBlock Grid.Row="0" Margin="0 0 0 5" TextTrimming="CharacterEllipsis" >
<Run Classes="h5" Text="{CompiledBinding State.Name, FallbackValue=Title}" />
<Run Classes="subtitle">by</Run>
<Run Classes="subtitle" Text="{CompiledBinding CurrentUser, FallbackValue=Author}" />
</TextBlock>
<TextBlock Grid.Row="1"
Classes="subtitle"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
Text="{CompiledBinding State.Summary, FallbackValue=Summary}"></TextBlock>
<ItemsControl Grid.Row="2" ItemsSource="{CompiledBinding Categories}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="8"></StackPanel>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<avalonia:MaterialIcon Kind="{CompiledBinding Icon}" Margin="0 0 3 0"></avalonia:MaterialIcon>
<TextBlock Text="{CompiledBinding Name}" TextTrimming="CharacterEllipsis" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</Grid>
</Border>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,19 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps;
public partial class SubmitStepView : ReactiveUserControl<SubmitStepViewModel>
{
public SubmitStepView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View File

@ -0,0 +1,73 @@
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using Artemis.UI.Screens.Workshop.Categories;
using Artemis.WebClient.Workshop;
using Artemis.WebClient.Workshop.Services;
using IdentityModel;
using ReactiveUI;
using StrawberryShake;
using System;
using System.IO;
using Avalonia.Media.Imaging;
namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps;
public class SubmitStepViewModel : SubmissionViewModel
{
private ReadOnlyObservableCollection<CategoryViewModel>? _categories;
private Bitmap? _iconBitmap;
/// <inheritdoc />
public SubmitStepViewModel(IAuthenticationService authenticationService, IWorkshopClient workshopClient)
{
CurrentUser = authenticationService.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Name)?.Value;
GoBack = ReactiveCommand.Create(() => State.ChangeScreen<SpecificationsStepViewModel>());
Continue = ReactiveCommand.Create(() => State.ChangeScreen<UploadStepViewModel>());
ContinueText = "Submit";
this.WhenActivated(d =>
{
if (State.Icon != null)
{
State.Icon.Seek(0, SeekOrigin.Begin);
IconBitmap = new Bitmap(State.Icon);
IconBitmap.DisposeWith(d);
}
Observable.FromAsync(workshopClient.GetCategories.ExecuteAsync).Subscribe(PopulateCategories).DisposeWith(d);
});
}
public Bitmap? IconBitmap
{
get => _iconBitmap;
set => RaiseAndSetIfChanged(ref _iconBitmap, value);
}
public string? CurrentUser { get; }
public ReadOnlyObservableCollection<CategoryViewModel>? Categories
{
get => _categories;
set => RaiseAndSetIfChanged(ref _categories, value);
}
public override ReactiveCommand<Unit, Unit> Continue { get; }
public override ReactiveCommand<Unit, Unit> GoBack { get; }
private void PopulateCategories(IOperationResult<IGetCategoriesResult> result)
{
if (result.Data == null)
Categories = null;
else
{
Categories = new ReadOnlyObservableCollection<CategoryViewModel>(
new ObservableCollection<CategoryViewModel>(result.Data.Categories.Where(c => State.Categories.Contains(c.Id)).Select(c => new CategoryViewModel(c)))
);
}
}
}

View File

@ -0,0 +1,31 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:steps="clr-namespace:Artemis.UI.Screens.Workshop.SubmissionWizard.Steps"
mc:Ignorable="d" d:DesignWidth="970" d:DesignHeight="900"
x:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.UploadStepView"
x:DataType="steps:UploadStepViewModel">
<StackPanel Margin="0 50 0 0" >
<StackPanel.Styles>
<Styles>
<Style Selector="TextBlock">
<Setter Property="TextAlignment" Value="Center"></Setter>
<Setter Property="TextWrapping" Value="Wrap"></Setter>
</Style>
</Styles>
</StackPanel.Styles>
<TextBlock Theme="{StaticResource TitleTextBlockStyle}" TextAlignment="Center" TextWrapping="Wrap">
Uploading your submission...
</TextBlock>
<TextBlock TextAlignment="Center" TextWrapping="Wrap">
Wooo, the final step, that was pretty easy, right!?
</TextBlock>
<StackPanel IsVisible="{CompiledBinding Finished}" Margin="0 100 0 0">
<Lottie Path="/Assets/Animations/success.json" RepeatCount="1" Width="300" Height="300"></Lottie>
<TextBlock Theme="{StaticResource TitleTextBlockStyle}">All done! Hit finish to view your submission.</TextBlock>
</StackPanel>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,19 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps;
public partial class UploadStepView : ReactiveUserControl<UploadStepViewModel>
{
public UploadStepView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View File

@ -0,0 +1,101 @@
using System;
using System.Reactive;
using System.Reactive.Linq;
using System.Threading;
using System.Threading.Tasks;
using Artemis.UI.Shared.Services;
using Artemis.WebClient.Workshop;
using Artemis.WebClient.Workshop.UploadHandlers;
using ReactiveUI;
using StrawberryShake;
using System.Reactive.Disposables;
using Artemis.Core;
namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps;
public class UploadStepViewModel : SubmissionViewModel
{
private readonly IWorkshopClient _workshopClient;
private readonly EntryUploadHandlerFactory _entryUploadHandlerFactory;
private readonly IWindowService _windowService;
private bool _finished;
/// <inheritdoc />
public UploadStepViewModel(IWorkshopClient workshopClient, EntryUploadHandlerFactory entryUploadHandlerFactory, IWindowService windowService)
{
_workshopClient = workshopClient;
_entryUploadHandlerFactory = entryUploadHandlerFactory;
_windowService = windowService;
ShowGoBack = false;
ContinueText = "Finish";
Continue = ReactiveCommand.Create(ExecuteContinue, this.WhenAnyValue(vm => vm.Finished));
this.WhenActivated(d => Observable.FromAsync(ExecuteUpload).Subscribe().DisposeWith(d));
}
/// <inheritdoc />
public override ReactiveCommand<Unit, Unit> Continue { get; }
/// <inheritdoc />
public override ReactiveCommand<Unit, Unit> GoBack { get; } = null!;
public bool Finished
{
get => _finished;
set => RaiseAndSetIfChanged(ref _finished, value);
}
public async Task ExecuteUpload(CancellationToken cancellationToken)
{
IOperationResult<IAddEntryResult> result = await _workshopClient.AddEntry.ExecuteAsync(new CreateEntryInput
{
EntryType = State.EntryType,
Name = State.Name,
Summary = State.Summary,
Description = State.Description,
Categories = State.Categories,
Tags = State.Tags
}, cancellationToken);
Guid? entryId = result.Data?.AddEntry?.Id;
if (result.IsErrorResult() || entryId == null)
{
await _windowService.ShowConfirmContentDialog("Failed to create workshop entry", result.Errors.ToString() ?? "Not even an error message", "Close", null);
return;
}
if (cancellationToken.IsCancellationRequested)
return;
// Create the workshop entry
try
{
IEntryUploadHandler uploadHandler = _entryUploadHandlerFactory.CreateHandler(State.EntryType);
EntryUploadResult uploadResult = await uploadHandler.CreateReleaseAsync(entryId.Value, State.EntrySource!, cancellationToken);
if (!uploadResult.IsSuccess)
{
string? message = uploadResult.Message;
if (message != null)
message += "\r\n\r\n";
else
message = "";
message += "Your submission has still been saved, you may try to upload a new release";
await _windowService.ShowConfirmContentDialog("Failed to upload workshop entry", message, "Close", null);
return;
}
Finished = true;
}
catch (Exception e)
{
// Something went wrong when creating a release :c
// We'll keep the workshop entry so that the user can make changes and try again
}
}
private void ExecuteContinue()
{
throw new NotImplementedException();
}
}

View File

@ -73,7 +73,7 @@ public class ValidateEmailStepViewModel : SubmissionViewModel
private void ExecuteContinue()
{
State.ChangeScreen<EntryTypeViewModel>();
State.ChangeScreen<EntryTypeStepViewModel>();
}
private async Task ExecuteRefresh(CancellationToken ct)

View File

@ -3,7 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:steps="clr-namespace:Artemis.UI.Screens.Workshop.SubmissionWizard.Steps"
mc:Ignorable="d" d:DesignWidth="970" d:DesignHeight="625"
mc:Ignorable="d" d:DesignWidth="970" d:DesignHeight="900"
x:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.WelcomeStepView"
x:DataType="steps:WelcomeStepViewModel">
<StackPanel Margin="0 50 0 0" >
@ -14,6 +14,6 @@
Here we'll take you, step by step, through the process of uploading your submission to the workshop.
</TextBlock>
<Lottie Path="/Assets/Animations/workshop-wizard.json" RepeatCount="1" Width="300" Height="400"></Lottie>
<Lottie Path="/Assets/Animations/workshop-wizard.json" RepeatCount="1" Width="500" Height="700"></Lottie>
</StackPanel>
</UserControl>

View File

@ -38,7 +38,7 @@ public class WelcomeStepViewModel : SubmissionViewModel
else
{
if (_authenticationService.Claims.Any(c => c.Type == JwtClaimTypes.EmailVerified && c.Value == "true"))
State.ChangeScreen<EntryTypeViewModel>();
State.ChangeScreen<EntryTypeStepViewModel>();
else
State.ChangeScreen<ValidateEmailStepViewModel>();
}

View File

@ -12,7 +12,7 @@
Icon="/Assets/Images/Logo/application.ico"
Title="Artemis | Workshop submission wizard"
Width="1000"
Height="735"
Height="950"
WindowStartupLocation="CenterOwner">
<Grid Margin="15" RowDefinitions="Auto,*,Auto">
<Grid RowDefinitions="*,*" ColumnDefinitions="Auto,*,Auto" Margin="0 0 0 15">

View File

@ -1,5 +1,9 @@
using System.Reflection;
using Artemis.WebClient.Workshop.Extensions;
using Artemis.WebClient.Workshop.Repositories;
using Artemis.WebClient.Workshop.Services;
using Artemis.WebClient.Workshop.State;
using Artemis.WebClient.Workshop.UploadHandlers;
using DryIoc;
using DryIoc.Microsoft.DependencyInjection;
using IdentityModel.Client;
@ -18,11 +22,17 @@ public static class ContainerExtensions
/// <param name="container">The builder building the current container</param>
public static void RegisterWorkshopClient(this IContainer container)
{
Assembly[] workshopAssembly = {typeof(WorkshopConstants).Assembly};
ServiceCollection serviceCollection = new();
serviceCollection
.AddHttpClient()
.AddWorkshopClient()
.AddHttpMessageHandler<WorkshopClientStoreAccessor, AuthenticationDelegatingHandler>()
.ConfigureHttpClient(client => client.BaseAddress = new Uri(WorkshopConstants.WORKSHOP_URL + "/graphql"));
serviceCollection.AddHttpClient(WorkshopConstants.WORKSHOP_CLIENT_NAME)
.AddHttpMessageHandler<AuthenticationDelegatingHandler>()
.ConfigureHttpClient(client => client.BaseAddress = new Uri(WorkshopConstants.WORKSHOP_URL));
serviceCollection.AddSingleton<IDiscoveryCache>(r =>
{
@ -34,5 +44,8 @@ public static class ContainerExtensions
container.Register<IAuthenticationRepository, AuthenticationRepository>(Reuse.Singleton);
container.Register<IAuthenticationService, AuthenticationService>(Reuse.Singleton);
container.Register<EntryUploadHandlerFactory>(Reuse.Transient);
container.RegisterMany(workshopAssembly, type => type.IsAssignableTo<IEntryUploadHandler>(), Reuse.Transient);
}
}

View File

@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace Artemis.Web.Workshop.Entities;
public class Release
{
public Guid Id { get; set; }
[MaxLength(64)]
public string Version { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public long Downloads { get; set; }
public long DownloadSize { get; set; }
[MaxLength(32)]
public string? Md5Hash { get; set; }
public Guid EntryId { get; set; }
}

View File

@ -0,0 +1,18 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using StrawberryShake;
namespace Artemis.WebClient.Workshop.Extensions;
public static class ClientBuilderExtensions
{
public static IClientBuilder<T> AddHttpMessageHandler<T, THandler>(this IClientBuilder<T> builder) where THandler : DelegatingHandler where T : IStoreAccessor
{
builder.Services.Configure<HttpClientFactoryOptions>(
builder.ClientName,
options => options.HttpMessageHandlerBuilderActions.Add(b => b.AdditionalHandlers.Add(b.Services.GetRequiredService<THandler>()))
);
return builder;
}
}

View File

@ -0,0 +1,5 @@
mutation AddEntry ($input: CreateEntryInput!) {
addEntry(input: $input) {
id
}
}

View File

@ -0,0 +1,22 @@
using System.Net.Http.Headers;
namespace Artemis.WebClient.Workshop.Services;
public class AuthenticationDelegatingHandler : DelegatingHandler
{
private readonly IAuthenticationService _authenticationService;
/// <inheritdoc />
public AuthenticationDelegatingHandler(IAuthenticationService authenticationService)
{
_authenticationService = authenticationService;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string? token = await _authenticationService.GetBearer();
if (token != null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await base.SendAsync(request, cancellationToken);
}
}

View File

@ -0,0 +1,24 @@
using Artemis.WebClient.Workshop.UploadHandlers.Implementations;
using DryIoc;
namespace Artemis.WebClient.Workshop.UploadHandlers;
public class EntryUploadHandlerFactory
{
private readonly IContainer _container;
public EntryUploadHandlerFactory(IContainer container)
{
_container = container;
}
public IEntryUploadHandler CreateHandler(EntryType entryType)
{
return entryType switch
{
EntryType.Profile => _container.Resolve<ProfileEntryUploadHandler>(),
EntryType.Layout => _container.Resolve<LayoutEntryUploadHandler>(),
_ => throw new NotSupportedException($"EntryType '{entryType}' is not supported.")
};
}
}

View File

@ -0,0 +1,28 @@
using Artemis.Web.Workshop.Entities;
namespace Artemis.WebClient.Workshop.UploadHandlers;
public class EntryUploadResult
{
public bool IsSuccess { get; set; }
public string? Message { get; set; }
public Release? Release { get; set; }
public static EntryUploadResult FromFailure(string? message)
{
return new EntryUploadResult
{
IsSuccess = false,
Message = message
};
}
public static EntryUploadResult FromSuccess(Release release)
{
return new EntryUploadResult
{
IsSuccess = true,
Release = release
};
}
}

View File

@ -0,0 +1,7 @@

namespace Artemis.WebClient.Workshop.UploadHandlers;
public interface IEntryUploadHandler
{
Task<EntryUploadResult> CreateReleaseAsync(Guid entryId, object file, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,12 @@
using RGB.NET.Layout;
namespace Artemis.WebClient.Workshop.UploadHandlers.Implementations;
public class LayoutEntryUploadHandler : IEntryUploadHandler
{
/// <inheritdoc />
public async Task<EntryUploadResult> CreateReleaseAsync(Guid entryId, object file, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
}

View File

@ -0,0 +1,61 @@
using System.IO.Compression;
using System.Net.Http.Headers;
using System.Text;
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.Web.Workshop.Entities;
using Newtonsoft.Json;
namespace Artemis.WebClient.Workshop.UploadHandlers.Implementations;
public class ProfileEntryUploadHandler : IEntryUploadHandler
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IProfileService _profileService;
public ProfileEntryUploadHandler(IHttpClientFactory httpClientFactory, IProfileService profileService)
{
_httpClientFactory = httpClientFactory;
_profileService = profileService;
}
/// <inheritdoc />
public async Task<EntryUploadResult> CreateReleaseAsync(Guid entryId, object file, CancellationToken cancellationToken)
{
if (file is not ProfileConfiguration profileConfiguration)
throw new InvalidOperationException("Can only create releases for profile configurations");
ProfileConfigurationExportModel export = _profileService.ExportProfile(profileConfiguration);
string json = JsonConvert.SerializeObject(export, IProfileService.ExportSettings);
using MemoryStream archiveStream = new();
// Create a ZIP archive with a single entry on the archive stream
using (ZipArchive archive = new(archiveStream, ZipArchiveMode.Create, true))
{
ZipArchiveEntry entry = archive.CreateEntry("profile.json");
await using (Stream entryStream = entry.Open())
{
await entryStream.WriteAsync(Encoding.Default.GetBytes(json), cancellationToken);
}
}
// Submit the archive
HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME);
// Construct the request
archiveStream.Seek(0, SeekOrigin.Begin);
MultipartFormDataContent content = new();
StreamContent streamContent = new(archiveStream);
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
content.Add(streamContent, "file", "file.zip");
// Submit
HttpResponseMessage response = await client.PostAsync("releases/upload/" + entryId, content, cancellationToken);
if (!response.IsSuccessStatusCode)
return EntryUploadResult.FromFailure($"{response.StatusCode} - {await response.Content.ReadAsStringAsync(cancellationToken)}");
Release? release = JsonConvert.DeserializeObject<Release>(await response.Content.ReadAsStringAsync(cancellationToken));
return release != null ? EntryUploadResult.FromSuccess(release) : EntryUploadResult.FromFailure("Failed to deserialize response");
}
}

View File

@ -4,4 +4,5 @@ public static class WorkshopConstants
{
public const string AUTHORITY_URL = "https://identity.artemis-rgb.com";
public const string WORKSHOP_URL = "https://workshop.artemis-rgb.com";
public const string WORKSHOP_CLIENT_NAME = "WorkshopApiClient";
}