diff --git a/src/Artemis.UI.Linux/App.axaml.cs b/src/Artemis.UI.Linux/App.axaml.cs index 52f729727..88f0f8179 100644 --- a/src/Artemis.UI.Linux/App.axaml.cs +++ b/src/Artemis.UI.Linux/App.axaml.cs @@ -1,3 +1,4 @@ +using System; using Artemis.Core.Services; using Artemis.UI.Linux.DryIoc; using Artemis.UI.Linux.Providers.Input; @@ -26,15 +27,11 @@ public class App : Application public override void OnFrameworkInitializationCompleted() { - if (Design.IsDesignMode) + if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || Design.IsDesignMode) return; - - if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) - return; - - ArtemisBootstrapper.Initialize(); - _applicationStateManager = new ApplicationStateManager(_container!, desktop.Args); + _applicationStateManager = new ApplicationStateManager(_container!, desktop.Args ?? Array.Empty()); + ArtemisBootstrapper.Initialize(); RegisterProviders(); } diff --git a/src/Artemis.UI.Shared/Controls/Pagination/Pagination.cs b/src/Artemis.UI.Shared/Controls/Pagination/Pagination.cs new file mode 100644 index 000000000..35b4fa96e --- /dev/null +++ b/src/Artemis.UI.Shared/Controls/Pagination/Pagination.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using ReactiveUI; + +namespace Artemis.UI.Shared.Pagination; + +/// +/// Represents a pagination control that can be used to switch between pages. +/// +[TemplatePart("PART_PreviousButton", typeof(Button))] +[TemplatePart("PART_NextButton", typeof(Button))] +[TemplatePart("PART_PagesView", typeof(StackPanel))] +public partial class Pagination : TemplatedControl +{ + /// + public Pagination() + { + PropertyChanged += OnPropertyChanged; + } + + public Button? PreviousButton { get; set; } + public Button? NextButton { get; set; } + public StackPanel? PagesView { get; set; } + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + if (PreviousButton != null) + PreviousButton.Click -= PreviousButtonOnClick; + if (NextButton != null) + NextButton.Click -= NextButtonOnClick; + + PreviousButton = e.NameScope.Find + + + + + + + + + + + + diff --git a/src/Artemis.UI.Shared/Styles/Sidebar.axaml b/src/Artemis.UI.Shared/Styles/Sidebar.axaml index 95359910d..5fbfb48d5 100644 --- a/src/Artemis.UI.Shared/Styles/Sidebar.axaml +++ b/src/Artemis.UI.Shared/Styles/Sidebar.axaml @@ -13,9 +13,15 @@ - --> + + \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Styles/TextBlock.axaml b/src/Artemis.UI.Shared/Styles/TextBlock.axaml index 9d3cf6ab4..8a5099cde 100644 --- a/src/Artemis.UI.Shared/Styles/TextBlock.axaml +++ b/src/Artemis.UI.Shared/Styles/TextBlock.axaml @@ -10,6 +10,15 @@ This is heading 5 This is heading 6 This is a subtitle + + This is heading 1 + This is heading 2 + This is heading 3 + This is heading 4 + This is heading 5 + This is heading 6 + This is a subtitle + @@ -50,4 +59,26 @@ + + + + + + + + diff --git a/src/Artemis.UI.Shared/Utilities.cs b/src/Artemis.UI.Shared/Utilities.cs index 344aa4834..3b1c58495 100644 --- a/src/Artemis.UI.Shared/Utilities.cs +++ b/src/Artemis.UI.Shared/Utilities.cs @@ -1,29 +1,29 @@ using System; using System.Reactive.Concurrency; using System.Reactive.Linq; +using System.Reactive.Subjects; using System.Threading; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Input.Platform; -using IContainer = DryIoc.IContainer; +using Avalonia.Threading; +using DryIoc; namespace Artemis.UI.Shared; /// -/// Static UI helpers. +/// Static UI helpers. /// public static class UI { + private static readonly BehaviorSubject MicaEnabledSubject = new(false); + + public static EventLoopScheduler BackgroundScheduler = new(ts => new Thread(ts)); + static UI() { KeyBindingsEnabled = InputElement.GotFocusEvent.Raised.Select(e => e.Item2.Source is not TextBox).StartWith(true); - } - - public static EventLoopScheduler BackgroundScheduler = new EventLoopScheduler(ts => new Thread(ts)); - - internal static void ClearCache() - { - DeviceVisualizer.BitmapCache.Clear(); + MicaEnabled = MicaEnabledSubject.AsObservable(); } /// @@ -40,4 +40,24 @@ public static class UI /// Gets a boolean indicating whether hotkeys are to be disabled. /// public static IObservable KeyBindingsEnabled { get; } + + /// + /// Gets a boolean indicating whether the Mica effect should be enabled. + /// + public static IObservable MicaEnabled { get; } + + /// + /// Changes whether Mica should be enabled. + /// + /// + public static void SetMicaEnabled(bool enabled) + { + if (MicaEnabledSubject.Value != enabled) + Dispatcher.UIThread.Invoke(() => MicaEnabledSubject.OnNext(enabled)); + } + + internal static void ClearCache() + { + DeviceVisualizer.BitmapCache.Clear(); + } } \ No newline at end of file diff --git a/src/Artemis.UI.Windows/App.axaml.cs b/src/Artemis.UI.Windows/App.axaml.cs index 617858fb9..9854a1ca5 100644 --- a/src/Artemis.UI.Windows/App.axaml.cs +++ b/src/Artemis.UI.Windows/App.axaml.cs @@ -43,8 +43,8 @@ public class App : Application { if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || Design.IsDesignMode || _shutDown) return; - - _applicationStateManager = new ApplicationStateManager(_container!, desktop.Args); + + _applicationStateManager = new ApplicationStateManager(_container!, desktop.Args ?? Array.Empty()); ArtemisBootstrapper.Initialize(); RegisterProviders(_container!); } @@ -52,11 +52,14 @@ public class App : Application private void RegisterProviders(IContainer container) { IInputService inputService = container.Resolve(); - inputService.AddInputProvider(container.Resolve(serviceKey: WindowsInputProvider.Id)); + inputService.AddInputProvider(container.Resolve(WindowsInputProvider.Id)); } private bool FocusExistingInstance() { + if (Design.IsDesignMode) + return false; + _artemisMutex = new Mutex(true, "Artemis-3c24b502-64e6-4587-84bf-9072970e535f", out bool createdNew); return !createdNew && RemoteFocus(); } diff --git a/src/Artemis.UI/Artemis.UI.csproj b/src/Artemis.UI/Artemis.UI.csproj index 02127d187..be43581c5 100644 --- a/src/Artemis.UI/Artemis.UI.csproj +++ b/src/Artemis.UI/Artemis.UI.csproj @@ -14,6 +14,7 @@ + @@ -42,4 +43,28 @@ + + + + + + + + + + + + + + + + + ProfileListEntryView.axaml + Code + + + StartupWizardView.axaml + Code + + \ No newline at end of file diff --git a/src/Artemis.UI/ArtemisBootstrapper.cs b/src/Artemis.UI/ArtemisBootstrapper.cs index 52cab9df9..dbed7a0f4 100644 --- a/src/Artemis.UI/ArtemisBootstrapper.cs +++ b/src/Artemis.UI/ArtemisBootstrapper.cs @@ -12,6 +12,7 @@ using Artemis.UI.Shared.DryIoc; using Artemis.UI.Shared.Services; using Artemis.VisualScripting.DryIoc; using Artemis.WebClient.Updating.DryIoc; +using Artemis.WebClient.Workshop.DryIoc; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -45,6 +46,7 @@ public static class ArtemisBootstrapper _container.RegisterUI(); _container.RegisterSharedUI(); _container.RegisterUpdatingClient(); + _container.RegisterWorkshopClient(); _container.RegisterNoStringEvaluating(); configureServices?.Invoke(_container); diff --git a/src/Artemis.UI/Assets/Images/avatar-placeholder.png b/src/Artemis.UI/Assets/Images/avatar-placeholder.png new file mode 100644 index 000000000..e7bdbefd2 Binary files /dev/null and b/src/Artemis.UI/Assets/Images/avatar-placeholder.png differ diff --git a/src/Artemis.UI/Assets/Images/workshop-banner.jpg b/src/Artemis.UI/Assets/Images/workshop-banner.jpg new file mode 100644 index 000000000..da259f5f5 Binary files /dev/null and b/src/Artemis.UI/Assets/Images/workshop-banner.jpg differ diff --git a/src/Artemis.UI/DryIoc/Factories/IVMFactory.cs b/src/Artemis.UI/DryIoc/Factories/IVMFactory.cs index e9c315f05..98fb7ea4e 100644 --- a/src/Artemis.UI/DryIoc/Factories/IVMFactory.cs +++ b/src/Artemis.UI/DryIoc/Factories/IVMFactory.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Reactive; using Artemis.Core; @@ -30,6 +31,8 @@ using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; using Artemis.WebClient.Updating; using DryIoc; +using DynamicData; +using Material.Icons; using ReactiveUI; namespace Artemis.UI.DryIoc.Factories; @@ -137,7 +140,7 @@ public class SidebarVmFactory : ISidebarVmFactory { _container = container; } - + public SidebarCategoryViewModel SidebarCategoryViewModel(ProfileCategory profileCategory) { return _container.Resolve(new object[] { profileCategory }); diff --git a/src/Artemis.UI/MainWindow.axaml b/src/Artemis.UI/MainWindow.axaml index d735ae109..f91d7815a 100644 --- a/src/Artemis.UI/MainWindow.axaml +++ b/src/Artemis.UI/MainWindow.axaml @@ -10,12 +10,12 @@ Icon="/Assets/Images/Logo/application.ico" Title="Artemis 2.0" MinWidth="600" - MinHeight="400" + MinHeight="400" PointerReleased="InputElement_OnPointerReleased"> + + + + + + + + @@ -41,51 +53,51 @@ - + - - + + - - + + - - + + - - + + - + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs b/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs index 17c0e0590..3e666815b 100644 --- a/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Reactive; @@ -23,34 +24,38 @@ namespace Artemis.UI.Screens.Sidebar; public class SidebarViewModel : ActivatableViewModelBase { + public const string ROOT_SCREEN = "root"; + private readonly IRouter _router; private readonly IWindowService _windowService; - private SidebarScreenViewModel? _selectedSidebarScreen; private ReadOnlyObservableCollection _sidebarCategories = new(new ObservableCollection()); + private SidebarScreenViewModel? _selectedScreen; public SidebarViewModel(IRouter router, IProfileService profileService, IWindowService windowService, ISidebarVmFactory sidebarVmFactory) { _router = router; _windowService = windowService; - SidebarScreens = new ObservableCollection + SidebarScreen = new SidebarScreenViewModel(MaterialIconKind.Abacus, ROOT_SCREEN, "", null, new ObservableCollection() { - new(MaterialIconKind.Home, "Home", "home"), -#if DEBUG - new(MaterialIconKind.TestTube, "Workshop", "workshop"), -#endif + new(MaterialIconKind.HomeOutline, "Home", "home"), + new(MaterialIconKind.TestTube, "Workshop", "workshop", null, new ObservableCollection + { + new(MaterialIconKind.FolderVideo, "Profiles", "workshop/profiles/1", "workshop/profiles"), + new(MaterialIconKind.KeyboardVariant, "Layouts", "workshop/layouts/1", "workshop/layouts"), + }), new(MaterialIconKind.Devices, "Surface Editor", "surface-editor"), - new(MaterialIconKind.Cog, "Settings", "settings") - }; + new(MaterialIconKind.SettingsOutline, "Settings", "settings") + }); AddCategory = ReactiveCommand.CreateFromTask(ExecuteAddCategory); + this.WhenAnyValue(vm => vm.SelectedScreen).WhereNotNull().Subscribe(NavigateToScreen); + this.WhenAnyValue(vm => vm.SelectedScreen).WhereNotNull().Subscribe(s => SidebarScreen.ExpandIfRequired(s)); SourceList profileCategories = new(); - - this.WhenAnyValue(vm => vm.SelectedSidebarScreen).WhereNotNull().Subscribe(NavigateToScreen); this.WhenActivated(d => { - _router.CurrentPath.WhereNotNull().Subscribe(r => SelectedSidebarScreen = SidebarScreens.FirstOrDefault(s => s.Matches(r))).DisposeWith(d); + _router.CurrentPath.WhereNotNull().Subscribe(r => SelectedScreen = SidebarScreen.GetMatch(r)).DisposeWith(d); Observable.FromEventPattern(x => profileService.ProfileCategoryAdded += x, x => profileService.ProfileCategoryAdded -= x) .Subscribe(e => profileCategories.Add(e.EventArgs.ProfileCategory)) @@ -75,11 +80,17 @@ public class SidebarViewModel : ActivatableViewModelBase .DisposeWith(d); SidebarCategories = categoryViewModels; - SelectedSidebarScreen = SidebarScreens.First(); }); + SelectedScreen = SidebarScreen.Screens.First(); } - public ObservableCollection SidebarScreens { get; } + public SidebarScreenViewModel SidebarScreen { get; } + + public SidebarScreenViewModel? SelectedScreen + { + get => _selectedScreen; + set => RaiseAndSetIfChanged(ref _selectedScreen, value); + } public ReadOnlyObservableCollection SidebarCategories { @@ -87,12 +98,6 @@ public class SidebarViewModel : ActivatableViewModelBase set => RaiseAndSetIfChanged(ref _sidebarCategories, value); } - public SidebarScreenViewModel? SelectedSidebarScreen - { - get => _selectedSidebarScreen; - set => RaiseAndSetIfChanged(ref _selectedSidebarScreen, value); - } - public ReactiveCommand AddCategory { get; } private async Task ExecuteAddCategory() @@ -112,7 +117,7 @@ public class SidebarViewModel : ActivatableViewModelBase { try { - await _router.Navigate(sidebarScreenViewModel.Path, new RouterNavigationOptions {IgnoreOnPartialMatch = true}); + await _router.Navigate(sidebarScreenViewModel.Path); } catch (Exception e) { diff --git a/src/Artemis.UI/Screens/Workshop/Categories/CategoriesView.axaml b/src/Artemis.UI/Screens/Workshop/Categories/CategoriesView.axaml new file mode 100644 index 000000000..d629cc7d3 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Categories/CategoriesView.axaml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Categories/CategoriesView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Categories/CategoriesView.axaml.cs new file mode 100644 index 000000000..15ac17ab5 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Categories/CategoriesView.axaml.cs @@ -0,0 +1,25 @@ +using Avalonia; +using Avalonia.Input; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Categories; + +public partial class CategoriesView : ReactiveUserControl +{ + public CategoriesView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void InputElement_OnPointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (e.InitialPressMouseButton == MouseButton.Left && sender is IDataContextProvider p && p.DataContext is CategoryViewModel categoryViewModel) + categoryViewModel.IsSelected = !categoryViewModel.IsSelected; + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Categories/CategoriesViewModel.cs b/src/Artemis.UI/Screens/Workshop/Categories/CategoriesViewModel.cs new file mode 100644 index 000000000..a72737141 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Categories/CategoriesViewModel.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using Artemis.UI.Shared; +using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Extensions; +using DynamicData; +using DynamicData.Binding; +using ReactiveUI; +using StrawberryShake; + +namespace Artemis.UI.Screens.Workshop.Categories; + +public class CategoriesViewModel : ActivatableViewModelBase +{ + private ObservableAsPropertyHelper?>? _categoryFilters; + + public CategoriesViewModel(IWorkshopClient client) + { + client.GetCategories + .Watch(ExecutionStrategy.CacheFirst) + .SelectOperationResult(c => c.Categories) + .ToObservableChangeSet(c => c.Id) + .Transform(c => new CategoryViewModel(c)) + .Bind(out ReadOnlyObservableCollection categoryViewModels) + .Subscribe(); + + Categories = categoryViewModels; + + this.WhenActivated(d => + { + _categoryFilters = Categories.ToObservableChangeSet() + .AutoRefresh(c => c.IsSelected) + .Filter(e => e.IsSelected) + .Select(_ => CreateFilter()) + .ToProperty(this, vm => vm.CategoryFilters) + .DisposeWith(d); + }); + } + + public ReadOnlyObservableCollection Categories { get; } + public IReadOnlyList? CategoryFilters => _categoryFilters?.Value; + + private IReadOnlyList? CreateFilter() + { + List categories = Categories.Where(c => c.IsSelected).Select(c => (int?) c.Id).ToList(); + if (!categories.Any()) + return null; + + List categoryFilters = new(); + foreach (int? category in categories) + { + categoryFilters.Add(new EntryFilterInput + { + Categories = new ListFilterInputTypeOfCategoryFilterInput {Some = new CategoryFilterInput {Id = new IntOperationFilterInput {Eq = category}}} + }); + } + + return categoryFilters; + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Categories/CategoryViewModel.cs b/src/Artemis.UI/Screens/Workshop/Categories/CategoryViewModel.cs new file mode 100644 index 000000000..08d991009 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Categories/CategoryViewModel.cs @@ -0,0 +1,29 @@ +using System; +using Artemis.UI.Shared; +using Artemis.WebClient.Workshop; +using Material.Icons; + +namespace Artemis.UI.Screens.Workshop.Categories; + +public class CategoryViewModel : ViewModelBase +{ + private bool _isSelected; + + public CategoryViewModel(IGetCategories_Categories category) + { + Id = category.Id; + Name = category.Name; + if (Enum.TryParse(typeof(MaterialIconKind), category.Icon, out object? icon)) + Icon = icon as MaterialIconKind? ?? MaterialIconKind.QuestionMarkCircle; + } + + public int Id { get; } + public string Name { get; } + public MaterialIconKind Icon { get; } + + public bool IsSelected + { + get => _isSelected; + set => RaiseAndSetIfChanged(ref _isSelected, value); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserView.axaml b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserView.axaml new file mode 100644 index 000000000..2b28230f7 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserView.axaml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sign out + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserView.axaml.cs b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserView.axaml.cs new file mode 100644 index 000000000..b690eb8dc --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserView.axaml.cs @@ -0,0 +1,21 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.CurrentUser; + +public partial class CurrentUserView : ReactiveUserControl +{ + public CurrentUserView() + { + InitializeComponent(); + } + + private void Button_OnClick(object? sender, RoutedEventArgs e) + { + UserMenu.ContextFlyout?.Hide(); + ViewModel?.Logout(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserViewModel.cs b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserViewModel.cs new file mode 100644 index 000000000..c204c3165 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/CurrentUser/CurrentUserViewModel.cs @@ -0,0 +1,120 @@ +using System; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Shared; +using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Services; +using Avalonia.Media.Imaging; +using Flurl.Http; +using ReactiveUI; +using Serilog; + +namespace Artemis.UI.Screens.Workshop.CurrentUser; + +public class CurrentUserViewModel : ActivatableViewModelBase +{ + private readonly ILogger _logger; + private readonly IAuthenticationService _authenticationService; + private bool _loading = true; + private Bitmap? _avatar; + private string? _email; + private string? _name; + private string? _userId; + + public CurrentUserViewModel(ILogger logger, IAuthenticationService authenticationService) + { + _logger = logger; + _authenticationService = authenticationService; + Login = ReactiveCommand.CreateFromTask(ExecuteLogin); + + this.WhenActivated(d => ReactiveCommand.CreateFromTask(ExecuteAutoLogin).Execute().Subscribe().DisposeWith(d)); + } + + public bool Loading + { + get => _loading; + set => RaiseAndSetIfChanged(ref _loading, value); + } + + public string? UserId + { + get => _userId; + set => RaiseAndSetIfChanged(ref _userId, value); + } + + public string? Name + { + get => _name; + set => RaiseAndSetIfChanged(ref _name, value); + } + + public string? Email + { + get => _email; + set => RaiseAndSetIfChanged(ref _email, value); + } + + public Bitmap? Avatar + { + get => _avatar; + set => RaiseAndSetIfChanged(ref _avatar, value); + } + + public ReactiveCommand Login { get; } + + public void Logout() + { + _authenticationService.Logout(); + } + + private async Task ExecuteLogin(CancellationToken cancellationToken) + { + await _authenticationService.Login(); + await LoadCurrentUser(); + } + + private async Task ExecuteAutoLogin(CancellationToken cancellationToken) + { + try + { + await _authenticationService.AutoLogin(); + await LoadCurrentUser(); + } + catch (Exception e) + { + _logger.Warning(e, "Failed to load the current user"); + } + finally + { + Loading = false; + } + } + + private async Task LoadCurrentUser() + { + if (!_authenticationService.IsLoggedIn) + return; + + UserId = _authenticationService.Claims.FirstOrDefault(c => c.Type == "sub")?.Value; + Name = _authenticationService.Claims.FirstOrDefault(c => c.Type == "name")?.Value; + Email = _authenticationService.Claims.FirstOrDefault(c => c.Type == "email")?.Value; + + if (UserId != null) + await LoadAvatar(UserId); + } + + private async Task LoadAvatar(string userId) + { + try + { + Avatar = new Bitmap(await $"{WorkshopConstants.AUTHORITY_URL}/user/avatar/{userId}".GetStreamAsync()); + } + catch (Exception) + { + // ignored + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml b/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml new file mode 100644 index 000000000..9af92602f --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml @@ -0,0 +1,71 @@ + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml.cs new file mode 100644 index 000000000..4ce9dc9dd --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml.cs @@ -0,0 +1,19 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Entries; + +public partial class EntryListView : ReactiveUserControl +{ + public EntryListView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs new file mode 100644 index 000000000..0691c5b53 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs @@ -0,0 +1,41 @@ +using System; +using System.Reactive; +using System.Threading.Tasks; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; +using Artemis.WebClient.Workshop; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Entries; + +public class EntryListViewModel : ViewModelBase +{ + private readonly IRouter _router; + + public EntryListViewModel(IGetEntries_Entries_Items entry, IRouter router) + { + _router = router; + Entry = entry; + NavigateToEntry = ReactiveCommand.CreateFromTask(ExecuteNavigateToEntry); + } + + public IGetEntries_Entries_Items Entry { get; } + public ReactiveCommand NavigateToEntry { get; } + + private async Task ExecuteNavigateToEntry() + { + switch (Entry.EntryType) + { + case EntryType.Layout: + await _router.Navigate($"workshop/layouts/{Entry.Id}"); + break; + case EntryType.Profile: + await _router.Navigate($"workshop/profiles/{Entry.Id}"); + break; + case EntryType.Plugin: + break; + default: + throw new ArgumentOutOfRangeException(); + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml new file mode 100644 index 000000000..d0717860f --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Featured submissions + Not yet implemented, here we'll show submissions we think are worth some extra attention. + + Recently updated + Not yet implemented, here we'll a few of the most recent uploads/updates to the workshop. + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml.cs new file mode 100644 index 000000000..e28f6659b --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeView.axaml.cs @@ -0,0 +1,19 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Home; + +public partial class WorkshopHomeView : ReactiveUserControl +{ + public WorkshopHomeView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs new file mode 100644 index 000000000..67391c0bd --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Home/WorkshopHomeViewModel.cs @@ -0,0 +1,33 @@ +using System.Reactive; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Screens.Workshop.SubmissionWizard; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; +using Artemis.UI.Shared.Services; +using Artemis.WebClient.Workshop; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Home; + +public class WorkshopHomeViewModel : ActivatableViewModelBase, IWorkshopViewModel +{ + private readonly IWindowService _windowService; + + public WorkshopHomeViewModel(IRouter router, IWindowService windowService) + { + _windowService = windowService; + AddSubmission = ReactiveCommand.CreateFromTask(ExecuteAddSubmission); + Navigate = ReactiveCommand.CreateFromTask(async r => await router.Navigate(r)); + } + + public ReactiveCommand AddSubmission { get; } + public ReactiveCommand Navigate { get; } + + private async Task ExecuteAddSubmission(CancellationToken arg) + { + await _windowService.ShowDialogAsync(); + } + + public EntryType? EntryType => null; +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsView.axaml b/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsView.axaml new file mode 100644 index 000000000..4e48f869c --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsView.axaml @@ -0,0 +1,25 @@ + + + + + + + + + + Side panel + + + + Layout details panel + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsView.axaml.cs new file mode 100644 index 000000000..3a529743e --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsView.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Layout; + +public partial class LayoutDetailsView : ReactiveUserControl +{ + public LayoutDetailsView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsViewModel.cs new file mode 100644 index 000000000..dcfb608bf --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Layout/LayoutDetailsViewModel.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Screens.Workshop.Parameters; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; +using Artemis.WebClient.Workshop; +using StrawberryShake; + +namespace Artemis.UI.Screens.Workshop.Layout; + +public class LayoutDetailsViewModel : RoutableScreen, IWorkshopViewModel +{ + private readonly IWorkshopClient _client; + private IGetEntryById_Entry? _entry; + + public LayoutDetailsViewModel(IWorkshopClient client) + { + _client = client; + } + + public EntryType? EntryType => null; + + public IGetEntryById_Entry? Entry + { + get => _entry; + set => RaiseAndSetIfChanged(ref _entry, value); + } + + public override async Task OnNavigating(WorkshopDetailParameters parameters, NavigationArguments args, CancellationToken cancellationToken) + { + await GetEntry(parameters.EntryId, cancellationToken); + } + + private async Task GetEntry(Guid entryId, CancellationToken cancellationToken) + { + IOperationResult result = await _client.GetEntryById.ExecuteAsync(entryId, cancellationToken); + if (result.IsErrorResult()) + return; + + Entry = result.Data?.Entry; + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Layout/LayoutListView.axaml b/src/Artemis.UI/Screens/Workshop/Layout/LayoutListView.axaml new file mode 100644 index 000000000..151ec2089 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Layout/LayoutListView.axaml @@ -0,0 +1,25 @@ + + + + + + Categories + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Layout/LayoutListView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Layout/LayoutListView.axaml.cs new file mode 100644 index 000000000..9d604f4f6 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Layout/LayoutListView.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Layout; + +public partial class LayoutListView : ReactiveUserControl +{ + public LayoutListView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Layout/LayoutListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Layout/LayoutListViewModel.cs new file mode 100644 index 000000000..17ac0ca9e --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Layout/LayoutListViewModel.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Screens.Workshop.Categories; +using Artemis.UI.Screens.Workshop.Parameters; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; +using Artemis.WebClient.Workshop; + +namespace Artemis.UI.Screens.Workshop.Layout; + +public class LayoutListViewModel : RoutableScreen, IWorkshopViewModel +{ + private int _page; + + public LayoutListViewModel(CategoriesViewModel categoriesViewModel) + { + CategoriesViewModel = categoriesViewModel; + } + + public CategoriesViewModel CategoriesViewModel { get; } + + public int Page + { + get => _page; + set => RaiseAndSetIfChanged(ref _page, value); + } + + public override Task OnNavigating(WorkshopListParameters parameters, NavigationArguments args, CancellationToken cancellationToken) + { + Page = Math.Max(1, parameters.Page); + return Task.CompletedTask; + } + + public EntryType? EntryType => WebClient.Workshop.EntryType.Layout; +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Parameters/WorkshopDetailParameters.cs b/src/Artemis.UI/Screens/Workshop/Parameters/WorkshopDetailParameters.cs new file mode 100644 index 000000000..dfd37805f --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Parameters/WorkshopDetailParameters.cs @@ -0,0 +1,8 @@ +using System; + +namespace Artemis.UI.Screens.Workshop.Parameters; + +public class WorkshopDetailParameters +{ + public Guid EntryId { get; set; } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Parameters/WorkshopListParameters.cs b/src/Artemis.UI/Screens/Workshop/Parameters/WorkshopListParameters.cs new file mode 100644 index 000000000..abff5bdb8 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Parameters/WorkshopListParameters.cs @@ -0,0 +1,6 @@ +namespace Artemis.UI.Screens.Workshop.Parameters; + +public class WorkshopListParameters +{ + public int Page { get; set; } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml new file mode 100644 index 000000000..12d0a8227 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml @@ -0,0 +1,25 @@ + + + + + + + + + + Side panel + + + + Profile details panel + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml.cs new file mode 100644 index 000000000..25ca88e9f --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsView.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Profile; + +public partial class ProfileDetailsView : ReactiveUserControl +{ + public ProfileDetailsView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs new file mode 100644 index 000000000..f6ae477ba --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileDetailsViewModel.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Screens.Workshop.Parameters; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; +using Artemis.WebClient.Workshop; +using StrawberryShake; + +namespace Artemis.UI.Screens.Workshop.Profile; + +public class ProfileDetailsViewModel : RoutableScreen, IWorkshopViewModel +{ + private readonly IWorkshopClient _client; + private IGetEntryById_Entry? _entry; + + public ProfileDetailsViewModel(IWorkshopClient client) + { + _client = client; + } + + public EntryType? EntryType => null; + + public IGetEntryById_Entry? Entry + { + get => _entry; + set => RaiseAndSetIfChanged(ref _entry, value); + } + + public override async Task OnNavigating(WorkshopDetailParameters parameters, NavigationArguments args, CancellationToken cancellationToken) + { + await GetEntry(parameters.EntryId, cancellationToken); + } + + private async Task GetEntry(Guid entryId, CancellationToken cancellationToken) + { + IOperationResult result = await _client.GetEntryById.ExecuteAsync(entryId, cancellationToken); + if (result.IsErrorResult()) + return; + + Entry = result.Data?.Entry; + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListView.axaml b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListView.axaml new file mode 100644 index 000000000..3ae086124 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListView.axaml @@ -0,0 +1,41 @@ + + + + + + Categories + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListView.axaml.cs new file mode 100644 index 000000000..fb6c372c7 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListView.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Profile; + +public partial class ProfileListView : ReactiveUserControl +{ + public ProfileListView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs new file mode 100644 index 000000000..98e45f00b --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Screens.Workshop.Categories; +using Artemis.UI.Screens.Workshop.Entries; +using Artemis.UI.Screens.Workshop.Parameters; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; +using Artemis.UI.Shared.Services; +using Artemis.UI.Shared.Services.Builders; +using Artemis.WebClient.Workshop; +using ReactiveUI; +using StrawberryShake; + +namespace Artemis.UI.Screens.Workshop.Profile; + +public class ProfileListViewModel : RoutableScreen, IWorkshopViewModel +{ + private readonly IRouter _router; + private readonly INotificationService _notificationService; + private readonly IWorkshopClient _workshopClient; + private readonly ObservableAsPropertyHelper _showPagination; + private readonly ObservableAsPropertyHelper _isLoading; + private List? _entries; + private int _page; + private int _loadedPage = -1; + private int _totalPages = 1; + private int _entriesPerPage = 10; + + public ProfileListViewModel(IWorkshopClient workshopClient, IRouter router, CategoriesViewModel categoriesViewModel, INotificationService notificationService) + { + _workshopClient = workshopClient; + _router = router; + _notificationService = notificationService; + _showPagination = this.WhenAnyValue(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); + + CategoriesViewModel = categoriesViewModel; + + // Respond to page changes + this.WhenAnyValue(vm => vm.Page).Skip(1).Subscribe(p => Task.Run(() => _router.Navigate($"workshop/profiles/{p}"))); + // Respond to filter changes + this.WhenActivated(d => CategoriesViewModel.WhenAnyValue(vm => vm.CategoryFilters).Skip(1).Subscribe(_ => + { + // Reset to page one, will trigger a query + if (Page != 1) + Page = 1; + // If already at page one, force a query + else + Task.Run(() => Query(CancellationToken.None)); + }).DisposeWith(d)); + } + + public bool ShowPagination => _showPagination.Value; + public bool IsLoading => _isLoading.Value; + + public CategoriesViewModel CategoriesViewModel { get; } + + public List? Entries + { + get => _entries; + set => RaiseAndSetIfChanged(ref _entries, value); + } + + public int Page + { + get => _page; + set => RaiseAndSetIfChanged(ref _page, value); + } + + public int LoadedPage + { + get => _loadedPage; + set => RaiseAndSetIfChanged(ref _loadedPage, value); + } + + public int TotalPages + { + get => _totalPages; + set => RaiseAndSetIfChanged(ref _totalPages, value); + } + + public int EntriesPerPage + { + get => _entriesPerPage; + set => RaiseAndSetIfChanged(ref _entriesPerPage, value); + } + + public override async Task OnNavigating(WorkshopListParameters parameters, NavigationArguments args, CancellationToken cancellationToken) + { + Page = Math.Max(1, parameters.Page); + + // Throttle page changes + await Task.Delay(200, cancellationToken); + + if (!cancellationToken.IsCancellationRequested) + await Query(cancellationToken); + } + + private async Task Query(CancellationToken cancellationToken) + { + try + { + EntryFilterInput filter = GetFilter(); + IOperationResult entries = await _workshopClient.GetEntries.ExecuteAsync(filter, EntriesPerPage * (Page - 1), EntriesPerPage, cancellationToken); + entries.EnsureNoErrors(); + + if (entries.Data?.Entries?.Items != null) + { + Entries = entries.Data.Entries.Items.Select(n => new EntryListViewModel(n, _router)).ToList(); + TotalPages = (int) Math.Ceiling(entries.Data.Entries.TotalCount / (double) EntriesPerPage); + } + else + TotalPages = 1; + } + catch (Exception e) + { + _notificationService.CreateNotification() + .WithTitle("Failed to load entries") + .WithMessage(e.Message) + .WithSeverity(NotificationSeverity.Error) + .Show(); + } + finally + { + LoadedPage = Page; + } + } + + private EntryFilterInput GetFilter() + { + EntryFilterInput filter = new() + { + EntryType = new EntryTypeOperationFilterInput {Eq = WebClient.Workshop.EntryType.Profile}, + And = CategoriesViewModel.CategoryFilters + }; + + return filter; + } + + public EntryType? EntryType => null; +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml b/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml new file mode 100644 index 000000000..171d4c400 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml.cs new file mode 100644 index 000000000..e9da03bdb --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml.cs @@ -0,0 +1,18 @@ +using System; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Artemis.UI.Screens.Workshop.Search; + +public partial class SearchView : UserControl +{ + public SearchView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchViewModel.cs b/src/Artemis.UI/Screens/Workshop/Search/SearchViewModel.cs new file mode 100644 index 000000000..b3370ee8b --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchViewModel.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Screens.Workshop.CurrentUser; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Routing; +using Artemis.WebClient.Workshop; +using ReactiveUI; +using StrawberryShake; + +namespace Artemis.UI.Screens.Workshop.Search; + +public class SearchViewModel : ViewModelBase +{ + public CurrentUserViewModel CurrentUserViewModel { get; } + private readonly IRouter _router; + private readonly IWorkshopClient _workshopClient; + private EntryType? _entryType; + private ISearchEntries_SearchEntries? _selectedEntry; + + public SearchViewModel(IWorkshopClient workshopClient, IRouter router, CurrentUserViewModel currentUserViewModel) + { + CurrentUserViewModel = currentUserViewModel; + _workshopClient = workshopClient; + _router = router; + SearchAsync = ExecuteSearchAsync; + + this.WhenAnyValue(vm => vm.SelectedEntry).WhereNotNull().Subscribe(NavigateToEntry); + } + + public Func>> SearchAsync { get; } + + public ISearchEntries_SearchEntries? SelectedEntry + { + get => _selectedEntry; + set => RaiseAndSetIfChanged(ref _selectedEntry, value); + } + + public EntryType? EntryType + { + get => _entryType; + set => RaiseAndSetIfChanged(ref _entryType, value); + } + + private void NavigateToEntry(ISearchEntries_SearchEntries entry) + { + string? url = null; + if (entry.EntryType == WebClient.Workshop.EntryType.Profile) + url = $"workshop/profiles/{entry.Id}"; + if (entry.EntryType == WebClient.Workshop.EntryType.Layout) + url = $"workshop/layouts/{entry.Id}"; + + if (url != null) + Task.Run(() => _router.Navigate(url)); + } + + private async Task> ExecuteSearchAsync(string? input, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(input)) + return new List(); + + IOperationResult results = await _workshopClient.SearchEntries.ExecuteAsync(input, EntryType, cancellationToken); + return results.Data?.SearchEntries.Cast() ?? new List(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchViewStyles.axaml b/src/Artemis.UI/Screens/Workshop/Search/SearchViewStyles.axaml new file mode 100644 index 000000000..584a83924 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchViewStyles.axaml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepView.axaml new file mode 100644 index 000000000..7341a2307 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepView.axaml @@ -0,0 +1,11 @@ + + + Welcome to the Workshop Submission Wizard 🧙 + Here we'll take you, step by step, through the process of uploading your submission to the workshop. + + diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepView.axaml.cs new file mode 100644 index 000000000..9de30c623 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepView.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; + +public partial class WelcomeStepView : ReactiveUserControl +{ + public WelcomeStepView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepViewModel.cs new file mode 100644 index 000000000..cf70f6410 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/Steps/WelcomeStepViewModel.cs @@ -0,0 +1,22 @@ +using System.Reactive; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; + +public class WelcomeStepViewModel : SubmissionViewModel +{ + #region Overrides of SubmissionViewModel + + /// + public override ReactiveCommand Continue { get; } + + /// + public override ReactiveCommand GoBack { get; } = null!; + + public WelcomeStepViewModel() + { + ShowGoBack = false; + } + + #endregion +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml new file mode 100644 index 000000000..18315e3fc --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml.cs new file mode 100644 index 000000000..58dade5ef --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardView.axaml.cs @@ -0,0 +1,33 @@ +using System; +using System.Reactive.Disposables; +using Artemis.UI.Shared; +using Avalonia; +using Avalonia.Threading; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard; + +public partial class SubmissionWizardView : ReactiveAppWindow +{ + public SubmissionWizardView() + { + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + + this.WhenActivated(d => ViewModel.WhenAnyValue(vm => vm.Screen).Subscribe(Navigate).DisposeWith(d)); + } + + private void Navigate(SubmissionViewModel viewModel) + { + try + { + Dispatcher.UIThread.Invoke(() => Frame.NavigateFromObject(viewModel)); + } + catch (Exception) + { + // ignored + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardViewModel.cs b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardViewModel.cs new file mode 100644 index 000000000..fed2c3241 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/SubmissionWizard/SubmissionWizardViewModel.cs @@ -0,0 +1,47 @@ +using System.Reactive; +using Artemis.UI.Screens.Workshop.CurrentUser; +using Artemis.UI.Screens.Workshop.SubmissionWizard.Steps; +using Artemis.UI.Shared; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.SubmissionWizard; + +public class SubmissionWizardViewModel : DialogViewModelBase +{ + private SubmissionViewModel _screen; + + public SubmissionWizardViewModel(CurrentUserViewModel currentUserViewModel) + { + _screen = new WelcomeStepViewModel(); + CurrentUserViewModel = currentUserViewModel; + } + + public CurrentUserViewModel CurrentUserViewModel { get; } + + public SubmissionViewModel Screen + { + get => _screen; + set => RaiseAndSetIfChanged(ref _screen, value); + } +} + +public abstract class SubmissionViewModel : ActivatableViewModelBase +{ + private bool _showFinish; + private bool _showGoBack = true; + + public abstract ReactiveCommand Continue { get; } + public abstract ReactiveCommand GoBack { get; } + + public bool ShowGoBack + { + get => _showGoBack; + set => RaiseAndSetIfChanged(ref _showGoBack, value); + } + + public bool ShowFinish + { + get => _showFinish; + set => RaiseAndSetIfChanged(ref _showFinish, value); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/WorkshopView.axaml b/src/Artemis.UI/Screens/Workshop/WorkshopView.axaml index e5048738b..39cd6ca6c 100644 --- a/src/Artemis.UI/Screens/Workshop/WorkshopView.axaml +++ b/src/Artemis.UI/Screens/Workshop/WorkshopView.axaml @@ -2,76 +2,15 @@ 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:builders="clr-namespace:Artemis.UI.Shared.Services.Builders;assembly=Artemis.UI.Shared" - xmlns:controls1="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" - xmlns:attachedProperties="clr-namespace:Artemis.UI.Shared.AttachedProperties;assembly=Artemis.UI.Shared" xmlns:workshop="clr-namespace:Artemis.UI.Screens.Workshop" - xmlns:shared="clr-namespace:Artemis.UI.Shared;assembly=Artemis.UI.Shared" - xmlns:controls="clr-namespace:Artemis.UI.Shared.Controls;assembly=Artemis.UI.Shared" - xmlns:gradientPicker="clr-namespace:Artemis.UI.Shared.Controls.GradientPicker;assembly=Artemis.UI.Shared" - xmlns:materialIconPicker="clr-namespace:Artemis.UI.Shared.MaterialIconPicker;assembly=Artemis.UI.Shared" + xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" + xmlns:ui="clr-namespace:Artemis.UI" mc:Ignorable="d" d:DesignWidth="800" x:Class="Artemis.UI.Screens.Workshop.WorkshopView" x:DataType="workshop:WorkshopViewModel"> - - - - - - Navigation test - - - - Notification tests - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/WorkshopView.axaml.cs b/src/Artemis.UI/Screens/Workshop/WorkshopView.axaml.cs index c875bd294..c013afa8e 100644 --- a/src/Artemis.UI/Screens/Workshop/WorkshopView.axaml.cs +++ b/src/Artemis.UI/Screens/Workshop/WorkshopView.axaml.cs @@ -1,5 +1,8 @@ -using Avalonia.Markup.Xaml; +using System; +using System.Reactive.Disposables; using Avalonia.ReactiveUI; +using Avalonia.Threading; +using ReactiveUI; namespace Artemis.UI.Screens.Workshop; @@ -8,6 +11,6 @@ public partial class WorkshopView : ReactiveUserControl public WorkshopView() { InitializeComponent(); + this.WhenActivated(d => ViewModel.WhenAnyValue(vm => vm.Screen).Subscribe(vm => WorkshopFrame.NavigateFromObject(vm ?? ViewModel?.HomeViewModel)).DisposeWith(d)); } - } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs b/src/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs index 555f0a636..f6b53a013 100644 --- a/src/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/WorkshopViewModel.cs @@ -1,82 +1,38 @@ -using System.Reactive; -using System.Reactive.Linq; -using Artemis.Core; +using System; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Screens.Workshop.Home; +using Artemis.UI.Screens.Workshop.Search; using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; -using Artemis.UI.Shared.Services; -using Artemis.UI.Shared.Services.Builders; -using Avalonia.Input; -using ReactiveUI; -using SkiaSharp; +using Artemis.WebClient.Workshop; namespace Artemis.UI.Screens.Workshop; -public class WorkshopViewModel : ActivatableViewModelBase, IMainScreenViewModel +public class WorkshopViewModel : RoutableScreen, IMainScreenViewModel { - private readonly ObservableAsPropertyHelper _cursor; - private readonly INotificationService _notificationService; + private readonly SearchViewModel _searchViewModel; - private ColorGradient _colorGradient = new() + public WorkshopViewModel(SearchViewModel searchViewModel, WorkshopHomeViewModel homeViewModel) { - new ColorGradientStop(new SKColor(0xFFFF6D00), 0f), - new ColorGradientStop(new SKColor(0xFFFE6806), 0.2f), - new ColorGradientStop(new SKColor(0xFFEF1788), 0.4f), - new ColorGradientStop(new SKColor(0xFFEF1788), 0.6f), - new ColorGradientStop(new SKColor(0xFF00FCCC), 0.8f), - new ColorGradientStop(new SKColor(0xFF00FCCC), 1f) - }; - - private StandardCursorType _selectedCursor; - private double _testValue; - private string? _navigationPath; - - public WorkshopViewModel(INotificationService notificationService, IRouter router) - { - _notificationService = notificationService; - _cursor = this.WhenAnyValue(vm => vm.SelectedCursor).Select(c => new Cursor(c)).ToProperty(this, vm => vm.Cursor); - - DisplayName = "Workshop"; - ShowNotification = ReactiveCommand.Create(ExecuteShowNotification); - TestNavigation = ReactiveCommand.CreateFromTask(async () => await router.Navigate(NavigationPath!), this.WhenAnyValue(vm => vm.NavigationPath).Select(p => !string.IsNullOrWhiteSpace(p))); + _searchViewModel = searchViewModel; + + TitleBarViewModel = searchViewModel; + HomeViewModel = homeViewModel; } - public ViewModelBase? TitleBarViewModel => null; - public ReactiveCommand ShowNotification { get; set; } - public ReactiveCommand TestNavigation { get; set; } + public ViewModelBase TitleBarViewModel { get; } + public WorkshopHomeViewModel HomeViewModel { get; } - public StandardCursorType SelectedCursor + /// + public override Task OnNavigating(NavigationArguments args, CancellationToken cancellationToken) { - get => _selectedCursor; - set => RaiseAndSetIfChanged(ref _selectedCursor, value); + _searchViewModel.EntryType = Screen?.EntryType; + return Task.CompletedTask; } +} - public Cursor Cursor => _cursor.Value; - - public string? NavigationPath - { - get => _navigationPath; - set => RaiseAndSetIfChanged(ref _navigationPath, value); - } - - public ColorGradient ColorGradient - { - get => _colorGradient; - set => RaiseAndSetIfChanged(ref _colorGradient, value); - } - - public double TestValue - { - get => _testValue; - set => RaiseAndSetIfChanged(ref _testValue, value); - } - - public void CreateRandomGradient() - { - ColorGradient = ColorGradient.GetRandom(6); - } - - private void ExecuteShowNotification(NotificationSeverity severity) - { - _notificationService.CreateNotification().WithTitle("Test title").WithMessage("Test message").WithSeverity(severity).Show(); - } +public interface IWorkshopViewModel +{ + public EntryType? EntryType { get; } } \ No newline at end of file diff --git a/src/Artemis.UI/Styles/Artemis.axaml b/src/Artemis.UI/Styles/Artemis.axaml index 242bef032..a121ec71b 100644 --- a/src/Artemis.UI/Styles/Artemis.axaml +++ b/src/Artemis.UI/Styles/Artemis.axaml @@ -8,4 +8,12 @@ + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Styles/TreeView.axaml b/src/Artemis.UI/Styles/TreeView.axaml new file mode 100644 index 000000000..0041e20fb --- /dev/null +++ b/src/Artemis.UI/Styles/TreeView.axaml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 31 + 12 + 12, 0, 12, 0 + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.WebClient.Updating/.graphqlconfig b/src/Artemis.WebClient.Updating/.graphqlconfig deleted file mode 100644 index 727ec86a0..000000000 --- a/src/Artemis.WebClient.Updating/.graphqlconfig +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "Untitled GraphQL Schema", - "schemaPath": "schema.graphql", - "extensions": { - "endpoints": { - "Default GraphQL Endpoint": { - "url": "https://updating.artemis-rgb.com/graphql", - "headers": { - "user-agent": "JS GraphQL" - }, - "introspect": true - } - } - } -} \ No newline at end of file diff --git a/src/Artemis.WebClient.Updating/Artemis.WebClient.Updating.csproj b/src/Artemis.WebClient.Updating/Artemis.WebClient.Updating.csproj index 056aa9531..515d3bb6c 100644 --- a/src/Artemis.WebClient.Updating/Artemis.WebClient.Updating.csproj +++ b/src/Artemis.WebClient.Updating/Artemis.WebClient.Updating.csproj @@ -14,4 +14,8 @@ + + + + diff --git a/src/Artemis.WebClient.Updating/graphql.config.yml b/src/Artemis.WebClient.Updating/graphql.config.yml new file mode 100644 index 000000000..784e49e2e --- /dev/null +++ b/src/Artemis.WebClient.Updating/graphql.config.yml @@ -0,0 +1,8 @@ +schema: schema.graphql +extensions: + endpoints: + Default GraphQL Endpoint: + url: https://updating.artemis-rgb.com/graphql + headers: + user-agent: JS GraphQL + introspect: true diff --git a/src/Artemis.WebClient.Updating/schema.extensions.graphql b/src/Artemis.WebClient.Updating/schema.extensions.graphql deleted file mode 100644 index 0b5fbd98b..000000000 --- a/src/Artemis.WebClient.Updating/schema.extensions.graphql +++ /dev/null @@ -1,13 +0,0 @@ -scalar _KeyFieldSet - -directive @key(fields: _KeyFieldSet!) on SCHEMA | OBJECT - -directive @serializationType(name: String!) on SCALAR - -directive @runtimeType(name: String!) on SCALAR - -directive @enumValue(value: String!) on ENUM_VALUE - -directive @rename(name: String!) on INPUT_FIELD_DEFINITION | INPUT_OBJECT | ENUM | ENUM_VALUE - -extend schema @key(fields: "id") \ No newline at end of file diff --git a/src/Artemis.WebClient.Updating/schema.graphql b/src/Artemis.WebClient.Updating/schema.graphql index fe855cb6c..c0973062b 100644 --- a/src/Artemis.WebClient.Updating/schema.graphql +++ b/src/Artemis.WebClient.Updating/schema.graphql @@ -1,4 +1,4 @@ -# This file was generated based on ".graphqlconfig". Do not edit manually. +# This file was generated. Do not edit manually. schema { query: Query @@ -107,10 +107,11 @@ type Release { type ReleaseStatistic { count: Int! + date: Date! lastReportedUsage: DateTime! linuxCount: Int! osxCount: Int! - releaseId: UUID! + release: Release windowsCount: Int! } @@ -144,6 +145,9 @@ enum SortEnumType { DESC } +"The `Date` scalar represents an ISO-8601 compliant date type." +scalar Date + "The `DateTime` scalar represents an ISO-8601 compliant date time type." scalar DateTime @@ -176,6 +180,21 @@ input BooleanOperationFilterInput { neq: Boolean } +input DateOperationFilterInput { + eq: Date + gt: Date + gte: Date + in: [Date] + lt: Date + lte: Date + neq: Date + ngt: Date + ngte: Date + nin: [Date] + nlt: Date + nlte: Date +} + input DateTimeOperationFilterInput { eq: DateTime gt: DateTime @@ -265,20 +284,22 @@ input ReleaseSortInput { input ReleaseStatisticFilterInput { and: [ReleaseStatisticFilterInput!] count: IntOperationFilterInput + date: DateOperationFilterInput lastReportedUsage: DateTimeOperationFilterInput linuxCount: IntOperationFilterInput or: [ReleaseStatisticFilterInput!] osxCount: IntOperationFilterInput - releaseId: UuidOperationFilterInput + release: ReleaseFilterInput windowsCount: IntOperationFilterInput } input ReleaseStatisticSortInput { count: SortEnumType + date: SortEnumType lastReportedUsage: SortEnumType linuxCount: SortEnumType osxCount: SortEnumType - releaseId: SortEnumType + release: ReleaseSortInput windowsCount: SortEnumType } diff --git a/src/Artemis.WebClient.Workshop/.config/dotnet-tools.json b/src/Artemis.WebClient.Workshop/.config/dotnet-tools.json new file mode 100644 index 000000000..7d8626c03 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "strawberryshake.tools": { + "version": "13.0.0-rc.4", + "commands": [ + "dotnet-graphql" + ] + } + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/.graphqlrc.json b/src/Artemis.WebClient.Workshop/.graphqlrc.json new file mode 100644 index 000000000..3cd18817b --- /dev/null +++ b/src/Artemis.WebClient.Workshop/.graphqlrc.json @@ -0,0 +1,22 @@ +{ + "schema": "schema.graphql", + "documents": "**/*.graphql", + "extensions": { + "strawberryShake": { + "name": "WorkshopClient", + "namespace": "Artemis.WebClient.Workshop", + "url": "https://workshop.artemis-rgb.com/graphql/", + "emitGeneratedCode": false, + "records": { + "inputs": false, + "entities": false + }, + "transportProfiles": [ + { + "default": "Http", + "subscription": "WebSocket" + } + ] + } + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj new file mode 100644 index 000000000..e09fefe42 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Artemis.WebClient.Workshop.csproj @@ -0,0 +1,38 @@ + + + + + net7.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + + MSBuild:GenerateGraphQLCode + + + MSBuild:GenerateGraphQLCode + + + diff --git a/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs new file mode 100644 index 000000000..bdcdbede6 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/DryIoc/ContainerExtensions.cs @@ -0,0 +1,38 @@ +using Artemis.WebClient.Workshop.Repositories; +using Artemis.WebClient.Workshop.Services; +using DryIoc; +using DryIoc.Microsoft.DependencyInjection; +using IdentityModel.Client; +using Microsoft.Extensions.DependencyInjection; + +namespace Artemis.WebClient.Workshop.DryIoc; + +/// +/// Provides an extension method to register services onto a DryIoc . +/// +public static class ContainerExtensions +{ + /// + /// Registers the updating client into the container. + /// + /// The builder building the current container + public static void RegisterWorkshopClient(this IContainer container) + { + ServiceCollection serviceCollection = new(); + serviceCollection + .AddHttpClient() + .AddWorkshopClient() + .ConfigureHttpClient(client => client.BaseAddress = new Uri(WorkshopConstants.WORKSHOP_URL + "/graphql")); + + serviceCollection.AddSingleton(r => + { + IHttpClientFactory factory = r.GetRequiredService(); + return new DiscoveryCache(WorkshopConstants.AUTHORITY_URL, () => factory.CreateClient()); + }); + + container.WithDependencyInjectionAdapter(serviceCollection); + + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Entities/RefreshTokenEntity.cs b/src/Artemis.WebClient.Workshop/Entities/RefreshTokenEntity.cs new file mode 100644 index 000000000..1abbdfe22 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Entities/RefreshTokenEntity.cs @@ -0,0 +1,7 @@ +namespace Artemis.WebClient.Workshop.Entities; + +public class RefreshTokenEntity +{ + public string RefreshToken { get; set; } + +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Exceptions/ArtemisWebClientException.cs b/src/Artemis.WebClient.Workshop/Exceptions/ArtemisWebClientException.cs new file mode 100644 index 000000000..1bd2af386 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Exceptions/ArtemisWebClientException.cs @@ -0,0 +1,24 @@ +using System; + +namespace Artemis.Core; + +/// +/// An exception thrown when a web client related error occurs +/// +public class ArtemisWebClientException : Exception +{ + /// + public ArtemisWebClientException() + { + } + + /// + public ArtemisWebClientException(string? message) : base(message) + { + } + + /// + public ArtemisWebClientException(string? message, Exception? innerException) : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Extensions/ReactiveExtensions.cs b/src/Artemis.WebClient.Workshop/Extensions/ReactiveExtensions.cs new file mode 100644 index 000000000..266574c01 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Extensions/ReactiveExtensions.cs @@ -0,0 +1,30 @@ +using System.Reactive.Linq; +using ReactiveUI; +using StrawberryShake; + +namespace Artemis.WebClient.Workshop.Extensions; + +public static class ReactiveExtensions +{ + /// + /// Projects the data of the provided operation result into a new observable sequence if the result is successfull and + /// contains data. + /// + /// A sequence of operation results to invoke a transform function on. + /// A transform function to apply to the data of each source element. + /// The type of data contained in the operation result. + /// The type of data to project from the result. + /// + /// An observable sequence whose elements are the result of invoking the transform function on each element of + /// source. + /// + public static IObservable SelectOperationResult(this IObservable> source, Func selector) where TSource : class + { + return source + .Where(s => !s.Errors.Any()) + .Select(s => s.Data) + .WhereNotNull() + .Select(selector) + .WhereNotNull(); + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Queries/GetCategories.graphql b/src/Artemis.WebClient.Workshop/Queries/GetCategories.graphql new file mode 100644 index 000000000..d39be92f2 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Queries/GetCategories.graphql @@ -0,0 +1,7 @@ +query GetCategories { + categories { + id + name + icon + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql b/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql new file mode 100644 index 000000000..c7e639c31 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Queries/GetEntries.graphql @@ -0,0 +1,18 @@ +query GetEntries($filter: EntryFilterInput $skip: Int $take: Int) { + entries(where: $filter skip: $skip take: $take) { + totalCount + items { + id + author + name + summary + entryType + downloads + createdAt + categories { + name + icon + } + } + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Queries/GetEntryById.graphql b/src/Artemis.WebClient.Workshop/Queries/GetEntryById.graphql new file mode 100644 index 000000000..e5ba4a96d --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Queries/GetEntryById.graphql @@ -0,0 +1,7 @@ +query GetEntryById($id: UUID!) { + entry(id: $id) { + author + name + entryType + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Queries/SearchEntries.graphql b/src/Artemis.WebClient.Workshop/Queries/SearchEntries.graphql new file mode 100644 index 000000000..620aeb70d --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Queries/SearchEntries.graphql @@ -0,0 +1,13 @@ +query SearchEntries($input: String! $type: EntryType) { + searchEntries(input: $input type: $type) { + id + name + summary + entryType + categories { + id + name + icon + } + } +} diff --git a/src/Artemis.WebClient.Workshop/Repositories/AuthenticationRepository.cs b/src/Artemis.WebClient.Workshop/Repositories/AuthenticationRepository.cs new file mode 100644 index 000000000..9a3021c1f --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Repositories/AuthenticationRepository.cs @@ -0,0 +1,36 @@ +using Artemis.WebClient.Workshop.Entities; +using LiteDB; + +namespace Artemis.WebClient.Workshop.Repositories; + +internal class AuthenticationRepository : IAuthenticationRepository +{ + private readonly LiteRepository _repository; + + public AuthenticationRepository(LiteRepository repository) + { + _repository = repository; + _repository.Database.GetCollection().EnsureIndex(s => s.RefreshToken); + } + + /// + public void SetRefreshToken(string? refreshToken) + { + _repository.Database.GetCollection().DeleteAll(); + + if (refreshToken != null) + _repository.Insert(new RefreshTokenEntity {RefreshToken = refreshToken}); + } + + /// + public string? GetRefreshToken() + { + return _repository.Query().FirstOrDefault()?.RefreshToken; + } +} + +internal interface IAuthenticationRepository +{ + void SetRefreshToken(string? refreshToken); + string? GetRefreshToken(); +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/AccessToken.cs b/src/Artemis.WebClient.Workshop/Services/AccessToken.cs new file mode 100644 index 000000000..f67dd9395 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Services/AccessToken.cs @@ -0,0 +1,25 @@ +using Artemis.Core; +using IdentityModel.Client; + +namespace Artemis.WebClient.Workshop.Services; + +internal class AuthenticationToken +{ + public AuthenticationToken(TokenResponse tokenResponse) + { + if (tokenResponse.AccessToken == null) + throw new ArtemisWebClientException("Token response contains no access token"); + if (tokenResponse.RefreshToken == null) + throw new ArtemisWebClientException("Token response contains no refresh token"); + + AccessToken = tokenResponse.AccessToken; + RefreshToken = tokenResponse.RefreshToken; + ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn); + } + + public DateTimeOffset ExpiresAt { get; private set; } + public bool Expired => DateTimeOffset.UtcNow.AddSeconds(5) >= ExpiresAt; + + public string AccessToken { get; private set; } + public string RefreshToken { get; private set; } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs b/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs new file mode 100644 index 000000000..eed47ecff --- /dev/null +++ b/src/Artemis.WebClient.Workshop/Services/IAuthenticationService.cs @@ -0,0 +1,214 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Artemis.Core; +using Artemis.Core.Services; +using Artemis.WebClient.Workshop.Repositories; +using IdentityModel; +using IdentityModel.Client; + +namespace Artemis.WebClient.Workshop.Services; + +public interface IAuthenticationService : IProtectedArtemisService +{ + bool IsLoggedIn { get; } + string? UserCode { get; } + ReadOnlyObservableCollection Claims { get; } + + Task GetBearer(); + Task AutoLogin(); + Task Login(); + void Logout(); +} + +internal class AuthenticationService : CorePropertyChanged, IAuthenticationService +{ + private const string CLIENT_ID = "artemis.desktop"; + + private readonly IDiscoveryCache _discoveryCache; + private readonly IAuthenticationRepository _authenticationRepository; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ObservableCollection _claims = new(); + private readonly SemaphoreSlim _bearerLock = new(1); + + private AuthenticationToken? _token; + private string? _userCode; + private bool _isLoggedIn; + + public AuthenticationService(IHttpClientFactory httpClientFactory, IDiscoveryCache discoveryCache, IAuthenticationRepository authenticationRepository) + { + _httpClientFactory = httpClientFactory; + _discoveryCache = discoveryCache; + _authenticationRepository = authenticationRepository; + + Claims = new ReadOnlyObservableCollection(_claims); + } + + /// + public bool IsLoggedIn + { + get => _isLoggedIn; + private set => SetAndNotify(ref _isLoggedIn, value); + } + + /// + public string? UserCode + { + get => _userCode; + private set => SetAndNotify(ref _userCode, value); + } + + /// + public ReadOnlyObservableCollection Claims { get; } + + public async Task GetBearer() + { + await _bearerLock.WaitAsync(); + + try + { + // If not logged in, attempt to auto login first + if (!IsLoggedIn) + await AutoLogin(); + + if (_token == null) + return null; + + // If the token is expiring, refresh it + if (_token.Expired && !await UseRefreshToken(_token.RefreshToken)) + return null; + + return _token.AccessToken; + } + finally + { + _bearerLock.Release(); + } + } + + /// + public async Task AutoLogin() + { + if (IsLoggedIn) + return true; + + string? refreshToken = _authenticationRepository.GetRefreshToken(); + if (refreshToken == null) + return false; + + return await UseRefreshToken(refreshToken); + } + + /// + public async Task Login() + { + DiscoveryDocumentResponse disco = await GetDiscovery(); + HttpClient client = _httpClientFactory.CreateClient(); + DeviceAuthorizationResponse response = await client.RequestDeviceAuthorizationAsync(new DeviceAuthorizationRequest + { + Address = disco.DeviceAuthorizationEndpoint, + ClientId = CLIENT_ID, + Scope = "openid profile email offline_access api" + }); + if (response.IsError) + throw new ArtemisWebClientException("Failed to request device authorization: " + response.Error); + if (response.DeviceCode == null) + throw new ArtemisWebClientException("Failed to request device authorization: Got no device code"); + + DateTimeOffset timeout = DateTimeOffset.UtcNow.AddSeconds(response.ExpiresIn ?? 1800); + + Process.Start(new ProcessStartInfo {FileName = response.VerificationUriComplete, UseShellExecute = true}); + await Task.Delay(TimeSpan.FromSeconds(response.Interval)); + while (DateTimeOffset.UtcNow < timeout) + { + bool success = await AttemptRequestDeviceToken(client, response.DeviceCode); + if (success) + return true; + await Task.Delay(TimeSpan.FromSeconds(response.Interval)); + } + + return false; + } + + /// + public void Logout() + { + _token = null; + _claims.Clear(); + _authenticationRepository.SetRefreshToken(null); + + IsLoggedIn = false; + } + + private async Task GetDiscovery() + { + DiscoveryDocumentResponse disco = await _discoveryCache.GetAsync(); + if (disco.IsError) + throw new ArtemisWebClientException("Failed to retrieve discovery document: " + disco.Error); + + return disco; + } + + private async Task AttemptRequestDeviceToken(HttpClient client, string deviceCode) + { + DiscoveryDocumentResponse disco = await GetDiscovery(); + TokenResponse response = await client.RequestDeviceTokenAsync(new DeviceTokenRequest + { + Address = disco.TokenEndpoint, + ClientId = CLIENT_ID, + DeviceCode = deviceCode + }); + + if (response.IsError) + { + if (response.Error == OidcConstants.TokenErrors.AuthorizationPending || response.Error == OidcConstants.TokenErrors.SlowDown) + return false; + + throw new ArtemisWebClientException("Failed to request device token: " + response.Error); + } + + SetCurrentUser(response); + return true; + } + + private void SetCurrentUser(TokenResponse response) + { + _token = new AuthenticationToken(response); + _authenticationRepository.SetRefreshToken(_token.RefreshToken); + + JwtSecurityTokenHandler handler = new(); + JwtSecurityToken? token = handler.ReadJwtToken(response.IdentityToken); + if (token == null) + throw new ArtemisWebClientException("Failed to read JWT token"); + + _claims.Clear(); + foreach (Claim responseClaim in token.Claims) + _claims.Add(responseClaim); + + IsLoggedIn = true; + } + + private async Task UseRefreshToken(string refreshToken) + { + DiscoveryDocumentResponse disco = await GetDiscovery(); + HttpClient client = _httpClientFactory.CreateClient(); + TokenResponse response = await client.RequestRefreshTokenAsync(new RefreshTokenRequest() + { + Address = disco.TokenEndpoint, + ClientId = CLIENT_ID, + RefreshToken = refreshToken + }); + + if (response.IsError) + { + if (response.Error == OidcConstants.TokenErrors.ExpiredToken) + return false; + + throw new ArtemisWebClientException("Failed to request refresh token: " + response.Error); + } + + SetCurrentUser(response); + return false; + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/WorkshopConstants.cs b/src/Artemis.WebClient.Workshop/WorkshopConstants.cs new file mode 100644 index 000000000..b514ae862 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/WorkshopConstants.cs @@ -0,0 +1,7 @@ +namespace Artemis.WebClient.Workshop; + +public static class WorkshopConstants +{ + public const string AUTHORITY_URL = "https://identity.artemis-rgb.com"; + public const string WORKSHOP_URL = "https://workshop.artemis-rgb.com"; +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Workshop/graphql.config.yml b/src/Artemis.WebClient.Workshop/graphql.config.yml new file mode 100644 index 000000000..9662a514f --- /dev/null +++ b/src/Artemis.WebClient.Workshop/graphql.config.yml @@ -0,0 +1,8 @@ +schema: schema.graphql +extensions: + endpoints: + Default GraphQL Endpoint: + url: https://workshop.artemis-rgb.com/graphql + headers: + user-agent: JS GraphQL + introspect: true diff --git a/src/Artemis.WebClient.Workshop/schema.graphql b/src/Artemis.WebClient.Workshop/schema.graphql new file mode 100644 index 000000000..59efce290 --- /dev/null +++ b/src/Artemis.WebClient.Workshop/schema.graphql @@ -0,0 +1,310 @@ +# This file was generated. Do not edit manually. + +schema { + query: Query + mutation: Mutation +} + +type Category { + icon: String! + id: Int! + name: String! +} + +"Information about the offset pagination." +type CollectionSegmentInfo { + "Indicates whether more items exist following the set defined by the clients arguments." + hasNextPage: Boolean! + "Indicates whether more items exist prior the set defined by the clients arguments." + hasPreviousPage: Boolean! +} + +"A segment of a collection." +type EntriesCollectionSegment { + "A flattened list of the items." + items: [Entry!] + "Information to aid in pagination." + pageInfo: CollectionSegmentInfo! + totalCount: Int! +} + +type Entry { + author: String! + authorId: UUID! + categories: [Category!]! + createdAt: DateTime! + description: String! + downloads: Long! + entryType: EntryType! + icon: Image + id: UUID! + images: [Image!]! + name: String! + releases: [Release!]! + summary: String! + tags: [Tag!]! +} + +type Image { + id: UUID! + mimeType: String! +} + +type Mutation { + addEntry(input: CreateEntryInput!): Entry + updateEntry(input: UpdateEntryInput!): Entry +} + +type Query { + categories(order: [CategorySortInput!], where: CategoryFilterInput): [Category!]! + entries(order: [EntrySortInput!], skip: Int, take: Int, where: EntryFilterInput): EntriesCollectionSegment + entry(id: UUID!): Entry + searchEntries(input: String!, order: [EntrySortInput!], type: EntryType, where: EntryFilterInput): [Entry!]! +} + +type Release { + createdAt: DateTime! + downloadSize: Long! + downloads: Long! + entry: Entry! + entryId: UUID! + id: UUID! + md5Hash: String + version: String! +} + +type Tag { + id: Int! + name: String! +} + +enum ApplyPolicy { + AFTER_RESOLVER + BEFORE_RESOLVER + VALIDATION +} + +enum EntryType { + LAYOUT + PLUGIN + PROFILE +} + +enum SortEnumType { + ASC + DESC +} + +"The `DateTime` scalar represents an ISO-8601 compliant date time type." +scalar DateTime + +"The `Long` scalar type represents non-fractional signed whole 64-bit numeric values. Long can represent values between -(2^63) and 2^63 - 1." +scalar Long + +scalar UUID + +input CategoryFilterInput { + and: [CategoryFilterInput!] + icon: StringOperationFilterInput + id: IntOperationFilterInput + name: StringOperationFilterInput + or: [CategoryFilterInput!] +} + +input CategorySortInput { + icon: SortEnumType + id: SortEnumType + name: SortEnumType +} + +input CreateEntryInput { + categories: [Int!]! + description: String! + entryType: EntryType! + name: String! + summary: String! + tags: [String!]! +} + +input DateTimeOperationFilterInput { + eq: DateTime + gt: DateTime + gte: DateTime + in: [DateTime] + lt: DateTime + lte: DateTime + neq: DateTime + ngt: DateTime + ngte: DateTime + nin: [DateTime] + nlt: DateTime + nlte: DateTime +} + +input EntryFilterInput { + and: [EntryFilterInput!] + author: StringOperationFilterInput + authorId: UuidOperationFilterInput + categories: ListFilterInputTypeOfCategoryFilterInput + createdAt: DateTimeOperationFilterInput + description: StringOperationFilterInput + downloads: LongOperationFilterInput + entryType: EntryTypeOperationFilterInput + icon: ImageFilterInput + id: UuidOperationFilterInput + images: ListFilterInputTypeOfImageFilterInput + name: StringOperationFilterInput + or: [EntryFilterInput!] + releases: ListFilterInputTypeOfReleaseFilterInput + summary: StringOperationFilterInput + tags: ListFilterInputTypeOfTagFilterInput +} + +input EntrySortInput { + author: SortEnumType + authorId: SortEnumType + createdAt: SortEnumType + description: SortEnumType + downloads: SortEnumType + entryType: SortEnumType + icon: ImageSortInput + id: SortEnumType + name: SortEnumType + summary: SortEnumType +} + +input EntryTypeOperationFilterInput { + eq: EntryType + in: [EntryType!] + neq: EntryType + nin: [EntryType!] +} + +input ImageFilterInput { + and: [ImageFilterInput!] + id: UuidOperationFilterInput + mimeType: StringOperationFilterInput + or: [ImageFilterInput!] +} + +input ImageSortInput { + id: SortEnumType + mimeType: 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 ListFilterInputTypeOfCategoryFilterInput { + all: CategoryFilterInput + any: Boolean + none: CategoryFilterInput + some: CategoryFilterInput +} + +input ListFilterInputTypeOfImageFilterInput { + all: ImageFilterInput + any: Boolean + none: ImageFilterInput + some: ImageFilterInput +} + +input ListFilterInputTypeOfReleaseFilterInput { + all: ReleaseFilterInput + any: Boolean + none: ReleaseFilterInput + some: ReleaseFilterInput +} + +input ListFilterInputTypeOfTagFilterInput { + all: TagFilterInput + any: Boolean + none: TagFilterInput + some: TagFilterInput +} + +input LongOperationFilterInput { + eq: Long + gt: Long + gte: Long + in: [Long] + lt: Long + lte: Long + neq: Long + ngt: Long + ngte: Long + nin: [Long] + nlt: Long + nlte: Long +} + +input ReleaseFilterInput { + and: [ReleaseFilterInput!] + createdAt: DateTimeOperationFilterInput + downloadSize: LongOperationFilterInput + downloads: LongOperationFilterInput + entry: EntryFilterInput + entryId: UuidOperationFilterInput + id: UuidOperationFilterInput + md5Hash: StringOperationFilterInput + or: [ReleaseFilterInput!] + version: StringOperationFilterInput +} + +input StringOperationFilterInput { + and: [StringOperationFilterInput!] + contains: String + endsWith: String + eq: String + in: [String] + ncontains: String + nendsWith: String + neq: String + nin: [String] + nstartsWith: String + or: [StringOperationFilterInput!] + startsWith: String +} + +input TagFilterInput { + and: [TagFilterInput!] + id: IntOperationFilterInput + name: StringOperationFilterInput + or: [TagFilterInput!] +} + +input UpdateEntryInput { + categories: [Int!]! + description: String! + id: UUID! + name: String! + summary: String! + tags: [String!]! +} + +input UuidOperationFilterInput { + eq: UUID + gt: UUID + gte: UUID + in: [UUID] + lt: UUID + lte: UUID + neq: UUID + ngt: UUID + ngte: UUID + nin: [UUID] + nlt: UUID + nlte: UUID +} diff --git a/src/Artemis.props b/src/Artemis.props index 121967a89..8bbeacbcb 100644 --- a/src/Artemis.props +++ b/src/Artemis.props @@ -1,6 +1,6 @@ - 11.0.0 + 11.0.1 2.0.0 2.0.0-prerelease.83 2.88.3 diff --git a/src/Artemis.sln b/src/Artemis.sln index 1e74be560..7f25476e6 100644 --- a/src/Artemis.sln +++ b/src/Artemis.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.32014.148 @@ -21,6 +21,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Artemis.VisualScripting", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Artemis.WebClient.Updating", "Artemis.WebClient.Updating\Artemis.WebClient.Updating.csproj", "{7C8C6F50-0CC8-45B3-B608-A7218C005E4B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Artemis.WebClient.Workshop", "Artemis.WebClient.Workshop\Artemis.WebClient.Workshop.csproj", "{2B982C2E-3CBC-4DAB-9167-CCCA4C78E92B}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{44482312-142F-44A4-992C-0AF0F26BAE54}" ProjectSection(SolutionItems) = preProject Artemis.props = Artemis.props @@ -70,6 +72,10 @@ Global {7C8C6F50-0CC8-45B3-B608-A7218C005E4B}.Debug|x64.Build.0 = Debug|Any CPU {7C8C6F50-0CC8-45B3-B608-A7218C005E4B}.Release|x64.ActiveCfg = Release|Any CPU {7C8C6F50-0CC8-45B3-B608-A7218C005E4B}.Release|x64.Build.0 = Release|Any CPU + {2B982C2E-3CBC-4DAB-9167-CCCA4C78E92B}.Debug|x64.ActiveCfg = Debug|Any CPU + {2B982C2E-3CBC-4DAB-9167-CCCA4C78E92B}.Debug|x64.Build.0 = Debug|Any CPU + {2B982C2E-3CBC-4DAB-9167-CCCA4C78E92B}.Release|x64.ActiveCfg = Release|Any CPU + {2B982C2E-3CBC-4DAB-9167-CCCA4C78E92B}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE