feat: Add optional raster logo to software platforms

Platforms (Xbox, PlayStation, PC, etc.) were displayed as plain text/chips
with no visual identity. This adds a single optional logo per platform,
shown as a small icon next to the platform name.

Unlike CompanyLogo (which supports multiple SVG logos per company,
versioned by year, for rebrand history), platforms only need one raster
logo with no history, so this introduces a simpler, separate model:
LogoId (Guid?) and LogoExtension (string?) stored directly on
SoftwarePlatform rather than a new child entity.

Backend:
- SoftwarePlatform gains nullable LogoId/LogoExtension columns
  (migration AddSoftwarePlatformLogo).
- New POST/DELETE /software/platforms/{id}/logo endpoints (Admin only).
  Upload accepts JPEG/PNG/WebP/BMP up to 5MB, validated by extension and
  content type. Reuses the existing ImageMagick-CLI-backed Photos helper
  (same one used for screenshots/photos) to convert the upload into
  JPEG/WebP/AVIF variants under assets/photos/platform-logos/, sized for
  an icon (256px full / 64px thumb) rather than the 4K/512px tiers used
  for photos. Replacing or deleting a logo cleans up all rendered files.
- SoftwarePlatformDto exposes logo_id/logo_extension; GET list/single
  projections include them. Platform cache is invalidated on logo
  upload/delete same as other mutations.
- Photos.EnsureCreated for "platform-logos" registered at startup.

Web UI (legacy Blazor):
- SoftwarePlatformDialog (admin edit dialog) gets an upload/preview/
  remove logo control, calling the new endpoints directly since there's
  only one logo to manage (no separate logo-list page needed).
- Platform chips on the /software landing page and the admin platforms
  grid show a small thumbnail next to the name when a logo is set.

Uno app (Marechai.App):
- AdminSoftwarePlatformsViewModel/Page get the same upload/remove logo
  capability in the edit panel, using FileOpenPicker and the regenerated
  Kiota client. New localized strings (LogoLabel, UploadLogoButton,
  RemoveLogoButton, FailedToUploadLogo, FailedToRemoveLogo) added across
  all 5 languages (en/de/es/fr/pt-BR).

API client:
- openapi.yaml and Marechai.ApiClient regenerated from a running server
  instance to pick up the new endpoints and DTO fields (LogoRequestBuilder,
  extended SoftwarePlatformDto). Diff is purely additive.
This commit is contained in:
2026-06-26 11:18:41 +01:00
parent effa7b6791
commit d2ab7fc931
25 changed files with 14137 additions and 13 deletions

View File

@@ -16,6 +16,16 @@ namespace Marechai.ApiClient.Models
public IDictionary<string, object> AdditionalData { get; set; }
/// <summary>The id property</summary>
public int? Id { get; set; }
/// <summary>The logo_extension property</summary>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public string? LogoExtension { get; set; }
#nullable restore
#else
public string LogoExtension { get; set; }
#endif
/// <summary>The logo_id property</summary>
public Guid? LogoId { get; set; }
/// <summary>The name property</summary>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
@@ -50,6 +60,8 @@ namespace Marechai.ApiClient.Models
return new Dictionary<string, Action<IParseNode>>
{
{ "id", n => { Id = n.GetIntValue(); } },
{ "logo_extension", n => { LogoExtension = n.GetStringValue(); } },
{ "logo_id", n => { LogoId = n.GetGuidValue(); } },
{ "name", n => { Name = n.GetStringValue(); } },
};
}
@@ -61,6 +73,8 @@ namespace Marechai.ApiClient.Models
{
if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer));
writer.WriteIntValue("id", Id);
writer.WriteStringValue("logo_extension", LogoExtension);
writer.WriteGuidValue("logo_id", LogoId);
writer.WriteStringValue("name", Name);
writer.WriteAdditionalData(AdditionalData);
}

View File

