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

Router - Added reload

Workshop - Added image management to existing entries
Workshop - Fixed pagination in layouts page
This commit is contained in:
RobertBeekman 2024-01-28 22:43:20 +01:00 committed by Robert
parent 19082481c3
commit 78b96deeab
25 changed files with 328 additions and 100 deletions

View File

@ -19,14 +19,16 @@ public static class ArtemisLayoutExtensions
/// Renders the layout to a bitmap.
/// </summary>
/// <param name="layout">The layout to render</param>
/// <param name="previewLeds">A value indicating whether or not to draw LEDs on the image.</param>
/// <param name="scale">The scale at which to draw the layout.</param>
/// <returns>The resulting bitmap.</returns>
public static RenderTargetBitmap RenderLayout(this ArtemisLayout layout, bool previewLeds)
public static RenderTargetBitmap RenderLayout(this ArtemisLayout layout, bool previewLeds, int scale = 2)
{
string? path = layout.Image?.LocalPath;
// Create a bitmap that'll be used to render the device and LED images just once
// Render 4 times the actual size of the device to make sure things look sharp when zoomed in
RenderTargetBitmap renderTargetBitmap = new(new PixelSize((int) layout.RgbLayout.Width * 2, (int) layout.RgbLayout.Height * 2));
RenderTargetBitmap renderTargetBitmap = new(new PixelSize((int) layout.RgbLayout.Width * scale, (int) layout.RgbLayout.Height * scale));
using DrawingContext context = renderTargetBitmap.CreateDrawingContext();
@ -45,8 +47,8 @@ public static class ArtemisLayoutExtensions
if (ledPath == null || !File.Exists(ledPath))
continue;
using Bitmap bitmap = new(ledPath);
using Bitmap scaledBitmap = bitmap.CreateScaledBitmap(new PixelSize((led.RgbLayout.Width * 2).RoundToInt(), (led.RgbLayout.Height * 2).RoundToInt()));
context.DrawImage(scaledBitmap, new Rect(led.RgbLayout.X * 2, led.RgbLayout.Y * 2, scaledBitmap.Size.Width, scaledBitmap.Size.Height));
using Bitmap scaledBitmap = bitmap.CreateScaledBitmap(new PixelSize((led.RgbLayout.Width * scale).RoundToInt(), (led.RgbLayout.Height * scale).RoundToInt()));
context.DrawImage(scaledBitmap, new Rect(led.RgbLayout.X * scale, led.RgbLayout.Y * scale, scaledBitmap.Size.Width, scaledBitmap.Size.Height));
}
if (!previewLeds)
@ -55,14 +57,14 @@ public static class ArtemisLayoutExtensions
// Draw LED geometry using a rainbow gradient
ColorGradient colors = ColorGradient.GetUnicornBarf();
colors.ToggleSeamless();
context.PushTransform(Matrix.CreateScale(2, 2));
context.PushTransform(Matrix.CreateScale(scale, scale));
foreach (ArtemisLedLayout led in layout.Leds)
{
Geometry? geometry = CreateLedGeometry(led);
if (geometry == null)
continue;
Color color = colors.GetColor((led.RgbLayout.X + led.RgbLayout.Width / 2) / layout.RgbLayout.Width).ToColor();
Color color = colors.GetColor((led.RgbLayout.X + led.RgbLayout.Width / scale) / layout.RgbLayout.Width).ToColor();
SolidColorBrush fillBrush = new() {Color = color, Opacity = 0.4};
SolidColorBrush penBrush = new() {Color = color};
Pen pen = new(penBrush) {LineJoin = PenLineJoin.Round};

View File

@ -27,6 +27,12 @@ public interface IRouter
/// <param name="options">Optional navigation options used to control navigation behaviour.</param>
/// <returns>A task representing the operation</returns>
Task Navigate(string path, RouterNavigationOptions? options = null);
/// <summary>
/// Asynchronously reloads the current route
/// </summary>
/// <returns>A task representing the operation</returns>
Task Reload();
/// <summary>
/// Asynchronously navigates back to the previous active route.

View File

@ -79,6 +79,19 @@ internal class Router : CorePropertyChanged, IRouter, IDisposable
await Dispatcher.UIThread.InvokeAsync(() => InternalNavigate(path, options));
}
/// <inheritdoc />
public async Task Reload()
{
string path = _currentRouteSubject.Value ?? "blank";
// Routing takes place on the UI thread with processing heavy tasks offloaded by the router itself
await Dispatcher.UIThread.InvokeAsync(async () =>
{
await InternalNavigate("blank", new RouterNavigationOptions {AddToHistory = false, RecycleScreens = false, EnableLogging = false});
await InternalNavigate(path, new RouterNavigationOptions {AddToHistory = false, RecycleScreens = false});
});
}
private async Task InternalNavigate(string path, RouterNavigationOptions options)
{
if (_root == null)

View File

@ -7,16 +7,17 @@
x:Class="Artemis.UI.Screens.Debugger.Routing.RoutingDebugView"
x:DataType="routing:RoutingDebugViewModel">
<Grid RowDefinitions="Auto,Auto,*" ColumnDefinitions="*,Auto">
<Grid RowDefinitions="Auto,Auto,*" ColumnDefinitions="*,Auto,Auto">
<TextBox Grid.Row="0" Grid.Column="0" Watermark="Enter a route to navigate to" Text="{CompiledBinding Route}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{CompiledBinding Navigate}"></KeyBinding>
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Row="0" Grid.Column="1" Margin="5 0 0 0" Command="{CompiledBinding Navigate}">Navigate</Button>
<Button Grid.Row="0" Grid.Column="1" Margin="5 0 0 0" Command="{CompiledBinding Reload}">Reload</Button>
<Button Grid.Row="0" Grid.Column="2" Margin="5 0 0 0" Command="{CompiledBinding Navigate}">Navigate</Button>
<TextBlock Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2" Margin="0 15">Navigation logs</TextBlock>
<ScrollViewer Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2" Name="LogsScrollViewer" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto">
<TextBlock Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" Margin="0 15">Navigation logs</TextBlock>
<ScrollViewer Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" Name="LogsScrollViewer" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto">
<SelectableTextBlock
Inlines="{CompiledBinding Lines}"
FontFamily="{StaticResource RobotoMono}"

View File

@ -29,6 +29,7 @@ public partial class RoutingDebugViewModel : ActivatableViewModelBase
{
_router = router;
DisplayName = "Routing";
Reload = ReactiveCommand.CreateFromTask(ExecutReload);
Navigate = ReactiveCommand.CreateFromTask(ExecuteNavigate, this.WhenAnyValue(vm => vm.Route).Select(r => !string.IsNullOrWhiteSpace(r)));
_formatter = new MessageTemplateTextFormatter(
@ -48,6 +49,7 @@ public partial class RoutingDebugViewModel : ActivatableViewModelBase
}
public InlineCollection Lines { get; } = new();
public ReactiveCommand<Unit, Unit> Reload { get; }
public ReactiveCommand<Unit, Unit> Navigate { get; }
private void OnLogEventAdded(object? sender, LogEventEventArgs e)
@ -87,6 +89,18 @@ public partial class RoutingDebugViewModel : ActivatableViewModelBase
if (Lines.Count > MAX_ENTRIES)
Lines.RemoveRange(0, Lines.Count - MAX_ENTRIES);
}
private async Task ExecutReload(CancellationToken arg)
{
try
{
await _router.Reload();
}
catch (Exception)
{
// ignored
}
}
private async Task ExecuteNavigate(CancellationToken arg)
{

View File

@ -1,10 +1,11 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.Root;
public partial class BlankView : UserControl
public partial class BlankView : ReactiveUserControl<BlankViewModel>
{
public BlankView()
{

View File

@ -12,10 +12,10 @@ public class LayoutListViewModel : List.EntryListViewModel
public LayoutListViewModel(IWorkshopClient workshopClient,
IRouter router,
CategoriesViewModel categoriesViewModel,
List.EntryListInputViewModel entryListInputViewModel,
EntryListInputViewModel entryListInputViewModel,
INotificationService notificationService,
Func<IGetEntries_Entries_Items, EntryListItemViewModel> getEntryListViewModel)
: base("workshop/entries/layout", workshopClient, router, categoriesViewModel, entryListInputViewModel, notificationService, getEntryListViewModel)
: base("workshop/entries/layouts", workshopClient, router, categoriesViewModel, entryListInputViewModel, notificationService, getEntryListViewModel)
{
entryListInputViewModel.SearchWatermark = "Search layouts";
}

View File

@ -10,33 +10,27 @@ namespace Artemis.UI.Screens.Workshop.Image;
public partial class ImagePropertiesDialogViewModel : ContentDialogViewModelBase
{
private readonly ImageUploadRequest _image;
[Notify] private string? _name;
[Notify] private string? _description;
public ImagePropertiesDialogViewModel(ImageUploadRequest image)
public ImagePropertiesDialogViewModel(string name, string description)
{
_image = image;
_name = image.Name;
_description = image.Description;
_name = string.IsNullOrWhiteSpace(name) ? null : name;
_description = string.IsNullOrWhiteSpace(description) ? null : description;
Confirm = ReactiveCommand.Create(ExecuteConfirm, ValidationContext.Valid);
this.ValidationRule(vm => vm.Name, input => !string.IsNullOrWhiteSpace(input), "Name is required");
this.ValidationRule(vm => vm.Name, input => input?.Length <= 50, "Name can be a maximum of 50 characters");
this.ValidationRule(vm => vm.Description, input => input?.Length <= 150, "Description can be a maximum of 150 characters");
this.ValidationRule(vm => vm.Description, input => input == null || input.Length <= 150, "Description can be a maximum of 150 characters");
}
public ReactiveCommand<Unit, Unit> Confirm { get; }
private void ExecuteConfirm()
{
if (string.IsNullOrWhiteSpace(Name))
if (!ValidationContext.IsValid)
return;
_image.Name = Name;
_image.Description = string.IsNullOrWhiteSpace(Description) ? null : Description;
ContentDialog?.Hide(ContentDialogResult.Primary);
}
}

View File

@ -11,7 +11,7 @@
<UserControl.Resources>
<converters:BytesToStringConverter x:Key="BytesToStringConverter" />
</UserControl.Resources>
<Border Classes="card" Padding="0" Width="300" ClipToBounds="True" Margin="5">
<Border Classes="card" Padding="0" ClipToBounds="True">
<Grid RowDefinitions="230,*">
<Rectangle Grid.Row="0" Fill="{DynamicResource CheckerboardBrush}" />
<Image Grid.Row="0"

View File

@ -1,64 +1,97 @@
using System;
using System.IO;
using System.Net.Http;
using System.Reactive.Disposables;
using System.Threading.Tasks;
using System.Windows.Input;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services;
using Artemis.WebClient.Workshop;
using Artemis.WebClient.Workshop.Handlers.UploadHandlers;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
using PropertyChanged.SourceGenerator;
using ReactiveUI;
using ReactiveUI.Validation.Extensions;
using ContentDialogButton = Artemis.UI.Shared.Services.Builders.ContentDialogButton;
namespace Artemis.UI.Screens.Workshop.Image;
public partial class ImageSubmissionViewModel : ValidatableViewModelBase
{
private readonly ImageUploadRequest _image;
private readonly IWindowService _windowService;
[Notify(Setter.Private)] private Bitmap? _bitmap;
[Notify(Setter.Private)] private string? _imageDimensions;
[Notify(Setter.Private)] private long _fileSize;
[Notify] private string? _name;
[Notify] private string? _description;
[Notify] private bool _hasChanges;
[Notify] private ICommand? _remove;
public ImageSubmissionViewModel(ImageUploadRequest image, IWindowService windowService)
public ImageSubmissionViewModel(ImageUploadRequest imageUploadRequest, IWindowService windowService)
{
_image = image;
ImageUploadRequest = imageUploadRequest;
_windowService = windowService;
FileSize = _image.File.Length;
Name = _image.Name;
Description = _image.Description;
FileSize = imageUploadRequest.File.Length;
Name = imageUploadRequest.Name;
Description = imageUploadRequest.Description;
HasChanges = true;
this.WhenActivated(d =>
{
Dispatcher.UIThread.Invoke(() =>
{
_image.File.Seek(0, SeekOrigin.Begin);
Bitmap = new Bitmap(_image.File);
imageUploadRequest.File.Seek(0, SeekOrigin.Begin);
Bitmap = new Bitmap(imageUploadRequest.File);
ImageDimensions = Bitmap.Size.Width + "x" + Bitmap.Size.Height;
Bitmap.DisposeWith(d);
}, DispatcherPriority.Background);
});
}
public ImageSubmissionViewModel(IImage existingImage, IWindowService windowService, IHttpClientFactory httpClientFactory)
{
_windowService = windowService;
Id = existingImage.Id;
Name = existingImage.Name;
Description = existingImage.Description;
// Download the image
this.WhenActivated(d =>
{
Dispatcher.UIThread.Invoke(async () =>
{
HttpClient client = httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME);
byte[] bytes = await client.GetByteArrayAsync($"/images/{existingImage.Id}.png");
MemoryStream stream = new(bytes);
Bitmap = new Bitmap(stream);
FileSize = stream.Length;
ImageDimensions = Bitmap.Size.Width + "x" + Bitmap.Size.Height;
Bitmap.DisposeWith(d);
}, DispatcherPriority.Background);
});
PropertyChanged += (_, args) => HasChanges = HasChanges || args.PropertyName == nameof(Name) || args.PropertyName == nameof(Description);
}
public ImageUploadRequest? ImageUploadRequest { get; }
public Guid? Id { get; }
public async Task<ContentDialogResult> Edit()
{
ContentDialogResult result = await _windowService.CreateContentDialog()
.WithTitle("Edit image properties")
.WithViewModel(out ImagePropertiesDialogViewModel vm, _image)
.WithViewModel(out ImagePropertiesDialogViewModel vm, Name ?? string.Empty, Description ?? string.Empty)
.HavingPrimaryButton(b => b.WithText("Confirm").WithCommand(vm.Confirm))
.WithCloseButtonText("Cancel")
.WithDefaultButton(ContentDialogButton.Primary)
.ShowAsync();
Name = _image.Name;
Description = _image.Description;
Name = vm.Name;
Description = vm.Description;
return result;
}

View File

@ -12,7 +12,7 @@
<UserControl.Resources>
<converters:DateTimeConverter x:Key="DateTimeConverter" />
</UserControl.Resources>
<Grid ColumnDefinitions="300,*" RowDefinitions="*, Auto">
<Grid ColumnDefinitions="300,*,300" RowDefinitions="*, Auto">
<StackPanel Grid.Column="0" Grid.RowSpan="2" Spacing="10">
<Border Classes="card" VerticalAlignment="Top" Margin="0 0 10 0">
<StackPanel>
@ -48,8 +48,35 @@
View workshop page
</controls:HyperlinkButton>
</StackPanel>
<ContentControl Grid.Column="1" Grid.Row="0" Content="{CompiledBinding EntrySpecificationsViewModel}"></ContentControl>
<StackPanel Grid.Column="1" Grid.Row="1" HorizontalAlignment="Right" Spacing="5" Orientation="Horizontal" Margin="0 10 0 0">
<Border Grid.Column="2" Grid.Row="0" Classes="card" Margin="10 0 0 0">
<Grid RowDefinitions="*,Auto">
<ScrollViewer Grid.Row="0" Classes="with-padding" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{CompiledBinding Images}">
<ItemsControl.Styles>
<Styles>
<Style Selector="ItemsControl > ContentPresenter">
<Setter Property="Margin" Value="0 0 0 10"></Setter>
</Style>
<Style Selector="ItemsControl > ContentPresenter:nth-last-child(1)">
<Setter Property="Margin" Value="0 0 0 0"></Setter>
</Style>
</Styles>
</ItemsControl.Styles>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</ScrollViewer>
<Button Grid.Row="1" HorizontalAlignment="Stretch" Command="{CompiledBinding AddImage}">Add image</Button>
</Grid>
</Border>
<StackPanel Grid.Column="0" Grid.ColumnSpan="3" Grid.Row="1" HorizontalAlignment="Right" Spacing="5" Orientation="Horizontal" Margin="0 10 0 0">
<Button Command="{CompiledBinding DiscardChanges}">Discard changes</Button>
<Button Command="{CompiledBinding SaveChanges}">Save</Button>
</StackPanel>

View File

@ -1,23 +1,23 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Reactive;
using System.Threading;
using System.Threading.Tasks;
using Artemis.UI.Screens.Workshop.Entries;
using Artemis.UI.Screens.Workshop.Image;
using Artemis.UI.Screens.Workshop.Parameters;
using Artemis.UI.Screens.Workshop.SubmissionWizard;
using Artemis.UI.Shared.Routing;
using Artemis.UI.Shared.Services;
using Artemis.UI.Shared.Utilities;
using Artemis.WebClient.Workshop;
using Artemis.WebClient.Workshop.Exceptions;
using Artemis.WebClient.Workshop.Handlers.UploadHandlers;
using Artemis.WebClient.Workshop.Services;
using Avalonia.Media.Imaging;
using FluentAvalonia.UI.Controls;
using PropertyChanged.SourceGenerator;
using ReactiveUI;
using StrawberryShake;
@ -28,34 +28,50 @@ namespace Artemis.UI.Screens.Workshop.Library;
public partial class SubmissionDetailViewModel : RoutableScreen<WorkshopDetailParameters>
{
private readonly IWorkshopClient _client;
private readonly Func<EntrySpecificationsViewModel> _getGetSpecificationsVm;
private readonly IRouter _router;
private readonly IWindowService _windowService;
private readonly IWorkshopService _workshopService;
private readonly IRouter _router;
private readonly Func<EntrySpecificationsViewModel> _getGetSpecificationsViewModel;
private readonly Func<IImage, ImageSubmissionViewModel> _getExistingImageSubmissionViewModel;
private readonly Func<ImageUploadRequest, ImageSubmissionViewModel> _getImageSubmissionViewModel;
private readonly List<ImageSubmissionViewModel> _removedImages = new();
[Notify] private IGetSubmittedEntryById_Entry? _entry;
[Notify] private EntrySpecificationsViewModel? _entrySpecificationsViewModel;
[Notify(Setter.Private)] private bool _hasChanges;
public SubmissionDetailViewModel(IWorkshopClient client, IWindowService windowService, IWorkshopService workshopService, IRouter router, Func<EntrySpecificationsViewModel> getSpecificationsVm) {
public SubmissionDetailViewModel(IWorkshopClient client,
IWindowService windowService,
IWorkshopService workshopService,
IRouter router,
Func<EntrySpecificationsViewModel> getSpecificationsViewModel,
Func<IImage, ImageSubmissionViewModel> getExistingImageSubmissionViewModel,
Func<ImageUploadRequest, ImageSubmissionViewModel> getImageSubmissionViewModel)
{
_client = client;
_windowService = windowService;
_workshopService = workshopService;
_router = router;
_getGetSpecificationsVm = getSpecificationsVm;
_getGetSpecificationsViewModel = getSpecificationsViewModel;
_getExistingImageSubmissionViewModel = getExistingImageSubmissionViewModel;
_getImageSubmissionViewModel = getImageSubmissionViewModel;
CreateRelease = ReactiveCommand.CreateFromTask(ExecuteCreateRelease);
DeleteSubmission = ReactiveCommand.CreateFromTask(ExecuteDeleteSubmission);
ViewWorkshopPage = ReactiveCommand.CreateFromTask(ExecuteViewWorkshopPage);
AddImage = ReactiveCommand.CreateFromTask(ExecuteAddImage);
DiscardChanges = ReactiveCommand.CreateFromTask(ExecuteDiscardChanges, this.WhenAnyValue(vm => vm.HasChanges));
SaveChanges = ReactiveCommand.CreateFromTask(ExecuteSaveChanges, this.WhenAnyValue(vm => vm.HasChanges));
}
public ObservableCollection<ImageSubmissionViewModel> Images { get; } = new();
public ReactiveCommand<Unit, Unit> CreateRelease { get; }
public ReactiveCommand<Unit, Unit> DeleteSubmission { get; }
public ReactiveCommand<Unit, Unit> ViewWorkshopPage { get; }
public ReactiveCommand<Unit, Unit> AddImage { get; }
public ReactiveCommand<Unit, Unit> SaveChanges { get; }
public ReactiveCommand<Unit, Unit> DiscardChanges { get; }
public override async Task OnNavigating(WorkshopDetailParameters parameters, NavigationArguments args, CancellationToken cancellationToken)
{
IOperationResult<IGetSubmittedEntryByIdResult> result = await _client.GetSubmittedEntryById.ExecuteAsync(parameters.EntryId, cancellationToken);
@ -63,7 +79,8 @@ public partial class SubmissionDetailViewModel : RoutableScreen<WorkshopDetailPa
return;
Entry = result.Data?.Entry;
await ApplyFromEntry(cancellationToken);
await ApplyDetailsFromEntry(cancellationToken);
ApplyImagesFromEntry();
}
public override async Task OnClosing(NavigationArguments args)
@ -76,34 +93,69 @@ public partial class SubmissionDetailViewModel : RoutableScreen<WorkshopDetailPa
args.Cancel();
}
private async Task ApplyFromEntry(CancellationToken cancellationToken)
private async Task ApplyDetailsFromEntry(CancellationToken cancellationToken)
{
// Clean up event handlers
if (EntrySpecificationsViewModel != null)
{
EntrySpecificationsViewModel.PropertyChanged -= InputChanged;
((INotifyCollectionChanged) EntrySpecificationsViewModel.SelectedCategories).CollectionChanged -= InputChanged;
EntrySpecificationsViewModel.Tags.CollectionChanged -= InputChanged;
}
if (Entry == null)
{
EntrySpecificationsViewModel = null;
return;
}
EntrySpecificationsViewModel specificationsViewModel = _getGetSpecificationsViewModel();
specificationsViewModel.IconBitmap = await GetEntryIcon(cancellationToken);
specificationsViewModel.Name = Entry.Name;
specificationsViewModel.Summary = Entry.Summary;
specificationsViewModel.Description = Entry.Description;
specificationsViewModel.PreselectedCategories = Entry.Categories.Select(c => c.Id).ToList();
specificationsViewModel.Tags.Clear();
foreach (string tag in Entry.Tags.Select(c => c.Name))
specificationsViewModel.Tags.Add(tag);
EntrySpecificationsViewModel = specificationsViewModel;
EntrySpecificationsViewModel.PropertyChanged += InputChanged;
((INotifyCollectionChanged) EntrySpecificationsViewModel.SelectedCategories).CollectionChanged += InputChanged;
EntrySpecificationsViewModel.Tags.CollectionChanged += InputChanged;
ApplyImagesFromEntry();
}
private void ApplyImagesFromEntry()
{
foreach (ImageSubmissionViewModel imageSubmissionViewModel in Images)
imageSubmissionViewModel.PropertyChanged -= InputChanged;
Images.Clear();
_removedImages.Clear();
if (Entry == null)
return;
if (EntrySpecificationsViewModel != null)
foreach (IImage image in Entry.Images)
AddImageViewModel(_getExistingImageSubmissionViewModel(image));
}
private void AddImageViewModel(ImageSubmissionViewModel viewModel)
{
viewModel.PropertyChanged += InputChanged;
viewModel.Remove = ReactiveCommand.Create(() =>
{
EntrySpecificationsViewModel.PropertyChanged -= EntrySpecificationsViewModelOnPropertyChanged;
((INotifyCollectionChanged) EntrySpecificationsViewModel.SelectedCategories).CollectionChanged -= SelectedCategoriesOnCollectionChanged;
EntrySpecificationsViewModel.Tags.CollectionChanged -= TagsOnCollectionChanged;
}
// _removedImages is a list of images that are to be deleted, images without an ID never existed in the first place so only add those with an ID
if (viewModel.Id != null)
_removedImages.Add(viewModel);
EntrySpecificationsViewModel viewModel = _getGetSpecificationsVm();
viewModel.IconBitmap = await GetEntryIcon(cancellationToken);
viewModel.Name = Entry.Name;
viewModel.Summary = Entry.Summary;
viewModel.Description = Entry.Description;
viewModel.PreselectedCategories = Entry.Categories.Select(c => c.Id).ToList();
viewModel.Tags.Clear();
foreach (string tag in Entry.Tags.Select(c => c.Name))
viewModel.Tags.Add(tag);
EntrySpecificationsViewModel = viewModel;
EntrySpecificationsViewModel.PropertyChanged += EntrySpecificationsViewModelOnPropertyChanged;
((INotifyCollectionChanged) EntrySpecificationsViewModel.SelectedCategories).CollectionChanged += SelectedCategoriesOnCollectionChanged;
EntrySpecificationsViewModel.Tags.CollectionChanged += TagsOnCollectionChanged;
Images.Remove(viewModel);
UpdateHasChanges();
});
Images.Add(viewModel);
}
private async Task<Bitmap?> GetEntryIcon(CancellationToken cancellationToken)
@ -128,19 +180,22 @@ public partial class SubmissionDetailViewModel : RoutableScreen<WorkshopDetailPa
EntrySpecificationsViewModel.Summary != Entry.Summary ||
EntrySpecificationsViewModel.IconChanged ||
!tags.SequenceEqual(Entry.Tags.Select(t => t.Name).OrderBy(t => t)) ||
!categories.SequenceEqual(Entry.Categories.Select(c => c.Id).OrderBy(c => c));
!categories.SequenceEqual(Entry.Categories.Select(c => c.Id).OrderBy(c => c)) ||
Images.Any(i => i.HasChanges) ||
_removedImages.Any();
}
private async Task ExecuteDiscardChanges()
{
await ApplyFromEntry(CancellationToken.None);
await ApplyDetailsFromEntry(CancellationToken.None);
ApplyImagesFromEntry();
}
private async Task ExecuteSaveChanges(CancellationToken cancellationToken)
{
if (Entry == null || EntrySpecificationsViewModel == null || !EntrySpecificationsViewModel.ValidationContext.GetIsValid())
return;
UpdateEntryInput input = new()
{
Id = Entry.Id,
@ -163,7 +218,30 @@ public partial class SubmissionDetailViewModel : RoutableScreen<WorkshopDetailPa
throw new ArtemisWorkshopException("Failed to upload image. " + imageResult.Message);
}
foreach (ImageSubmissionViewModel imageViewModel in Images)
{
// Upload new images
if (imageViewModel.ImageUploadRequest != null)
{
await _workshopService.UploadEntryImage(Entry.Id, imageViewModel.ImageUploadRequest, cancellationToken);
}
// Update existing images
else if (imageViewModel.HasChanges && imageViewModel.Id != null)
{
if (imageViewModel.Name != null && imageViewModel.Description != null)
await _client.UpdateEntryImage.ExecuteAsync(imageViewModel.Id.Value, imageViewModel.Name, imageViewModel.Description, cancellationToken);
}
}
// Delete old images
foreach (ImageSubmissionViewModel imageViewModel in _removedImages)
{
if (imageViewModel.Id != null)
await _workshopService.DeleteEntryImage(imageViewModel.Id.Value, cancellationToken);
}
HasChanges = false;
await _router.Reload();
}
private async Task ExecuteCreateRelease(CancellationToken cancellationToken)
@ -189,23 +267,43 @@ public partial class SubmissionDetailViewModel : RoutableScreen<WorkshopDetailPa
await _router.Navigate("workshop/library/submissions");
}
private async Task ExecuteAddImage(CancellationToken arg)
{
string[]? result = await _windowService.CreateOpenFileDialog().WithAllowMultiple().HavingFilter(f => f.WithBitmaps()).ShowAsync();
if (result == null)
return;
foreach (string path in result)
{
FileStream stream = new(path, FileMode.Open, FileAccess.Read);
if (stream.Length > ImageUploadRequest.MAX_FILE_SIZE)
{
await _windowService.ShowConfirmContentDialog("File too big", $"File {path} exceeds maximum file size of 10 MB", "Skip file", null);
await stream.DisposeAsync();
continue;
}
ImageUploadRequest request = new(stream, Path.GetFileName(path), string.Empty);
ImageSubmissionViewModel viewModel = _getImageSubmissionViewModel(request);
// Show the dialog to give the image a name and description
if (await viewModel.Edit() != ContentDialogResult.Primary)
{
await stream.DisposeAsync();
continue;
}
AddImageViewModel(viewModel);
}
}
private async Task ExecuteViewWorkshopPage()
{
if (Entry != null)
await _workshopService.NavigateToEntry(Entry.Id, Entry.EntryType);
}
private void TagsOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
UpdateHasChanges();
}
private void SelectedCategoriesOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
UpdateHasChanges();
}
private void EntrySpecificationsViewModelOnPropertyChanged(object? sender, PropertyChangedEventArgs e)
private void InputChanged(object? sender, EventArgs e)
{
UpdateHasChanges();
}

