Enhance metadata fetching and UI improvements
Build and Push Docker Image / build-and-push (push) Successful in 25s

- Added `MetadataFetchSucceeded` and `MetadataFetchFailed` enums.
- Improved metadata fetch logging with detailed outcomes.
- Enhanced toolbar formatting options in `RegistryAdmin`.
- Made `LanguageOptions` dynamic based on the environment.
- Styled Quill text-size classes in public header content.
- Improved error handling in `RegistryMetadataService`.
- Adjusted supported cultures for development environments.
- Updated localization for new metadata fetch statuses.
This commit is contained in:
Arne Moerman
2026-05-22 16:40:29 +02:00
parent b59ad6e5ab
commit 3b219da4eb
11 changed files with 148 additions and 48 deletions
@@ -22,5 +22,7 @@ public enum UserActionType
MarkPurchased = 3, MarkPurchased = 3,
UnmarkPurchased = 4, UnmarkPurchased = 4,
MarkPartialPurchase = 5, MarkPartialPurchase = 5,
LogContribution = 6 LogContribution = 6,
MetadataFetchSucceeded = 7,
MetadataFetchFailed = 8
} }
@@ -2,12 +2,14 @@
@using BirthList.Web.Features.Localization @using BirthList.Web.Features.Localization
@using BirthList.Web.Features.Registries @using BirthList.Web.Features.Registries
@using BirthList.Web.Services @using BirthList.Web.Services
@using Microsoft.Extensions.Hosting
@implements IDisposable @implements IDisposable
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider @inject AuthenticationStateProvider AuthenticationStateProvider
@inject RegistryUserContext RegistryUserContext @inject RegistryUserContext RegistryUserContext
@inject ProfileCompletionService ProfileCompletionService @inject ProfileCompletionService ProfileCompletionService
@inject IHostEnvironment HostEnvironment
@if (ShowProfileCompletionPrompt) @if (ShowProfileCompletionPrompt)
{ {
@@ -70,12 +72,21 @@
private bool ShowProfileCompletionPrompt { get; set; } private bool ShowProfileCompletionPrompt { get; set; }
private string CurrentCulture { get; set; } = "en"; private string CurrentCulture { get; set; } = "en";
private static readonly IReadOnlyList<LanguageOption> LanguageOptions = private IReadOnlyList<LanguageOption> LanguageOptions => HostEnvironment.IsDevelopment()
? DevelopmentLanguageOptions
: ProductionLanguageOptions;
private static readonly IReadOnlyList<LanguageOption> ProductionLanguageOptions =
[ [
new("en", "English"), new("en", "English"),
new("nl-NL", "Nederlands (NL)"), new("nl-NL", "Nederlands (NL)"),
new("nl-BE", "Nederlands (BE)"), new("nl-BE", "Nederlands (BE)"),
new("fr-FR", "Français"), new("fr-FR", "Français")
];
private static readonly IReadOnlyList<LanguageOption> DevelopmentLanguageOptions =
[
..ProductionLanguageOptions,
new("qps-Ploc", "Pseudo") new("qps-Ploc", "Pseudo")
]; ];
@@ -65,6 +65,12 @@ else
case "LogContribution": case "LogContribution":
<span class="badge bg-secondary">@L["RegistryActionLog.Badge.ContributionLogged"]</span> <span class="badge bg-secondary">@L["RegistryActionLog.Badge.ContributionLogged"]</span>
break; break;
case "MetadataFetchSucceeded":
<span class="badge bg-success">@L["RegistryActionLog.Badge.MetadataFetchSucceeded"]</span>
break;
case "MetadataFetchFailed":
<span class="badge bg-danger">@L["RegistryActionLog.Badge.MetadataFetchFailed"]</span>
break;
default: default:
<span class="badge bg-dark">@log.ActionType</span> <span class="badge bg-dark">@log.ActionType</span>
break; break;
@@ -1,3 +1,4 @@
using BirthList.Domain.Entities;
using BirthList.Web.Authorization; using BirthList.Web.Authorization;
using BirthList.Web.Features.Registries; using BirthList.Web.Features.Registries;
using BirthList.Web.Services; using BirthList.Web.Services;
@@ -41,10 +42,11 @@ public partial class RegistryAdmin : ComponentBase
protected string ActiveTab { get; set; } = "items"; protected string ActiveTab { get; set; } = "items";
protected RenderFragment ToolbarContent => builder => protected RenderFragment ToolbarContent => builder =>
{ {
builder.AddMarkupContent(0, "<span class='ql-formats'><select class='ql-header'><option selected></option><option value='1'></option><option value='2'></option></select></span>"); builder.AddMarkupContent(0, @"<span class='ql-formats'><select class='ql-size'><option value='small'></option><option selected></option><option value='large'></option><option value='huge'></option></select><select class='ql-header'><option selected></option><option value='1'></option><option value='2'></option><option value='3'></option><option value='4'></option><option value='5'></option></select></span>");
builder.AddMarkupContent(1, "<span class='ql-formats'><button class='ql-bold'></button><button class='ql-italic'></button><button class='ql-underline'></button></span>"); builder.AddMarkupContent(1, @"<span class='ql-formats'><button class='ql-bold'></button><button class='ql-italic'></button><button class='ql-underline'></button><button class='ql-strike'></button></span>");
builder.AddMarkupContent(2, "<span class='ql-formats'><button class='ql-list' value='ordered'></button><button class='ql-list' value='bullet'></button></span>"); builder.AddMarkupContent(2, @"<span class='ql-formats'><select class=""ql-color""></select><select class=""ql-background""></select></span>");
builder.AddMarkupContent(3, "<span class='ql-formats'><button class='ql-link'></button><button class='ql-clean'></button></span>"); builder.AddMarkupContent(3, @"<span class='ql-formats'><button class='ql-list' value='ordered'></button><button class='ql-list' value='bullet'></button></span>");
builder.AddMarkupContent(4, @"<span class='ql-formats'><button class='ql-link'></button><button class='ql-clean'></button></span>");
}; };
private bool _pendingEditorLoad; private bool _pendingEditorLoad;
@@ -583,41 +585,95 @@ public partial class RegistryAdmin : ComponentBase
return; return;
} }
var metadata = await RegistryMetadataService.FetchAsync(ItemModel.ProductUrl, CancellationToken.None).ConfigureAwait(false); var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (metadata is null) var actorUserId = string.IsNullOrWhiteSpace(userId) ? "unknown-user" : userId;
async Task LogMetadataAsync(UserActionType actionType, string details)
{ {
return; try
{
await RegistryService.LogUserActionAsync(
RegistryId,
actorUserId,
actionType,
ItemModel.Id,
TruncateLogDetails(details),
CancellationToken.None).ConfigureAwait(false);
}
catch (ArgumentException)
{
// Keep autofetch UX stable even if logging cannot be persisted.
}
} }
if (!string.IsNullOrWhiteSpace(metadata.NormalizedUrl)) try
{ {
ItemModel.ProductUrl = metadata.NormalizedUrl; var metadata = await RegistryMetadataService.FetchAsync(ItemModel.ProductUrl, CancellationToken.None).ConfigureAwait(false);
if (metadata is null)
{
await LogMetadataAsync(UserActionType.MetadataFetchFailed, $"metadata-fetch failed; url={ItemModel.ProductUrl}; reason=no-metadata").ConfigureAwait(false);
return;
}
if (!string.IsNullOrWhiteSpace(metadata.NormalizedUrl))
{
ItemModel.ProductUrl = metadata.NormalizedUrl;
}
if ((string.IsNullOrWhiteSpace(ItemModel.Name) || string.Equals(ItemModel.Name, "Amazon", StringComparison.OrdinalIgnoreCase)) && !string.IsNullOrWhiteSpace(metadata.Title))
{
ItemModel.Name = metadata.Title;
}
if ((string.IsNullOrWhiteSpace(ItemModel.Description) || string.Equals(ItemModel.Description, "Amazon", StringComparison.OrdinalIgnoreCase)) && !string.IsNullOrWhiteSpace(metadata.Description))
{
ItemModel.Description = metadata.Description;
}
if (string.IsNullOrWhiteSpace(ItemModel.PictureUrl) && !string.IsNullOrWhiteSpace(metadata.ImageUrl))
{
ItemModel.PictureUrl = metadata.ImageUrl;
}
if (!ItemModel.PriceAmount.HasValue && metadata.PriceAmount.HasValue)
{
ItemModel.PriceAmount = metadata.PriceAmount;
}
if (!string.IsNullOrWhiteSpace(metadata.CurrencyCode))
{
ItemModel.CurrencyCode = metadata.CurrencyCode;
}
var summary = $"metadata-fetch success; url={ItemModel.ProductUrl}; title={(string.IsNullOrWhiteSpace(metadata.Title) ? "-" : metadata.Title)}; image={(string.IsNullOrWhiteSpace(metadata.ImageUrl) ? "no" : "yes")}; price={(metadata.PriceAmount.HasValue ? metadata.PriceAmount.Value.ToString("0.00") : "-")}; currency={(string.IsNullOrWhiteSpace(metadata.CurrencyCode) ? "-" : metadata.CurrencyCode)}";
await LogMetadataAsync(UserActionType.MetadataFetchSucceeded, summary).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
await LogMetadataAsync(UserActionType.MetadataFetchFailed, $"metadata-fetch failed; url={ItemModel.ProductUrl}; reason=http; message={ex.Message}").ConfigureAwait(false);
}
catch (TaskCanceledException ex)
{
await LogMetadataAsync(UserActionType.MetadataFetchFailed, $"metadata-fetch failed; url={ItemModel.ProductUrl}; reason=timeout-or-cancel; message={ex.Message}").ConfigureAwait(false);
}
catch (InvalidOperationException ex)
{
await LogMetadataAsync(UserActionType.MetadataFetchFailed, $"metadata-fetch failed; url={ItemModel.ProductUrl}; reason=invalid-operation; message={ex.Message}").ConfigureAwait(false);
}
catch (Exception ex)
{
await LogMetadataAsync(UserActionType.MetadataFetchFailed, $"metadata-fetch failed; url={ItemModel.ProductUrl}; reason=unexpected-{ex.GetType().Name}; message={ex.Message}").ConfigureAwait(false);
}
}
private static string TruncateLogDetails(string details)
{
if (string.IsNullOrWhiteSpace(details))
{
return string.Empty;
} }
if ((string.IsNullOrWhiteSpace(ItemModel.Name) || string.Equals(ItemModel.Name, "Amazon", StringComparison.OrdinalIgnoreCase)) && !string.IsNullOrWhiteSpace(metadata.Title)) return details.Length <= 500 ? details : details[..500];
{
ItemModel.Name = metadata.Title;
}
if ((string.IsNullOrWhiteSpace(ItemModel.Description) || string.Equals(ItemModel.Description, "Amazon", StringComparison.OrdinalIgnoreCase)) && !string.IsNullOrWhiteSpace(metadata.Description))
{
ItemModel.Description = metadata.Description;
}
if (string.IsNullOrWhiteSpace(ItemModel.PictureUrl) && !string.IsNullOrWhiteSpace(metadata.ImageUrl))
{
ItemModel.PictureUrl = metadata.ImageUrl;
}
if (!ItemModel.PriceAmount.HasValue && metadata.PriceAmount.HasValue)
{
ItemModel.PriceAmount = metadata.PriceAmount;
}
if (!string.IsNullOrWhiteSpace(metadata.CurrencyCode))
{
ItemModel.CurrencyCode = metadata.CurrencyCode;
}
} }
protected async Task CreateInviteAsync() protected async Task CreateInviteAsync()
@@ -168,3 +168,16 @@
.category-toggle span.bi { .category-toggle span.bi {
margin-right: 0.25rem; margin-right: 0.25rem;
} }
/* Render Quill text-size classes in public header content */
.header-content ::deep .ql-size-small {
font-size: 0.75em;
}
.header-content ::deep .ql-size-large {
font-size: 1.5em;
}
.header-content ::deep .ql-size-huge {
font-size: 2.5em;
}
@@ -23,15 +23,12 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto
request.Headers.AcceptLanguage.ParseAdd("en-US,en;q=0.9"); request.Headers.AcceptLanguage.ParseAdd("en-US,en;q=0.9");
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) response.EnsureSuccessStatusCode();
{
return null;
}
var html = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var html = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(html)) if (string.IsNullOrWhiteSpace(html))
{ {
return null; throw new InvalidOperationException("Metadata response content was empty.");
} }
var meta = ParseMeta(html); var meta = ParseMeta(html);
+10 -7
View File
@@ -13,10 +13,10 @@ using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -136,15 +136,18 @@ builder.Services.AddLocalization(options =>
options.ResourcesPath = "Resources"; options.ResourcesPath = "Resources";
}); });
var supportedCultures = new[] var supportedCultures = new List<CultureInfo>
{ {
new CultureInfo("en"), new("en"),
new CultureInfo("nl-NL"), new("nl-BE"),
new CultureInfo("nl-BE"), new("fr-FR")
new CultureInfo("fr-FR"),
new CultureInfo("qps-Ploc")
}; };
if (builder.Environment.IsDevelopment())
{
supportedCultures.Add(new CultureInfo("qps-Ploc"));
}
builder.Services.Configure<RequestLocalizationOptions>(options => builder.Services.Configure<RequestLocalizationOptions>(options =>
{ {
options.DefaultRequestCulture = new RequestCulture("en"); options.DefaultRequestCulture = new RequestCulture("en");
@@ -216,6 +216,8 @@
<data name="RegistryActionLog.Badge.PurchaseUnmarked" xml:space="preserve"><value>Achat annulé</value></data> <data name="RegistryActionLog.Badge.PurchaseUnmarked" xml:space="preserve"><value>Achat annulé</value></data>
<data name="RegistryActionLog.Badge.PartialPurchase" xml:space="preserve"><value>Achat partiel</value></data> <data name="RegistryActionLog.Badge.PartialPurchase" xml:space="preserve"><value>Achat partiel</value></data>
<data name="RegistryActionLog.Badge.ContributionLogged" xml:space="preserve"><value>Participation enregistrée</value></data> <data name="RegistryActionLog.Badge.ContributionLogged" xml:space="preserve"><value>Participation enregistrée</value></data>
<data name="RegistryActionLog.Badge.MetadataFetchSucceeded" xml:space="preserve"><value>Remplissage auto réussi</value></data>
<data name="RegistryActionLog.Badge.MetadataFetchFailed" xml:space="preserve"><value>Remplissage auto échoué</value></data>
<data name="Error.PageTitle" xml:space="preserve"><value>Erreur</value></data> <data name="Error.PageTitle" xml:space="preserve"><value>Erreur</value></data>
<data name="Error.Title" xml:space="preserve"><value>Erreur.</value></data> <data name="Error.Title" xml:space="preserve"><value>Erreur.</value></data>
@@ -216,6 +216,8 @@
<data name="RegistryActionLog.Badge.PurchaseUnmarked" xml:space="preserve"><value>Aankoop verwijderd</value></data> <data name="RegistryActionLog.Badge.PurchaseUnmarked" xml:space="preserve"><value>Aankoop verwijderd</value></data>
<data name="RegistryActionLog.Badge.PartialPurchase" xml:space="preserve"><value>Gedeeltelijke aankoop</value></data> <data name="RegistryActionLog.Badge.PartialPurchase" xml:space="preserve"><value>Gedeeltelijke aankoop</value></data>
<data name="RegistryActionLog.Badge.ContributionLogged" xml:space="preserve"><value>Deelname geregistreerd</value></data> <data name="RegistryActionLog.Badge.ContributionLogged" xml:space="preserve"><value>Deelname geregistreerd</value></data>
<data name="RegistryActionLog.Badge.MetadataFetchSucceeded" xml:space="preserve"><value>Automatisch ophalen gelukt</value></data>
<data name="RegistryActionLog.Badge.MetadataFetchFailed" xml:space="preserve"><value>Automatisch ophalen mislukt</value></data>
<data name="Error.PageTitle" xml:space="preserve"><value>Fout</value></data> <data name="Error.PageTitle" xml:space="preserve"><value>Fout</value></data>
<data name="Error.Title" xml:space="preserve"><value>Fout.</value></data> <data name="Error.Title" xml:space="preserve"><value>Fout.</value></data>
@@ -621,6 +621,12 @@
<data name="RegistryActionLog.Badge.ContributionLogged" xml:space="preserve"> <data name="RegistryActionLog.Badge.ContributionLogged" xml:space="preserve">
<value>[[Contribution logged]]</value> <value>[[Contribution logged]]</value>
</data> </data>
<data name="RegistryActionLog.Badge.MetadataFetchSucceeded" xml:space="preserve">
<value>[[Auto fetch succeeded]]</value>
</data>
<data name="RegistryActionLog.Badge.MetadataFetchFailed" xml:space="preserve">
<value>[[Auto fetch failed]]</value>
</data>
<data name="Error.PageTitle" xml:space="preserve"> <data name="Error.PageTitle" xml:space="preserve">
<value>[[Error]]</value> <value>[[Error]]</value>
</data> </data>
@@ -223,6 +223,8 @@
<data name="RegistryActionLog.Badge.PurchaseUnmarked" xml:space="preserve"><value>Purchase unmarked</value></data> <data name="RegistryActionLog.Badge.PurchaseUnmarked" xml:space="preserve"><value>Purchase unmarked</value></data>
<data name="RegistryActionLog.Badge.PartialPurchase" xml:space="preserve"><value>Partial purchase</value></data> <data name="RegistryActionLog.Badge.PartialPurchase" xml:space="preserve"><value>Partial purchase</value></data>
<data name="RegistryActionLog.Badge.ContributionLogged" xml:space="preserve"><value>Contribution logged</value></data> <data name="RegistryActionLog.Badge.ContributionLogged" xml:space="preserve"><value>Contribution logged</value></data>
<data name="RegistryActionLog.Badge.MetadataFetchSucceeded" xml:space="preserve"><value>Auto fetch succeeded</value></data>
<data name="RegistryActionLog.Badge.MetadataFetchFailed" xml:space="preserve"><value>Auto fetch failed</value></data>
<data name="Error.PageTitle" xml:space="preserve"><value>Error</value></data> <data name="Error.PageTitle" xml:space="preserve"><value>Error</value></data>
<data name="Error.Title" xml:space="preserve"><value>Error.</value></data> <data name="Error.Title" xml:space="preserve"><value>Error.</value></data>