mirror of
https://github.com/claunia/marechai.git
synced 2025-12-16 19:14:25 +00:00
Reorganize project structure.
This commit is contained in:
249
Marechai.App/Presentation/ViewModels/ComputersListViewModel.cs
Normal file
249
Marechai.App/Presentation/ViewModels/ComputersListViewModel.cs
Normal file
@@ -0,0 +1,249 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using Marechai.App.Services;
|
||||
using Uno.Extensions.Navigation;
|
||||
|
||||
namespace Marechai.App.Presentation;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for displaying a filtered list of computers
|
||||
/// </summary>
|
||||
public partial class ComputersListViewModel : ObservableObject
|
||||
{
|
||||
private readonly ComputersService _computersService;
|
||||
private readonly IComputersListFilterContext _filterContext;
|
||||
private readonly IStringLocalizer _localizer;
|
||||
private readonly ILogger<ComputersListViewModel> _logger;
|
||||
private readonly INavigator _navigator;
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<ComputerListItem> computersList = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private string errorMessage = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string filterDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool hasError;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isDataLoaded;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isLoading;
|
||||
|
||||
[ObservableProperty]
|
||||
private string pageTitle = string.Empty;
|
||||
|
||||
public ComputersListViewModel(ComputersService computersService, IStringLocalizer localizer,
|
||||
ILogger<ComputersListViewModel> logger, INavigator navigator,
|
||||
IComputersListFilterContext filterContext)
|
||||
{
|
||||
_computersService = computersService;
|
||||
_localizer = localizer;
|
||||
_logger = logger;
|
||||
_navigator = navigator;
|
||||
_filterContext = filterContext;
|
||||
LoadData = new AsyncRelayCommand(LoadDataAsync);
|
||||
GoBackCommand = new AsyncRelayCommand(GoBackAsync);
|
||||
NavigateToComputerCommand = new AsyncRelayCommand<ComputerListItem>(NavigateToComputerAsync);
|
||||
}
|
||||
|
||||
public IAsyncRelayCommand LoadData { get; }
|
||||
public ICommand GoBackCommand { get; }
|
||||
public IAsyncRelayCommand<ComputerListItem> NavigateToComputerCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the filter type
|
||||
/// </summary>
|
||||
public ComputerListFilterType FilterType
|
||||
{
|
||||
get => _filterContext.FilterType;
|
||||
set => _filterContext.FilterType = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the filter value
|
||||
/// </summary>
|
||||
public string FilterValue
|
||||
{
|
||||
get => _filterContext.FilterValue;
|
||||
set => _filterContext.FilterValue = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads computers based on the current filter
|
||||
/// </summary>
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
IsLoading = true;
|
||||
ErrorMessage = string.Empty;
|
||||
HasError = false;
|
||||
IsDataLoaded = false;
|
||||
ComputersList.Clear();
|
||||
|
||||
_logger.LogInformation("LoadDataAsync called. FilterType={FilterType}, FilterValue={FilterValue}",
|
||||
FilterType,
|
||||
FilterValue);
|
||||
|
||||
// Update title and filter description based on filter type
|
||||
UpdateFilterDescription();
|
||||
|
||||
// Load computers from the API based on the current filter
|
||||
await LoadComputersFromApiAsync();
|
||||
|
||||
_logger.LogInformation("LoadComputersFromApiAsync completed. ComputersList.Count={Count}",
|
||||
ComputersList.Count);
|
||||
|
||||
if(ComputersList.Count == 0)
|
||||
{
|
||||
ErrorMessage = _localizer["No computers found for this filter"].Value;
|
||||
HasError = true;
|
||||
|
||||
_logger.LogWarning("No computers found for filter: {FilterType} {FilterValue}",
|
||||
FilterType,
|
||||
FilterValue);
|
||||
}
|
||||
else
|
||||
IsDataLoaded = true;
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error loading computers: {Exception}", ex.Message);
|
||||
ErrorMessage = _localizer["Failed to load computers. Please try again later."].Value;
|
||||
HasError = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the title and filter description based on the current filter
|
||||
/// </summary>
|
||||
private void UpdateFilterDescription()
|
||||
{
|
||||
switch(FilterType)
|
||||
{
|
||||
case ComputerListFilterType.All:
|
||||
PageTitle = _localizer["All Computers"];
|
||||
FilterDescription = _localizer["Browsing all computers in the database"];
|
||||
|
||||
break;
|
||||
|
||||
case ComputerListFilterType.Letter:
|
||||
if(!string.IsNullOrEmpty(FilterValue) && FilterValue.Length == 1)
|
||||
{
|
||||
PageTitle = $"{_localizer["Computers Starting with"]} {FilterValue}";
|
||||
FilterDescription = $"{_localizer["Showing computers that start with"]} {FilterValue}";
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case ComputerListFilterType.Year:
|
||||
if(!string.IsNullOrEmpty(FilterValue) && int.TryParse(FilterValue, out int year))
|
||||
{
|
||||
PageTitle = $"{_localizer["Computers from"]} {year}";
|
||||
FilterDescription = $"{_localizer["Showing computers released in"]} {year}";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads computers from the API based on the current filter
|
||||
/// </summary>
|
||||
private async Task LoadComputersFromApiAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
List<MachineDto> computers = FilterType switch
|
||||
{
|
||||
ComputerListFilterType.Letter when FilterValue.Length == 1 =>
|
||||
await _computersService.GetComputersByLetterAsync(FilterValue[0]),
|
||||
|
||||
ComputerListFilterType.Year when int.TryParse(FilterValue, out int year) =>
|
||||
await _computersService.GetComputersByYearAsync(year),
|
||||
|
||||
_ => await _computersService.GetAllComputersAsync()
|
||||
};
|
||||
|
||||
// Add computers to the list sorted by name
|
||||
foreach(MachineDto computer in computers.OrderBy(c => c.Name))
|
||||
{
|
||||
int year = computer.Introduced?.Year ?? 0;
|
||||
int id = UntypedNodeExtractor.ExtractInt(computer.Id);
|
||||
|
||||
_logger.LogInformation("Computer: {Name}, Introduced: {Introduced}, Year: {Year}, Company: {Company}, ID: {Id}",
|
||||
computer.Name,
|
||||
computer.Introduced,
|
||||
year,
|
||||
computer.Company,
|
||||
id);
|
||||
|
||||
ComputersList.Add(new ComputerListItem
|
||||
{
|
||||
Id = id,
|
||||
Name = computer.Name ?? string.Empty,
|
||||
Year = year,
|
||||
Manufacturer = computer.Company ?? string.Empty
|
||||
});
|
||||
}
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error loading computers from API");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigates back to the computers main view
|
||||
/// </summary>
|
||||
private async Task GoBackAsync()
|
||||
{
|
||||
await _navigator.NavigateViewModelAsync<ComputersViewModel>(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigates to the computer detail view
|
||||
/// </summary>
|
||||
private async Task NavigateToComputerAsync(ComputerListItem? computer)
|
||||
{
|
||||
if(computer is null) return;
|
||||
|
||||
_logger.LogInformation("Navigating to computer detail: {ComputerName} (ID: {ComputerId})",
|
||||
computer.Name,
|
||||
computer.Id);
|
||||
|
||||
var navParam = new MachineViewNavigationParameter
|
||||
{
|
||||
MachineId = computer.Id,
|
||||
NavigationSource = this
|
||||
};
|
||||
|
||||
await _navigator.NavigateViewModelAsync<MachineViewViewModel>(this, data: navParam);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data model for a computer in the list
|
||||
/// </summary>
|
||||
public class ComputerListItem
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int Year { get; set; }
|
||||
public string Manufacturer { get; set; } = string.Empty;
|
||||
}
|
||||
207
Marechai.App/Presentation/ViewModels/ComputersViewModel.cs
Normal file
207
Marechai.App/Presentation/ViewModels/ComputersViewModel.cs
Normal file
@@ -0,0 +1,207 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using Marechai.App.Services;
|
||||
using Uno.Extensions.Navigation;
|
||||
|
||||
namespace Marechai.App.Presentation;
|
||||
|
||||
public partial class ComputersViewModel : ObservableObject
|
||||
{
|
||||
private readonly ComputersService _computersService;
|
||||
private readonly IComputersListFilterContext _filterContext;
|
||||
private readonly IStringLocalizer _localizer;
|
||||
private readonly ILogger<ComputersViewModel> _logger;
|
||||
private readonly INavigator _navigator;
|
||||
|
||||
[ObservableProperty]
|
||||
private int computerCount;
|
||||
|
||||
[ObservableProperty]
|
||||
private string computerCountText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string errorMessage = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool hasError;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isDataLoaded;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isLoading;
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<char> lettersList = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private int maximumYear;
|
||||
|
||||
[ObservableProperty]
|
||||
private int minimumYear;
|
||||
|
||||
[ObservableProperty]
|
||||
private string yearsGridTitle = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<int> yearsList = [];
|
||||
|
||||
public ComputersViewModel(ComputersService computersService, IStringLocalizer localizer,
|
||||
ILogger<ComputersViewModel> logger, INavigator navigator,
|
||||
IComputersListFilterContext filterContext)
|
||||
{
|
||||
_computersService = computersService;
|
||||
_localizer = localizer;
|
||||
_logger = logger;
|
||||
_navigator = navigator;
|
||||
_filterContext = filterContext;
|
||||
LoadData = new AsyncRelayCommand(LoadDataAsync);
|
||||
GoBackCommand = new AsyncRelayCommand(GoBackAsync);
|
||||
NavigateByLetterCommand = new AsyncRelayCommand<char>(NavigateByLetterAsync);
|
||||
NavigateByYearCommand = new AsyncRelayCommand<int>(NavigateByYearAsync);
|
||||
NavigateAllComputersCommand = new AsyncRelayCommand(NavigateAllComputersAsync);
|
||||
|
||||
InitializeLetters();
|
||||
}
|
||||
|
||||
public IAsyncRelayCommand LoadData { get; }
|
||||
public ICommand GoBackCommand { get; }
|
||||
public IAsyncRelayCommand<char> NavigateByLetterCommand { get; }
|
||||
public IAsyncRelayCommand<int> NavigateByYearCommand { get; }
|
||||
public IAsyncRelayCommand NavigateAllComputersCommand { get; }
|
||||
public string Title { get; } = "Computers";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the alphabet list (A-Z)
|
||||
/// </summary>
|
||||
private void InitializeLetters()
|
||||
{
|
||||
LettersList.Clear();
|
||||
|
||||
for(var c = 'A'; c <= 'Z'; c++) LettersList.Add(c);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads computers count, minimum and maximum years from the API
|
||||
/// </summary>
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
IsLoading = true;
|
||||
ErrorMessage = string.Empty;
|
||||
HasError = false;
|
||||
IsDataLoaded = false;
|
||||
YearsList.Clear();
|
||||
|
||||
// Load all data in parallel for better performance
|
||||
Task<int> countTask = _computersService.GetComputersCountAsync();
|
||||
Task<int> minYearTask = _computersService.GetMinimumYearAsync();
|
||||
Task<int> maxYearTask = _computersService.GetMaximumYearAsync();
|
||||
await Task.WhenAll(countTask, minYearTask, maxYearTask);
|
||||
|
||||
ComputerCount = countTask.Result;
|
||||
MinimumYear = minYearTask.Result;
|
||||
MaximumYear = maxYearTask.Result;
|
||||
|
||||
// Update display text
|
||||
ComputerCountText = _localizer["Computers in the database"];
|
||||
|
||||
// Generate years list
|
||||
if(MinimumYear > 0 && MaximumYear > 0)
|
||||
{
|
||||
for(int year = MinimumYear; year <= MaximumYear; year++) YearsList.Add(year);
|
||||
|
||||
YearsGridTitle = $"Browse by Year ({MinimumYear} - {MaximumYear})";
|
||||
}
|
||||
|
||||
if(ComputerCount == 0)
|
||||
{
|
||||
ErrorMessage = _localizer["No computers found"].Value;
|
||||
HasError = true;
|
||||
}
|
||||
else
|
||||
IsDataLoaded = true;
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
_logger.LogError("Error loading computers data: {Exception}", ex.Message);
|
||||
ErrorMessage = _localizer["Failed to load computers data. Please try again later."].Value;
|
||||
HasError = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles back navigation
|
||||
/// </summary>
|
||||
private async Task GoBackAsync()
|
||||
{
|
||||
await _navigator.NavigateViewModelAsync<MainViewModel>(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigates to computers filtered by letter
|
||||
/// </summary>
|
||||
private async Task NavigateByLetterAsync(char letter)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Navigating to computers by letter: {Letter}", letter);
|
||||
_filterContext.FilterType = ComputerListFilterType.Letter;
|
||||
_filterContext.FilterValue = letter.ToString();
|
||||
await _navigator.NavigateViewModelAsync<ComputersListViewModel>(this);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
_logger.LogError("Error navigating to letter computers: {Exception}", ex.Message);
|
||||
ErrorMessage = _localizer["Failed to navigate. Please try again."].Value;
|
||||
HasError = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigates to computers filtered by year
|
||||
/// </summary>
|
||||
private async Task NavigateByYearAsync(int year)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Navigating to computers by year: {Year}", year);
|
||||
_filterContext.FilterType = ComputerListFilterType.Year;
|
||||
_filterContext.FilterValue = year.ToString();
|
||||
await _navigator.NavigateViewModelAsync<ComputersListViewModel>(this);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
_logger.LogError("Error navigating to year computers: {Exception}", ex.Message);
|
||||
ErrorMessage = _localizer["Failed to navigate. Please try again."].Value;
|
||||
HasError = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigates to all computers view
|
||||
/// </summary>
|
||||
private async Task NavigateAllComputersAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Navigating to all computers");
|
||||
_filterContext.FilterType = ComputerListFilterType.All;
|
||||
_filterContext.FilterValue = string.Empty;
|
||||
await _navigator.NavigateViewModelAsync<ComputersListViewModel>(this);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
_logger.LogError("Error navigating to all computers: {Exception}", ex.Message);
|
||||
ErrorMessage = _localizer["Failed to navigate. Please try again."].Value;
|
||||
HasError = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
376
Marechai.App/Presentation/ViewModels/MachineViewViewModel.cs
Normal file
376
Marechai.App/Presentation/ViewModels/MachineViewViewModel.cs
Normal file
@@ -0,0 +1,376 @@
|
||||
/******************************************************************************
|
||||
// MARECHAI: Master repository of computing history artifacts information
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// Author(s) : Natalia Portillo <claunia@claunia.com>
|
||||
//
|
||||
// --[ License ] --------------------------------------------------------------
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// ----------------------------------------------------------------------------
|
||||
// Copyright © 2003-2026 Natalia Portillo
|
||||
*******************************************************************************/
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading.Tasks;
|
||||
using Humanizer;
|
||||
using Marechai.App.Services;
|
||||
using Marechai.Data;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Uno.Extensions.Navigation;
|
||||
|
||||
namespace Marechai.App.Presentation;
|
||||
|
||||
public partial class MachineViewViewModel : ObservableObject
|
||||
{
|
||||
private readonly ComputersService _computersService;
|
||||
private readonly ILogger<MachineViewViewModel> _logger;
|
||||
private readonly INavigator _navigator;
|
||||
private object? _navigationSource;
|
||||
|
||||
[ObservableProperty]
|
||||
private string companyName = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string errorMessage = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? familyName;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool hasError;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? introductionDateDisplay;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isDataLoaded;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isLoading;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isPrototype;
|
||||
|
||||
[ObservableProperty]
|
||||
private string machineName = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? modelName;
|
||||
|
||||
[ObservableProperty]
|
||||
private Visibility showFamily = Visibility.Collapsed;
|
||||
|
||||
[ObservableProperty]
|
||||
private Visibility showFamilyOrModel = Visibility.Collapsed;
|
||||
|
||||
[ObservableProperty]
|
||||
private Visibility showGpus = Visibility.Collapsed;
|
||||
|
||||
[ObservableProperty]
|
||||
private Visibility showIntroductionDate = Visibility.Collapsed;
|
||||
|
||||
[ObservableProperty]
|
||||
private Visibility showMemory = Visibility.Collapsed;
|
||||
|
||||
[ObservableProperty]
|
||||
private Visibility showModel = Visibility.Collapsed;
|
||||
|
||||
[ObservableProperty]
|
||||
private Visibility showProcessors = Visibility.Collapsed;
|
||||
|
||||
[ObservableProperty]
|
||||
private Visibility showSoundSynthesizers = Visibility.Collapsed;
|
||||
|
||||
[ObservableProperty]
|
||||
private Visibility showStorage = Visibility.Collapsed;
|
||||
|
||||
public MachineViewViewModel(ILogger<MachineViewViewModel> logger, INavigator navigator,
|
||||
ComputersService computersService)
|
||||
{
|
||||
_logger = logger;
|
||||
_navigator = navigator;
|
||||
_computersService = computersService;
|
||||
}
|
||||
|
||||
public ObservableCollection<ProcessorDisplayItem> Processors { get; } = [];
|
||||
public ObservableCollection<MemoryDisplayItem> Memory { get; } = [];
|
||||
public ObservableCollection<GpuDisplayItem> Gpus { get; } = [];
|
||||
public ObservableCollection<SoundSynthesizerDisplayItem> SoundSynthesizers { get; } = [];
|
||||
public ObservableCollection<StorageDisplayItem> Storage { get; } = [];
|
||||
|
||||
[RelayCommand]
|
||||
public async Task GoBack()
|
||||
{
|
||||
// If we came from News, navigate back to News
|
||||
if(_navigationSource is NewsViewModel)
|
||||
{
|
||||
await _navigator.NavigateViewModelAsync<NewsViewModel>(this);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, try to go back in the navigation stack
|
||||
await _navigator.GoBack(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the navigation source (where we came from).
|
||||
/// </summary>
|
||||
public void SetNavigationSource(object? source)
|
||||
{
|
||||
_navigationSource = source;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public Task LoadData()
|
||||
{
|
||||
// Placeholder for retry functionality
|
||||
HasError = false;
|
||||
ErrorMessage = string.Empty;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task LoadMachineAsync(int machineId)
|
||||
{
|
||||
try
|
||||
{
|
||||
IsLoading = true;
|
||||
IsDataLoaded = false;
|
||||
HasError = false;
|
||||
ErrorMessage = string.Empty;
|
||||
Processors.Clear();
|
||||
Memory.Clear();
|
||||
Gpus.Clear();
|
||||
SoundSynthesizers.Clear();
|
||||
Storage.Clear();
|
||||
|
||||
_logger.LogInformation("Loading machine {MachineId}", machineId);
|
||||
|
||||
// Fetch machine data from API
|
||||
MachineDto? machine = await _computersService.GetMachineByIdAsync(machineId);
|
||||
|
||||
if(machine is null)
|
||||
{
|
||||
HasError = true;
|
||||
ErrorMessage = "Machine not found";
|
||||
IsLoading = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate basic information
|
||||
MachineName = machine.Name ?? string.Empty;
|
||||
CompanyName = machine.Company ?? string.Empty;
|
||||
FamilyName = machine.FamilyName;
|
||||
ModelName = machine.Model;
|
||||
|
||||
// Check if this is a prototype (year 1000 is used as placeholder for prototypes)
|
||||
IsPrototype = machine.Introduced?.Year == 1000;
|
||||
|
||||
// Set introduction date if available and not a prototype
|
||||
if(machine.Introduced.HasValue && machine.Introduced.Value.Year != 1000)
|
||||
IntroductionDateDisplay = machine.Introduced.Value.ToString("MMMM d, yyyy");
|
||||
|
||||
// Populate processors
|
||||
if(machine.Processors != null)
|
||||
{
|
||||
foreach(ProcessorDto processor in machine.Processors)
|
||||
{
|
||||
var details = new List<string>();
|
||||
int speed = UntypedNodeExtractor.ExtractInt(processor.Speed);
|
||||
int gprSize = UntypedNodeExtractor.ExtractInt(processor.GprSize);
|
||||
int cores = UntypedNodeExtractor.ExtractInt(processor.Cores);
|
||||
|
||||
if(speed > 0) details.Add($"{speed} MHz");
|
||||
if(gprSize > 0) details.Add($"{gprSize} bits");
|
||||
if(cores > 1) details.Add($"{cores} cores");
|
||||
|
||||
Processors.Add(new ProcessorDisplayItem
|
||||
{
|
||||
DisplayName = processor.Name ?? string.Empty,
|
||||
Manufacturer = processor.Company ?? string.Empty,
|
||||
HasDetails = details.Count > 0,
|
||||
DetailsText = string.Join(", ", details)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Populate memory
|
||||
if(machine.Memory != null)
|
||||
{
|
||||
foreach(MemoryDto mem in machine.Memory)
|
||||
{
|
||||
long size = UntypedNodeExtractor.ExtractLong(mem.Size);
|
||||
|
||||
string sizeStr = size > 0
|
||||
? size > 1024 ? $"{size} bytes ({size.Bytes().Humanize()})" : $"{size} bytes"
|
||||
: "Unknown";
|
||||
|
||||
// Get humanized memory usage description
|
||||
string usageDescription = mem.Usage.HasValue
|
||||
? ((MemoryUsage)mem.Usage.Value).Humanize()
|
||||
: "Unknown";
|
||||
|
||||
Memory.Add(new MemoryDisplayItem
|
||||
{
|
||||
SizeDisplay = sizeStr,
|
||||
TypeDisplay = usageDescription
|
||||
});
|
||||
}
|
||||
} // Populate GPUs
|
||||
|
||||
if(machine.Gpus != null)
|
||||
{
|
||||
foreach(GpuDto gpu in machine.Gpus)
|
||||
{
|
||||
Gpus.Add(new GpuDisplayItem
|
||||
{
|
||||
DisplayName = gpu.Name ?? string.Empty,
|
||||
Manufacturer = gpu.Company ?? string.Empty,
|
||||
HasManufacturer = !string.IsNullOrEmpty(gpu.Company)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Populate sound synthesizers
|
||||
if(machine.SoundSynthesizers != null)
|
||||
{
|
||||
foreach(SoundSynthDto synth in machine.SoundSynthesizers)
|
||||
{
|
||||
var details = new List<string>();
|
||||
int voices = UntypedNodeExtractor.ExtractInt(synth.Voices);
|
||||
|
||||
if(voices > 0) details.Add($"{voices} voices");
|
||||
|
||||
SoundSynthesizers.Add(new SoundSynthesizerDisplayItem
|
||||
{
|
||||
DisplayName = synth.Name ?? string.Empty,
|
||||
HasDetails = details.Count > 0,
|
||||
DetailsText = string.Join(", ", details)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Populate storage
|
||||
if(machine.Storage != null)
|
||||
{
|
||||
foreach(StorageDto storage in machine.Storage)
|
||||
{
|
||||
long capacity = UntypedNodeExtractor.ExtractLong(storage.Capacity);
|
||||
|
||||
string displayText = capacity > 0
|
||||
? capacity > 1024
|
||||
? $"{capacity} bytes ({capacity.Bytes().Humanize()})"
|
||||
: $"{capacity} bytes"
|
||||
: "Storage";
|
||||
|
||||
// Get humanized storage type description
|
||||
string typeNote = storage.Type.HasValue ? ((StorageType)storage.Type.Value).Humanize() : "Unknown";
|
||||
|
||||
Storage.Add(new StorageDisplayItem
|
||||
{
|
||||
DisplayText = displayText,
|
||||
TypeNote = typeNote
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
UpdateVisibilities();
|
||||
IsDataLoaded = true;
|
||||
IsLoading = false;
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error loading machine {MachineId}", machineId);
|
||||
HasError = true;
|
||||
ErrorMessage = ex.Message;
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateVisibilities()
|
||||
{
|
||||
ShowIntroductionDate =
|
||||
!string.IsNullOrEmpty(IntroductionDateDisplay) ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
ShowFamily = !string.IsNullOrEmpty(FamilyName) ? Visibility.Visible : Visibility.Collapsed;
|
||||
ShowModel = !string.IsNullOrEmpty(ModelName) ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
ShowFamilyOrModel = ShowFamily == Visibility.Visible || ShowModel == Visibility.Visible
|
||||
? Visibility.Visible
|
||||
: Visibility.Collapsed;
|
||||
|
||||
ShowProcessors = Processors.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
ShowMemory = Memory.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
ShowGpus = Gpus.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
ShowSoundSynthesizers = SoundSynthesizers.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
ShowStorage = Storage.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display item for processor information
|
||||
/// </summary>
|
||||
public class ProcessorDisplayItem
|
||||
{
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string Manufacturer { get; set; } = string.Empty;
|
||||
public bool HasDetails { get; set; }
|
||||
public string DetailsText { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display item for memory information
|
||||
/// </summary>
|
||||
public class MemoryDisplayItem
|
||||
{
|
||||
public string SizeDisplay { get; set; } = string.Empty;
|
||||
public string TypeDisplay { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display item for GPU information
|
||||
/// </summary>
|
||||
public class GpuDisplayItem
|
||||
{
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string Manufacturer { get; set; } = string.Empty;
|
||||
public bool HasManufacturer { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display item for sound synthesizer information
|
||||
/// </summary>
|
||||
public class SoundSynthesizerDisplayItem
|
||||
{
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public bool HasDetails { get; set; }
|
||||
public string DetailsText { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display item for storage information
|
||||
/// </summary>
|
||||
public class StorageDisplayItem
|
||||
{
|
||||
public string DisplayText { get; set; } = string.Empty;
|
||||
public string TypeNote { get; set; } = string.Empty;
|
||||
}
|
||||
177
Marechai.App/Presentation/ViewModels/MainViewModel.cs
Normal file
177
Marechai.App/Presentation/ViewModels/MainViewModel.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
using Uno.Extensions.Navigation;
|
||||
|
||||
namespace Marechai.App.Presentation;
|
||||
|
||||
public partial class MainViewModel : ObservableObject
|
||||
{
|
||||
private readonly IStringLocalizer _localizer;
|
||||
private readonly INavigator _navigator;
|
||||
[ObservableProperty]
|
||||
private bool isSidebarOpen = true;
|
||||
[ObservableProperty]
|
||||
private Dictionary<string, string> localizedStrings = new();
|
||||
[ObservableProperty]
|
||||
private string loginLogoutButtonText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string? name;
|
||||
[ObservableProperty]
|
||||
private NewsViewModel? newsViewModel;
|
||||
[ObservableProperty]
|
||||
private bool sidebarContentVisible = true;
|
||||
|
||||
public MainViewModel(IStringLocalizer localizer, IOptions<AppConfig> appInfo, INavigator navigator,
|
||||
NewsViewModel newsViewModel)
|
||||
{
|
||||
_navigator = navigator;
|
||||
_localizer = localizer;
|
||||
NewsViewModel = newsViewModel;
|
||||
Title = "Marechai";
|
||||
Title += $" - {localizer["ApplicationName"]}";
|
||||
if(appInfo?.Value?.Environment != null) Title += $" - {appInfo.Value.Environment}";
|
||||
|
||||
GoToSecond = new AsyncRelayCommand(GoToSecondView);
|
||||
|
||||
// Initialize localized strings
|
||||
InitializeLocalizedStrings();
|
||||
|
||||
// Initialize commands
|
||||
NavigateToNewsCommand = new AsyncRelayCommand(NavigateToMainAsync);
|
||||
NavigateToBooksCommand = new AsyncRelayCommand(() => NavigateTo("books"));
|
||||
NavigateToCompaniesCommand = new AsyncRelayCommand(() => NavigateTo("companies"));
|
||||
NavigateToComputersCommand = new AsyncRelayCommand(() => NavigateTo("computers"));
|
||||
NavigateToConsolesCommand = new AsyncRelayCommand(() => NavigateTo("consoles"));
|
||||
NavigateToDocumentsCommand = new AsyncRelayCommand(() => NavigateTo("documents"));
|
||||
NavigateToDumpsCommand = new AsyncRelayCommand(() => NavigateTo("dumps"));
|
||||
NavigateToGraphicalProcessingUnitsCommand = new AsyncRelayCommand(() => NavigateTo("gpus"));
|
||||
NavigateToMagazinesCommand = new AsyncRelayCommand(() => NavigateTo("magazines"));
|
||||
NavigateToPeopleCommand = new AsyncRelayCommand(() => NavigateTo("people"));
|
||||
NavigateToProcessorsCommand = new AsyncRelayCommand(() => NavigateTo("processors"));
|
||||
NavigateToSoftwareCommand = new AsyncRelayCommand(() => NavigateTo("software"));
|
||||
NavigateToSoundSynthesizersCommand = new AsyncRelayCommand(() => NavigateTo("soundsynthesizers"));
|
||||
NavigateToSettingsCommand = new AsyncRelayCommand(() => NavigateTo("settings"));
|
||||
LoginLogoutCommand = new RelayCommand(HandleLoginLogout);
|
||||
ToggleSidebarCommand = new RelayCommand(() => IsSidebarOpen = !IsSidebarOpen);
|
||||
|
||||
UpdateLoginLogoutButtonText();
|
||||
}
|
||||
|
||||
public string? Title { get; }
|
||||
|
||||
public ICommand GoToSecond { get; }
|
||||
|
||||
public ICommand NavigateToNewsCommand { get; }
|
||||
public ICommand NavigateToBooksCommand { get; }
|
||||
public ICommand NavigateToCompaniesCommand { get; }
|
||||
public ICommand NavigateToComputersCommand { get; }
|
||||
public ICommand NavigateToConsolesCommand { get; }
|
||||
public ICommand NavigateToDocumentsCommand { get; }
|
||||
public ICommand NavigateToDumpsCommand { get; }
|
||||
public ICommand NavigateToGraphicalProcessingUnitsCommand { get; }
|
||||
public ICommand NavigateToMagazinesCommand { get; }
|
||||
public ICommand NavigateToPeopleCommand { get; }
|
||||
public ICommand NavigateToProcessorsCommand { get; }
|
||||
public ICommand NavigateToSoftwareCommand { get; }
|
||||
public ICommand NavigateToSoundSynthesizersCommand { get; }
|
||||
public ICommand NavigateToSettingsCommand { get; }
|
||||
public ICommand LoginLogoutCommand { get; }
|
||||
public ICommand ToggleSidebarCommand { get; }
|
||||
|
||||
private void InitializeLocalizedStrings()
|
||||
{
|
||||
LocalizedStrings = new Dictionary<string, string>
|
||||
{
|
||||
{
|
||||
"News", _localizer["News"]
|
||||
},
|
||||
{
|
||||
"Books", _localizer["Books"]
|
||||
},
|
||||
{
|
||||
"Companies", _localizer["Companies"]
|
||||
},
|
||||
{
|
||||
"Computers", _localizer["Computers"]
|
||||
},
|
||||
{
|
||||
"Consoles", _localizer["Consoles"]
|
||||
},
|
||||
{
|
||||
"Documents", _localizer["Documents"]
|
||||
},
|
||||
{
|
||||
"Dumps", _localizer["Dumps"]
|
||||
},
|
||||
{
|
||||
"GraphicalProcessingUnits", _localizer["GraphicalProcessingUnits"]
|
||||
},
|
||||
{
|
||||
"Magazines", _localizer["Magazines"]
|
||||
},
|
||||
{
|
||||
"People", _localizer["People"]
|
||||
},
|
||||
{
|
||||
"Processors", _localizer["Processors"]
|
||||
},
|
||||
{
|
||||
"Software", _localizer["Software"]
|
||||
},
|
||||
{
|
||||
"SoundSynthesizers", _localizer["SoundSynthesizers"]
|
||||
},
|
||||
{
|
||||
"Settings", _localizer["Settings"]
|
||||
},
|
||||
{
|
||||
"Login", _localizer["Login"]
|
||||
},
|
||||
{
|
||||
"Logout", _localizer["Logout"]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void UpdateLoginLogoutButtonText()
|
||||
{
|
||||
// TODO: Check if user is logged in
|
||||
// For now, always show "Login"
|
||||
LoginLogoutButtonText = LocalizedStrings["Login"];
|
||||
}
|
||||
|
||||
private static void HandleLoginLogout()
|
||||
{
|
||||
// TODO: Implement login/logout logic
|
||||
}
|
||||
|
||||
private async Task NavigateTo(string destination)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Navigate within the Main region using relative navigation
|
||||
// The "./" prefix means navigate within the current page's region
|
||||
await _navigator.NavigateRouteAsync(this, $"./{destination}");
|
||||
}
|
||||
catch(Exception)
|
||||
{
|
||||
// Navigation error - fail silently for now
|
||||
// TODO: Add error handling/logging
|
||||
}
|
||||
}
|
||||
|
||||
private async Task NavigateToMainAsync()
|
||||
{
|
||||
// Navigate to News page (the default/home page)
|
||||
await NavigateTo("News");
|
||||
}
|
||||
|
||||
private async Task GoToSecondView()
|
||||
{
|
||||
// Navigate to Second view model providing qualifier and data
|
||||
await _navigator.NavigateViewModelAsync<SecondViewModel>(this, "Second", new Entity(Name ?? ""));
|
||||
}
|
||||
}
|
||||
174
Marechai.App/Presentation/ViewModels/NewsViewModel.cs
Normal file
174
Marechai.App/Presentation/ViewModels/NewsViewModel.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading.Tasks;
|
||||
using Marechai.App.Services;
|
||||
using Marechai.Data;
|
||||
using Uno.Extensions.Navigation;
|
||||
|
||||
namespace Marechai.App.Presentation;
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for NewsDto with generated display text
|
||||
/// </summary>
|
||||
public class NewsItemViewModel
|
||||
{
|
||||
public required NewsDto News { get; init; }
|
||||
public required string DisplayText { get; init; }
|
||||
public required IAsyncRelayCommand<NewsDto> NavigateToItemCommand { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Determines if this news item can be navigated to (only computers and consoles)
|
||||
/// </summary>
|
||||
public bool CanNavigateToItem
|
||||
{
|
||||
get
|
||||
{
|
||||
if(News?.Type is null) return false;
|
||||
var type = (NewsType)News.Type.Value;
|
||||
|
||||
return type is NewsType.NewComputerInDb
|
||||
or NewsType.NewConsoleInDb
|
||||
or NewsType.UpdatedComputerInDb
|
||||
or NewsType.UpdatedConsoleInDb
|
||||
or NewsType.NewComputerInCollection
|
||||
or NewsType.NewConsoleInCollection
|
||||
or NewsType.UpdatedComputerInCollection
|
||||
or NewsType.UpdatedConsoleInCollection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public partial class NewsViewModel : ObservableObject
|
||||
{
|
||||
private readonly IStringLocalizer _localizer;
|
||||
private readonly ILogger<NewsViewModel> _logger;
|
||||
private readonly INavigator _navigator;
|
||||
private readonly NewsService _newsService;
|
||||
|
||||
[ObservableProperty]
|
||||
private string errorMessage = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool hasError;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isLoading;
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<NewsItemViewModel> newsList = [];
|
||||
|
||||
public NewsViewModel(NewsService newsService, IStringLocalizer localizer, ILogger<NewsViewModel> logger,
|
||||
INavigator navigator)
|
||||
{
|
||||
_newsService = newsService;
|
||||
_localizer = localizer;
|
||||
_logger = logger;
|
||||
_navigator = navigator;
|
||||
LoadNews = new AsyncRelayCommand(LoadNewsAsync);
|
||||
}
|
||||
|
||||
public IAsyncRelayCommand LoadNews { get; }
|
||||
|
||||
[RelayCommand]
|
||||
private async Task NavigateToNewsItem(NewsDto news)
|
||||
{
|
||||
if(news?.Type is null) return;
|
||||
|
||||
var newsType = (NewsType)news.Type.Value;
|
||||
|
||||
// Only navigate for computer and console news items
|
||||
bool isComputerOrConsole = newsType is NewsType.NewComputerInDb
|
||||
or NewsType.NewConsoleInDb
|
||||
or NewsType.UpdatedComputerInDb
|
||||
or NewsType.UpdatedConsoleInDb
|
||||
or NewsType.NewComputerInCollection
|
||||
or NewsType.NewConsoleInCollection
|
||||
or NewsType.UpdatedComputerInCollection
|
||||
or NewsType.UpdatedConsoleInCollection;
|
||||
|
||||
if(!isComputerOrConsole) return;
|
||||
|
||||
// Extract the machine ID from AffectedId
|
||||
if(news.AffectedId is null) return;
|
||||
|
||||
int machineId = UntypedNodeExtractor.ExtractInt(news.AffectedId);
|
||||
|
||||
if(machineId <= 0) return;
|
||||
|
||||
// Navigate to machine view with source information
|
||||
var navParam = new MachineViewNavigationParameter
|
||||
{
|
||||
MachineId = machineId,
|
||||
NavigationSource = this
|
||||
};
|
||||
|
||||
await _navigator.NavigateViewModelAsync<MachineViewViewModel>(this, data: navParam);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to extract int from UntypedNode
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Generates localized text based on NewsType
|
||||
/// </summary>
|
||||
private string GetLocalizedTextForNewsType(NewsType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
NewsType.NewComputerInDb => _localizer["New computer in database"].Value,
|
||||
NewsType.NewConsoleInDb => _localizer["New console in database"].Value,
|
||||
NewsType.NewComputerInCollection => _localizer["New computer in collection"].Value,
|
||||
NewsType.NewConsoleInCollection => _localizer["New console in collection"].Value,
|
||||
NewsType.UpdatedComputerInDb => _localizer["Updated computer in database"].Value,
|
||||
NewsType.UpdatedConsoleInDb => _localizer["Updated console in database"].Value,
|
||||
NewsType.UpdatedComputerInCollection => _localizer["Updated computer in collection"].Value,
|
||||
NewsType.UpdatedConsoleInCollection => _localizer["Updated console in collection"].Value,
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the latest news from the API
|
||||
/// </summary>
|
||||
private async Task LoadNewsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
IsLoading = true;
|
||||
ErrorMessage = string.Empty;
|
||||
HasError = false;
|
||||
NewsList.Clear();
|
||||
|
||||
List<NewsDto> news = await _newsService.GetLatestNewsAsync();
|
||||
|
||||
if(news.Count == 0)
|
||||
{
|
||||
ErrorMessage = _localizer["No news available"].Value;
|
||||
HasError = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach(NewsDto item in news)
|
||||
{
|
||||
NewsList.Add(new NewsItemViewModel
|
||||
{
|
||||
News = item,
|
||||
DisplayText = GetLocalizedTextForNewsType((NewsType)(item.Type ?? 0)),
|
||||
NavigateToItemCommand = NavigateToNewsItemCommand
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
_logger.LogError("Error loading news: {Exception}", ex.Message);
|
||||
ErrorMessage = _localizer["Failed to load news. Please try again later."].Value;
|
||||
HasError = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
5
Marechai.App/Presentation/ViewModels/SecondViewModel.cs
Normal file
5
Marechai.App/Presentation/ViewModels/SecondViewModel.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace Marechai.App.Presentation;
|
||||
|
||||
public partial record SecondViewModel(Entity Entity)
|
||||
{
|
||||
}
|
||||
15
Marechai.App/Presentation/ViewModels/ShellViewModel.cs
Normal file
15
Marechai.App/Presentation/ViewModels/ShellViewModel.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using Uno.Extensions.Navigation;
|
||||
|
||||
namespace Marechai.App.Presentation;
|
||||
|
||||
public class ShellViewModel
|
||||
{
|
||||
private readonly INavigator _navigator;
|
||||
|
||||
public ShellViewModel(
|
||||
INavigator navigator)
|
||||
{
|
||||
_navigator = navigator;
|
||||
// Add code here to initialize or attach event handlers to singleton services
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user