using BirthList.Domain.Entities; using BirthList.Web.Authorization; using BirthList.Web.Features.Registries; using BirthList.Web.Services; using Blazored.TextEditor; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using Microsoft.JSInterop; namespace BirthList.Web.Components.Pages; [Authorize] public partial class RegistryAdmin : ComponentBase { [Parameter] public Guid RegistryId { get; set; } [Inject] private RegistryService RegistryService { get; set; } = null!; [Inject] private RegistryMetadataService RegistryMetadataService { get; set; } = null!; [Inject] private RegistryAuthorizationService RegistryAuthorizationService { get; set; } = null!; [Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!; [Inject] private NavigationManager NavigationManager { get; set; } = null!; [Inject] private SmtpEmailSender EmailSender { get; set; } = null!; [Inject] private SmtpConfigurationStatusService SmtpConfigurationStatusService { get; set; } = null!; [Inject] private IJSRuntime JSRuntime { get; set; } = null!; protected RegistrySettingsEditModel SettingsModel { get; } = new(); protected RegistryItemEditModel ItemModel { get; private set; } = new(); protected string PreferSecondHandString { get => ItemModel.PreferSecondHand switch { null => "", true => "true", false => "false" }; set => ItemModel.PreferSecondHand = value switch { "true" => true, "false" => false, _ => null }; } protected IReadOnlyList Items { get; private set; } = []; protected IReadOnlyList ItemCategories { get; private set; } = []; protected string? NewCategoryName { get; set; } protected string? CategoryRenameName { get; set; } protected Guid? EditingCategoryId { get; set; } protected string? ItemManagementMessage { get; private set; } protected IReadOnlyList Admins { get; private set; } = []; protected IReadOnlyList AccessibleUserAddresses { get; private set; } = []; protected bool IsAuthorized { get; private set; } protected bool IsSmtpConfigured { get; private set; } protected string? InviteEmail { get; set; } protected string? InviteLink { get; private set; } protected BlazoredTextEditor? TextEditor { get; set; } protected string ActiveTab { get; set; } = "items"; protected RenderFragment ToolbarContent => builder => { builder.AddMarkupContent(0, @""); builder.AddMarkupContent(1, @""); builder.AddMarkupContent(2, @""); builder.AddMarkupContent(3, @""); builder.AddMarkupContent(4, @""); }; private bool _pendingEditorLoad; private string? _lastLoadedHeaderContentHtml; private Guid? _draggedItemId; private Guid? _draggedCategoryId; private Guid? _itemDropCategoryId; private Guid? _itemDropTargetItemId; private bool _itemDropAfterTarget; private Guid? _categoryDropTargetId; private bool _categoryDropAfterTarget; private bool _categoryDropAtEnd; private bool _dropOperationInProgress; protected override async Task OnParametersSetAsync() { var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(userId)) { IsAuthorized = false; return; } IsAuthorized = await RegistryAuthorizationService.IsRegistryAdminAsync(RegistryId, userId, CancellationToken.None).ConfigureAwait(false); if (!IsAuthorized) { return; } IsSmtpConfigured = SmtpConfigurationStatusService.IsConfigured(); await LoadAsync().ConfigureAwait(false); } protected override async Task OnAfterRenderAsync(bool firstRender) { if (TextEditor is null || !_pendingEditorLoad) { return; } try { await TextEditor.LoadHTMLContent(SettingsModel.HeaderContentHtml ?? string.Empty).ConfigureAwait(false); _lastLoadedHeaderContentHtml = SettingsModel.HeaderContentHtml ?? string.Empty; _pendingEditorLoad = false; } catch (JSException) { // Editor scripts may still be initializing on first render in some environments. } catch (InvalidOperationException) { // Ignore transient prerender/circuit timing issues. } } protected void SetActiveTab(string tab) { ActiveTab = tab; } protected string GetTabClass(string tab) { return ActiveTab == tab ? "active" : ""; } protected string GetTabPaneClass(string tab) { return ActiveTab == tab ? "show active" : ""; } protected async Task SaveSettingsAsync() { if (TextEditor is not null) { try { SettingsModel.HeaderContentHtml = await TextEditor.GetHTML().ConfigureAwait(false); } catch (JSException) { // Preserve existing content when editor JS isn't ready yet. } catch (InvalidOperationException) { // Preserve existing content when interop isn't available. } } await RegistryService.UpdateRegistrySettingsAsync(RegistryId, SettingsModel, CancellationToken.None).ConfigureAwait(false); } protected async Task SaveItemAsync() { ItemManagementMessage = null; if (!ItemModel.CategoryId.HasValue && ItemCategories.Count > 0) { ItemModel.CategoryId = ItemCategories[0].Id; } await RegistryService.UpsertRegistryItemAsync(RegistryId, ItemModel, CancellationToken.None).ConfigureAwait(false); await ReloadItemManagementAsync().ConfigureAwait(false); ItemModel = new RegistryItemEditModel { CurrencyCode = SettingsModel.CurrencyCode, DesiredQuantity = 1, CategoryId = ItemCategories.FirstOrDefault()?.Id }; } protected async Task DeleteItemAsync(Guid? itemId) { if (!itemId.HasValue) { return; } ItemManagementMessage = null; await RegistryService.DeleteRegistryItemAsync(RegistryId, itemId.Value, CancellationToken.None).ConfigureAwait(false); await ReloadItemManagementAsync().ConfigureAwait(false); } protected void EditItem(RegistryItemEditModel model) { ItemManagementMessage = null; ItemModel = new RegistryItemEditModel { Id = model.Id, CategoryId = model.CategoryId, Name = model.Name, PictureUrl = model.PictureUrl, ProductUrl = model.ProductUrl, Description = model.Description, PriceAmount = model.PriceAmount, CurrencyCode = model.CurrencyCode, DesiredQuantity = model.DesiredQuantity, ParticipationAllowed = model.ParticipationAllowed, ParticipationTargetAmount = model.ParticipationTargetAmount, PreferSecondHand = model.PreferSecondHand, IsGiven = model.IsGiven }; } protected async Task AddCategoryAsync() { ItemManagementMessage = null; if (string.IsNullOrWhiteSpace(NewCategoryName)) { ItemManagementMessage = "Category name is required."; return; } try { await RegistryService.AddRegistryItemCategoryAsync(RegistryId, NewCategoryName, CancellationToken.None).ConfigureAwait(false); NewCategoryName = null; await ReloadItemManagementAsync().ConfigureAwait(false); ItemModel.CategoryId ??= ItemCategories.LastOrDefault()?.Id; } catch (InvalidOperationException ex) { ItemManagementMessage = ex.Message; } } protected void StartCategoryRename(RegistryItemCategoryEditModel category) { ArgumentNullException.ThrowIfNull(category); ItemManagementMessage = null; EditingCategoryId = category.Id; CategoryRenameName = category.Name; } protected void CancelCategoryRename() { EditingCategoryId = null; CategoryRenameName = null; } protected async Task SaveCategoryRenameAsync(Guid categoryId) { ItemManagementMessage = null; if (string.IsNullOrWhiteSpace(CategoryRenameName)) { ItemManagementMessage = "Category name is required."; return; } try { await RegistryService.RenameRegistryItemCategoryAsync(RegistryId, categoryId, CategoryRenameName, CancellationToken.None).ConfigureAwait(false); await ReloadItemManagementAsync().ConfigureAwait(false); EditingCategoryId = null; CategoryRenameName = null; } catch (InvalidOperationException ex) { ItemManagementMessage = ex.Message; } } protected async Task RemoveCategoryAsync(Guid categoryId) { ItemManagementMessage = null; if (ItemCategories.Count <= 1) { ItemManagementMessage = "At least one category is required."; return; } try { await RegistryService.RemoveRegistryItemCategoryAsync(RegistryId, categoryId, CancellationToken.None).ConfigureAwait(false); await ReloadItemManagementAsync().ConfigureAwait(false); if (ItemModel.CategoryId == categoryId) { ItemModel.CategoryId = ItemCategories.FirstOrDefault()?.Id; } } catch (InvalidOperationException ex) { ItemManagementMessage = ex.Message; } } protected void OnItemDragStart(Guid itemId) { _draggedItemId = itemId; _draggedCategoryId = null; _itemDropCategoryId = null; _itemDropTargetItemId = null; _itemDropAfterTarget = false; _categoryDropTargetId = null; _categoryDropAfterTarget = false; _categoryDropAtEnd = false; } protected void OnItemDragEnd() { _draggedItemId = null; _itemDropCategoryId = null; _itemDropTargetItemId = null; _itemDropAfterTarget = false; } protected void OnCategoryDragStart(Guid categoryId) { _draggedCategoryId = categoryId; _draggedItemId = null; _itemDropCategoryId = null; _itemDropTargetItemId = null; _itemDropAfterTarget = false; _categoryDropTargetId = null; _categoryDropAfterTarget = false; _categoryDropAtEnd = false; } protected void OnCategoryDragEnd() { _draggedCategoryId = null; _categoryDropTargetId = null; _categoryDropAfterTarget = false; _categoryDropAtEnd = false; } protected static void OnDragOver(DragEventArgs _) { } protected async Task OnItemDragOverAsync(Guid categoryId, Guid targetItemId, DragEventArgs args) { if (!_draggedItemId.HasValue) { return; } _itemDropCategoryId = categoryId; _itemDropTargetItemId = targetItemId; var isBottomHalf = await IsPointerInBottomHalfAsync($"item-row-{targetItemId}", args.ClientY).ConfigureAwait(false); _itemDropAfterTarget = isBottomHalf; await InvokeAsync(StateHasChanged); } protected void OnCategoryItemsDragOver(Guid categoryId) { if (!_draggedItemId.HasValue) { return; } var category = ItemCategories.FirstOrDefault(x => x.Id == categoryId); if (category is null || category.Items.Count > 0) { return; } _itemDropCategoryId = categoryId; _itemDropTargetItemId = null; _itemDropAfterTarget = false; } protected async Task OnCategoryDragOverAsync(Guid categoryId, DragEventArgs args) { if (!_draggedCategoryId.HasValue) { return; } _categoryDropTargetId = categoryId; _categoryDropAfterTarget = await IsPointerInBottomHalfAsync($"category-card-{categoryId}", args.ClientY).ConfigureAwait(false); _categoryDropAtEnd = false; await InvokeAsync(StateHasChanged); } protected void OnCategoryListEndDragOver() { if (!_draggedCategoryId.HasValue) { return; } _categoryDropTargetId = null; _categoryDropAfterTarget = false; _categoryDropAtEnd = true; } protected string GetItemDropClass(Guid categoryId, Guid itemId) { if (_itemDropCategoryId != categoryId || _itemDropTargetItemId != itemId) { return string.Empty; } return _itemDropAfterTarget ? "drop-target-item-after" : "drop-target-item-before"; } protected string GetCategoryItemsDropClass(Guid categoryId) { return _itemDropCategoryId == categoryId && !_itemDropTargetItemId.HasValue ? "drop-target-item-end" : string.Empty; } protected string GetCategoryDropClass(Guid categoryId) { if (_categoryDropTargetId != categoryId) { return string.Empty; } return _categoryDropAfterTarget ? "drop-target-category-after" : "drop-target-category-before"; } protected string GetCategoryListEndDropClass() { return _categoryDropAtEnd ? "drop-target-category-list-end" : string.Empty; } protected async Task OnItemDropAsync(Guid categoryId, Guid targetItemId) { if (!_draggedItemId.HasValue || _dropOperationInProgress) { return; } _dropOperationInProgress = true; try { var draggedItem = Items.FirstOrDefault(x => x.Id == _draggedItemId.Value); var targetCategory = ItemCategories.FirstOrDefault(x => x.Id == categoryId); if (draggedItem is null || targetCategory is null) { _draggedItemId = null; _itemDropCategoryId = null; _itemDropTargetItemId = null; _itemDropAfterTarget = false; return; } var targetItems = targetCategory.Items.Where(x => x.Id.HasValue).ToList(); var targetIndex = targetItems.FindIndex(x => x.Id == targetItemId); if (targetIndex < 0) { targetIndex = targetItems.Count; } var placeAfter = _itemDropTargetItemId == targetItemId && _itemDropAfterTarget; if (placeAfter) { targetIndex++; } if (draggedItem.CategoryId == categoryId) { var currentIndex = targetItems.FindIndex(x => x.Id == _draggedItemId.Value); if (currentIndex >= 0 && currentIndex < targetIndex) { targetIndex--; } } await RegistryService.MoveRegistryItemAsync(RegistryId, _draggedItemId.Value, categoryId, targetIndex, CancellationToken.None).ConfigureAwait(false); await ReloadItemManagementAsync().ConfigureAwait(false); _draggedItemId = null; _itemDropCategoryId = null; _itemDropTargetItemId = null; _itemDropAfterTarget = false; } finally { _dropOperationInProgress = false; } } protected async Task OnCategoryItemsDropAsync(Guid categoryId) { if (!_draggedItemId.HasValue || _dropOperationInProgress) { return; } _dropOperationInProgress = true; try { var targetCategory = ItemCategories.FirstOrDefault(x => x.Id == categoryId); if (targetCategory is null) { _draggedItemId = null; _itemDropCategoryId = null; _itemDropTargetItemId = null; _itemDropAfterTarget = false; return; } var targetIndex = targetCategory.Items.Count; await RegistryService.MoveRegistryItemAsync(RegistryId, _draggedItemId.Value, categoryId, targetIndex, CancellationToken.None).ConfigureAwait(false); await ReloadItemManagementAsync().ConfigureAwait(false); _draggedItemId = null; _itemDropCategoryId = null; _itemDropTargetItemId = null; _itemDropAfterTarget = false; } finally { _dropOperationInProgress = false; } } protected async Task OnCategoryDropAsync(Guid targetCategoryId) { if (!_draggedCategoryId.HasValue || _draggedCategoryId == targetCategoryId || _dropOperationInProgress) { return; } _dropOperationInProgress = true; try { var targetIndex = ItemCategories.ToList().FindIndex(x => x.Id == targetCategoryId); if (targetIndex < 0) { _draggedCategoryId = null; _categoryDropTargetId = null; _categoryDropAfterTarget = false; return; } var placeAfter = _categoryDropTargetId == targetCategoryId && _categoryDropAfterTarget; if (placeAfter) { targetIndex++; } var sourceIndex = ItemCategories.ToList().FindIndex(x => x.Id == _draggedCategoryId.Value); if (sourceIndex >= 0 && sourceIndex < targetIndex) { targetIndex--; } await RegistryService.MoveRegistryCategoryAsync(RegistryId, _draggedCategoryId.Value, targetIndex, CancellationToken.None).ConfigureAwait(false); await ReloadItemManagementAsync().ConfigureAwait(false); _draggedCategoryId = null; _categoryDropTargetId = null; _categoryDropAfterTarget = false; _categoryDropAtEnd = false; } finally { _dropOperationInProgress = false; } } protected async Task OnCategoryListEndDropAsync() { if (!_draggedCategoryId.HasValue || _dropOperationInProgress) { return; } _dropOperationInProgress = true; try { await RegistryService.MoveRegistryCategoryAsync(RegistryId, _draggedCategoryId.Value, ItemCategories.Count, CancellationToken.None).ConfigureAwait(false); await ReloadItemManagementAsync().ConfigureAwait(false); _draggedCategoryId = null; _categoryDropTargetId = null; _categoryDropAfterTarget = false; _categoryDropAtEnd = false; } finally { _dropOperationInProgress = false; } } protected async Task FetchMetadataAsync() { if (string.IsNullOrWhiteSpace(ItemModel.ProductUrl)) { return; } var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false); var actorUserId = string.IsNullOrWhiteSpace(userId) ? "unknown-user" : userId; async Task LogMetadataAsync(UserActionType actionType, string details) { 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. } } try { 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; } return details.Length <= 500 ? details : details[..500]; } protected async Task CreateInviteAsync() { var inviteId = await RegistryService.CreateAdminInviteAsync(RegistryId, InviteEmail, TimeSpan.FromDays(7), CancellationToken.None).ConfigureAwait(false); var token = await RegistryService.GetInviteTokenAsync(inviteId, CancellationToken.None).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(token)) { return; } InviteLink = NavigationManager.ToAbsoluteUri($"/registry/{RegistryId}/invite/{Uri.EscapeDataString(token)}").AbsoluteUri; if (!string.IsNullOrWhiteSpace(InviteEmail)) { await EmailSender.SendInviteAsync(InviteEmail, InviteLink).ConfigureAwait(false); } Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false); } protected async Task DeleteAdminAsync(string? userId) { if (string.IsNullOrWhiteSpace(userId)) { return; } await RegistryService.RemoveRegistryAdminAsync(RegistryId, userId, CancellationToken.None).ConfigureAwait(false); Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false); } protected void AddContributionAmountQrCode() { SettingsModel.ContributionAmountQrCodes.Add(new ContributionAmountQrCodeModel()); } protected void RemoveContributionAmountQrCode(ContributionAmountQrCodeModel model) { ArgumentNullException.ThrowIfNull(model); SettingsModel.ContributionAmountQrCodes.Remove(model); } private async Task LoadAsync() { var settings = await RegistryService.GetRegistrySettingsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false); if (settings is not null) { SettingsModel.BabyName = settings.BabyName; SettingsModel.BirthDate = settings.BirthDate; SettingsModel.HeaderContentHtml = settings.HeaderContentHtml; SettingsModel.ShippingAddress = settings.ShippingAddress; SettingsModel.CurrencyCode = settings.CurrencyCode; SettingsModel.ThemeKey = settings.ThemeKey; SettingsModel.BankAccountDisplayName = settings.BankAccountDisplayName; SettingsModel.BankAccountIban = settings.BankAccountIban; SettingsModel.BankAccountBic = settings.BankAccountBic; SettingsModel.ShowBankAccountName = settings.ShowBankAccountName; SettingsModel.ContributionQrCodeUrl = settings.ContributionQrCodeUrl; SettingsModel.ContributionAmountQrCodes = settings.ContributionAmountQrCodes; var currentHeaderContent = SettingsModel.HeaderContentHtml ?? string.Empty; _pendingEditorLoad = currentHeaderContent != _lastLoadedHeaderContentHtml; } Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false); AccessibleUserAddresses = await RegistryService.GetRegistryAccessibleUserAddressesAsync(RegistryId, CancellationToken.None).ConfigureAwait(false); await ReloadItemManagementAsync().ConfigureAwait(false); ItemModel = new RegistryItemEditModel { CurrencyCode = SettingsModel.CurrencyCode, DesiredQuantity = 1, CategoryId = ItemCategories.FirstOrDefault()?.Id }; } private async Task ReloadItemManagementAsync() { var categories = await RegistryService.GetRegistryItemCategoriesAsync(RegistryId, CancellationToken.None).ConfigureAwait(false); var items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false); Items = items; var itemsByCategory = items .Where(x => x.CategoryId.HasValue) .GroupBy(x => x.CategoryId!.Value) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); ItemCategories = categories .OrderBy(x => x.SortOrder) .Select(category => new RegistryItemCategoryEditModel { Id = category.Id, Name = category.Name, SortOrder = category.SortOrder, Items = itemsByCategory.TryGetValue(category.Id, out var categoryItems) ? categoryItems : [] }) .ToList(); } private async Task IsPointerInBottomHalfAsync(string elementId, double clientY) { try { return await JSRuntime.InvokeAsync("birthListDrag.isPointerInBottomHalf", elementId, clientY).ConfigureAwait(false); } catch (JSException) { return false; } catch (InvalidOperationException) { return false; } } }