1
0
mirror of https://github.com/Artemis-RGB/Artemis synced 2025-12-13 05:48:35 +00:00

Images WIP

This commit is contained in:
RobertBeekman 2023-11-05 21:27:41 +01:00
parent 6b4e84c95a
commit 0667d58ed8
56 changed files with 783 additions and 454 deletions

View File

@ -58,4 +58,19 @@
<HintPath>..\..\..\RGB.NET\bin\net7.0\RGB.NET.Layout.dll</HintPath> <HintPath>..\..\..\RGB.NET\bin\net7.0\RGB.NET.Layout.dll</HintPath>
</Reference> </Reference>
</ItemGroup> </ItemGroup>
<ItemGroup>
<Compile Update="Screens\Workshop\Entries\List\EntryListInputView.axaml.cs">
<DependentUpon>EntryListInputView.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Screens\Workshop\Entries\List\EntryListItemView.axaml.cs">
<DependentUpon>EntryListItemView.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Screens\Workshop\Entries\Details\EntrySpecificationsView.axaml.cs">
<DependentUpon>EntrySpecificationsView.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
</ItemGroup>
</Project> </Project>

View File

@ -6,6 +6,7 @@ using Artemis.UI.Screens.Debugger.Logs;
using Artemis.UI.Screens.Debugger.Performance; using Artemis.UI.Screens.Debugger.Performance;
using Artemis.UI.Screens.Debugger.Render; using Artemis.UI.Screens.Debugger.Render;
using Artemis.UI.Screens.Debugger.Routing; using Artemis.UI.Screens.Debugger.Routing;
using Artemis.UI.Screens.Debugger.Workshop;
using Artemis.UI.Services.Interfaces; using Artemis.UI.Services.Interfaces;
using Artemis.UI.Shared; using Artemis.UI.Shared;
using PropertyChanged.SourceGenerator; using PropertyChanged.SourceGenerator;
@ -17,9 +18,9 @@ public partial class DebugViewModel : ActivatableViewModelBase, IScreen
{ {
[Notify] private ViewModelBase _selectedItem; [Notify] private ViewModelBase _selectedItem;
public DebugViewModel(IDebugService debugService, RenderDebugViewModel render, DataModelDebugViewModel dataModel, PerformanceDebugViewModel performance, RoutingDebugViewModel routing, LogsDebugViewModel logs) public DebugViewModel(IDebugService debugService, RenderDebugViewModel render, DataModelDebugViewModel dataModel, PerformanceDebugViewModel performance, RoutingDebugViewModel routing, WorkshopDebugViewModel workshop, LogsDebugViewModel logs)
{ {
Items = new ObservableCollection<ViewModelBase> {render, dataModel, performance, routing, logs}; Items = new ObservableCollection<ViewModelBase> {render, dataModel, performance, routing, workshop, logs};
_selectedItem = render; _selectedItem = render;
this.WhenActivated(d => Disposable.Create(debugService.ClearDebugger).DisposeWith(d)); this.WhenActivated(d => Disposable.Create(debugService.ClearDebugger).DisposeWith(d));

View File

@ -0,0 +1,32 @@
<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:workshop="clr-namespace:Artemis.UI.Screens.Debugger.Workshop"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Debugger.Workshop.WorkshopDebugView"
x:DataType="workshop:WorkshopDebugViewModel">
<ScrollViewer Classes="with-padding">
<StackPanel>
<Label>Workshop Status</Label>
<Border Classes="card-condensed">
<SelectableTextBlock Text="{CompiledBinding WorkshopStatus}" FontFamily="{StaticResource RobotoMono}" FontSize="13" />
</Border>
<Label Margin="0 10 0 0">Auth token (DO NOT SHARE)</Label>
<Border Classes="card-condensed">
<SelectableTextBlock Text="{CompiledBinding Token}" FontFamily="{StaticResource RobotoMono}" FontSize="13" TextWrapping="Wrap" />
</Border>
<Label Margin="0 10 0 0">Email verified</Label>
<Border Classes="card-condensed">
<SelectableTextBlock Text="{CompiledBinding EmailVerified}" FontFamily="{StaticResource RobotoMono}" FontSize="13" />
</Border>
<Label Margin="0 10 0 0">Claims</Label>
<Border Classes="card-condensed">
<SelectableTextBlock Text="{CompiledBinding Claims}" FontFamily="{StaticResource RobotoMono}" FontSize="13" />
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@ -0,0 +1,14 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.Debugger.Workshop;
public partial class WorkshopDebugView : ReactiveUserControl<WorkshopDebugViewModel>
{
public WorkshopDebugView()
{
InitializeComponent();
}
}

View File

@ -0,0 +1,30 @@
using System.Threading;
using Artemis.UI.Extensions;
using Artemis.UI.Shared;
using Artemis.WebClient.Workshop.Services;
using Newtonsoft.Json;
using PropertyChanged.SourceGenerator;
namespace Artemis.UI.Screens.Debugger.Workshop;
public partial class WorkshopDebugViewModel : ActivatableViewModelBase
{
[Notify] private string? _token;
[Notify] private bool _emailVerified;
[Notify] private string? _claims;
[Notify] private IWorkshopService.WorkshopStatus? _workshopStatus;
public WorkshopDebugViewModel(IWorkshopService workshopService, IAuthenticationService authenticationService)
{
DisplayName = "Workshop";
this.WhenActivatedAsync(async _ =>
{
Token = await authenticationService.GetBearer();
EmailVerified = authenticationService.GetIsEmailVerified();
Claims = JsonConvert.SerializeObject(authenticationService.Claims, Formatting.Indented);
WorkshopStatus = await workshopService.GetWorkshopStatus(CancellationToken.None);
});
}
}

View File

@ -0,0 +1,24 @@
<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:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
xmlns:details="clr-namespace:Artemis.UI.Screens.Workshop.Entries.Details"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.Entries.Details.EntryImageView"
x:DataType="details:EntryImageViewModel">
<Border Classes="card" Padding="0" Width="300" ClipToBounds="True" Margin="0 5 0 0">
<Grid RowDefinitions="230,*">
<Rectangle Grid.Row="0" Fill="{DynamicResource CheckerboardBrush}" />
<Image Grid.Row="0"
VerticalAlignment="Center"
HorizontalAlignment="Center"
RenderOptions.BitmapInterpolationMode="HighQuality"
asyncImageLoader:ImageLoader.Source="{CompiledBinding ThumbnailUrl, Mode=OneWay}" />
<StackPanel Grid.Row="1" Margin="12">
<TextBlock Text="{CompiledBinding Image.Name}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="subtitle" Text="{CompiledBinding Image.Description}" TextWrapping="Wrap" />
</StackPanel>
</Grid>
</Border>
</UserControl>

View File

@ -0,0 +1,13 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Artemis.UI.Screens.Workshop.Entries.Details;
public partial class EntryImageView : UserControl
{
public EntryImageView()
{
InitializeComponent();
}
}

View File

@ -0,0 +1,17 @@
using Artemis.WebClient.Workshop;
namespace Artemis.UI.Screens.Workshop.Entries.Details;
public class EntryImageViewModel
{
public EntryImageViewModel(IImage image)
{
Image = image;
Url = $"{WorkshopConstants.WORKSHOP_URL}/images/{image.Id}.png";
ThumbnailUrl = $"{WorkshopConstants.WORKSHOP_URL}/images/{image.Id}-thumb.png";
}
public IImage Image { get; }
public string Url { get; }
public string ThumbnailUrl { get; }
}

View File

@ -0,0 +1,28 @@
<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:system="clr-namespace:System;assembly=System.Runtime"
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
xmlns:details="clr-namespace:Artemis.UI.Screens.Workshop.Entries.Details"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.Entries.Details.EntryImagesView"
x:DataType="details:EntryImagesViewModel">
<ItemsControl ItemsSource="{CompiledBinding Images}" Margin="0 -16 0 0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.DataTemplates>
<DataTemplate x:DataType="details:EntryImageViewModel">
<Border CornerRadius="6"
Margin="0 16 0 0"
MaxWidth="300"
ClipToBounds="True">
<Image Stretch="UniformToFill" asyncImageLoader:ImageLoader.Source="{CompiledBinding ThumbnailUrl, Mode=OneWay}" />
</Border>
</DataTemplate>
</ItemsControl.DataTemplates>
</ItemsControl>
</UserControl>

View File

@ -0,0 +1,13 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Artemis.UI.Screens.Workshop.Entries.Details;
public partial class EntryImagesView : UserControl
{
public EntryImagesView()
{
InitializeComponent();
}
}

View File

@ -0,0 +1,16 @@
using System.Collections.ObjectModel;
using System.Linq;
using Artemis.UI.Shared;
using Artemis.WebClient.Workshop;
namespace Artemis.UI.Screens.Workshop.Entries.Details;
public class EntryImagesViewModel : ViewModelBase
{
public ObservableCollection<EntryImageViewModel> Images { get; }
public EntryImagesViewModel(IEntryDetails entryDetails)
{
Images = new ObservableCollection<EntryImageViewModel>(entryDetails.Images.Select(i => new EntryImageViewModel(i)));
}
}

View File

@ -0,0 +1,83 @@
<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:details="clr-namespace:Artemis.UI.Screens.Workshop.Entries.Details"
xmlns:il="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:converters="clr-namespace:Artemis.UI.Converters"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.Entries.Details.EntryInfoView"
x:DataType="details:EntryInfoViewModel">
<UserControl.Resources>
<converters:EntryIconUriConverter x:Key="EntryIconUriConverter" />
<converters:DateTimeConverter x:Key="DateTimeConverter" />
</UserControl.Resources>
<StackPanel>
<Panel>
<Border CornerRadius="6"
HorizontalAlignment="Left"
Margin="0 0 10 0"
Width="80"
Height="80"
ClipToBounds="True">
<Image Stretch="UniformToFill" il:ImageLoader.Source="{CompiledBinding Entry.Id, Converter={StaticResource EntryIconUriConverter}, Mode=OneWay}" />
</Border>
<Button Classes="icon-button"
VerticalAlignment="Top"
HorizontalAlignment="Right"
Command="{CompiledBinding CopyShareLink}"
ToolTip.Tip="Copy share link">
<avalonia:MaterialIcon Kind="ShareVariant" />
</Button>
</Panel>
<TextBlock Theme="{StaticResource TitleTextBlockStyle}"
MaxLines="3"
TextTrimming="CharacterEllipsis"
Text="{CompiledBinding Entry.Name, FallbackValue=Title }" />
<TextBlock Classes="subtitle" TextTrimming="CharacterEllipsis" Text="{CompiledBinding Entry.Author, FallbackValue=Author}" />
<TextBlock Margin="0 8" TextWrapping="Wrap" Text="{CompiledBinding Entry.Summary, FallbackValue=Summary}" />
<!-- Categories -->
<ItemsControl ItemsSource="{CompiledBinding Entry.Categories}" Margin="0 0 -8 0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"></WrapPanel>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="0 0 8 0">
<avalonia:MaterialIcon Kind="{CompiledBinding Icon}" Margin="0 0 3 0"></avalonia:MaterialIcon>
<TextBlock Text="{CompiledBinding Name}" TextTrimming="CharacterEllipsis" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Border Classes="card-separator"></Border>
<TextBlock Margin="0 0 0 8">
<avalonia:MaterialIcon Kind="Downloads" />
<Run Classes="h5" Text="{CompiledBinding Entry.Downloads, FallbackValue=0}" />
<Run>downloads</Run>
</TextBlock>
<TextBlock Classes="subtitle"
ToolTip.Tip="{CompiledBinding Entry.CreatedAt, Converter={StaticResource DateTimeConverter}}">
<avalonia:MaterialIcon Kind="Calendar" />
<Run>Created</Run>
<Run Text="{CompiledBinding Entry.CreatedAt, Converter={StaticResource DateTimeConverter}, ConverterParameter='humanize'}"></Run>
</TextBlock>
<TextBlock Classes="subtitle"
ToolTip.Tip="{CompiledBinding UpdatedAt, Converter={StaticResource DateTimeConverter}}">
<avalonia:MaterialIcon Kind="Update" />
<Run>Updated</Run>
<Run Text="{CompiledBinding UpdatedAt, Converter={StaticResource DateTimeConverter}, ConverterParameter='humanize'}"></Run>
</TextBlock>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,13 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Artemis.UI.Screens.Workshop.Entries.Details;
public partial class EntryInfoView : UserControl
{
public EntryInfoView()
{
InitializeComponent();
}
}

View File

@ -0,0 +1,28 @@
using System;
using System.Threading.Tasks;
using Artemis.Core;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services;
using Artemis.WebClient.Workshop;
namespace Artemis.UI.Screens.Workshop.Entries.Details;
public class EntryInfoViewModel : ViewModelBase
{
private readonly INotificationService _notificationService;
public IGetEntryById_Entry Entry { get; }
public DateTimeOffset? UpdatedAt { get; }
public EntryInfoViewModel(IGetEntryById_Entry entry, INotificationService notificationService)
{
_notificationService = notificationService;
Entry = entry;
UpdatedAt = Entry.LatestRelease?.CreatedAt ?? Entry.CreatedAt;
}
public async Task CopyShareLink()
{
await Shared.UI.Clipboard.SetTextAsync($"{WorkshopConstants.WORKSHOP_URL}/entries/{Entry.Id}/{StringUtilities.UrlFriendly(Entry.Name)}");
_notificationService.CreateNotification().WithTitle("Copied share link to clipboard.").Show();
}
}

View File

@ -0,0 +1,52 @@
<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:details="clr-namespace:Artemis.UI.Screens.Workshop.Entries.Details"
xmlns:converters="clr-namespace:Artemis.UI.Converters"
xmlns:sharedConverters="clr-namespace:Artemis.UI.Shared.Converters;assembly=Artemis.UI.Shared"
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.Entries.Details.EntryReleasesView"
x:DataType="details:EntryReleasesViewModel">
<UserControl.Resources>
<converters:DateTimeConverter x:Key="DateTimeConverter" />
<sharedConverters:BytesToStringConverter x:Key="BytesToStringConverter" />
</UserControl.Resources>
<StackPanel>
<TextBlock Theme="{StaticResource SubtitleTextBlockStyle}">Latest release</TextBlock>
<Border Classes="card-separator" />
<Button HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Command="{CompiledBinding DownloadLatestRelease}">
<Grid ColumnDefinitions="Auto,*">
<!-- Icon -->
<Border Grid.Column="0"
CornerRadius="4"
Background="{StaticResource SystemAccentColor}"
VerticalAlignment="Center"
Margin="0 6"
Width="50"
Height="50"
ClipToBounds="True">
<avalonia:MaterialIcon Kind="Download"></avalonia:MaterialIcon>
</Border>
<!-- Body -->
<StackPanel Grid.Column="1" Margin="10 0" VerticalAlignment="Center">
<TextBlock Text="{CompiledBinding Entry.LatestRelease.Version, FallbackValue=Version}"></TextBlock>
<TextBlock Classes="subtitle">
<avalonia:MaterialIcon Kind="BoxOutline" />
<Run Text="{CompiledBinding Entry.LatestRelease.DownloadSize, Converter={StaticResource BytesToStringConverter}, Mode=OneWay}"></Run>
</TextBlock>
<TextBlock Classes="subtitle"
ToolTip.Tip="{CompiledBinding Entry.LatestRelease.CreatedAt, Converter={StaticResource DateTimeConverter}}">
<avalonia:MaterialIcon Kind="Calendar" />
<Run>Created</Run>
<Run Text="{CompiledBinding Entry.LatestRelease.CreatedAt, Converter={StaticResource DateTimeConverter}, ConverterParameter='humanize'}"></Run>
</TextBlock>
</StackPanel>
</Grid>
</Button>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,13 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Artemis.UI.Screens.Workshop.Entries.Details;
public partial class EntryReleasesView : UserControl
{
public EntryReleasesView()
{
InitializeComponent();
}
}

View File

@ -0,0 +1,59 @@
using System;
using System.Reactive;
using System.Threading;
using System.Threading.Tasks;
using Artemis.UI.Shared;
using Artemis.UI.Shared.Services;
using Artemis.UI.Shared.Services.Builders;
using Artemis.UI.Shared.Utilities;
using Artemis.WebClient.Workshop;
using Artemis.WebClient.Workshop.Handlers.InstallationHandlers;
using Humanizer;
using ReactiveUI;
namespace Artemis.UI.Screens.Workshop.Entries.Details;
public class EntryReleasesViewModel : ViewModelBase
{
private readonly EntryInstallationHandlerFactory _factory;
private readonly IWindowService _windowService;
private readonly INotificationService _notificationService;
public EntryReleasesViewModel(IGetEntryById_Entry entry, EntryInstallationHandlerFactory factory, IWindowService windowService, INotificationService notificationService)
{
_factory = factory;
_windowService = windowService;
_notificationService = notificationService;
Entry = entry;
DownloadLatestRelease = ReactiveCommand.CreateFromTask(ExecuteDownloadLatestRelease);
}
public IGetEntryById_Entry Entry { get; }
public ReactiveCommand<Unit, Unit> DownloadLatestRelease { get; }
private async Task ExecuteDownloadLatestRelease(CancellationToken cancellationToken)
{
if (Entry.LatestRelease == null)
return;
bool confirm = await _windowService.ShowConfirmContentDialog(
"Install latest release",
$"Are you sure you want to download and install version {Entry.LatestRelease.Version} of {Entry.Name}?"
);
if (!confirm)
return;
IEntryInstallationHandler installationHandler = _factory.CreateHandler(Entry.EntryType);
EntryInstallResult result = await installationHandler.InstallAsync(Entry, Entry.LatestRelease.Id, new Progress<StreamProgress>(), cancellationToken);
if (result.IsSuccess)
_notificationService.CreateNotification().WithTitle($"{Entry.EntryType.Humanize(LetterCasing.Sentence)} installed").WithSeverity(NotificationSeverity.Success).Show();
else
{
_notificationService.CreateNotification()
.WithTitle($"Failed to install {Entry.EntryType.Humanize(LetterCasing.LowerCase)}")
.WithMessage(result.Message)
.WithSeverity(NotificationSeverity.Error).Show();
}
}
}

View File

@ -9,9 +9,10 @@
xmlns:avaloniaEdit="https://github.com/avaloniaui/avaloniaedit" xmlns:avaloniaEdit="https://github.com/avaloniaui/avaloniaedit"
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia.Tight" xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia.Tight"
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:details="clr-namespace:Artemis.UI.Screens.Workshop.Entries.Details"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="800" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="800"
x:Class="Artemis.UI.Screens.Workshop.Entries.EntrySpecificationsView" x:Class="Artemis.UI.Screens.Workshop.Entries.Details.EntrySpecificationsView"
x:DataType="entries:EntrySpecificationsViewModel"> x:DataType="details:EntrySpecificationsViewModel">
<Grid RowDefinitions="Auto,Auto,*,Auto"> <Grid RowDefinitions="Auto,Auto,*,Auto">
<StackPanel> <StackPanel>
<StackPanel.Styles> <StackPanel.Styles>

View File

@ -1,5 +1,4 @@
using System.Linq; using System.Linq;
using Artemis.UI.Shared.Extensions;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Media; using Avalonia.Media;
@ -8,8 +7,9 @@ using Avalonia.ReactiveUI;
using AvaloniaEdit.TextMate; using AvaloniaEdit.TextMate;
using ReactiveUI; using ReactiveUI;
using TextMateSharp.Grammars; using TextMateSharp.Grammars;
using VisualExtensions = Artemis.UI.Shared.Extensions.VisualExtensions;
namespace Artemis.UI.Screens.Workshop.Entries; namespace Artemis.UI.Screens.Workshop.Entries.Details;
public partial class EntrySpecificationsView : ReactiveUserControl<EntrySpecificationsViewModel> public partial class EntrySpecificationsView : ReactiveUserControl<EntrySpecificationsViewModel>
{ {
@ -23,7 +23,7 @@ public partial class EntrySpecificationsView : ReactiveUserControl<EntrySpecific
DescriptionEditor.Options.AllowScrollBelowDocument = false; DescriptionEditor.Options.AllowScrollBelowDocument = false;
RegistryOptions options = new(ThemeName.Dark); RegistryOptions options = new(ThemeName.Dark);
TextMate.Installation? install = DescriptionEditor.InstallTextMate(options); TextMate.Installation? install = TextMate.InstallTextMate(DescriptionEditor, options);
install.SetGrammar(options.GetScopeByExtension(".md")); install.SetGrammar(options.GetScopeByExtension(".md"));
@ -45,8 +45,8 @@ public partial class EntrySpecificationsView : ReactiveUserControl<EntrySpecific
if (_previewScrollViewer != null) if (_previewScrollViewer != null)
_previewScrollViewer.PropertyChanged -= PreviewScrollViewerOnPropertyChanged; _previewScrollViewer.PropertyChanged -= PreviewScrollViewerOnPropertyChanged;
_editorScrollViewer = DescriptionEditor.GetVisualChildrenOfType<ScrollViewer>().FirstOrDefault(); _editorScrollViewer = VisualExtensions.GetVisualChildrenOfType<ScrollViewer>(DescriptionEditor).FirstOrDefault();
_previewScrollViewer = DescriptionPreview.GetVisualChildrenOfType<ScrollViewer>().FirstOrDefault(); _previewScrollViewer = VisualExtensions.GetVisualChildrenOfType<ScrollViewer>(DescriptionPreview).FirstOrDefault();
if (_editorScrollViewer != null) if (_editorScrollViewer != null)
_editorScrollViewer.PropertyChanged += EditorScrollViewerOnPropertyChanged; _editorScrollViewer.PropertyChanged += EditorScrollViewerOnPropertyChanged;

View File

@ -22,7 +22,7 @@ using ReactiveUI.Validation.Extensions;
using ReactiveUI.Validation.Helpers; using ReactiveUI.Validation.Helpers;
using StrawberryShake; using StrawberryShake;
namespace Artemis.UI.Screens.Workshop.Entries; namespace Artemis.UI.Screens.Workshop.Entries.Details;
public partial class EntrySpecificationsViewModel : ValidatableViewModelBase public partial class EntrySpecificationsViewModel : ValidatableViewModelBase
{ {
@ -52,12 +52,12 @@ public partial class EntrySpecificationsViewModel : ValidatableViewModelBase
.Subscribe(); .Subscribe();
SelectedCategories = selectedCategories; SelectedCategories = selectedCategories;
this.ValidationRule(vm => vm.Name, s => !string.IsNullOrWhiteSpace(s), "Name is required"); this.ValidationRule<EntrySpecificationsViewModel, string>(vm => vm.Name, s => !string.IsNullOrWhiteSpace(s), "Name is required");
this.ValidationRule(vm => vm.Summary, s => !string.IsNullOrWhiteSpace(s), "Summary is required"); this.ValidationRule<EntrySpecificationsViewModel, string>(vm => vm.Summary, s => !string.IsNullOrWhiteSpace(s), "Summary is required");
ValidationHelper descriptionRule = this.ValidationRule(vm => vm.Description, s => !string.IsNullOrWhiteSpace(s), "Description is required"); ValidationHelper descriptionRule = this.ValidationRule<EntrySpecificationsViewModel, string>(vm => vm.Description, s => !string.IsNullOrWhiteSpace(s), "Description is required");
// 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<EntrySpecificationsViewModel, Bitmap>(vm => vm.IconBitmap, s => s != null, "Icon required");
ValidationHelper categoriesRule = this.ValidationRule(vm => vm.Categories, Categories.ToObservableChangeSet().AutoRefresh(c => c.IsSelected).Filter(c => c.IsSelected).IsNotEmpty(), ValidationHelper categoriesRule = this.ValidationRule(vm => vm.Categories, Categories.ToObservableChangeSet().AutoRefresh(c => c.IsSelected).Filter(c => c.IsSelected).IsNotEmpty(),
"At least one category must be selected" "At least one category must be selected"
); );

View File

@ -1,8 +0,0 @@
<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"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.Entries.EntryInstallationDialogView">
Welcome to Avalonia!
</UserControl>

View File

@ -1,13 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Artemis.UI.Screens.Workshop.Entries;
public partial class EntryInstallationDialogView : UserControl
{
public EntryInstallationDialogView()
{
InitializeComponent();
}
}

View File

@ -1,8 +0,0 @@
using Artemis.UI.Shared;
namespace Artemis.UI.Screens.Workshop.Entries;
public class EntryInstallationDialogViewModel : ContentDialogViewModelBase
{
}

View File

@ -4,9 +4,10 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:entries="clr-namespace:Artemis.UI.Screens.Workshop.Entries" xmlns:entries="clr-namespace:Artemis.UI.Screens.Workshop.Entries"
xmlns:system="clr-namespace:System;assembly=System.Runtime" xmlns:system="clr-namespace:System;assembly=System.Runtime"
xmlns:list="clr-namespace:Artemis.UI.Screens.Workshop.Entries.List"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Artemis.UI.Screens.Workshop.Entries.EntryListInputView" x:Class="Artemis.UI.Screens.Workshop.Entries.List.EntryListInputView"
x:DataType="entries:EntryListInputViewModel"> x:DataType="list:EntryListInputViewModel">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*" MaxWidth="500" /> <ColumnDefinition Width="*" MaxWidth="500" />

View File

@ -1,8 +1,6 @@
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace Artemis.UI.Screens.Workshop.Entries; namespace Artemis.UI.Screens.Workshop.Entries.List;
public partial class EntryListInputView : UserControl public partial class EntryListInputView : UserControl
{ {

View File

@ -4,7 +4,7 @@ using Artemis.UI.Shared;
using PropertyChanged.SourceGenerator; using PropertyChanged.SourceGenerator;
using ReactiveUI; using ReactiveUI;
namespace Artemis.UI.Screens.Workshop.Entries; namespace Artemis.UI.Screens.Workshop.Entries.List;
public partial class EntryListInputViewModel : ViewModelBase public partial class EntryListInputViewModel : ViewModelBase
{ {

View File

@ -6,9 +6,10 @@
xmlns:entries1="clr-namespace:Artemis.UI.Screens.Workshop.Entries" xmlns:entries1="clr-namespace:Artemis.UI.Screens.Workshop.Entries"
xmlns:il="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia" xmlns:il="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
xmlns:converters="clr-namespace:Artemis.UI.Converters" xmlns:converters="clr-namespace:Artemis.UI.Converters"
xmlns:list="clr-namespace:Artemis.UI.Screens.Workshop.Entries.List"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="110" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="110"
x:Class="Artemis.UI.Screens.Workshop.Entries.EntryListItemView" x:Class="Artemis.UI.Screens.Workshop.Entries.List.EntryListItemView"
x:DataType="entries1:EntryListItemViewModel"> x:DataType="list:EntryListItemViewModel">
<UserControl.Resources> <UserControl.Resources>
<converters:EntryIconUriConverter x:Key="EntryIconUriConverter" /> <converters:EntryIconUriConverter x:Key="EntryIconUriConverter" />
<converters:DateTimeConverter x:Key="DateTimeConverter" /> <converters:DateTimeConverter x:Key="DateTimeConverter" />

View File

@ -1,6 +1,6 @@
using Avalonia.ReactiveUI; using Avalonia.ReactiveUI;
namespace Artemis.UI.Screens.Workshop.Entries; namespace Artemis.UI.Screens.Workshop.Entries.List;
public partial class EntryListItemView : ReactiveUserControl<EntryListItemViewModel> public partial class EntryListItemView : ReactiveUserControl<EntryListItemViewModel>
{ {

View File

@ -1,17 +1,12 @@
using System; using System;
using System.Reactive; using System.Reactive;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Artemis.UI.Shared; using Artemis.UI.Shared;
using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Routing;
using Artemis.WebClient.Workshop; using Artemis.WebClient.Workshop;
using Artemis.WebClient.Workshop.Services;
using Avalonia.Media.Imaging;
using ReactiveUI; using ReactiveUI;
namespace Artemis.UI.Screens.Workshop.Entries; namespace Artemis.UI.Screens.Workshop.Entries.List;
public class EntryListItemViewModel : ActivatableViewModelBase public class EntryListItemViewModel : ActivatableViewModelBase
{ {

View File

@ -17,7 +17,7 @@ using PropertyChanged.SourceGenerator;
using ReactiveUI; using ReactiveUI;
using StrawberryShake; using StrawberryShake;
namespace Artemis.UI.Screens.Workshop.Entries; namespace Artemis.UI.Screens.Workshop.Entries.List;
public abstract partial class EntryListViewModel : RoutableScreen<WorkshopListParameters> public abstract partial class EntryListViewModel : RoutableScreen<WorkshopListParameters>
{ {
@ -42,8 +42,8 @@ public abstract partial class EntryListViewModel : RoutableScreen<WorkshopListPa
_route = route; _route = route;
_workshopClient = workshopClient; _workshopClient = workshopClient;
_notificationService = notificationService; _notificationService = notificationService;
_showPagination = this.WhenAnyValue(vm => vm.TotalPages).Select(t => t > 1).ToProperty(this, vm => vm.ShowPagination); _showPagination = this.WhenAnyValue<EntryListViewModel, int>(vm => vm.TotalPages).Select(t => t > 1).ToProperty(this, vm => vm.ShowPagination);
_isLoading = this.WhenAnyValue(vm => vm.Page, vm => vm.LoadedPage, (p, c) => p != c).ToProperty(this, vm => vm.IsLoading); _isLoading = this.WhenAnyValue<EntryListViewModel, bool, int, int>(vm => vm.Page, vm => vm.LoadedPage, (p, c) => p != c).ToProperty(this, vm => vm.IsLoading);
CategoriesViewModel = categoriesViewModel; CategoriesViewModel = categoriesViewModel;
InputViewModel = entryListInputViewModel; InputViewModel = entryListInputViewModel;
@ -56,7 +56,7 @@ public abstract partial class EntryListViewModel : RoutableScreen<WorkshopListPa
Entries = entries; Entries = entries;
// Respond to page changes // Respond to page changes
this.WhenAnyValue(vm => vm.Page).Skip(1).Subscribe(p => Task.Run(() => router.Navigate($"{_route}/{p}"))); this.WhenAnyValue<EntryListViewModel, int>(vm => vm.Page).Skip(1).Subscribe(p => Task.Run(() => router.Navigate($"{_route}/{p}")));
this.WhenActivated(d => this.WhenActivated(d =>
{ {

View File

@ -1,17 +1,18 @@
using System; using System;
using Artemis.UI.Screens.Workshop.Categories; using Artemis.UI.Screens.Workshop.Categories;
using Artemis.UI.Screens.Workshop.Entries.List;
using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Routing;
using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services;
using Artemis.WebClient.Workshop; using Artemis.WebClient.Workshop;
namespace Artemis.UI.Screens.Workshop.Entries.Tabs; namespace Artemis.UI.Screens.Workshop.Entries.Tabs;
public class LayoutListViewModel : EntryListViewModel public class LayoutListViewModel : List.EntryListViewModel
{ {
public LayoutListViewModel(IWorkshopClient workshopClient, public LayoutListViewModel(IWorkshopClient workshopClient,
IRouter router, IRouter router,
CategoriesViewModel categoriesViewModel, CategoriesViewModel categoriesViewModel,
EntryListInputViewModel entryListInputViewModel, List.EntryListInputViewModel entryListInputViewModel,
INotificationService notificationService, INotificationService notificationService,
Func<IGetEntries_Entries_Items, EntryListItemViewModel> getEntryListViewModel) Func<IGetEntries_Entries_Items, EntryListItemViewModel> getEntryListViewModel)
: base("workshop/entries/layout", workshopClient, router, categoriesViewModel, entryListInputViewModel, notificationService, getEntryListViewModel) : base("workshop/entries/layout", workshopClient, router, categoriesViewModel, entryListInputViewModel, notificationService, getEntryListViewModel)

View File

@ -1,17 +1,18 @@
using System; using System;
using Artemis.UI.Screens.Workshop.Categories; using Artemis.UI.Screens.Workshop.Categories;
using Artemis.UI.Screens.Workshop.Entries.List;
using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Routing;
using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services;
using Artemis.WebClient.Workshop; using Artemis.WebClient.Workshop;
namespace Artemis.UI.Screens.Workshop.Entries.Tabs; namespace Artemis.UI.Screens.Workshop.Entries.Tabs;
public class ProfileListViewModel : EntryListViewModel public class ProfileListViewModel : List.EntryListViewModel
{ {
public ProfileListViewModel(IWorkshopClient workshopClient, public ProfileListViewModel(IWorkshopClient workshopClient,
IRouter router, IRouter router,
CategoriesViewModel categoriesViewModel, CategoriesViewModel categoriesViewModel,
EntryListInputViewModel entryListInputViewModel, List.EntryListInputViewModel entryListInputViewModel,
INotificationService notificationService, INotificationService notificationService,
Func<IGetEntries_Entries_Items, EntryListItemViewModel> getEntryListViewModel) Func<IGetEntries_Entries_Items, EntryListItemViewModel> getEntryListViewModel)
: base("workshop/entries/profiles", workshopClient, router, categoriesViewModel, entryListInputViewModel, notificationService, getEntryListViewModel) : base("workshop/entries/profiles", workshopClient, router, categoriesViewModel, entryListInputViewModel, notificationService, getEntryListViewModel)

View File

@ -20,22 +20,27 @@
RenderOptions.BitmapInterpolationMode="HighQuality" RenderOptions.BitmapInterpolationMode="HighQuality"
Source="{CompiledBinding Bitmap}" /> Source="{CompiledBinding Bitmap}" />
<StackPanel Grid.Row="1" Margin="12"> <StackPanel Grid.Row="1" Margin="12">
<TextBlock Text="{CompiledBinding FileName, FallbackValue=Unnamed image}" TextTrimming="CharacterEllipsis" /> <TextBlock Text="{CompiledBinding Name, FallbackValue=Unnamed image}" TextTrimming="CharacterEllipsis" />
<StackPanel> <TextBlock TextWrapping="Wrap" Classes="subtitle" Text="{CompiledBinding Description}" />
<TextBlock TextWrapping="Wrap" Classes="subtitle" Text="{CompiledBinding ImageDimensions, Mode=OneWay}" /> <Separator Margin="-4 10" />
<TextBlock TextWrapping="Wrap" Classes="subtitle" Text="{CompiledBinding FileSize, Converter={StaticResource BytesToStringConverter}, Mode=OneWay}" /> <TextBlock TextWrapping="Wrap" Classes="subtitle">
</StackPanel> <Run Text="{CompiledBinding ImageDimensions}" /> - <Run Text="{CompiledBinding FileSize, Converter={StaticResource BytesToStringConverter}, Mode=OneWay}" />
</TextBlock>
</StackPanel> </StackPanel>
<Button Grid.Row="1" <StackPanel Grid.Row="1" Spacing="5" Margin="6" VerticalAlignment="Bottom" HorizontalAlignment="Right">
VerticalAlignment="Bottom" <Button Classes="icon-button"
HorizontalAlignment="Right" Command="{CompiledBinding Remove}"
Margin="6" ToolTip.Tip="Edit">
Classes="icon-button" <avalonia:MaterialIcon Kind="Edit" />
</Button>
<Button Classes="icon-button"
Command="{CompiledBinding Remove}" Command="{CompiledBinding Remove}"
ToolTip.Tip="Remove"> ToolTip.Tip="Remove">
<avalonia:MaterialIcon Kind="Trash" /> <avalonia:MaterialIcon Kind="Trash" />
</Button> </Button>
</StackPanel>
</Grid> </Grid>
</Border> </Border>
</UserControl> </UserControl>

View File

@ -2,39 +2,43 @@ using System.IO;
using System.Reactive.Disposables; using System.Reactive.Disposables;
using System.Windows.Input; using System.Windows.Input;
using Artemis.UI.Shared; using Artemis.UI.Shared;
using Artemis.WebClient.Workshop.Handlers.UploadHandlers;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using Avalonia.Threading; using Avalonia.Threading;
using PropertyChanged.SourceGenerator; using PropertyChanged.SourceGenerator;
using ReactiveUI; using ReactiveUI;
using ReactiveUI.Validation.Extensions;
namespace Artemis.UI.Screens.Workshop.Image; namespace Artemis.UI.Screens.Workshop.Image;
public partial class ImageSubmissionViewModel : ActivatableViewModelBase public partial class ImageSubmissionViewModel : ValidatableViewModelBase
{ {
[Notify(Setter.Private)] private Bitmap? _bitmap; [Notify(Setter.Private)] private Bitmap? _bitmap;
[Notify(Setter.Private)] private string? _fileName;
[Notify(Setter.Private)] private string? _imageDimensions; [Notify(Setter.Private)] private string? _imageDimensions;
[Notify(Setter.Private)] private long _fileSize; [Notify(Setter.Private)] private long _fileSize;
[Notify] private string? _name;
[Notify] private string? _description;
[Notify] private ICommand? _remove; [Notify] private ICommand? _remove;
public ImageSubmissionViewModel(Stream imageStream) public ImageSubmissionViewModel(ImageUploadRequest image)
{ {
this.WhenActivated(d => this.WhenActivated(d =>
{ {
Dispatcher.UIThread.Invoke(() => Dispatcher.UIThread.Invoke(() =>
{ {
imageStream.Seek(0, SeekOrigin.Begin); image.File.Seek(0, SeekOrigin.Begin);
Bitmap = new Bitmap(imageStream); Bitmap = new Bitmap(image.File);
FileSize = imageStream.Length; FileSize = image.File.Length;
ImageDimensions = Bitmap.Size.Width + "x" + Bitmap.Size.Height; ImageDimensions = Bitmap.Size.Width + "x" + Bitmap.Size.Height;
Name = image.Name;
if (imageStream is FileStream fileStream) Description = image.Description;
FileName = Path.GetFileName(fileStream.Name);
else
FileName = "Unnamed image";
Bitmap.DisposeWith(d); Bitmap.DisposeWith(d);
}, DispatcherPriority.Background); }, DispatcherPriority.Background);
}); });
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");
} }
} }

View File

@ -2,132 +2,31 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
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:Layout="clr-namespace:Artemis.UI.Screens.Workshop.Layout" xmlns:layout="clr-namespace:Artemis.UI.Screens.Workshop.Layout"
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:il="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
xmlns:converters="clr-namespace:Artemis.UI.Converters"
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia.Tight" xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia.Tight"
xmlns:mdsvg="https://github.com/whistyun/Markdown.Avalonia.Svg"
xmlns:ui="clr-namespace:Artemis.UI"
xmlns:converters1="clr-namespace:Artemis.UI.Shared.Converters;assembly=Artemis.UI.Shared"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="800" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="800"
x:Class="Artemis.UI.Screens.Workshop.Layout.LayoutDetailsView" x:Class="Artemis.UI.Screens.Workshop.Layout.LayoutDetailsView"
x:DataType="Layout:LayoutDetailsViewModel"> x:DataType="layout:LayoutDetailsViewModel">
<UserControl.Resources> <Grid ColumnDefinitions="300,*, 300" RowDefinitions="Auto,*">
<converters:EntryIconUriConverter x:Key="EntryIconUriConverter" />
<converters:DateTimeConverter x:Key="DateTimeConverter" />
<converters1:BytesToStringConverter x:Key="BytesToStringConverter" />
</UserControl.Resources>
<Grid ColumnDefinitions="300,*" RowDefinitions="Auto,*">
<StackPanel Grid.Row="1" Grid.Column="0" Margin="0 0 10 0" Spacing="10"> <StackPanel Grid.Row="1" Grid.Column="0" Margin="0 0 10 0" Spacing="10">
<Border Classes="card" VerticalAlignment="Top"> <Border Classes="card" VerticalAlignment="Top">
<StackPanel> <ContentControl Content="{CompiledBinding EntryInfoViewModel}" />
<Border CornerRadius="6"
HorizontalAlignment="Left"
Margin="0 0 10 0"
Width="80"
Height="80"
ClipToBounds="True">
<Image Stretch="UniformToFill" il:ImageLoader.Source="{CompiledBinding Entry.Id, Converter={StaticResource EntryIconUriConverter}, Mode=OneWay}" />
</Border> </Border>
<TextBlock Theme="{StaticResource TitleTextBlockStyle}"
MaxLines="3"
TextTrimming="CharacterEllipsis"
Text="{CompiledBinding Entry.Name, FallbackValue=Title }" />
<TextBlock Classes="subtitle" TextTrimming="CharacterEllipsis" Text="{CompiledBinding Entry.Author, FallbackValue=Author}" />
<TextBlock Margin="0 8" TextWrapping="Wrap" Text="{CompiledBinding Entry.Summary, FallbackValue=Summary}" />
<!-- Categories -->
<ItemsControl ItemsSource="{CompiledBinding Entry.Categories}" Margin="0 0 -8 0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"></WrapPanel>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="0 0 8 0">
<avalonia:MaterialIcon Kind="{CompiledBinding Icon}" Margin="0 0 3 0"></avalonia:MaterialIcon>
<TextBlock Text="{CompiledBinding Name}" TextTrimming="CharacterEllipsis" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Border Classes="card-separator"></Border>
<TextBlock Margin="0 0 0 8">
<avalonia:MaterialIcon Kind="Downloads" />
<Run Classes="h5" Text="{CompiledBinding Entry.Downloads, FallbackValue=0}" />
<Run>downloads</Run>
</TextBlock>
<TextBlock Classes="subtitle"
ToolTip.Tip="{CompiledBinding Entry.CreatedAt, Converter={StaticResource DateTimeConverter}}">
<avalonia:MaterialIcon Kind="Calendar" />
<Run>Created</Run>
<Run Text="{CompiledBinding Entry.CreatedAt, Converter={StaticResource DateTimeConverter}, ConverterParameter='humanize'}"></Run>
</TextBlock>
<TextBlock Classes="subtitle"
ToolTip.Tip="{CompiledBinding UpdatedAt, Converter={StaticResource DateTimeConverter}}">
<avalonia:MaterialIcon Kind="Update" />
<Run>Updated</Run>
<Run Text="{CompiledBinding UpdatedAt, Converter={StaticResource DateTimeConverter}, ConverterParameter='humanize'}"></Run>
</TextBlock>
</StackPanel>
</Border>
<Border Classes="card" VerticalAlignment="Top" IsVisible="{CompiledBinding Entry.LatestRelease, Converter={x:Static ObjectConverters.IsNotNull}}"> <Border Classes="card" VerticalAlignment="Top" IsVisible="{CompiledBinding Entry.LatestRelease, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel> <ContentControl Content="{CompiledBinding EntryReleasesViewModel}" />
<TextBlock Theme="{StaticResource SubtitleTextBlockStyle}">Latest release</TextBlock>
<Border Classes="card-separator" />
<Button HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Command="{CompiledBinding DownloadLatestRelease}">
<Grid ColumnDefinitions="Auto,*">
<!-- Icon -->
<Border Grid.Column="0"
CornerRadius="4"
Background="{StaticResource SystemAccentColor}"
VerticalAlignment="Center"
Margin="0 6"
Width="50"
Height="50"
ClipToBounds="True">
<avalonia:MaterialIcon Kind="Download"></avalonia:MaterialIcon>
</Border>
<!-- Body -->
<StackPanel Grid.Column="1" Margin="10 0" VerticalAlignment="Center">
<TextBlock Text="{CompiledBinding Entry.LatestRelease.Version, FallbackValue=Version}"></TextBlock>
<TextBlock Classes="subtitle">
<avalonia:MaterialIcon Kind="BoxOutline" />
<Run Text="{CompiledBinding Entry.LatestRelease.DownloadSize, Converter={StaticResource BytesToStringConverter}, Mode=OneWay}"></Run>
</TextBlock>
<TextBlock Classes="subtitle"
ToolTip.Tip="{CompiledBinding Entry.LatestRelease.CreatedAt, Converter={StaticResource DateTimeConverter}}">
<avalonia:MaterialIcon Kind="Calendar" />
<Run>Created</Run>
<Run Text="{CompiledBinding Entry.LatestRelease.CreatedAt, Converter={StaticResource DateTimeConverter}, ConverterParameter='humanize'}"></Run>
</TextBlock>
</StackPanel>
</Grid>
</Button>
</StackPanel>
</Border> </Border>
</StackPanel> </StackPanel>
<Border Classes="card" Grid.Row="1" Grid.Column="1"> <Border Classes="card" Grid.Row="1" Grid.Column="1">
<mdxaml:MarkdownScrollViewer Markdown="{CompiledBinding Entry.Description}" MarkdownStyleName="FluentAvalonia"> <mdxaml:MarkdownScrollViewer Markdown="{CompiledBinding Entry.Description}" MarkdownStyleName="FluentAvalonia">
<mdxaml:MarkdownScrollViewer.Styles> <mdxaml:MarkdownScrollViewer.Styles>
<StyleInclude Source="/Styles/Markdown.axaml" /> <StyleInclude Source="/Styles/Markdown.axaml" />
</mdxaml:MarkdownScrollViewer.Styles> </mdxaml:MarkdownScrollViewer.Styles>
</mdxaml:MarkdownScrollViewer> </mdxaml:MarkdownScrollViewer>
</Border> </Border>
<StackPanel Grid.Row="1" Grid.Column="2" IsVisible="{CompiledBinding Entry.Images.Count}">
<ContentControl Content="{CompiledBinding EntryImagesViewModel}" />
</StackPanel>
</Grid> </Grid>
</UserControl> </UserControl>

View File

@ -1,17 +1,11 @@
using System; using System;
using System.Reactive;
using System.Reactive.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Artemis.Core; using Artemis.UI.Screens.Workshop.Entries.Details;
using Artemis.UI.Screens.Workshop.Parameters; using Artemis.UI.Screens.Workshop.Parameters;
using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Routing;
using Artemis.UI.Shared.Services;
using Artemis.UI.Shared.Services.Builders;
using Artemis.UI.Shared.Utilities;
using Artemis.WebClient.Workshop; using Artemis.WebClient.Workshop;
using PropertyChanged.SourceGenerator; using PropertyChanged.SourceGenerator;
using ReactiveUI;
using StrawberryShake; using StrawberryShake;
namespace Artemis.UI.Screens.Workshop.Layout; namespace Artemis.UI.Screens.Workshop.Layout;
@ -19,25 +13,25 @@ namespace Artemis.UI.Screens.Workshop.Layout;
public partial class LayoutDetailsViewModel : RoutableScreen<WorkshopDetailParameters> public partial class LayoutDetailsViewModel : RoutableScreen<WorkshopDetailParameters>
{ {
private readonly IWorkshopClient _client; private readonly IWorkshopClient _client;
private readonly INotificationService _notificationService; private readonly Func<IGetEntryById_Entry, EntryInfoViewModel> _getEntryInfoViewModel;
private readonly IWindowService _windowService; private readonly Func<IGetEntryById_Entry, EntryReleasesViewModel> _getEntryReleasesViewModel;
private readonly ObservableAsPropertyHelper<DateTimeOffset?> _updatedAt; private readonly Func<IGetEntryById_Entry, EntryImagesViewModel> _getEntryImagesViewModel;
[Notify(Setter.Private)] private IGetEntryById_Entry? _entry; [Notify] private IGetEntryById_Entry? _entry;
[Notify] private EntryInfoViewModel? _entryInfoViewModel;
[Notify] private EntryReleasesViewModel? _entryReleasesViewModel;
[Notify] private EntryImagesViewModel? _entryImagesViewModel;
public LayoutDetailsViewModel(IWorkshopClient client, INotificationService notificationService, IWindowService windowService) public LayoutDetailsViewModel(IWorkshopClient client,
Func<IGetEntryById_Entry, EntryInfoViewModel> getEntryInfoViewModel,
Func<IGetEntryById_Entry, EntryReleasesViewModel> getEntryReleasesViewModel,
Func<IGetEntryById_Entry, EntryImagesViewModel> getEntryImagesViewModel)
{ {
_client = client; _client = client;
_notificationService = notificationService; _getEntryInfoViewModel = getEntryInfoViewModel;
_windowService = windowService; _getEntryReleasesViewModel = getEntryReleasesViewModel;
_updatedAt = this.WhenAnyValue(vm => vm.Entry).Select(e => e?.LatestRelease?.CreatedAt ?? e?.CreatedAt).ToProperty(this, vm => vm.UpdatedAt); _getEntryImagesViewModel = getEntryImagesViewModel;
DownloadLatestRelease = ReactiveCommand.CreateFromTask(ExecuteDownloadLatestRelease);
} }
public ReactiveCommand<Unit, Unit> DownloadLatestRelease { get; }
public DateTimeOffset? UpdatedAt => _updatedAt.Value;
public override async Task OnNavigating(WorkshopDetailParameters parameters, NavigationArguments args, CancellationToken cancellationToken) public override async Task OnNavigating(WorkshopDetailParameters parameters, NavigationArguments args, CancellationToken cancellationToken)
{ {
await GetEntry(parameters.EntryId, cancellationToken); await GetEntry(parameters.EntryId, cancellationToken);
@ -50,10 +44,16 @@ public partial class LayoutDetailsViewModel : RoutableScreen<WorkshopDetailParam
return; return;
Entry = result.Data?.Entry; Entry = result.Data?.Entry;
} if (Entry == null)
private Task ExecuteDownloadLatestRelease(CancellationToken cancellationToken)
{ {
return Task.CompletedTask; EntryInfoViewModel = null;
EntryReleasesViewModel = null;
}
else
{
EntryInfoViewModel = _getEntryInfoViewModel(Entry);
EntryReleasesViewModel = _getEntryReleasesViewModel(Entry);
EntryImagesViewModel = _getEntryImagesViewModel(Entry);
}
} }
} }

View File

@ -26,8 +26,9 @@
<Button Grid.Row="1" <Button Grid.Row="1"
Grid.Column="0" Grid.Column="0"
Grid.ColumnSpan="2" Grid.ColumnSpan="2"
Margin="0 25 0 0"
HorizontalAlignment="Right" HorizontalAlignment="Right"
VerticalAlignment="Bottom" VerticalAlignment="Top"
Classes="AppBarButton" Classes="AppBarButton"
Command="{CompiledBinding BrowseDeviceProvider}" Command="{CompiledBinding BrowseDeviceProvider}"
ToolTip.Tip="Browse"> ToolTip.Tip="Browse">

View File

@ -51,6 +51,7 @@ public partial class LayoutInfoViewModel : ValidatableViewModelBase
this.ValidationRule(vm => vm.Vendor, input => !string.IsNullOrWhiteSpace(input), "Device vendor is required"); this.ValidationRule(vm => vm.Vendor, input => !string.IsNullOrWhiteSpace(input), "Device vendor is required");
this.ValidationRule(vm => vm.DeviceProviderIdInput, input => Guid.TryParse(input, out _), "Must be a valid GUID formatted as: 00000000-0000-0000-0000-000000000000"); this.ValidationRule(vm => vm.DeviceProviderIdInput, input => Guid.TryParse(input, out _), "Must be a valid GUID formatted as: 00000000-0000-0000-0000-000000000000");
this.ValidationRule(vm => vm.DeviceProviderIdInput, input => !string.IsNullOrWhiteSpace(input), "Device provider ID is required"); this.ValidationRule(vm => vm.DeviceProviderIdInput, input => !string.IsNullOrWhiteSpace(input), "Device provider ID is required");
this.ValidationRule(vm => vm.DeviceProviderIdInput, input => input != "00000000-0000-0000-0000-000000000000", "Device provider ID is required");
} }
public string? DeviceProviders => _deviceProviders.Value; public string? DeviceProviders => _deviceProviders.Value;

View File

@ -21,6 +21,7 @@ using Avalonia.Media.Imaging;
using PropertyChanged.SourceGenerator; using PropertyChanged.SourceGenerator;
using ReactiveUI; using ReactiveUI;
using StrawberryShake; using StrawberryShake;
using EntrySpecificationsViewModel = Artemis.UI.Screens.Workshop.Entries.Details.EntrySpecificationsViewModel;
namespace Artemis.UI.Screens.Workshop.Library; namespace Artemis.UI.Screens.Workshop.Library;

View File

@ -3,141 +3,30 @@
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:profile="clr-namespace:Artemis.UI.Screens.Workshop.Profile" xmlns:profile="clr-namespace:Artemis.UI.Screens.Workshop.Profile"
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:il="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
xmlns:converters="clr-namespace:Artemis.UI.Converters"
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia.Tight" xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia.Tight"
xmlns:mdsvg="https://github.com/whistyun/Markdown.Avalonia.Svg"
xmlns:ui="clr-namespace:Artemis.UI"
xmlns:converters1="clr-namespace:Artemis.UI.Shared.Converters;assembly=Artemis.UI.Shared"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="800" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="800"
x:Class="Artemis.UI.Screens.Workshop.Profile.ProfileDetailsView" x:Class="Artemis.UI.Screens.Workshop.Profile.ProfileDetailsView"
x:DataType="profile:ProfileDetailsViewModel"> x:DataType="profile:ProfileDetailsViewModel">
<UserControl.Resources> <Grid ColumnDefinitions="300,*, 300" RowDefinitions="Auto,*">
<converters:EntryIconUriConverter x:Key="EntryIconUriConverter" />
<converters:DateTimeConverter x:Key="DateTimeConverter" />
<converters1:BytesToStringConverter x:Key="BytesToStringConverter" />
</UserControl.Resources>
<Grid ColumnDefinitions="300,*" RowDefinitions="Auto,*">
<StackPanel Grid.Row="1" Grid.Column="0" Margin="0 0 10 0" Spacing="10"> <StackPanel Grid.Row="1" Grid.Column="0" Margin="0 0 10 0" Spacing="10">
<Border Classes="card" VerticalAlignment="Top"> <Border Classes="card" VerticalAlignment="Top">
<StackPanel> <ContentControl Content="{CompiledBinding EntryInfoViewModel}" />
<Panel>
<Border CornerRadius="6"
HorizontalAlignment="Left"
Margin="0 0 10 0"
Width="80"
Height="80"
ClipToBounds="True">
<Image Stretch="UniformToFill" il:ImageLoader.Source="{CompiledBinding Entry.Id, Converter={StaticResource EntryIconUriConverter}, Mode=OneWay}" />
</Border> </Border>
<Button Classes="icon-button"
VerticalAlignment="Top"
HorizontalAlignment="Right"
Command="{CompiledBinding CopyShareLink}"
ToolTip.Tip="Copy share link">
<avalonia:MaterialIcon Kind="ShareVariant"/>
</Button>
</Panel>
<TextBlock Theme="{StaticResource TitleTextBlockStyle}"
MaxLines="3"
TextTrimming="CharacterEllipsis"
Text="{CompiledBinding Entry.Name, FallbackValue=Title }" />
<TextBlock Classes="subtitle" TextTrimming="CharacterEllipsis" Text="{CompiledBinding Entry.Author, FallbackValue=Author}" />
<TextBlock Margin="0 8" TextWrapping="Wrap" Text="{CompiledBinding Entry.Summary, FallbackValue=Summary}" />
<!-- Categories -->
<ItemsControl ItemsSource="{CompiledBinding Entry.Categories}" Margin="0 0 -8 0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"></WrapPanel>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="0 0 8 0">
<avalonia:MaterialIcon Kind="{CompiledBinding Icon}" Margin="0 0 3 0"></avalonia:MaterialIcon>
<TextBlock Text="{CompiledBinding Name}" TextTrimming="CharacterEllipsis" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Border Classes="card-separator"></Border>
<TextBlock Margin="0 0 0 8">
<avalonia:MaterialIcon Kind="Downloads" />
<Run Classes="h5" Text="{CompiledBinding Entry.Downloads, FallbackValue=0}" />
<Run>downloads</Run>
</TextBlock>
<TextBlock Classes="subtitle"
ToolTip.Tip="{CompiledBinding Entry.CreatedAt, Converter={StaticResource DateTimeConverter}}">
<avalonia:MaterialIcon Kind="Calendar" />
<Run>Created</Run>
<Run Text="{CompiledBinding Entry.CreatedAt, Converter={StaticResource DateTimeConverter}, ConverterParameter='humanize'}"></Run>
</TextBlock>
<TextBlock Classes="subtitle"
ToolTip.Tip="{CompiledBinding UpdatedAt, Converter={StaticResource DateTimeConverter}}">
<avalonia:MaterialIcon Kind="Update" />
<Run>Updated</Run>
<Run Text="{CompiledBinding UpdatedAt, Converter={StaticResource DateTimeConverter}, ConverterParameter='humanize'}"></Run>
</TextBlock>
</StackPanel>
</Border>
<Border Classes="card" VerticalAlignment="Top" IsVisible="{CompiledBinding Entry.LatestRelease, Converter={x:Static ObjectConverters.IsNotNull}}"> <Border Classes="card" VerticalAlignment="Top" IsVisible="{CompiledBinding Entry.LatestRelease, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel> <ContentControl Content="{CompiledBinding EntryReleasesViewModel}" />
<TextBlock Theme="{StaticResource SubtitleTextBlockStyle}">Latest release</TextBlock>
<Border Classes="card-separator" />
<Button HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Command="{CompiledBinding DownloadLatestRelease}">
<Grid ColumnDefinitions="Auto,*">
<!-- Icon -->
<Border Grid.Column="0"
CornerRadius="4"
Background="{StaticResource SystemAccentColor}"
VerticalAlignment="Center"
Margin="0 6"
Width="50"
Height="50"
ClipToBounds="True">
<avalonia:MaterialIcon Kind="Download"></avalonia:MaterialIcon>
</Border>
<!-- Body -->
<StackPanel Grid.Column="1" Margin="10 0" VerticalAlignment="Center">
<TextBlock Text="{CompiledBinding Entry.LatestRelease.Version, FallbackValue=Version}"></TextBlock>
<TextBlock Classes="subtitle">
<avalonia:MaterialIcon Kind="BoxOutline" />
<Run Text="{CompiledBinding Entry.LatestRelease.DownloadSize, Converter={StaticResource BytesToStringConverter}, Mode=OneWay}"></Run>
</TextBlock>
<TextBlock Classes="subtitle"
ToolTip.Tip="{CompiledBinding Entry.LatestRelease.CreatedAt, Converter={StaticResource DateTimeConverter}}">
<avalonia:MaterialIcon Kind="Calendar" />
<Run>Created</Run>
<Run Text="{CompiledBinding Entry.LatestRelease.CreatedAt, Converter={StaticResource DateTimeConverter}, ConverterParameter='humanize'}"></Run>
</TextBlock>
</StackPanel>
</Grid>
</Button>
</StackPanel>
</Border> </Border>
</StackPanel> </StackPanel>
<Border Classes="card" Grid.Row="1" Grid.Column="1"> <Border Classes="card" Grid.Row="1" Grid.Column="1">
<mdxaml:MarkdownScrollViewer Markdown="{CompiledBinding Entry.Description}" MarkdownStyleName="FluentAvalonia"> <mdxaml:MarkdownScrollViewer Markdown="{CompiledBinding Entry.Description}" MarkdownStyleName="FluentAvalonia">
<mdxaml:MarkdownScrollViewer.Styles> <mdxaml:MarkdownScrollViewer.Styles>
<StyleInclude Source="/Styles/Markdown.axaml" /> <StyleInclude Source="/Styles/Markdown.axaml" />
</mdxaml:MarkdownScrollViewer.Styles> </mdxaml:MarkdownScrollViewer.Styles>
</mdxaml:MarkdownScrollViewer> </mdxaml:MarkdownScrollViewer>
</Border> </Border>
<StackPanel Grid.Row="1" Grid.Column="2" IsVisible="{CompiledBinding Entry.Images.Count}">
<ContentControl Content="{CompiledBinding EntryImagesViewModel}" />
</StackPanel>
</Grid> </Grid>
</UserControl> </UserControl>

View File

@ -1,18 +1,11 @@
using System; using System;
using System.Reactive;
using System.Reactive.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Artemis.Core; using Artemis.UI.Screens.Workshop.Entries.Details;
using Artemis.UI.Screens.Workshop.Parameters; using Artemis.UI.Screens.Workshop.Parameters;
using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Routing;
using Artemis.UI.Shared.Services;
using Artemis.UI.Shared.Services.Builders;
using Artemis.UI.Shared.Utilities;
using Artemis.WebClient.Workshop; using Artemis.WebClient.Workshop;
using Artemis.WebClient.Workshop.Handlers.InstallationHandlers;
using PropertyChanged.SourceGenerator; using PropertyChanged.SourceGenerator;
using ReactiveUI;
using StrawberryShake; using StrawberryShake;
namespace Artemis.UI.Screens.Workshop.Profile; namespace Artemis.UI.Screens.Workshop.Profile;
@ -20,30 +13,25 @@ namespace Artemis.UI.Screens.Workshop.Profile;
public partial class ProfileDetailsViewModel : RoutableScreen<WorkshopDetailParameters> public partial class ProfileDetailsViewModel : RoutableScreen<WorkshopDetailParameters>
{ {
private readonly IWorkshopClient _client; private readonly IWorkshopClient _client;
private readonly ProfileEntryInstallationHandler _installationHandler; private readonly Func<IGetEntryById_Entry, EntryInfoViewModel> _getEntryInfoViewModel;
private readonly INotificationService _notificationService; private readonly Func<IGetEntryById_Entry, EntryReleasesViewModel> _getEntryReleasesViewModel;
private readonly ObservableAsPropertyHelper<DateTimeOffset?> _updatedAt; private readonly Func<IGetEntryById_Entry, EntryImagesViewModel> _getEntryImagesViewModel;
private readonly IWindowService _windowService; [Notify] private IGetEntryById_Entry? _entry;
[Notify(Setter.Private)] private IGetEntryById_Entry? _entry; [Notify] private EntryInfoViewModel? _entryInfoViewModel;
[Notify] private EntryReleasesViewModel? _entryReleasesViewModel;
[Notify] private EntryImagesViewModel? _entryImagesViewModel;
public ProfileDetailsViewModel(IWorkshopClient client, ProfileEntryInstallationHandler installationHandler, INotificationService notificationService, IWindowService windowService) public ProfileDetailsViewModel(IWorkshopClient client,
Func<IGetEntryById_Entry, EntryInfoViewModel> getEntryInfoViewModel,
Func<IGetEntryById_Entry, EntryReleasesViewModel> getEntryReleasesViewModel,
Func<IGetEntryById_Entry, EntryImagesViewModel> getEntryImagesViewModel)
{ {
_client = client; _client = client;
_installationHandler = installationHandler; _getEntryInfoViewModel = getEntryInfoViewModel;
_notificationService = notificationService; _getEntryReleasesViewModel = getEntryReleasesViewModel;
_windowService = windowService; _getEntryImagesViewModel = getEntryImagesViewModel;
_updatedAt = this.WhenAnyValue(vm => vm.Entry).Select(e => e?.LatestRelease?.CreatedAt ?? e?.CreatedAt).ToProperty(this, vm => vm.UpdatedAt);
DownloadLatestRelease = ReactiveCommand.CreateFromTask(ExecuteDownloadLatestRelease);
CopyShareLink = ReactiveCommand.CreateFromTask(ExecuteCopyShareLink);
} }
public ReactiveCommand<Unit, Unit> CopyShareLink { get; set; }
public ReactiveCommand<Unit, Unit> DownloadLatestRelease { get; }
public DateTimeOffset? UpdatedAt => _updatedAt.Value;
public override async Task OnNavigating(WorkshopDetailParameters parameters, NavigationArguments args, CancellationToken cancellationToken) public override async Task OnNavigating(WorkshopDetailParameters parameters, NavigationArguments args, CancellationToken cancellationToken)
{ {
await GetEntry(parameters.EntryId, cancellationToken); await GetEntry(parameters.EntryId, cancellationToken);
@ -56,30 +44,16 @@ public partial class ProfileDetailsViewModel : RoutableScreen<WorkshopDetailPara
return; return;
Entry = result.Data?.Entry; Entry = result.Data?.Entry;
}
private async Task ExecuteDownloadLatestRelease(CancellationToken cancellationToken)
{
if (Entry?.LatestRelease == null)
return;
bool confirm = await _windowService.ShowConfirmContentDialog("Install profile?", "The profile will be downloaded and added to your sidebar automatically.");
if (!confirm)
return;
EntryInstallResult result = await _installationHandler.InstallAsync(Entry, Entry.LatestRelease.Id, new Progress<StreamProgress>(), cancellationToken);
if (result.IsSuccess)
_notificationService.CreateNotification().WithTitle("Profile installed").WithSeverity(NotificationSeverity.Success).Show();
else
_notificationService.CreateNotification().WithTitle("Failed to install profile").WithMessage(result.Message).WithSeverity(NotificationSeverity.Error).Show();
}
private async Task ExecuteCopyShareLink(CancellationToken arg)
{
if (Entry == null) if (Entry == null)
return; {
EntryInfoViewModel = null;
await Shared.UI.Clipboard.SetTextAsync($"{WorkshopConstants.WORKSHOP_URL}/entries/{Entry.Id}/{StringUtilities.UrlFriendly(Entry.Name)}"); EntryReleasesViewModel = null;
_notificationService.CreateNotification().WithTitle("Copied share link to clipboard.").Show(); }
else
{
EntryInfoViewModel = _getEntryInfoViewModel(Entry);
EntryReleasesViewModel = _getEntryReleasesViewModel(Entry);
EntryImagesViewModel = _getEntryImagesViewModel(Entry);
}
} }
} }

View File

@ -33,7 +33,7 @@ public class SubmissionWizardState : IDisposable
public List<long> Categories { get; set; } = new(); public List<long> Categories { get; set; } = new();
public List<string> Tags { get; set; } = new(); public List<string> Tags { get; set; } = new();
public List<Stream> Images { get; set; } = new(); public List<ImageUploadRequest> Images { get; set; } = new();
public IEntrySource? EntrySource { get; set; } public IEntrySource? EntrySource { get; set; }
@ -67,7 +67,7 @@ public class SubmissionWizardState : IDisposable
public void Dispose() public void Dispose()
{ {
Icon?.Dispose(); Icon?.Dispose();
foreach (Stream stream in Images) foreach (ImageUploadRequest image in Images)
stream.Dispose(); image.File.Dispose();
} }
} }

View File

@ -7,6 +7,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Artemis.UI.Screens.Workshop.Image; using Artemis.UI.Screens.Workshop.Image;
using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services;
using Artemis.WebClient.Workshop.Handlers.UploadHandlers;
using DynamicData; using DynamicData;
using ReactiveUI; using ReactiveUI;
@ -16,37 +17,39 @@ public class ImagesStepViewModel : SubmissionViewModel
{ {
private const long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB private const long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
private readonly IWindowService _windowService; private readonly IWindowService _windowService;
private readonly SourceList<Stream> _imageStreams; private readonly Func<ImageUploadRequest, ImageSubmissionViewModel> _getImageSubmissionViewModel;
private readonly SourceList<ImageUploadRequest> _stateImages;
public ImagesStepViewModel(IWindowService windowService, Func<Stream, ImageSubmissionViewModel> imageSubmissionViewModel) public ImagesStepViewModel(IWindowService windowService, Func<ImageUploadRequest, ImageSubmissionViewModel> getImageSubmissionViewModel)
{ {
_windowService = windowService; _windowService = windowService;
_getImageSubmissionViewModel = getImageSubmissionViewModel;
Continue = ReactiveCommand.Create(() => State.ChangeScreen<UploadStepViewModel>()); Continue = ReactiveCommand.Create(() => State.ChangeScreen<UploadStepViewModel>());
GoBack = ReactiveCommand.Create(() => State.ChangeScreen<SpecificationsStepViewModel>()); GoBack = ReactiveCommand.Create(() => State.ChangeScreen<SpecificationsStepViewModel>());
Secondary = ReactiveCommand.CreateFromTask(ExecuteAddImage); Secondary = ReactiveCommand.CreateFromTask(ExecuteAddImage);
SecondaryText = "Add image"; SecondaryText = "Add image";
_imageStreams = new SourceList<Stream>(); _stateImages = new SourceList<ImageUploadRequest>();
_imageStreams.Connect() _stateImages.Connect()
.Transform(p => CreateImageSubmissionViewModel(imageSubmissionViewModel, p)) .Transform(p => CreateImageSubmissionViewModel(p))
.Bind(out ReadOnlyObservableCollection<ImageSubmissionViewModel> images) .Bind(out ReadOnlyObservableCollection<ImageSubmissionViewModel> images)
.Subscribe(); .Subscribe();
Images = images; Images = images;
this.WhenActivated((CompositeDisposable d) => this.WhenActivated((CompositeDisposable d) =>
{ {
_imageStreams.Clear(); _stateImages.Clear();
_imageStreams.AddRange(State.Images); _stateImages.AddRange(State.Images);
}); });
} }
public ReadOnlyObservableCollection<ImageSubmissionViewModel> Images { get; } public ReadOnlyObservableCollection<ImageSubmissionViewModel> Images { get; }
private ImageSubmissionViewModel CreateImageSubmissionViewModel(Func<Stream, ImageSubmissionViewModel> imageSubmissionViewModel, Stream stream) private ImageSubmissionViewModel CreateImageSubmissionViewModel(ImageUploadRequest image)
{ {
ImageSubmissionViewModel viewModel = imageSubmissionViewModel(stream); ImageSubmissionViewModel viewModel = _getImageSubmissionViewModel(image);
viewModel.Remove = ReactiveCommand.Create(() => _imageStreams.Remove(stream)); viewModel.Remove = ReactiveCommand.Create(() => _stateImages.Remove(image));
return viewModel; return viewModel;
} }
@ -58,7 +61,7 @@ public class ImagesStepViewModel : SubmissionViewModel
foreach (string path in result) foreach (string path in result)
{ {
if (_imageStreams.Items.Any(i => i is FileStream fs && fs.Name == path)) if (_stateImages.Items.Any(i => i.File is FileStream fs && fs.Name == path))
continue; continue;
FileStream stream = new(path, FileMode.Open, FileAccess.Read); FileStream stream = new(path, FileMode.Open, FileAccess.Read);
@ -69,8 +72,9 @@ public class ImagesStepViewModel : SubmissionViewModel
continue; continue;
} }
_imageStreams.Add(stream); ImageUploadRequest request = new(stream, Path.GetFileName(path), string.Empty);
State.Images.Add(stream); _stateImages.Add(request);
State.Images.Add(request);
} }
} }
} }

View File

@ -166,14 +166,14 @@ public partial class LayoutSelectionStepViewModel : SubmissionViewModel
} }
State.Icon?.Dispose(); State.Icon?.Dispose();
foreach (Stream stateImage in State.Images) foreach (ImageUploadRequest stateImage in State.Images)
stateImage.Dispose(); stateImage.File.Dispose();
State.Images.Clear(); State.Images.Clear();
// Go through the hassle of resizing the image to 128x128 without losing aspect ratio, padding is added for this // Go through the hassle of resizing the image to 128x128 without losing aspect ratio, padding is added for this
State.Icon = ResizeImage(deviceWithoutLeds, 128); State.Icon = ResizeImage(deviceWithoutLeds, 128);
State.Images.Add(deviceWithoutLeds); State.Images.Add(new ImageUploadRequest(deviceWithoutLeds, "Layout preview (no LEDs)", "A preview of the device without its LEDs"));
State.Images.Add(deviceWithLeds); State.Images.Add(new ImageUploadRequest(deviceWithLeds, "Layout preview (with LEDs)", "A preview of the device with its LEDs"));
} }
private Stream ResizeImage(Stream image, int size) private Stream ResizeImage(Stream image, int size)

View File

@ -11,6 +11,7 @@ using Artemis.WebClient.Workshop;
using DynamicData; using DynamicData;
using PropertyChanged.SourceGenerator; using PropertyChanged.SourceGenerator;
using ReactiveUI; using ReactiveUI;
using EntrySpecificationsViewModel = Artemis.UI.Screens.Workshop.Entries.Details.EntrySpecificationsViewModel;
namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps;

View File

@ -133,12 +133,12 @@ public partial class UploadStepViewModel : SubmissionViewModel
return null; return null;
} }
foreach (Stream image in State.Images.ToList()) foreach (ImageUploadRequest image in State.Images.ToList())
{ {
// Upload image // Upload image
try try
{ {
ImageUploadResult imageUploadResult = await _workshopService.UploadEntryImage(entryId.Value, _progress, image, cancellationToken); ImageUploadResult imageUploadResult = await _workshopService.UploadEntryImage(entryId.Value, image, _progress, cancellationToken);
if (!imageUploadResult.IsSuccess) if (!imageUploadResult.IsSuccess)
throw new ArtemisWorkshopException(imageUploadResult.Message); throw new ArtemisWorkshopException(imageUploadResult.Message);
State.Images.Remove(image); State.Images.Remove(image);

View File

@ -15,7 +15,7 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
<PackageReference Include="ReactiveUI" Version="19.5.1" /> <PackageReference Include="ReactiveUI" Version="19.5.1" />
<PackageReference Include="StrawberryShake.Server" Version="13.6.0-preview.31" /> <PackageReference Include="StrawberryShake.Server" Version="13.7.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.0.3" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.0.3" />
<PackageReference Include="System.Reactive" Version="6.0.0" /> <PackageReference Include="System.Reactive" Version="6.0.0" />
</ItemGroup> </ItemGroup>

View File

@ -16,6 +16,7 @@ public class EntryInstallationHandlerFactory
return entryType switch return entryType switch
{ {
EntryType.Profile => _container.Resolve<ProfileEntryInstallationHandler>(), EntryType.Profile => _container.Resolve<ProfileEntryInstallationHandler>(),
EntryType.Layout => _container.Resolve<LayoutEntryInstallationHandler>(),
_ => throw new NotSupportedException($"EntryType '{entryType}' is not supported.") _ => throw new NotSupportedException($"EntryType '{entryType}' is not supported.")
}; };
} }

View File

@ -0,0 +1,45 @@
using Artemis.UI.Shared.Extensions;
using Artemis.UI.Shared.Utilities;
using Artemis.WebClient.Workshop.Services;
namespace Artemis.WebClient.Workshop.Handlers.InstallationHandlers;
public class LayoutEntryInstallationHandler : IEntryInstallationHandler
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IWorkshopService _workshopService;
public LayoutEntryInstallationHandler(IHttpClientFactory httpClientFactory, IWorkshopService workshopService)
{
_httpClientFactory = httpClientFactory;
_workshopService = workshopService;
}
public async Task<EntryInstallResult> InstallAsync(IGetEntryById_Entry entry, long releaseId, Progress<StreamProgress> progress, CancellationToken cancellationToken)
{
throw new NotImplementedException();
using MemoryStream stream = new();
// Download the provided release
try
{
HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME);
await client.DownloadDataAsync($"releases/download/{releaseId}", stream, progress, cancellationToken);
}
catch (Exception e)
{
return EntryInstallResult.FromFailure(e.Message);
}
// return EntryInstallResult.FromSuccess();
}
public async Task<EntryUninstallResult> UninstallAsync(InstalledEntry installedEntry, CancellationToken cancellationToken)
{
throw new NotImplementedException();
if (!Guid.TryParse(installedEntry.LocalReference, out Guid profileId))
return EntryUninstallResult.FromFailure("Local reference does not contain a GUID");
}
}

View File

@ -0,0 +1,16 @@
namespace Artemis.WebClient.Workshop.Handlers.UploadHandlers;
public class ImageUploadRequest
{
public ImageUploadRequest(Stream file, string name, string? description)
{
File = file;
Name = name.Length > 50 ? name.Substring(0, 50) : name;
if (description != null)
Description = description.Length > 150 ? description.Substring(0, 150) : description;
}
public Stream File { get; set; }
public string Name { get; set; }
public string? Description { get; set; }
}

View File

@ -1,13 +1,23 @@
using System.IO.Compression; using System.IO.Compression;
using System.Net.Http.Headers;
using Artemis.Core; using Artemis.Core;
using Artemis.UI.Shared.Utilities; using Artemis.UI.Shared.Utilities;
using Artemis.WebClient.Workshop.Entities;
using Artemis.WebClient.Workshop.Exceptions; using Artemis.WebClient.Workshop.Exceptions;
using Newtonsoft.Json;
using RGB.NET.Layout; using RGB.NET.Layout;
namespace Artemis.WebClient.Workshop.Handlers.UploadHandlers; namespace Artemis.WebClient.Workshop.Handlers.UploadHandlers;
public class LayoutEntryUploadHandler : IEntryUploadHandler public class LayoutEntryUploadHandler : IEntryUploadHandler
{ {
private readonly IHttpClientFactory _httpClientFactory;
public LayoutEntryUploadHandler(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<EntryUploadResult> CreateReleaseAsync(long entryId, IEntrySource entrySource, Progress<StreamProgress> progress, CancellationToken cancellationToken) public async Task<EntryUploadResult> CreateReleaseAsync(long entryId, IEntrySource entrySource, Progress<StreamProgress> progress, CancellationToken cancellationToken)
{ {
@ -46,15 +56,28 @@ public class LayoutEntryUploadHandler : IEntryUploadHandler
} }
archiveStream.Seek(0, SeekOrigin.Begin); archiveStream.Seek(0, SeekOrigin.Begin);
await using (FileStream fileStream = new(@"C:\Users\Robert\Desktop\layout-test.zip", FileMode.OpenOrCreate))
string desktopPath = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
string filePath = Path.Combine(desktopPath, "layout-test.zip");
await using (FileStream fileStream = new(filePath, FileMode.Create, FileAccess.Write))
{ {
archiveStream.WriteTo(fileStream); await archiveStream.CopyToAsync(fileStream, cancellationToken);
} }
archiveStream.Seek(0, SeekOrigin.Begin);
return new EntryUploadResult(); // Submit the archive
HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME);
// Construct the request
MultipartFormDataContent content = new();
ProgressableStreamContent streamContent = new(archiveStream, progress);
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");
} }
private static void CopyImage(string layoutPath, string? imagePath, ZipArchive archive) private static void CopyImage(string layoutPath, string? imagePath, ZipArchive archive)

View File

@ -3,6 +3,12 @@ fragment category on Category {
icon icon
} }
fragment image on Image {
id
name
description
}
fragment layoutInfo on LayoutInfo { fragment layoutInfo on LayoutInfo {
id id
deviceProvider deviceProvider
@ -21,3 +27,27 @@ fragment submittedEntry on Entry {
downloads downloads
createdAt createdAt
} }
fragment entryDetails on Entry {
id
author
name
summary
entryType
downloads
createdAt
description
categories {
...category
}
latestRelease {
id
version
downloadSize
md5Hash
createdAt
}
images {
...image
}
}

View File

@ -1,22 +1,5 @@
query GetEntryById($id: Long!) { query GetEntryById($id: Long!) {
entry(id: $id) { entry(id: $id) {
id ...entryDetails
author
name
summary
entryType
downloads
createdAt
description
categories {
...category
}
latestRelease {
id
version
downloadSize
md5Hash
createdAt
}
} }
} }

View File

@ -7,7 +7,7 @@ public interface IWorkshopService
{ {
Task<Stream?> GetEntryIcon(long entryId, CancellationToken cancellationToken); Task<Stream?> GetEntryIcon(long entryId, CancellationToken cancellationToken);
Task<ImageUploadResult> SetEntryIcon(long entryId, Progress<StreamProgress> progress, Stream icon, CancellationToken cancellationToken); Task<ImageUploadResult> SetEntryIcon(long entryId, Progress<StreamProgress> progress, Stream icon, CancellationToken cancellationToken);
Task<ImageUploadResult> UploadEntryImage(long entryId, Progress<StreamProgress> progress, Stream image, CancellationToken cancellationToken); Task<ImageUploadResult> UploadEntryImage(long entryId, ImageUploadRequest request, Progress<StreamProgress> progress, CancellationToken cancellationToken);
Task<WorkshopStatus> GetWorkshopStatus(CancellationToken cancellationToken); Task<WorkshopStatus> GetWorkshopStatus(CancellationToken cancellationToken);
Task<bool> ValidateWorkshopStatus(CancellationToken cancellationToken); Task<bool> ValidateWorkshopStatus(CancellationToken cancellationToken);
Task NavigateToEntry(long entryId, EntryType entryType); Task NavigateToEntry(long entryId, EntryType entryType);

View File

@ -57,16 +57,16 @@ public class WorkshopService : IWorkshopService
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<ImageUploadResult> UploadEntryImage(long entryId, Progress<StreamProgress> progress, Stream image, CancellationToken cancellationToken) public async Task<ImageUploadResult> UploadEntryImage(long entryId, ImageUploadRequest request, Progress<StreamProgress> progress, CancellationToken cancellationToken)
{ {
image.Seek(0, SeekOrigin.Begin); request.File.Seek(0, SeekOrigin.Begin);
// Submit the archive // Submit the archive
HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME); HttpClient client = _httpClientFactory.CreateClient(WorkshopConstants.WORKSHOP_CLIENT_NAME);
// Construct the request // Construct the request
MultipartFormDataContent content = new(); MultipartFormDataContent content = new();
ProgressableStreamContent streamContent = new(image, progress); ProgressableStreamContent streamContent = new(request.File, progress);
streamContent.Headers.ContentType = new MediaTypeHeaderValue("image/png"); streamContent.Headers.ContentType = new MediaTypeHeaderValue("image/png");
content.Add(streamContent, "file", "file.png"); content.Add(streamContent, "file", "file.png");

View File

@ -5,6 +5,8 @@ schema {
mutation: Mutation mutation: Mutation
} }
directive @tag(name: String!) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION
type Category { type Category {
icon: String! icon: String!
id: Long! id: Long!
@ -50,8 +52,13 @@ type Entry {
} }
type Image { type Image {
description: String
height: Int!
id: UUID! id: UUID!
mimeType: String! mimeType: String!
name: String!
size: Long!
width: Int!
} }
type LayoutInfo { type LayoutInfo {
@ -252,14 +259,39 @@ input EntryTypeOperationFilterInput {
input ImageFilterInput { input ImageFilterInput {
and: [ImageFilterInput!] and: [ImageFilterInput!]
description: StringOperationFilterInput
height: IntOperationFilterInput
id: UuidOperationFilterInput id: UuidOperationFilterInput
mimeType: StringOperationFilterInput mimeType: StringOperationFilterInput
name: StringOperationFilterInput
or: [ImageFilterInput!] or: [ImageFilterInput!]
size: LongOperationFilterInput
width: IntOperationFilterInput
} }
input ImageSortInput { input ImageSortInput {
description: SortEnumType
height: SortEnumType
id: SortEnumType id: SortEnumType
mimeType: SortEnumType mimeType: SortEnumType
name: SortEnumType
size: SortEnumType
width: SortEnumType
}
input IntOperationFilterInput {
eq: Int
gt: Int
gte: Int
in: [Int]
lt: Int
lte: Int
neq: Int
ngt: Int
ngte: Int
nin: [Int]
nlt: Int
nlte: Int
} }
input LayoutInfoFilterInput { input LayoutInfoFilterInput {