1
0
mirror of https://github.com/Artemis-RGB/Artemis synced 2025-12-13 05:48:35 +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) 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); return Resize(source, size);
} }

View File

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

View File

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

View File

@ -6,12 +6,12 @@ using ReactiveUI;
namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps;
public class EntryTypeViewModel : SubmissionViewModel public class EntryTypeStepViewModel : SubmissionViewModel
{ {
private EntryType? _selectedEntryType; private EntryType? _selectedEntryType;
/// <inheritdoc /> /// <inheritdoc />
public EntryTypeViewModel() public EntryTypeStepViewModel()
{ {
GoBack = ReactiveCommand.Create(() => State.ChangeScreen<WelcomeStepViewModel>()); GoBack = ReactiveCommand.Create(() => State.ChangeScreen<WelcomeStepViewModel>());
Continue = ReactiveCommand.Create(ExecuteContinue, this.WhenAnyValue(vm => vm.SelectedEntryType).Select(e => e != null)); 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); Claim? emailVerified = _authenticationService.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.EmailVerified);
if (emailVerified?.Value == "true") if (emailVerified?.Value == "true")
State.ChangeScreen<EntryTypeViewModel>(); State.ChangeScreen<EntryTypeStepViewModel>();
else else
State.ChangeScreen<ValidateEmailStepViewModel>(); State.ChangeScreen<ValidateEmailStepViewModel>();
} }

View File

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

View File

@ -43,7 +43,7 @@ public class ProfileSelectionStepViewModel : SubmissionViewModel
ProfilePreview = profilePreviewViewModel; 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)); Continue = ReactiveCommand.Create(ExecuteContinue, this.WhenAnyValue(vm => vm.SelectedProfile).Select(p => p != null));
this.WhenAnyValue(vm => vm.SelectedProfile).Subscribe(p => Update(p)); 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:categories="clr-namespace:Artemis.UI.Screens.Workshop.Categories"
xmlns:tagsInput="clr-namespace:Artemis.UI.Shared.TagsInput;assembly=Artemis.UI.Shared" xmlns:tagsInput="clr-namespace:Artemis.UI.Shared.TagsInput;assembly=Artemis.UI.Shared"
mc:Ignorable="d" d:DesignWidth="970" d:DesignHeight="625" mc:Ignorable="d" d:DesignWidth="970" d:DesignHeight="625"
x:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.EntrySpecificationsStepView" x:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.SpecificationsStepView"
x:DataType="steps:EntrySpecificationsStepViewModel"> x:DataType="steps:SpecificationsStepViewModel">
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,*">
<StackPanel Grid.Row="0"> <StackPanel Grid.Row="0">
<StackPanel.Styles> <StackPanel.Styles>

View File

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

View File

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

View File

@ -3,7 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:steps="clr-namespace:Artemis.UI.Screens.Workshop.SubmissionWizard.Steps" 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:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.WelcomeStepView"
x:DataType="steps:WelcomeStepViewModel"> x:DataType="steps:WelcomeStepViewModel">
<StackPanel Margin="0 50 0 0" > <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. Here we'll take you, step by step, through the process of uploading your submission to the workshop.
</TextBlock> </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> </StackPanel>
</UserControl> </UserControl>

View File

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

View File

@ -12,7 +12,7 @@
Icon="/Assets/Images/Logo/application.ico" Icon="/Assets/Images/Logo/application.ico"
Title="Artemis | Workshop submission wizard" Title="Artemis | Workshop submission wizard"
Width="1000" Width="1000"
Height="735" Height="950"
WindowStartupLocation="CenterOwner"> WindowStartupLocation="CenterOwner">
<Grid Margin="15" RowDefinitions="Auto,*,Auto"> <Grid Margin="15" RowDefinitions="Auto,*,Auto">
<Grid RowDefinitions="*,*" ColumnDefinitions="Auto,*,Auto" Margin="0 0 0 15"> <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.Repositories;
using Artemis.WebClient.Workshop.Services; using Artemis.WebClient.Workshop.Services;
using Artemis.WebClient.Workshop.State;
using Artemis.WebClient.Workshop.UploadHandlers;
using DryIoc; using DryIoc;
using DryIoc.Microsoft.DependencyInjection; using DryIoc.Microsoft.DependencyInjection;
using IdentityModel.Client; using IdentityModel.Client;
@ -18,11 +22,17 @@ public static class ContainerExtensions
/// <param name="container">The builder building the current container</param> /// <param name="container">The builder building the current container</param>
public static void RegisterWorkshopClient(this IContainer container) public static void RegisterWorkshopClient(this IContainer container)
{ {
Assembly[] workshopAssembly = {typeof(WorkshopConstants).Assembly};
ServiceCollection serviceCollection = new(); ServiceCollection serviceCollection = new();
serviceCollection serviceCollection
.AddHttpClient() .AddHttpClient()
.AddWorkshopClient() .AddWorkshopClient()
.AddHttpMessageHandler<WorkshopClientStoreAccessor, AuthenticationDelegatingHandler>()
.ConfigureHttpClient(client => client.BaseAddress = new Uri(WorkshopConstants.WORKSHOP_URL + "/graphql")); .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 => serviceCollection.AddSingleton<IDiscoveryCache>(r =>
{ {
@ -34,5 +44,8 @@ public static class ContainerExtensions
container.Register<IAuthenticationRepository, AuthenticationRepository>(Reuse.Singleton); container.Register<IAuthenticationRepository, AuthenticationRepository>(Reuse.Singleton);
container.Register<IAuthenticationService, AuthenticationService>(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 AUTHORITY_URL = "https://identity.artemis-rgb.com";
public const string WORKSHOP_URL = "https://workshop.artemis-rgb.com"; public const string WORKSHOP_URL = "https://workshop.artemis-rgb.com";
public const string WORKSHOP_CLIENT_NAME = "WorkshopApiClient";
} }