mirror of
https://github.com/claunia/marechai.git
synced 2026-07-08 17:57:08 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
13181
Marechai.Database/Migrations/20260626095434_AddSoftwarePlatformLogo.Designer.cs
generated
Normal file
13181
Marechai.Database/Migrations/20260626095434_AddSoftwarePlatformLogo.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
208
openapi.yaml
208
openapi.yaml
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user