diff --git a/src/Artemis.UI.Shared/Extensions/ArtemisLayoutExtensions.cs b/src/Artemis.UI.Shared/Extensions/ArtemisLayoutExtensions.cs index 7e0f3853a..b356e8c24 100644 --- a/src/Artemis.UI.Shared/Extensions/ArtemisLayoutExtensions.cs +++ b/src/Artemis.UI.Shared/Extensions/ArtemisLayoutExtensions.cs @@ -19,14 +19,16 @@ public static class ArtemisLayoutExtensions /// Renders the layout to a bitmap. /// /// The layout to render + /// A value indicating whether or not to draw LEDs on the image. + /// The scale at which to draw the layout. /// The resulting bitmap. - 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 RenderTargetBitmap RenderLayout(this ArtemisLayout layout, bool pr 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 RenderTargetBitmap RenderLayout(this ArtemisLayout layout, bool pr // 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}; diff --git a/src/Artemis.UI.Shared/Routing/Router/IRouter.cs b/src/Artemis.UI.Shared/Routing/Router/IRouter.cs index cfbd0eb89..e23be6eae 100644 --- a/src/Artemis.UI.Shared/Routing/Router/IRouter.cs +++ b/src/Artemis.UI.Shared/Routing/Router/IRouter.cs @@ -27,6 +27,12 @@ public interface IRouter /// Optional navigation options used to control navigation behaviour. /// A task representing the operation Task Navigate(string path, RouterNavigationOptions? options = null); + + /// + /// Asynchronously reloads the current route + /// + /// A task representing the operation + Task Reload(); /// /// Asynchronously navigates back to the previous active route. diff --git a/src/Artemis.UI.Shared/Routing/Router/Router.cs b/src/Artemis.UI.Shared/Routing/Router/Router.cs index a4afe117d..2f84eb145 100644 --- a/src/Artemis.UI.Shared/Routing/Router/Router.cs +++ b/src/Artemis.UI.Shared/Routing/Router/Router.cs @@ -79,6 +79,19 @@ public async Task Navigate(string path, RouterNavigationOptions? options = null) await Dispatcher.UIThread.InvokeAsync(() => InternalNavigate(path, options)); } + /// + 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) diff --git a/src/Artemis.UI/Screens/Debugger/Tabs/Routing/RoutingDebugView.axaml b/src/Artemis.UI/Screens/Debugger/Tabs/Routing/RoutingDebugView.axaml index 68230f577..cdefb0ec2 100644 --- a/src/Artemis.UI/Screens/Debugger/Tabs/Routing/RoutingDebugView.axaml +++ b/src/Artemis.UI/Screens/Debugger/Tabs/Routing/RoutingDebugView.axaml @@ -7,16 +7,17 @@ x:Class="Artemis.UI.Screens.Debugger.Routing.RoutingDebugView" x:DataType="routing:RoutingDebugViewModel"> - + - + + - Navigation logs - + Navigation logs + vm.Route).Select(r => !string.IsNullOrWhiteSpace(r))); _formatter = new MessageTemplateTextFormatter( @@ -48,6 +49,7 @@ public RoutingDebugViewModel(IRouter router) } public InlineCollection Lines { get; } = new(); + public ReactiveCommand Reload { get; } public ReactiveCommand Navigate { get; } private void OnLogEventAdded(object? sender, LogEventEventArgs e) @@ -87,6 +89,18 @@ private void LimitLines() 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) { diff --git a/src/Artemis.UI/Screens/Root/BlankView.axaml.cs b/src/Artemis.UI/Screens/Root/BlankView.axaml.cs index c4c8b84be..e145f1b7f 100644 --- a/src/Artemis.UI/Screens/Root/BlankView.axaml.cs +++ b/src/Artemis.UI/Screens/Root/BlankView.axaml.cs @@ -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 { public BlankView() { diff --git a/src/Artemis.UI/Screens/Workshop/Entries/Tabs/LayoutListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/LayoutListViewModel.cs index f4d5237e8..6cab02f27 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/Tabs/LayoutListViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/Tabs/LayoutListViewModel.cs @@ -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 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"; } diff --git a/src/Artemis.UI/Screens/Workshop/Image/ImagePropertiesDialogViewModel.cs b/src/Artemis.UI/Screens/Workshop/Image/ImagePropertiesDialogViewModel.cs index 476feed1d..9074035b8 100644 --- a/src/Artemis.UI/Screens/Workshop/Image/ImagePropertiesDialogViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Image/ImagePropertiesDialogViewModel.cs @@ -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 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); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Image/ImageSubmissionView.axaml b/src/Artemis.UI/Screens/Workshop/Image/ImageSubmissionView.axaml index 04d4b10de..4227ef203 100644 --- a/src/Artemis.UI/Screens/Workshop/Image/ImageSubmissionView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Image/ImageSubmissionView.axaml @@ -11,7 +11,7 @@ - + { 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 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; } diff --git a/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailView.axaml b/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailView.axaml index 99365cb52..dbb99d606 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailView.axaml @@ -12,7 +12,7 @@ - + @@ -48,8 +48,35 @@ View workshop page + - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs b/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs index a70e014a0..e617170f5 100644 --- a/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Library/SubmissionDetailViewModel.cs @@ -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 { private readonly IWorkshopClient _client; - private readonly Func _getGetSpecificationsVm; - private readonly IRouter _router; private readonly IWindowService _windowService; private readonly IWorkshopService _workshopService; + private readonly IRouter _router; + private readonly Func _getGetSpecificationsViewModel; + private readonly Func _getExistingImageSubmissionViewModel; + private readonly Func _getImageSubmissionViewModel; + private readonly List _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 getSpecificationsVm) { + public SubmissionDetailViewModel(IWorkshopClient client, + IWindowService windowService, + IWorkshopService workshopService, + IRouter router, + Func getSpecificationsViewModel, + Func getExistingImageSubmissionViewModel, + Func 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 Images { get; } = new(); public ReactiveCommand CreateRelease { get; } public ReactiveCommand DeleteSubmission { get; } public ReactiveCommand ViewWorkshopPage { get; } + public ReactiveCommand AddImage { get; } public ReactiveCommand SaveChanges { get; } public ReactiveCommand DiscardChanges { get; } - + public override async Task OnNavigating(WorkshopDetailParameters parameters, NavigationArguments args, CancellationToken cancellationToken) { IOperationResult result = await _client.GetSubmittedEntryById.ExecuteAsync(parameters.EntryId, cancellationToken); @@ -63,7 +79,8 @@ public override async Task OnNavigating(WorkshopDetailParameters parameters, Nav return; Entry = result.Data?.Entry; - await ApplyFromEntry(cancellationToken); + await ApplyDetailsFromEntry(cancellationToken); + ApplyImagesFromEntry(); } public override async Task OnClosing(NavigationArguments args) @@ -76,34 +93,69 @@ public override async Task OnClosing(NavigationArguments args) args.Cancel(); } - private async Task ApplyFromEntry(CancellationToken cancellationToken) + private async Task ApplyDetailsFromEntry(CancellationToken cancellationToken) { - if (Entry == null) - return; - + // Clean up event handlers if (EntrySpecificationsViewModel != null) { - EntrySpecificationsViewModel.PropertyChanged -= EntrySpecificationsViewModelOnPropertyChanged; - ((INotifyCollectionChanged) EntrySpecificationsViewModel.SelectedCategories).CollectionChanged -= SelectedCategoriesOnCollectionChanged; - EntrySpecificationsViewModel.Tags.CollectionChanged -= TagsOnCollectionChanged; + EntrySpecificationsViewModel.PropertyChanged -= InputChanged; + ((INotifyCollectionChanged) EntrySpecificationsViewModel.SelectedCategories).CollectionChanged -= InputChanged; + EntrySpecificationsViewModel.Tags.CollectionChanged -= InputChanged; } - EntrySpecificationsViewModel viewModel = _getGetSpecificationsVm(); + if (Entry == null) + { + EntrySpecificationsViewModel = null; + return; + } - 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(); + 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(); - viewModel.Tags.Clear(); + specificationsViewModel.Tags.Clear(); foreach (string tag in Entry.Tags.Select(c => c.Name)) - viewModel.Tags.Add(tag); + 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; + + foreach (IImage image in Entry.Images) + AddImageViewModel(_getExistingImageSubmissionViewModel(image)); + } + + private void AddImageViewModel(ImageSubmissionViewModel viewModel) + { + viewModel.PropertyChanged += InputChanged; + viewModel.Remove = ReactiveCommand.Create(() => + { + // _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; - EntrySpecificationsViewModel.PropertyChanged += EntrySpecificationsViewModelOnPropertyChanged; - ((INotifyCollectionChanged) EntrySpecificationsViewModel.SelectedCategories).CollectionChanged += SelectedCategoriesOnCollectionChanged; - EntrySpecificationsViewModel.Tags.CollectionChanged += TagsOnCollectionChanged; + Images.Remove(viewModel); + UpdateHasChanges(); + }); + Images.Add(viewModel); } private async Task GetEntryIcon(CancellationToken cancellationToken) @@ -128,19 +180,22 @@ private void UpdateHasChanges() 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 @@ private async Task ExecuteSaveChanges(CancellationToken cancellationToken) 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) + 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 @@ private async Task ExecuteDeleteSubmission(CancellationToken cancellationToken) await _router.Navigate("workshop/library/submissions"); } - private async Task ExecuteViewWorkshopPage() - { - if (Entry != null) - await _workshopService.NavigateToEntry(Entry.Id, Entry.EntryType); - } - - private void TagsOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + private async Task ExecuteAddImage(CancellationToken arg) { - UpdateHasChanges(); + 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 void SelectedCategoriesOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + private async Task ExecuteViewWorkshopPage() { - UpdateHasChanges(); + if (Entry != null) + await _workshopService.NavigateToEntry(Entry.Id, Entry.EntryType); } - private void EntrySpecificationsViewModelOnPropertyChanged(object? sender, PropertyChangedEventArgs e) + private void InputChanged(object? sender, EventArgs e) { UpdateHasChanges(); } diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepView.axaml index d563f760e..399f6cdb5 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepView.axaml +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepView.axaml @@ -23,6 +23,11 @@ + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepViewModel.cs index 3efdb1259..4462c8167 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/ImagesStepViewModel.cs @@ -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 _getImageSubmissionViewModel; private readonly SourceList _stateImages; @@ -66,7 +65,7 @@ private async Task ExecuteAddImage(CancellationToken arg) 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(); diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepView.axaml index 794174b2c..e9837b889 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepView.axaml +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepView.axaml @@ -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"> - + diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepViewModel.cs index a2396ae0e..d38a9b5b4 100644 --- a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/Layout/LayoutSelectionStepViewModel.cs @@ -145,7 +145,7 @@ await _windowService.ShowConfirmContentDialog( return true; } - private void SetDeviceImages() + private async Task SetDeviceImages() { if (Layout == null) return; @@ -153,15 +153,15 @@ private void SetDeviceImages() 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); } diff --git a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj index b81e3f37d..6b3873c41 100644 --- a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj +++ b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj @@ -39,7 +39,10 @@ MSBuild:GenerateGraphQLCode - + + MSBuild:GenerateGraphQLCode + + MSBuild:GenerateGraphQLCode diff --git a/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/ImageUploadRequest.cs b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/ImageUploadRequest.cs index 0bb2e4774..97aa46b58 100644 --- a/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/ImageUploadRequest.cs +++ b/src/Artemis.WebClient.Workshop/Handlers/UploadHandlers/ImageUploadRequest.cs @@ -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; diff --git a/src/Artemis.WebClient.Workshop/Queries/UpdateEntry.graphql b/src/Artemis.WebClient.Workshop/Mutations/UpdateEntry.graphql similarity index 100% rename from src/Artemis.WebClient.Workshop/Queries/UpdateEntry.graphql rename to src/Artemis.WebClient.Workshop/Mutations/UpdateEntry.graphql diff --git a/src/Artemis.WebClient.Workshop/Mutations/UpdateEntryImage.graphql b/src/Artemis.WebClient.Workshop/Mutations/UpdateEntryImage.graphql new file mode 100644 index 000000000..382871b54 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Mutations/UpdateEntryImage.graphql @@ -0,0 +1,5 @@ +mutation UpdateEntryImage ($id: UUID! $name: String! $description: String) { + updateEntryImage(input: {id: $id, name: $name description: $description}) { + id + } +} diff --git a/src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntryById.graphql b/src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntryById.graphql index 85106a433..1bcd98fd6 100644 --- a/src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntryById.graphql +++ b/src/Artemis.WebClient.Workshop/Queries/GetSubmittedEntryById.graphql @@ -11,5 +11,8 @@ query GetSubmittedEntryById($id: Long!) { layoutInfo { ...layoutInfo } + images { + ...image + } } } \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs index 3b36c6b2d..976e37805 100644 --- a/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs +++ b/src/Artemis.WebClient.Workshop/Services/Interfaces/IWorkshopService.cs @@ -7,6 +7,7 @@ public interface IWorkshopService Task GetEntryIcon(long entryId, CancellationToken cancellationToken); Task SetEntryIcon(long entryId, Stream icon, CancellationToken cancellationToken); Task UploadEntryImage(long entryId, ImageUploadRequest request, CancellationToken cancellationToken); + Task DeleteEntryImage(Guid id, CancellationToken cancellationToken); Task GetWorkshopStatus(CancellationToken cancellationToken); Task ValidateWorkshopStatus(CancellationToken cancellationToken); Task NavigateToEntry(long entryId, EntryType entryType); diff --git a/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs b/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs index 920d29f8e..97ba4299f 100644 --- a/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs +++ b/src/Artemis.WebClient.Workshop/Services/WorkshopService.cs @@ -80,6 +80,14 @@ public async Task UploadEntryImage(long entryId, ImageUploadR return ImageUploadResult.FromSuccess(); } + /// + 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(); + } + /// public async Task GetWorkshopStatus(CancellationToken cancellationToken) { diff --git a/src/Artemis.WebClient.Workshop/schema.graphql b/src/Artemis.WebClient.Workshop/schema.graphql index 8639411bf..959e11204 100644 --- a/src/Artemis.WebClient.Workshop/schema.graphql +++ b/src/Artemis.WebClient.Workshop/schema.graphql @@ -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!