View File

@ -23,6 +23,11 @@
<ScrollViewer Grid.Row="1" Margin="0 20 0 0">
<ItemsControl ItemsSource="{CompiledBinding Images}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<ContentControl Content="{CompiledBinding}" Margin="0 0 5 0" Width="300"></ContentControl>
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />

View File

@ -16,7 +16,6 @@ namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps;
public class ImagesStepViewModel : SubmissionViewModel
{
private const long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
private readonly IWindowService _windowService;
private readonly Func<ImageUploadRequest, ImageSubmissionViewModel> _getImageSubmissionViewModel;
private readonly SourceList<ImageUploadRequest> _stateImages;
@ -66,7 +65,7 @@ public class ImagesStepViewModel : SubmissionViewModel
continue;
FileStream stream = new(path, FileMode.Open, FileAccess.Read);
if (stream.Length > MAX_FILE_SIZE)
if (stream.Length > ImageUploadRequest.MAX_FILE_SIZE)
{
await _windowService.ShowConfirmContentDialog("File too big", $"File {path} exceeds maximum file size of 10 MB", "Skip file", null);
await stream.DisposeAsync();

View File

@ -7,7 +7,7 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.SubmissionWizard.Steps.Layout.LayoutSelectionStepView"
x:DataType="layout:LayoutSelectionStepViewModel">
<Grid RowDefinitions="Auto,*">
<Grid RowDefinitions="Auto,*, *">
<StackPanel>
<StackPanel.Styles>
<Styles>

View File

@ -145,7 +145,7 @@ public partial class LayoutSelectionStepViewModel : SubmissionViewModel
return true;
}
private void SetDeviceImages()
private async Task SetDeviceImages()
{
if (Layout == null)
return;
@ -153,15 +153,15 @@ public partial class LayoutSelectionStepViewModel : SubmissionViewModel
MemoryStream deviceWithoutLeds = new();
MemoryStream deviceWithLeds = new();
using (RenderTargetBitmap image = Layout.RenderLayout(false))
using (RenderTargetBitmap image = Layout.RenderLayout(false, 4))
{
image.Save(deviceWithoutLeds);
await Task.Run(() => image.Save(deviceWithoutLeds));
deviceWithoutLeds.Seek(0, SeekOrigin.Begin);
}
using (RenderTargetBitmap image = Layout.RenderLayout(true))
using (RenderTargetBitmap image = Layout.RenderLayout(true, 4))
{
image.Save(deviceWithLeds);
await Task.Run(() => image.Save(deviceWithLeds));
deviceWithLeds.Seek(0, SeekOrigin.Begin);
}

View File

@ -39,7 +39,10 @@
<GraphQL Update="Queries\RemoveEntry.graphql">
<Generator>MSBuild:GenerateGraphQLCode</Generator>
</GraphQL>
<GraphQL Update="Queries\UpdateEntry.graphql">
<GraphQL Update="Mutations\UpdateEntry.graphql">
<Generator>MSBuild:GenerateGraphQLCode</Generator>
</GraphQL>
<GraphQL Update="Mutations\UpdateEntryImage.graphql">
<Generator>MSBuild:GenerateGraphQLCode</Generator>
</GraphQL>
</ItemGroup>

View File

@ -2,6 +2,8 @@
public class ImageUploadRequest
{
public const long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
public ImageUploadRequest(Stream file, string name, string? description)
{
File = file;

View File

@ -0,0 +1,5 @@
mutation UpdateEntryImage ($id: UUID! $name: String! $description: String!) {
updateEntryImage(input: {id: $id, name: $name description: $description}) {
id
}
}

View File

@ -11,5 +11,8 @@ query GetSubmittedEntryById($id: Long!) {
layoutInfo {
...layoutInfo
}
images {
...image
}
}
}

View File

@ -7,6 +7,7 @@ public interface IWorkshopService
Task<Stream?> GetEntryIcon(long entryId, CancellationToken cancellationToken);
Task<ImageUploadResult> SetEntryIcon(long entryId, Stream icon, CancellationToken cancellationToken);
Task<ImageUploadResult> UploadEntryImage(long entryId, ImageUploadRequest request, CancellationToken cancellationToken);
Task DeleteEntryImage(Guid id, CancellationToken cancellationToken);
Task<WorkshopStatus> GetWorkshopStatus(CancellationToken cancellationToken);
Task<bool> ValidateWorkshopStatus(CancellationToken cancellationToken);
Task NavigateToEntry(long entryId, EntryType entryType);

View File

@ -80,6 +80,14 @@ public class WorkshopService : IWorkshopService
return ImageUploadResult.FromSuccess();
}
/// <inheritdoc />
public async Task DeleteEntryImage(Guid id, CancellationToken cancellationToken)
{
HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME);
HttpResponseMessage response = await client.DeleteAsync($"images/{id}", cancellationToken);
response.EnsureSuccessStatusCode();
}
/// <inheritdoc />
public async Task<IWorkshopService.WorkshopStatus> GetWorkshopStatus(CancellationToken cancellationToken)
{

View File

@ -2,9 +2,9 @@ namespace Artemis.WebClient.Workshop;
public static class WorkshopConstants
{
// public const string AUTHORITY_URL = "https://localhost:5001";
// public const string WORKSHOP_URL = "https://localhost:7281";
public const string AUTHORITY_URL = "https://identity.artemis-rgb.com";
public const string WORKSHOP_URL = "https://workshop.artemis-rgb.com";
public const string AUTHORITY_URL = "https://localhost:5001";
public const string WORKSHOP_URL = "https://localhost:7281";
// 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";
}

View File

@ -53,6 +53,8 @@ type Entry {
type Image {
description: String
entry: Entry
entryId: Long
height: Int!
id: UUID!
mimeType: String!
@ -79,6 +81,7 @@ type Mutation {
removeEntry(id: Long!): Entry
removeLayoutInfo(id: Long!): LayoutInfo!
updateEntry(input: UpdateEntryInput!): Entry
updateEntryImage(input: UpdateEntryImageInput!): Image
}
type Query {
@ -260,6 +263,8 @@ input EntryTypeOperationFilterInput {
input ImageFilterInput {
and: [ImageFilterInput!]
description: StringOperationFilterInput
entry: EntryFilterInput
entryId: LongOperationFilterInput
height: IntOperationFilterInput
id: UuidOperationFilterInput
mimeType: StringOperationFilterInput
@ -271,6 +276,8 @@ input ImageFilterInput {
input ImageSortInput {
description: SortEnumType
entry: EntrySortInput
entryId: SortEnumType
height: SortEnumType
id: SortEnumType
mimeType: SortEnumType
@ -418,6 +425,12 @@ input TagFilterInput {
or: [TagFilterInput!]
}
input UpdateEntryImageInput {
description: String!
id: UUID!
name: String!
}
input UpdateEntryInput {
categories: [Long!]!
description: String!