JT
9 months ago
16 changed files with 614 additions and 18 deletions
@ -0,0 +1,8 @@
|
||||
using System.Threading.Tasks; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Models; |
||||
|
||||
public interface IInfinitelyScroll |
||||
{ |
||||
Task LoadNextPageAsync(); |
||||
} |
@ -0,0 +1,175 @@
|
||||
using System; |
||||
using System.Collections.ObjectModel; |
||||
using System.ComponentModel; |
||||
using System.Linq; |
||||
using System.Threading.Tasks; |
||||
using CommunityToolkit.Mvvm.ComponentModel; |
||||
using CommunityToolkit.Mvvm.Input; |
||||
using DynamicData; |
||||
using DynamicData.Binding; |
||||
using FluentAvalonia.UI.Controls; |
||||
using Refit; |
||||
using StabilityMatrix.Avalonia.Models; |
||||
using StabilityMatrix.Avalonia.Services; |
||||
using StabilityMatrix.Avalonia.ViewModels.Base; |
||||
using StabilityMatrix.Avalonia.Views; |
||||
using StabilityMatrix.Core.Api; |
||||
using StabilityMatrix.Core.Attributes; |
||||
using StabilityMatrix.Core.Models.Api.OpenArt; |
||||
using StabilityMatrix.Core.Processes; |
||||
using Symbol = FluentIcons.Common.Symbol; |
||||
using SymbolIconSource = FluentIcons.Avalonia.Fluent.SymbolIconSource; |
||||
|
||||
namespace StabilityMatrix.Avalonia.ViewModels; |
||||
|
||||
[View(typeof(OpenArtBrowserPage))] |
||||
[Singleton] |
||||
public partial class OpenArtBrowserViewModel(IOpenArtApi openArtApi, INotificationService notificationService) |
||||
: PageViewModelBase, |
||||
IInfinitelyScroll |
||||
{ |
||||
private const int PageSize = 20; |
||||
|
||||
public override string Title => "Workflows"; |
||||
public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Whiteboard }; |
||||
|
||||
private SourceCache<OpenArtSearchResult, string> searchResultsCache = new(x => x.Id); |
||||
|
||||
[ObservableProperty] |
||||
[NotifyPropertyChangedFor(nameof(PageCount), nameof(CanGoBack), nameof(CanGoForward), nameof(CanGoToEnd))] |
||||
private OpenArtSearchResponse? latestSearchResponse; |
||||
|
||||
[ObservableProperty] |
||||
private IObservableCollection<OpenArtSearchResult> searchResults = |
||||
new ObservableCollectionExtended<OpenArtSearchResult>(); |
||||
|
||||
[ObservableProperty] |
||||
private string searchQuery = string.Empty; |
||||
|
||||
[ObservableProperty] |
||||
private bool isLoading; |
||||
|
||||
[ObservableProperty] |
||||
[NotifyPropertyChangedFor(nameof(InternalPageNumber), nameof(CanGoBack))] |
||||
private int displayedPageNumber = 1; |
||||
|
||||
public int InternalPageNumber => DisplayedPageNumber - 1; |
||||
|
||||
public int PageCount => |
||||
Math.Max( |
||||
1, |
||||
Convert.ToInt32(Math.Ceiling((LatestSearchResponse?.Total ?? 0) / Convert.ToDouble(PageSize))) |
||||
); |
||||
|
||||
public bool CanGoBack => InternalPageNumber > 0; |
||||
|
||||
public bool CanGoForward => PageCount > InternalPageNumber + 1; |
||||
|
||||
public bool CanGoToEnd => PageCount > InternalPageNumber + 1; |
||||
|
||||
protected override void OnInitialLoaded() |
||||
{ |
||||
searchResultsCache.Connect().DeferUntilLoaded().Bind(SearchResults).Subscribe(); |
||||
} |
||||
|
||||
public override async Task OnLoadedAsync() |
||||
{ |
||||
if (SearchResults.Any()) |
||||
return; |
||||
|
||||
await DoSearch(); |
||||
} |
||||
|
||||
[RelayCommand] |
||||
private async Task FirstPage() |
||||
{ |
||||
DisplayedPageNumber = 1; |
||||
await DoSearch(); |
||||
} |
||||
|
||||
[RelayCommand] |
||||
private async Task PreviousPage() |
||||
{ |
||||
DisplayedPageNumber--; |
||||
await DoSearch(InternalPageNumber); |
||||
} |
||||
|
||||
[RelayCommand] |
||||
private async Task NextPage() |
||||
{ |
||||
DisplayedPageNumber++; |
||||
await DoSearch(InternalPageNumber); |
||||
} |
||||
|
||||
[RelayCommand] |
||||
private async Task LastPage() |
||||
{ |
||||
DisplayedPageNumber = PageCount; |
||||
await DoSearch(PageCount - 1); |
||||
} |
||||
|
||||
[Localizable(false)] |
||||
[RelayCommand] |
||||
private void OpenModel(OpenArtSearchResult workflow) |
||||
{ |
||||
ProcessRunner.OpenUrl($"https://openart.ai/workflows/{workflow.Id}"); |
||||
} |
||||
|
||||
[RelayCommand] |
||||
private async Task SearchButton() |
||||
{ |
||||
DisplayedPageNumber = 1; |
||||
await DoSearch(); |
||||
} |
||||
|
||||
private async Task DoSearch(int page = 0) |
||||
{ |
||||
IsLoading = true; |
||||
|
||||
try |
||||
{ |
||||
var response = await openArtApi.SearchAsync( |
||||
new OpenArtSearchRequest |
||||
{ |
||||
Keyword = SearchQuery, |
||||
PageSize = PageSize, |
||||
CurrentPage = page |
||||
} |
||||
); |
||||
|
||||
searchResultsCache.EditDiff(response.Items, (a, b) => a.Id == b.Id); |
||||
LatestSearchResponse = response; |
||||
} |
||||
catch (ApiException e) |
||||
{ |
||||
notificationService.Show("Error retrieving workflows", e.Message); |
||||
} |
||||
finally |
||||
{ |
||||
IsLoading = false; |
||||
} |
||||
} |
||||
|
||||
public async Task LoadNextPageAsync() |
||||
{ |
||||
try |
||||
{ |
||||
DisplayedPageNumber++; |
||||
var response = await openArtApi.SearchAsync( |
||||
new OpenArtSearchRequest |
||||
{ |
||||
Keyword = SearchQuery, |
||||
PageSize = PageSize, |
||||
CurrentPage = InternalPageNumber |
||||
} |
||||
); |
||||
|
||||
searchResultsCache.AddOrUpdate(response.Items); |
||||
LatestSearchResponse = response; |
||||
} |
||||
catch (ApiException e) |
||||
{ |
||||
notificationService.Show("Unable to load the next page", e.Message); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,340 @@
|
||||
<controls:UserControlBase 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:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" |
||||
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" |
||||
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia" |
||||
xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters" |
||||
xmlns:openArt="clr-namespace:StabilityMatrix.Core.Models.Api.OpenArt;assembly=StabilityMatrix.Core" |
||||
xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages" |
||||
xmlns:helpers="clr-namespace:StabilityMatrix.Avalonia.Helpers" |
||||
xmlns:avalonia="https://github.com/projektanker/icons.avalonia" |
||||
xmlns:input="clr-namespace:FluentAvalonia.UI.Input;assembly=FluentAvalonia" |
||||
xmlns:viewModels="clr-namespace:StabilityMatrix.Avalonia.ViewModels" |
||||
x:DataType="viewModels:OpenArtBrowserViewModel" |
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" |
||||
x:Class="StabilityMatrix.Avalonia.Views.OpenArtBrowserPage"> |
||||
<UserControl.Styles> |
||||
<Style Selector="Border#HoverBorder"> |
||||
<Setter Property="Transitions"> |
||||
<Transitions> |
||||
<BoxShadowsTransition Property="BoxShadow" Duration="0:0:0.237" /> |
||||
</Transitions> |
||||
</Setter> |
||||
|
||||
<Style Selector="^ asyncImageLoader|AdvancedImage"> |
||||
<Setter Property="Transitions"> |
||||
<Transitions> |
||||
<TransformOperationsTransition Property="RenderTransform" |
||||
Duration="0:0:0.237"> |
||||
<TransformOperationsTransition.Easing> |
||||
<QuadraticEaseInOut /> |
||||
</TransformOperationsTransition.Easing> |
||||
</TransformOperationsTransition> |
||||
</Transitions> |
||||
</Setter> |
||||
</Style> |
||||
|
||||
<Style Selector="^:pointerover"> |
||||
<Setter Property="BoxShadow" Value="0 0 40 0 #60000000" /> |
||||
<Setter Property="Cursor" Value="Hand" /> |
||||
<Style Selector="^ asyncImageLoader|AdvancedImage"> |
||||
<Setter Property="CornerRadius" Value="12" /> |
||||
<Setter Property="RenderTransform" Value="scale(1.03, 1.03)" /> |
||||
</Style> |
||||
<Style Selector="^ Border#ModelCardBottom"> |
||||
<Setter Property="Background" Value="#CC000000" /> |
||||
</Style> |
||||
</Style> |
||||
|
||||
<Style Selector="^:not(:pointerover)"> |
||||
<Setter Property="BoxShadow" Value="0 0 20 0 #60000000" /> |
||||
<Setter Property="Cursor" Value="Arrow" /> |
||||
<Style Selector="^ asyncImageLoader|AdvancedImage"> |
||||
<Setter Property="CornerRadius" Value="8" /> |
||||
<Setter Property="RenderTransform" Value="scale(1, 1)" /> |
||||
</Style> |
||||
<Style Selector="^ Border#ModelCardBottom"> |
||||
<Setter Property="Background" Value="#99000000" /> |
||||
</Style> |
||||
</Style> |
||||
</Style> |
||||
</UserControl.Styles> |
||||
|
||||
<UserControl.Resources> |
||||
<input:StandardUICommand |
||||
x:Key="OpenModelCommand" |
||||
Command="{Binding OpenModelCommand}" /> |
||||
<converters:KiloFormatterStringConverter x:Key="KiloFormatterConverter" /> |
||||
<DataTemplate x:Key="OpenArtWorkflowTemplate" DataType="{x:Type openArt:OpenArtSearchResult}"> |
||||
<Border |
||||
Name="HoverBorder" |
||||
Padding="0" |
||||
BorderThickness="0" |
||||
Margin="8" |
||||
ClipToBounds="True" |
||||
CornerRadius="8"> |
||||
<Border.ContextFlyout> |
||||
<MenuFlyout> |
||||
<MenuItem Header="{x:Static lang:Resources.Action_OpenOnCivitAi}"> |
||||
<MenuItem.Icon> |
||||
<ui:SymbolIcon Symbol="Open" /> |
||||
</MenuItem.Icon> |
||||
</MenuItem> |
||||
</MenuFlyout> |
||||
</Border.ContextFlyout> |
||||
<Button |
||||
Name="ModelCard" |
||||
Classes="transparent-full" |
||||
Padding="0" |
||||
BorderThickness="0" |
||||
VerticalContentAlignment="Top" |
||||
CornerRadius="8"> |
||||
<!-- Command="{Binding ShowVersionDialogCommand}" --> |
||||
<!-- CommandParameter="{Binding CivitModel}" --> |
||||
<!-- IsEnabled="{Binding !IsImporting}"> --> |
||||
<Grid RowDefinitions="*, Auto"> |
||||
<controls:BetterAdvancedImage |
||||
Grid.Row="0" |
||||
Grid.RowSpan="2" |
||||
CornerRadius="8" |
||||
Width="330" |
||||
Height="400" |
||||
Source="{Binding Thumbnails[0].Url}" |
||||
Stretch="UniformToFill" |
||||
StretchDirection="Both" /> |
||||
|
||||
<!-- Username pill card --> |
||||
<Border |
||||
BoxShadow="inset 1.2 0 80 1.8 #66000000" |
||||
CornerRadius="16" |
||||
Margin="4" |
||||
Grid.Row="0" |
||||
HorizontalAlignment="Left" |
||||
VerticalAlignment="Bottom"> |
||||
<Border.Resources> |
||||
<DropShadowEffect |
||||
x:Key="TextDropShadowEffect" |
||||
BlurRadius="12" |
||||
Color="#FF000000" |
||||
Opacity="0.9" /> |
||||
<DropShadowEffect |
||||
x:Key="ImageDropShadowEffect" |
||||
BlurRadius="12" |
||||
Color="#FF000000" |
||||
Opacity="0.2" /> |
||||
</Border.Resources> |
||||
<Button |
||||
Command="{x:Static helpers:IOCommands.OpenUrlCommand}" |
||||
CommandParameter="{Binding Creator.DevProfileUrl}" |
||||
CornerRadius="16" |
||||
Classes="transparent" |
||||
Padding="10,4"> |
||||
<StackPanel Orientation="Horizontal" Spacing="6"> |
||||
<controls:BetterAdvancedImage |
||||
Width="22" |
||||
Height="22" |
||||
Effect="{StaticResource ImageDropShadowEffect}" |
||||
CornerRadius="11" |
||||
RenderOptions.BitmapInterpolationMode="HighQuality" |
||||
IsVisible="{Binding Creator.Avatar, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" |
||||
Source="{Binding Creator.Avatar}" /> |
||||
<TextBlock |
||||
VerticalAlignment="Center" |
||||
Effect="{StaticResource TextDropShadowEffect}" |
||||
Text="{Binding Creator.Name}" /> |
||||
</StackPanel> |
||||
</Button> |
||||
</Border> |
||||
|
||||
<Border |
||||
Name="ModelCardBottom" |
||||
Grid.Row="1"> |
||||
<Grid ColumnDefinitions="*, Auto" RowDefinitions="Auto, Auto, Auto"> |
||||
|
||||
<!-- |
||||
TextTrimming causing issues with unicode chars until |
||||
https://github.com/AvaloniaUI/Avalonia/pull/13385 is released |
||||
--> |
||||
<TextBlock |
||||
Grid.ColumnSpan="2" |
||||
MaxWidth="250" |
||||
Margin="8,0,8,0" |
||||
HorizontalAlignment="Left" |
||||
VerticalAlignment="Center" |
||||
FontWeight="SemiBold" |
||||
Foreground="{DynamicResource TextControlForeground}" |
||||
LetterSpacing="0.33" |
||||
Text="{Binding Name}" |
||||
TextWrapping="NoWrap" |
||||
ToolTip.Tip="{Binding Name}" /> |
||||
|
||||
<StackPanel |
||||
Grid.Row="2" |
||||
Grid.Column="0" |
||||
Orientation="Horizontal"> |
||||
|
||||
<controls:StarsRating |
||||
Margin="8,8,0,8" |
||||
Background="#66000000" |
||||
FontSize="16" |
||||
Foreground="{DynamicResource ThemeEldenRingOrangeColor}" |
||||
Value="{Binding Stats.Rating}" /> |
||||
<TextBlock |
||||
Margin="4,0,0,0" |
||||
VerticalAlignment="Center" |
||||
Text="{Binding Stats.NumReviews}" |
||||
TextAlignment="Center" /> |
||||
</StackPanel> |
||||
|
||||
<StackPanel |
||||
Grid.Row="2" |
||||
Grid.Column="1" |
||||
HorizontalAlignment="Right" |
||||
Orientation="Horizontal"> |
||||
<avalonia:Icon Value="fa-solid fa-heart" /> |
||||
<TextBlock |
||||
Margin="4,0" |
||||
VerticalAlignment="Center" |
||||
Text="{Binding Stats.NumLikes, Converter={StaticResource KiloFormatterConverter}}" /> |
||||
|
||||
<avalonia:Icon Margin="4,0" Value="fa-solid fa-download" /> |
||||
<TextBlock |
||||
Margin="0,0,4,0" |
||||
VerticalAlignment="Center" |
||||
Text="{Binding Stats.NumDownloads, Converter={StaticResource KiloFormatterConverter}}" /> |
||||
</StackPanel> |
||||
<Button |
||||
Grid.Row="0" |
||||
Grid.Column="1" |
||||
Width="32" |
||||
Margin="0,4,4,0" |
||||
HorizontalAlignment="Right" |
||||
VerticalAlignment="Top" |
||||
HorizontalContentAlignment="Right" |
||||
VerticalContentAlignment="Top" |
||||
BorderThickness="0" |
||||
Classes="transparent"> |
||||
<ui:SymbolIcon FontSize="18" Symbol="MoreVertical" /> |
||||
<Button.Flyout> |
||||
<MenuFlyout> |
||||
<MenuItem Command="{StaticResource OpenModelCommand}" |
||||
CommandParameter="{Binding }" |
||||
Header="{x:Static lang:Resources.Action_OpenOnCivitAi}"> |
||||
<MenuItem.Icon> |
||||
<ui:SymbolIcon Symbol="Open" /> |
||||
</MenuItem.Icon> |
||||
</MenuItem> |
||||
</MenuFlyout> |
||||
</Button.Flyout> |
||||
</Button> |
||||
</Grid> |
||||
</Border> |
||||
</Grid> |
||||
</Button> |
||||
</Border> |
||||
|
||||
</DataTemplate> |
||||
</UserControl.Resources> |
||||
|
||||
<Grid RowDefinitions="Auto, Auto, *, Auto"> |
||||
<TextBlock Text="OpenArt Workflow Browser" |
||||
FontSize="24" |
||||
Margin="8,8" /> |
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" |
||||
Margin="8,0"> |
||||
|
||||
<TextBox |
||||
HorizontalAlignment="Stretch" |
||||
Text="{Binding SearchQuery, Mode=TwoWay}" |
||||
Watermark="{x:Static lang:Resources.Action_Search}" |
||||
Classes="search"/> |
||||
|
||||
<Button |
||||
Grid.Column="1" |
||||
Width="80" |
||||
Margin="8,0,8,0" |
||||
VerticalAlignment="Stretch" |
||||
Classes="accent" |
||||
Command="{Binding SearchButtonCommand}" |
||||
IsDefault="True"> |
||||
<Grid> |
||||
<controls:ProgressRing |
||||
MinWidth="16" |
||||
MinHeight="16" |
||||
VerticalAlignment="Center" |
||||
BorderThickness="4" |
||||
IsIndeterminate="True" |
||||
IsVisible="{Binding SearchButtonCommand.IsRunning}" /> |
||||
<TextBlock |
||||
VerticalAlignment="Center" |
||||
IsVisible="{Binding !SearchButtonCommand.IsRunning}" |
||||
Text="{x:Static lang:Resources.Action_Search}" /> |
||||
</Grid> |
||||
</Button> |
||||
</Grid> |
||||
|
||||
<controls:ProgressRing Grid.Row="2" |
||||
IsVisible="{Binding IsLoading}" |
||||
IsIndeterminate="True" |
||||
Width="128" |
||||
Height="128"/> |
||||
|
||||
<ScrollViewer Grid.Row="2" |
||||
ScrollChanged="ScrollViewer_OnScrollChanged" |
||||
IsVisible="{Binding !IsLoading}"> |
||||
<ItemsRepeater ItemsSource="{Binding SearchResults}" |
||||
ItemTemplate="{StaticResource OpenArtWorkflowTemplate}"> |
||||
<ItemsRepeater.Layout> |
||||
<UniformGridLayout MinColumnSpacing="4" MinRowSpacing="4"/> |
||||
</ItemsRepeater.Layout> |
||||
</ItemsRepeater> |
||||
</ScrollViewer> |
||||
|
||||
<StackPanel Grid.Row="3" |
||||
HorizontalAlignment="Center" |
||||
Margin="0,8,0,8" |
||||
Orientation="Horizontal"> |
||||
<Button |
||||
Margin="0,0,8,0" |
||||
Command="{Binding FirstPageCommand}" |
||||
IsEnabled="{Binding CanGoBack}" |
||||
ToolTip.Tip="{x:Static lang:Resources.Label_FirstPage}"> |
||||
<avalonia:Icon Value="fa-solid fa-backward-fast" /> |
||||
</Button> |
||||
<Button |
||||
Margin="0,0,16,0" |
||||
Command="{Binding PreviousPageCommand}" |
||||
IsEnabled="{Binding CanGoBack}" |
||||
ToolTip.Tip="{x:Static lang:Resources.Label_PreviousPage}"> |
||||
<avalonia:Icon Value="fa-solid fa-caret-left" /> |
||||
</Button> |
||||
<TextBlock Margin="8,0,4,0" TextAlignment="Center" |
||||
Text="{x:Static lang:Resources.Label_Page}" |
||||
VerticalAlignment="Center"/> |
||||
<ui:NumberBox Value="{Binding DisplayedPageNumber, FallbackValue=1}" |
||||
VerticalAlignment="Center" |
||||
SpinButtonPlacementMode="Hidden" |
||||
TextAlignment="Center"/> |
||||
<TextBlock Margin="4,0,8,0" VerticalAlignment="Center"> |
||||
<Run Text="/"/> |
||||
<Run Text="{Binding PageCount, FallbackValue=5}"/> |
||||
</TextBlock> |
||||
<Button |
||||
Margin="16,0,8,0" |
||||
Command="{Binding NextPageCommand}" |
||||
IsEnabled="{Binding CanGoForward}" |
||||
ToolTip.Tip="{x:Static lang:Resources.Label_NextPage}"> |
||||
<avalonia:Icon Value="fa-solid fa-caret-right" /> |
||||
</Button> |
||||
<Button |
||||
Command="{Binding LastPageCommand}" |
||||
IsEnabled="{Binding CanGoToEnd}" |
||||
ToolTip.Tip="{x:Static lang:Resources.Label_LastPage}"> |
||||
<avalonia:Icon Value="fa-solid fa-forward-fast" /> |
||||
</Button> |
||||
</StackPanel> |
||||
</Grid> |
||||
</controls:UserControlBase> |
@ -0,0 +1,32 @@
|
||||
using System; |
||||
using AsyncAwaitBestPractices; |
||||
using Avalonia.Controls; |
||||
using StabilityMatrix.Avalonia.Controls; |
||||
using StabilityMatrix.Avalonia.Models; |
||||
using StabilityMatrix.Core.Attributes; |
||||
|
||||
namespace StabilityMatrix.Avalonia.Views; |
||||
|
||||
[Singleton] |
||||
public partial class OpenArtBrowserPage : UserControlBase |
||||
{ |
||||
public OpenArtBrowserPage() |
||||
{ |
||||
InitializeComponent(); |
||||
} |
||||
|
||||
private void ScrollViewer_OnScrollChanged(object? sender, ScrollChangedEventArgs e) |
||||
{ |
||||
if (sender is not ScrollViewer scrollViewer) |
||||
return; |
||||
|
||||
if (scrollViewer.Offset.Y == 0) |
||||
return; |
||||
|
||||
var isAtEnd = Math.Abs(scrollViewer.Offset.Y - scrollViewer.ScrollBarMaximum.Y) < 0.1f; |
||||
if (isAtEnd && DataContext is IInfinitelyScroll scroll) |
||||
{ |
||||
scroll.LoadNextPageAsync().SafeFireAndForget(); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,14 @@
|
||||
using System.Text.Json.Serialization; |
||||
|
||||
namespace StabilityMatrix.Core.Models.Api.OpenArt; |
||||
|
||||
public class OpenArtDateTime |
||||
{ |
||||
[JsonPropertyName("_seconds")] |
||||
public long Seconds { get; set; } |
||||
|
||||
public DateTimeOffset ToDateTimeOffset() |
||||
{ |
||||
return DateTimeOffset.FromUnixTimeSeconds(Seconds); |
||||
} |
||||
} |
Loading…
Reference in new issue