@@ -0,0 +1,148 @@
// <auto-generated/>
#pragma warning disable CS0618
using Marechai.ApiClient.Models;
using Microsoft.Kiota.Abstractions.Extensions;
using Microsoft.Kiota.Abstractions.Serialization;
using Microsoft.Kiota.Abstractions;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using System.Threading;
using System;
namespace Marechai.ApiClient.Software.Platforms.Item.Logo
{
/// <summary>
/// Builds and executes requests for operations under \software\platforms\{id}\logo
/// </summary>
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
public partial class LogoRequestBuilder : BaseRequestBuilder
{
/// <summary>
/// Instantiates a new <see cref="global::Marechai.ApiClient.Software.Platforms.Item.Logo.LogoRequestBuilder"/> and sets the default values.
/// </summary>
/// <param name="pathParameters">Path parameters for the request</param>
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
public LogoRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/platforms/{id}/logo", pathParameters)
{
}
/// <summary>
/// Instantiates a new <see cref="global::Marechai.ApiClient.Software.Platforms.Item.Logo.LogoRequestBuilder"/> and sets the default values.
/// </summary>
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
public LogoRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software/platforms/{id}/logo", rawUrl)
{
}
/// <returns>A <see cref="Stream"/></returns>
/// <param name="cancellationToken">Cancellation token to use when cancelling requests</param>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 400 status code</exception>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 401 status code</exception>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 404 status code</exception>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public async Task<Stream?> DeleteAsync(Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
{
#nullable restore
#else
public async Task<Stream> DeleteAsync(Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
{
#endif
var requestInfo = ToDeleteRequestInformation(requestConfiguration);
var errorMapping = new Dictionary<string, ParsableFactory<IParsable>>
{
{ "400", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
{ "401", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
{ "404", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
};
return await RequestAdapter.SendPrimitiveAsync<Stream>(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false);
}
/// <returns>A <see cref="global::Marechai.ApiClient.Models.SoftwarePlatformDto"/></returns>
/// <param name="body">The request body</param>
/// <param name="cancellationToken">Cancellation token to use when cancelling requests</param>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 400 status code</exception>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 401 status code</exception>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 404 status code</exception>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public async Task<global::Marechai.ApiClient.Models.SoftwarePlatformDto?> PostAsync(MultipartBody body, Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
{
#nullable restore
#else
public async Task<global::Marechai.ApiClient.Models.SoftwarePlatformDto> PostAsync(MultipartBody body, Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
{
#endif
if(ReferenceEquals(body, null)) throw new ArgumentNullException(nameof(body));
var requestInfo = ToPostRequestInformation(body, requestConfiguration);
var errorMapping = new Dictionary<string, ParsableFactory<IParsable>>
{
{ "400", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
{ "401", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
{ "404", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
};
return await RequestAdapter.SendAsync<global::Marechai.ApiClient.Models.SoftwarePlatformDto>(requestInfo, global::Marechai.ApiClient.Models.SoftwarePlatformDto.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false);
}
/// <returns>A <see cref="RequestInformation"/></returns>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public RequestInformation ToDeleteRequestInformation(Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default)
{
#nullable restore
#else
public RequestInformation ToDeleteRequestInformation(Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default)
{
#endif
var requestInfo = new RequestInformation(Method.DELETE, UrlTemplate, PathParameters);
requestInfo.Configure(requestConfiguration);
requestInfo.Headers.TryAdd("Accept", "application/json, text/plain;q=0.9");
return requestInfo;
}
/// <returns>A <see cref="RequestInformation"/></returns>
/// <param name="body">The request body</param>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public RequestInformation ToPostRequestInformation(MultipartBody body, Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default)
{
#nullable restore
#else
public RequestInformation ToPostRequestInformation(MultipartBody body, Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default)
{
#endif
if(ReferenceEquals(body, null)) throw new ArgumentNullException(nameof(body));
var requestInfo = new RequestInformation(Method.POST, UrlTemplate, PathParameters);
requestInfo.Configure(requestConfiguration);
requestInfo.Headers.TryAdd("Accept", "application/json, text/plain;q=0.9");
requestInfo.SetContentFromParsable(RequestAdapter, "multipart/form-data", body);
return requestInfo;
}
/// <summary>
/// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored.
/// </summary>
/// <returns>A <see cref="global::Marechai.ApiClient.Software.Platforms.Item.Logo.LogoRequestBuilder"/></returns>
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
public global::Marechai.ApiClient.Software.Platforms.Item.Logo.LogoRequestBuilder WithUrl(string rawUrl)
{
return new global::Marechai.ApiClient.Software.Platforms.Item.Logo.LogoRequestBuilder(rawUrl, RequestAdapter);
}
/// <summary>
/// Configuration for the request such as headers, query parameters, and middleware options.
/// </summary>
[Obsolete("This class is deprecated. Please use the generic RequestConfiguration class generated by the generator.")]
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
public partial class LogoRequestBuilderDeleteRequestConfiguration : RequestConfiguration<DefaultQueryParameters>
{
}
/// <summary>
/// Configuration for the request such as headers, query parameters, and middleware options.
/// </summary>
[Obsolete("This class is deprecated. Please use the generic RequestConfiguration class generated by the generator.")]
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
public partial class LogoRequestBuilderPostRequestConfiguration : RequestConfiguration<DefaultQueryParameters>
{
}
}
}
#pragma warning restore CS0618

View File

@@ -1,6 +1,7 @@
// <auto-generated/>
#pragma warning disable CS0618
using Marechai.ApiClient.Models;
using Marechai.ApiClient.Software.Platforms.Item.Logo;
using Marechai.ApiClient.Software.Platforms.Item.Merge;
using Microsoft.Kiota.Abstractions.Extensions;
using Microsoft.Kiota.Abstractions.Serialization;
@@ -18,6 +19,11 @@ namespace Marechai.ApiClient.Software.Platforms.Item
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
public partial class PlatformsItemRequestBuilder : BaseRequestBuilder
{
/// <summary>The logo property</summary>
public global::Marechai.ApiClient.Software.Platforms.Item.Logo.LogoRequestBuilder Logo
{
get => new global::Marechai.ApiClient.Software.Platforms.Item.Logo.LogoRequestBuilder(PathParameters, RequestAdapter);
}
/// <summary>The merge property</summary>
public global::Marechai.ApiClient.Software.Platforms.Item.Merge.MergeRequestBuilder Merge
{

View File

@@ -1,6 +1,6 @@
{
"descriptionHash": "3D337767FA6373F382CEEACB98C9C3E789A760131EC1A4FE918AF9F2725263B2A9EEC893480759A7E4CA0D820AD5ECE7CE4D55A983E77ADF51B62E457D423397",
"descriptionLocation": "../openapi.yaml",
"descriptionHash": "5C3730407129FAA461B7167A033FE731B12274FF0C61CC86979B1A02F02F040A824B1A80C590DF23B86B576AF1C75D78AF1AC88B33FA65B429EB1F6E8A1F98DB",
"descriptionLocation": "../../../../../tmp/openapi.json",
"lockFileVersion": "1.0.0",
"kiotaVersion": "1.31.1",
"clientClassName": "Client",

View File

@@ -3,11 +3,13 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Marechai.ApiClient.Models;
using Marechai.App.Services;
using Marechai.App.Services.Authentication;
using Microsoft.Extensions.Configuration;
namespace Marechai.App.Presentation.ViewModels.Admin;
@@ -18,6 +20,7 @@ public partial class AdminSoftwarePlatformsViewModel : ObservableObject, IRegion
private readonly IStringLocalizer _localizer;
private readonly ILogger<AdminSoftwarePlatformsViewModel> _logger;
private readonly ITokenService _tokenService;
private readonly IConfiguration _configuration;
[ObservableProperty] private ObservableCollection<SoftwarePlatformDto> _platforms = [];
[ObservableProperty] private ObservableCollection<SoftwarePlatformDto> _filteredPlatforms = [];
@@ -31,21 +34,29 @@ public partial class AdminSoftwarePlatformsViewModel : ObservableObject, IRegion
[ObservableProperty] private bool _isEditing;
[ObservableProperty] private string _editPanelTitle = string.Empty;
[ObservableProperty] private string _platformName = string.Empty;
[ObservableProperty] private Guid? _logoId;
[ObservableProperty] private bool _isUploadingLogo;
private int? _editingId;
private List<SoftwarePlatformDto>? _allPlatforms;
public string? LogoThumbnailUrl => LogoId.HasValue
? $"{_configuration.GetSection("ApiClient:Url").Value}/assets/photos/platform-logos/thumbs/webp/4k/{LogoId}.webp"
: null;
public AdminSoftwarePlatformsViewModel(SoftwarePlatformsService service,
IJwtService jwtService,
ITokenService tokenService,
ILogger<AdminSoftwarePlatformsViewModel> logger,
IStringLocalizer localizer)
IStringLocalizer localizer,
IConfiguration configuration)
{
_service = service;
_jwtService = jwtService;
_tokenService = tokenService;
_logger = logger;
_localizer = localizer;
_configuration = configuration;
LoadCommand = new AsyncRelayCommand(LoadAsync);
OpenAddCommand = new RelayCommand(OpenAdd);
@@ -53,6 +64,8 @@ public partial class AdminSoftwarePlatformsViewModel : ObservableObject, IRegion
DeleteCommand = new AsyncRelayCommand<SoftwarePlatformDto>(DeleteAsync);
SaveCommand = new AsyncRelayCommand(SaveAsync);
CancelEditCommand = new RelayCommand(CancelEdit);
UploadLogoCommand = new AsyncRelayCommand(UploadLogoAsync);
RemoveLogoCommand = new AsyncRelayCommand(RemoveLogoAsync);
CheckAdminRole();
}
@@ -63,6 +76,10 @@ public partial class AdminSoftwarePlatformsViewModel : ObservableObject, IRegion
public IAsyncRelayCommand<SoftwarePlatformDto> DeleteCommand { get; }
public IAsyncRelayCommand SaveCommand { get; }
public IRelayCommand CancelEditCommand { get; }
public IAsyncRelayCommand UploadLogoCommand { get; }
public IAsyncRelayCommand RemoveLogoCommand { get; }
partial void OnLogoIdChanged(Guid? value) => OnPropertyChanged(nameof(LogoThumbnailUrl));
public bool IsNavigationTarget(NavigationContext navigationContext) => true;
public void OnNavigatedFrom(NavigationContext navigationContext) { }
@@ -131,6 +148,7 @@ public partial class AdminSoftwarePlatformsViewModel : ObservableObject, IRegion
_editingId = item.Id;
EditPanelTitle = _localizer["EditSoftwarePlatformDialog_Title"];
PlatformName = item.Name ?? string.Empty;
LogoId = item.LogoId;
HasError = false;
ErrorMessage = string.Empty;
IsEditing = true;
@@ -210,7 +228,96 @@ public partial class AdminSoftwarePlatformsViewModel : ObservableObject, IRegion
private void ClearForm()
{
PlatformName = string.Empty;
LogoId = null;
HasError = false;
ErrorMessage = string.Empty;
}
private async Task UploadLogoAsync()
{
if(_editingId == null) return;
try
{
var picker = new Windows.Storage.Pickers.FileOpenPicker();
picker.FileTypeFilter.Add(".jpg");
picker.FileTypeFilter.Add(".jpeg");
picker.FileTypeFilter.Add(".png");
picker.FileTypeFilter.Add(".webp");
picker.FileTypeFilter.Add(".bmp");
#if !HAS_UNO
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(App.MainWindow);
WinRT.Interop.InitializeWithWindow.Initialize(picker, hwnd);
#endif
Windows.Storage.StorageFile? file = await picker.PickSingleFileAsync();
if(file == null) return;
IsUploadingLogo = true;
HasError = false;
using Stream stream = await file.OpenStreamForReadAsync();
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
byte[] fileBytes = ms.ToArray();
SoftwarePlatformDto? result =
await _service.UploadLogoAsync(_editingId.Value, fileBytes, file.Name, file.ContentType);
if(result == null)
{
ErrorMessage = _localizer["FailedToUploadLogo"];
HasError = true;
return;
}
LogoId = result.LogoId;
}
catch(Exception ex)
{
_logger.LogError(ex, "Error uploading logo for software platform {Id}", _editingId);
ErrorMessage = _localizer["FailedToUploadLogo"];
HasError = true;
}
finally
{
IsUploadingLogo = false;
}
}
private async Task RemoveLogoAsync()
{
if(_editingId == null) return;
try
{
IsUploadingLogo = true;
HasError = false;
bool success = await _service.DeleteLogoAsync(_editingId.Value);
if(!success)
{
ErrorMessage = _localizer["FailedToRemoveLogo"];
HasError = true;
return;
}
LogoId = null;
}
catch(Exception ex)
{
_logger.LogError(ex, "Error removing logo for software platform {Id}", _editingId);
ErrorMessage = _localizer["FailedToRemoveLogo"];
HasError = true;
}
finally
{
IsUploadingLogo = false;
}
}
}

View File

@@ -138,6 +138,20 @@
<TextBox Header="{Binding Source={StaticResource Strings}, Path=NameLabel}"
Text="{Binding PlatformName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
PlaceholderText="{Binding Source={StaticResource Strings}, Path=NameLabel}" />
<TextBlock Text="{Binding Source={StaticResource Strings}, Path=LogoLabel}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Image Source="{Binding LogoThumbnailUrl}" Width="48" Height="48"
Stretch="Uniform"
Visibility="{Binding LogoId, Converter={StaticResource NullToVisibilityConverter}}" />
<Button Content="{Binding Source={StaticResource Strings}, Path=UploadLogoButton}"
Command="{Binding UploadLogoCommand}"
IsEnabled="{Binding IsUploadingLogo, Converter={StaticResource InvertBoolConverter}}" />
<Button Content="{Binding Source={StaticResource Strings}, Path=RemoveLogoButton}"
Command="{Binding RemoveLogoCommand}"
Visibility="{Binding LogoId, Converter={StaticResource NullToVisibilityConverter}}"
IsEnabled="{Binding IsUploadingLogo, Converter={StaticResource InvertBoolConverter}}" />
</StackPanel>
</StackPanel>
</ScrollViewer>

View File

@@ -1839,6 +1839,11 @@
<data name="FailedToLoadSoftwarePlatforms" xml:space="preserve"><value>Softwareplattformen konnten nicht geladen werden.</value></data>
<data name="FailedToDeleteSoftwarePlatform" xml:space="preserve"><value>Softwareplattform konnte nicht gelöscht werden.</value></data>
<data name="FailedToSaveSoftwarePlatform" xml:space="preserve"><value>Softwareplattform konnte nicht gespeichert werden.</value></data>
<data name="FailedToUploadLogo" xml:space="preserve"><value>Logo konnte nicht hochgeladen werden.</value></data>
<data name="FailedToRemoveLogo" xml:space="preserve"><value>Logo konnte nicht entfernt werden.</value></data>
<data name="LogoLabel" xml:space="preserve"><value>Logo</value></data>
<data name="UploadLogoButton" xml:space="preserve"><value>Logo hochladen</value></data>
<data name="RemoveLogoButton" xml:space="preserve"><value>Logo entfernen</value></data>
<data name="FailedToLoadSoftwareFamilies" xml:space="preserve"><value>Softwarefamilien konnten nicht geladen werden.</value></data>
<data name="FailedToDeleteSoftwareFamily" xml:space="preserve"><value>Softwarefamilie konnte nicht gelöscht werden.</value></data>
<data name="FailedToSaveSoftwareFamily" xml:space="preserve"><value>Softwarefamilie konnte nicht gespeichert werden.</value></data>

View File

@@ -1979,6 +1979,11 @@
<data name="FailedToLoadSoftwarePlatforms" xml:space="preserve"><value>Error al cargar plataformas de software.</value></data>
<data name="FailedToDeleteSoftwarePlatform" xml:space="preserve"><value>Error al eliminar plataforma de software.</value></data>
<data name="FailedToSaveSoftwarePlatform" xml:space="preserve"><value>Error al guardar plataforma de software.</value></data>
<data name="FailedToUploadLogo" xml:space="preserve"><value>Error al subir el logo.</value></data>
<data name="FailedToRemoveLogo" xml:space="preserve"><value>Error al eliminar el logo.</value></data>
<data name="LogoLabel" xml:space="preserve"><value>Logo</value></data>
<data name="UploadLogoButton" xml:space="preserve"><value>Subir logo</value></data>
<data name="RemoveLogoButton" xml:space="preserve"><value>Eliminar logo</value></data>
<data name="FailedToLoadSoftwareFamilies" xml:space="preserve"><value>Error al cargar familias de software.</value></data>
<data name="FailedToDeleteSoftwareFamily" xml:space="preserve"><value>Error al eliminar familia de software.</value></data>
<data name="FailedToSaveSoftwareFamily" xml:space="preserve"><value>Error al guardar familia de software.</value></data>

View File

@@ -1943,6 +1943,11 @@
<data name="FailedToLoadSoftwarePlatforms" xml:space="preserve"><value>Échec du chargement des plateformes logicielles.</value></data>
<data name="FailedToDeleteSoftwarePlatform" xml:space="preserve"><value>Échec de la suppression de la plateforme logicielle.</value></data>
<data name="FailedToSaveSoftwarePlatform" xml:space="preserve"><value>Échec de l'enregistrement de la plateforme logicielle.</value></data>
<data name="FailedToUploadLogo" xml:space="preserve"><value>Échec du téléchargement du logo.</value></data>
<data name="FailedToRemoveLogo" xml:space="preserve"><value>Échec de la suppression du logo.</value></data>
<data name="LogoLabel" xml:space="preserve"><value>Logo</value></data>
<data name="UploadLogoButton" xml:space="preserve"><value>Télécharger le logo</value></data>
<data name="RemoveLogoButton" xml:space="preserve"><value>Supprimer le logo</value></data>
<data name="FailedToLoadSoftwareFamilies" xml:space="preserve"><value>Échec du chargement des familles de logiciels.</value></data>
<data name="FailedToDeleteSoftwareFamily" xml:space="preserve"><value>Échec de la suppression de la famille de logiciels.</value></data>
<data name="FailedToSaveSoftwareFamily" xml:space="preserve"><value>Échec de l'enregistrement de la famille de logiciels.</value></data>

View File

@@ -1839,6 +1839,11 @@
<data name="FailedToLoadSoftwarePlatforms" xml:space="preserve"><value>Falha ao carregar plataformas de software.</value></data>
<data name="FailedToDeleteSoftwarePlatform" xml:space="preserve"><value>Falha ao excluir plataforma de software.</value></data>
<data name="FailedToSaveSoftwarePlatform" xml:space="preserve"><value>Falha ao salvar plataforma de software.</value></data>
<data name="FailedToUploadLogo" xml:space="preserve"><value>Falha ao enviar o logo.</value></data>
<data name="FailedToRemoveLogo" xml:space="preserve"><value>Falha ao remover o logo.</value></data>
<data name="LogoLabel" xml:space="preserve"><value>Logo</value></data>
<data name="UploadLogoButton" xml:space="preserve"><value>Enviar logo</value></data>
<data name="RemoveLogoButton" xml:space="preserve"><value>Remover logo</value></data>
<data name="FailedToLoadSoftwareFamilies" xml:space="preserve"><value>Falha ao carregar famílias de software.</value></data>
<data name="FailedToDeleteSoftwareFamily" xml:space="preserve"><value>Falha ao excluir família de software.</value></data>
<data name="FailedToSaveSoftwareFamily" xml:space="preserve"><value>Falha ao salvar família de software.</value></data>

View File

@@ -2302,6 +2302,11 @@
<data name="FailedToLoadSoftwarePlatforms" xml:space="preserve"><value>Failed to load software platforms.</value></data>
<data name="FailedToDeleteSoftwarePlatform" xml:space="preserve"><value>Failed to delete software platform.</value></data>
<data name="FailedToSaveSoftwarePlatform" xml:space="preserve"><value>Failed to save software platform.</value></data>
<data name="FailedToUploadLogo" xml:space="preserve"><value>Failed to upload logo.</value></data>
<data name="FailedToRemoveLogo" xml:space="preserve"><value>Failed to remove logo.</value></data>
<data name="LogoLabel" xml:space="preserve"><value>Logo</value></data>
<data name="UploadLogoButton" xml:space="preserve"><value>Upload logo</value></data>
<data name="RemoveLogoButton" xml:space="preserve"><value>Remove logo</value></data>
<data name="FailedToLoadSoftwareFamilies" xml:space="preserve"><value>Failed to load software families.</value></data>
<data name="FailedToDeleteSoftwareFamily" xml:space="preserve"><value>Failed to delete software family.</value></data>
<data name="FailedToSaveSoftwareFamily" xml:space="preserve"><value>Failed to save software family.</value></data>

View File

@@ -2,8 +2,10 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Marechai.ApiClient.Models;
using Microsoft.Kiota.Abstractions;
namespace Marechai.App.Services;
@@ -95,4 +97,38 @@ public class SoftwarePlatformsService
return false;
}
}
public async Task<SoftwarePlatformDto?> UploadLogoAsync(int id, byte[] fileBytes, string fileName,
string contentType)
{
try
{
var body = new MultipartBody();
body.AddOrReplacePart("file", contentType, new MemoryStream(fileBytes), fileName);
return await _apiClient.Software.Platforms[id].Logo.PostAsync(body);
}
catch(Exception ex)
{
_logger.LogError(ex, "Error uploading logo for software platform {Id}", id);
return null;
}
}
public async Task<bool> DeleteLogoAsync(int id)
{
try
{
await _apiClient.Software.Platforms[id].Logo.DeleteAsync();
return true;
}
catch(Exception ex)
{
_logger.LogError(ex, "Error deleting logo for software platform {Id}", id);
return false;
}
}
}

View File

@@ -23,6 +23,7 @@
// Copyright © 2003-2026 Natalia Portillo
*******************************************************************************/
using System;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
@@ -33,4 +34,10 @@ public class SoftwarePlatformDto : BaseDto<ulong>
[JsonPropertyName("name")]
[Required]
public required string Name { get; set; }
[JsonPropertyName("logo_id")]
public Guid? LogoId { get; set; }
[JsonPropertyName("logo_extension")]
public string LogoExtension { get; set; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marechai.Database.Migrations
{
/// <inheritdoc />
public partial class AddSoftwarePlatformLogo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "LogoExtension",
table: "SoftwarePlatforms",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<Guid>(
name: "LogoId",
table: "SoftwarePlatforms",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LogoExtension",
table: "SoftwarePlatforms");
migrationBuilder.DropColumn(
name: "LogoId",
table: "SoftwarePlatforms");
}
}
}

View File

@@ -8206,6 +8206,12 @@ namespace Marechai.Database.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<DateTime>("CreatedOn"));
b.Property<string>("LogoExtension")
.HasColumnType("longtext");
b.Property<Guid?>("LogoId")
.HasColumnType("char(36)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("varchar(255)");

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
@@ -7,6 +8,8 @@ public class SoftwarePlatform : BaseModel<ulong>
{
[Required]
public string Name { get; set; } // Xbox, PlayStation, PC, etc.
public Guid? LogoId { get; set; }
public string LogoExtension { get; set; }
public virtual ICollection<SoftwareRelease> SoftwareReleases { get; set; }
public virtual ICollection<SoftwareScreenshot> Screenshots { get; set; }
public virtual ICollection<SoftwarePlatformsByMachine> Machines { get; set; }

View File

@@ -25,23 +25,27 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Marechai.Data.Dtos;
using Marechai.Database.Models;
using Marechai.Helpers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
namespace Marechai.Server.Controllers;
[Route("/software/platforms")]
[ApiController]
public class SoftwarePlatformsController(MarechaiContext context, IMemoryCache cache) : ControllerBase
public class SoftwarePlatformsController(MarechaiContext context, IMemoryCache cache,
IConfiguration configuration) : ControllerBase
{
// Software platforms barely change — cache the full list briefly so the
// /software landing page doesn't re-hit the DB on every load.
@@ -49,6 +53,15 @@ public class SoftwarePlatformsController(MarechaiContext context, IMemoryCache c
const string PLATFORMS_WITH_SOFTWARE_CACHE_KEY = "software:platforms:with-software";
static readonly TimeSpan _platformsCacheTtl = TimeSpan.FromMinutes(5);
static readonly HashSet<string> _allowedLogoExtensions = [".jpg", ".jpeg", ".png", ".webp", ".bmp"];
static readonly HashSet<string> _allowedLogoContentTypes =
[
"image/jpeg", "image/png", "image/webp", "image/bmp"
];
readonly string _assetRootPath = configuration["AssetRootPath"]!;
[HttpGet]
[AllowAnonymous]
[OutputCache(Duration = 300, VaryByQueryKeys = ["includeUnused"])]
@@ -68,8 +81,10 @@ public class SoftwarePlatformsController(MarechaiContext context, IMemoryCache c
List<SoftwarePlatformDto> platforms = await query.OrderBy(p => p.Name)
.Select(p => new SoftwarePlatformDto
{
Id = p.Id,
Name = p.Name
Id = p.Id,
Name = p.Name,
LogoId = p.LogoId,
LogoExtension = p.LogoExtension
})
.ToListAsync();
@@ -85,8 +100,10 @@ public class SoftwarePlatformsController(MarechaiContext context, IMemoryCache c
public Task<SoftwarePlatformDto> GetAsync(ulong id) => context.SoftwarePlatforms.Where(p => p.Id == id)
.Select(p => new SoftwarePlatformDto
{
Id = p.Id,
Name = p.Name
Id = p.Id,
Name = p.Name,
LogoId = p.LogoId,
LogoExtension = p.LogoExtension
})
.FirstOrDefaultAsync();
@@ -164,6 +181,146 @@ public class SoftwarePlatformsController(MarechaiContext context, IMemoryCache c
return Ok();
}
[HttpPost("{id:ulong}/logo")]
[Authorize(Roles = "Admin,UberAdmin")]
[RequestSizeLimit(5 * 1024 * 1024)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<SoftwarePlatformDto>> UploadLogoAsync(ulong id, IFormFile file)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwarePlatform platform = await context.SoftwarePlatforms.FindAsync(id);
if(platform is null) return NotFound();
if(file is null || file.Length == 0)
return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest);
if(file.Length > 5 * 1024 * 1024)
return Problem(detail: "File exceeds 5 MB limit.", statusCode: StatusCodes.Status400BadRequest);
string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty;
if(!_allowedLogoExtensions.Contains(extension))
return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP, BMP.",
statusCode: StatusCodes.Status400BadRequest);
if(!string.IsNullOrEmpty(file.ContentType) &&
!_allowedLogoContentTypes.Contains(file.ContentType.ToLowerInvariant()))
return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest);
// Remove any previous logo's files before writing the new one.
if(platform.LogoId.HasValue)
DeletePlatformLogoFiles(platform.LogoId.Value, platform.LogoExtension);
var guid = Guid.NewGuid();
Photos.EnsureCreated(_assetRootPath, false, "platform-logos");
string originalsDir = Path.Combine(_assetRootPath, "photos", "platform-logos", "originals");
string originalPath = Path.Combine(originalsDir, $"{guid}{extension}");
using(var ms = new MemoryStream())
{
await file.CopyToAsync(ms);
ms.Position = 0;
await using var fs = new FileStream(originalPath, FileMode.CreateNew, FileAccess.Write);
await ms.CopyToAsync(fs);
}
// A platform logo is a small icon, not a photo — render compact full/thumb tiers
// (256 / 64 px) instead of reusing Photos.ConversionWorker's 4K/512 photo sizing.
foreach((string format, string outExt) in new[]
{
("jpeg", "jpg"), ("webp", "webp"), ("avif", "avif")
})
{
string fullPath = Path.Combine(_assetRootPath, "photos", "platform-logos", format, "4k",
$"{guid}.{outExt}");
string thumbPath = Path.Combine(_assetRootPath, "photos", "platform-logos", "thumbs", format, "4k",
$"{guid}.{outExt}");
Photos.ConvertUsingImageMagick(originalPath, fullPath, 256, 256);
Photos.ConvertUsingImageMagick(originalPath, thumbPath, 64, 64);
}
platform.LogoId = guid;
platform.LogoExtension = extension.TrimStart('.');
await context.SaveChangesWithUserAsync(userId);
cache.Remove(PLATFORMS_CACHE_KEY);
cache.Remove(PLATFORMS_WITH_SOFTWARE_CACHE_KEY);
return Ok(new SoftwarePlatformDto
{
Id = platform.Id,
Name = platform.Name,
LogoId = platform.LogoId,
LogoExtension = platform.LogoExtension
});
}
[HttpDelete("{id:ulong}/logo")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> DeleteLogoAsync(ulong id)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwarePlatform platform = await context.SoftwarePlatforms.FindAsync(id);
if(platform is null) return NotFound();
if(!platform.LogoId.HasValue) return Ok();
DeletePlatformLogoFiles(platform.LogoId.Value, platform.LogoExtension);
platform.LogoId = null;
platform.LogoExtension = null;
await context.SaveChangesWithUserAsync(userId);
cache.Remove(PLATFORMS_CACHE_KEY);
cache.Remove(PLATFORMS_WITH_SOFTWARE_CACHE_KEY);
return Ok();
}
void DeletePlatformLogoFiles(Guid guid, string originalExtension)
{
if(!string.IsNullOrEmpty(originalExtension))
{
string originalPath = Path.Combine(_assetRootPath, "photos", "platform-logos", "originals",
$"{guid}.{originalExtension}");
if(System.IO.File.Exists(originalPath)) System.IO.File.Delete(originalPath);
}
foreach((string format, string outExt) in new[]
{
("jpeg", "jpg"), ("webp", "webp"), ("avif", "avif")
})
{
string fullPath = Path.Combine(_assetRootPath, "photos", "platform-logos", format, "4k",
$"{guid}.{outExt}");
string thumbPath = Path.Combine(_assetRootPath, "photos", "platform-logos", "thumbs", format, "4k",
$"{guid}.{outExt}");
if(System.IO.File.Exists(fullPath)) System.IO.File.Delete(fullPath);
if(System.IO.File.Exists(thumbPath)) System.IO.File.Delete(thumbPath);
}
}
/// <summary>
/// Merge multiple software platforms into a target platform.
/// </summary>

View File

@@ -178,6 +178,7 @@ file class Program
Photos.EnsureCreated(assetRootPath, false, "people");
Photos.EnsureCreated(assetRootPath, false, "software-screenshots");
Photos.EnsureCreated(assetRootPath, false, "software-covers");
Photos.EnsureCreated(assetRootPath, false, "platform-logos");
Console.WriteLine("\e[31;1mEnsuring scan folders exist...\e[0m");
Photos.EnsureCreated(assetRootPath, true, "books");
Photos.EnsureCreated(assetRootPath, true, "documents");

View File

@@ -1,6 +1,9 @@
@using Marechai.Services
@using Microsoft.AspNetCore.Components.Forms
@inject IStringLocalizer<SoftwarePlatformsService> L
@inject SoftwarePlatformsService SoftwarePlatformsService
@inject ApiAssetUrlProvider AssetUrls
<MudDialog>
<DialogContent>
@@ -11,6 +14,51 @@
RequiredError="@L["Name is required."]"
Class="mb-4"/>
@if(!IsNew)
{
<MudText Typo="Typo.subtitle2" Class="mb-2">@L["Logo"]</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3" Class="mb-4">
@if(_logoId.HasValue)
{
<picture>
<source srcset="@(AssetUrls.BaseUrl)/assets/photos/platform-logos/thumbs/avif/4k/@(_logoId).avif" type="image/avif">
<source srcset="@(AssetUrls.BaseUrl)/assets/photos/platform-logos/thumbs/webp/4k/@(_logoId).webp" type="image/webp">
<img alt="@Name" src="@(AssetUrls.BaseUrl)/assets/photos/platform-logos/thumbs/jpeg/4k/@(_logoId).jpg"
style="max-height: 48px; max-width: 48px; object-fit: contain;"/>
</picture>
}
else
{
<MudIcon Icon="@Icons.Material.Filled.ImageNotSupported" Color="Color.Default"/>
}
<MudFileUpload T="IBrowserFile" FilesChanged="OnLogoSelectedAsync" Accept=".jpg,.jpeg,.png,.webp,.bmp"
MaximumFileCount="1">
<CustomContent>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Upload"
Disabled="_isUploading"
OnClick="@context.OpenFilePickerAsync">
@(_isUploading ? L["Uploading..."] : L["Upload logo"])
</MudButton>
</CustomContent>
</MudFileUpload>
@if(_logoId.HasValue)
{
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Disabled="_isUploading"
OnClick="RemoveLogoAsync"/>
}
</MudStack>
@if(!string.IsNullOrWhiteSpace(_logoError))
{
<MudAlert Severity="Severity.Error" Class="mb-4">@_logoError</MudAlert>
}
}
@if(!string.IsNullOrWhiteSpace(_validationError))
{
<MudAlert Severity="Severity.Error" Class="mt-2">@_validationError</MudAlert>
@@ -24,11 +72,18 @@
@code {
string _validationError;
string _logoError;
bool _isUploading;
Guid? _logoId;
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public bool IsNew { get; set; }
[Parameter] public string Name { get; set; } = string.Empty;
[Parameter] public bool IsNew { get; set; }
[Parameter] public int Id { get; set; }
[Parameter] public string Name { get; set; } = string.Empty;
[Parameter] public Guid? LogoId { get; set; }
protected override void OnInitialized() => _logoId = LogoId;
void Cancel() => MudDialog.Cancel();
@@ -42,4 +97,50 @@
MudDialog.Close(DialogResult.Ok(new SoftwarePlatformDialogResult { Name = Name }));
}
async Task OnLogoSelectedAsync(IBrowserFile file)
{
if(file is null) return;
_logoError = null;
_isUploading = true;
try
{
using var stream = file.OpenReadStream(5 * 1024 * 1024);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
(Marechai.ApiClient.Models.SoftwarePlatformDto platform, string error) =
await SoftwarePlatformsService.UploadLogoAsync(Id, ms.ToArray(), file.Name, file.ContentType);
if(platform is not null)
_logoId = platform.LogoId;
else
_logoError = error;
}
catch(Exception ex)
{
_logoError = ex.Message;
}
finally
{
_isUploading = false;
}
}
async Task RemoveLogoAsync()
{
_logoError = null;
_isUploading = true;
(bool succeeded, string error) = await SoftwarePlatformsService.DeleteLogoAsync(Id);
if(succeeded)
_logoId = null;
else
_logoError = error;
_isUploading = false;
}
}

View File

@@ -5,6 +5,7 @@
@inject SoftwarePlatformsService SoftwarePlatformsService
@inject IStringLocalizer<SoftwarePlatformsService> L
@inject IDialogService DialogService
@inject ApiAssetUrlProvider AssetUrls
<MudPaper Class="pa-6 mb-6" Elevation="0" Style="background: linear-gradient(135deg, #546e7a 0%, #90a4ae 100%); border-radius: 16px;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
@@ -42,6 +43,19 @@
<MudDataGrid Items="_platforms" Dense="true" Hover="true" Striped="true" Filterable="true">
<Columns>
<TemplateColumn Title="@L["Logo"]" Sortable="false" Filterable="false">
<CellTemplate>
@if(context.Item.LogoId.HasValue)
{
<picture>
<source srcset="@(AssetUrls.BaseUrl)/assets/photos/platform-logos/thumbs/avif/4k/@(context.Item.LogoId).avif" type="image/avif">
<source srcset="@(AssetUrls.BaseUrl)/assets/photos/platform-logos/thumbs/webp/4k/@(context.Item.LogoId).webp" type="image/webp">
<img alt="" src="@(AssetUrls.BaseUrl)/assets/photos/platform-logos/thumbs/jpeg/4k/@(context.Item.LogoId).jpg"
style="height: 24px; width: 24px; object-fit: contain;"/>
</picture>
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.Name" Title="@L["Name"]"/>
<TemplateColumn Title="@L["Actions"]" Sortable="false" Filterable="false">
<CellTemplate>

View File

@@ -63,7 +63,9 @@ public partial class SoftwarePlatforms
DialogParameters<SoftwarePlatformDialog> parameters = new()
{
{ x => x.IsNew, false },
{ x => x.Name, platform.Name }
{ x => x.Id, (int)(platform.Id ?? 0) },
{ x => x.Name, platform.Name },
{ x => x.LogoId, platform.LogoId }
};
IDialogReference dialog = await DialogService.ShowAsync<SoftwarePlatformDialog>(L["Edit Platform"], parameters,

View File

@@ -33,6 +33,7 @@
@inherits OwningComponentBase<SoftwareService>
@inject IStringLocalizer<SoftwareService> L
@inject IDialogService DialogService
@inject ApiAssetUrlProvider AssetUrls
@if(!_loaded)
{
@@ -119,7 +120,18 @@
<div class="d-flex flex-wrap gap-1">
@foreach(SoftwarePlatformDto platform in _platforms.OrderBy(p => p.Name, NaturalUnitComparer.Instance))
{
<MudChip T="string" Href="@($"/software/platform/{platform.Id}")">@platform.Name</MudChip>
<MudChip T="string" Href="@($"/software/platform/{platform.Id}")">
@if(platform.LogoId.HasValue)
{
<picture>
<source srcset="@(AssetUrls.BaseUrl)/assets/photos/platform-logos/thumbs/avif/4k/@(platform.LogoId).avif" type="image/avif">
<source srcset="@(AssetUrls.BaseUrl)/assets/photos/platform-logos/thumbs/webp/4k/@(platform.LogoId).webp" type="image/webp">
<img alt="" src="@(AssetUrls.BaseUrl)/assets/photos/platform-logos/thumbs/jpeg/4k/@(platform.LogoId).jpg"
style="height: 18px; width: 18px; object-fit: contain; margin-right: 4px; vertical-align: middle;"/>
</picture>
}
@platform.Name
</MudChip>
}
</div>
</MudPaper>

View File

@@ -25,9 +25,11 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Marechai.ApiClient.Models;
using Microsoft.Kiota.Abstractions;
using Microsoft.Kiota.Abstractions.Serialization;
namespace Marechai.Services;
@@ -137,6 +139,47 @@ public class SoftwarePlatformsService(Marechai.ApiClient.Client client)
}
}
public async Task<(SoftwarePlatformDto platform, string error)> UploadLogoAsync(int id, byte[] fileBytes,
string fileName,
string contentType)
{
try
{
var body = new MultipartBody();
body.AddOrReplacePart("file", contentType, new MemoryStream(fileBytes), fileName);
SoftwarePlatformDto result = await client.Software.Platforms[id].Logo.PostAsync(body);
return (result, null);
}
catch(ApiException ex)
{
return (null, ExtractDetail(ex));
}
catch(Exception ex)
{
return (null, ex.Message);
}
}
public async Task<(bool succeeded, string error)> DeleteLogoAsync(int id)
{
try
{
await client.Software.Platforms[id].Logo.DeleteAsync();
return (true, null);
}
catch(ApiException ex)
{
return (false, ExtractDetail(ex));
}
catch(Exception ex)
{
return (false, ex.Message);
}
}
static string ExtractDetail(ApiException ex)
{
// Kiota maps server error responses to a typed ProblemDetails (which inherits from

View File

@@ -6,7 +6,7 @@
},
"servers": [
{
"url": "http://127.0.0.1:5023/"
"url": "http://127.0.0.1:5099/"
}
],
"paths": {
@@ -50483,6 +50483,202 @@
}
}
},
"/software/platforms/{id}/logo": {
"post": {
"tags": [
"SoftwarePlatforms"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "uint64"
}
}
],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"file": {
"$ref": "#/components/schemas/IFormFile"
}
}
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/SoftwarePlatformDto"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/SoftwarePlatformDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/SoftwarePlatformDto"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
},
"delete": {
"tags": [
"SoftwarePlatforms"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "uint64"
}
}
],
"responses": {
"200": {
"description": "OK"
},
"400": {
"description": "Bad Request",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/software/platforms/{id}/merge": {
"post": {
"tags": [
@@ -71656,6 +71852,16 @@
"name": {
"type": "string"
},
"logo_id": {
"type": [
"null",
"string"
],
"format": "uuid"
},
"logo_extension": {
"type": "string"
},
"id": {
"type": "integer",
"format": "uint64"