Add support for item categories in registries

Introduced `RegistryItemCategory` entity for grouping and ordering items within registries. Updated `RegistryItem` and `Registry` entities to support categorization. Added database migrations for `RegistryItemCategories` and updated `RegistryItems` with `CategoryId` and `SortOrder`.

Implemented drag-and-drop functionality for reordering categories and items using JavaScript and Blazor. Enhanced `RegistryAdmin` and `RegistryPublic` components to manage and display categories with collapsible sections.

Updated `RegistryService` to handle category operations, including adding, renaming, removing, and reordering. Added new view models and updated CSS for category styling. Refactored logic to ensure proper ordering and fallback for unassigned items.
This commit is contained in:
Arne Moerman
2026-05-19 17:02:31 +02:00
parent fa704ab996
commit 09349cb7b7
21 changed files with 2334 additions and 252 deletions
@@ -54,6 +54,17 @@ else
<span class="bi bi-people"></span> Administrators
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(GetTabClass("addresses"))"
id="addresses-tab"
@onclick='() => SetActiveTab("addresses")'
type="button"
role="tab"
aria-controls="addresses-content"
aria-selected="@(ActiveTab == "addresses" ? "true" : "false")">
<span class="bi bi-house"></span> Addresses
</button>
</li>
<li class="nav-item" role="presentation">
<a href="/registry/@RegistryId/admin/action-log" class="nav-link">
<span class="bi bi-clock-history"></span> Action Log
@@ -119,64 +130,162 @@ else
<InputCheckbox @bind-Value="ItemModel.IsGiven" />
<label>Given</label>
</div>
<div class="col-md-6">
<label class="form-label">Category</label>
<InputSelect class="form-select" @bind-Value="ItemModel.CategoryId">
@foreach (var category in ItemCategories)
{
<option value="@category.Id">@category.Name</option>
}
</InputSelect>
</div>
</div>
<button class="btn btn-primary mt-3" type="submit">Save item</button>
</EditForm>
</section>
<section>
<h2>Items</h2>
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Desired Qty</th>
<th>Participation</th>
<th>Purchased by / Contributed by</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Items)
{
<tr>
<td>@item.Name</td>
<td>@item.DesiredQuantity</td>
<td>@(item.ParticipationAllowed ? "Yes" : "No")</td>
<td>
@if (item.Purchasers.Count > 0)
{
<div class="mb-1">
<small><strong>Purchased:</strong></small>
<div class="small">
@foreach (var purchaser in item.Purchasers)
{
<div>@purchaser.DisplayName (@purchaser.Quantity)</div>
}
</div>
</div>
}
@if (item.Contributors.Count > 0)
{
<div>
<small><strong>Contributed:</strong></small>
<div class="small">
@foreach (var contributor in item.Contributors)
{
<div>@contributor.DisplayName (@contributor.Amount.ToString("0.00"))</div>
}
</div>
</div>
}
</td>
<td>
<button class="btn btn-sm btn-outline-secondary me-2" @onclick="() => EditItem(item)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteItemAsync(item.Id)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
<div class="d-flex justify-content-between align-items-end flex-wrap gap-3">
<h2 class="mb-0">Categories and items</h2>
<div class="d-flex gap-2">
<InputText class="form-control" @bind-Value="NewCategoryName" placeholder="New category" />
<button type="button" class="btn btn-outline-primary" @onclick="AddCategoryAsync">Add category</button>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(ItemManagementMessage))
{
<div class="alert alert-warning mt-3 mb-0" role="alert">@ItemManagementMessage</div>
}
<p class="text-muted mt-3 mb-2">Drag categories or items to reorder. Drop items into another category to regroup them.</p>
<div class="category-groups mt-3">
@foreach (var category in ItemCategories)
{
<section id="category-card-@category.Id"
class="card category-group @GetCategoryDropClass(category.Id)"
draggable="true"
@ondragstart="() => OnCategoryDragStart(category.Id)"
@ondragend="OnCategoryDragEnd"
@ondragover="@(args => OnCategoryDragOverAsync(category.Id, args))"
@ondragover:preventDefault="true"
@ondrop:stopPropagation="true"
@ondrop:preventDefault="true"
@ondrop="() => OnCategoryDropAsync(category.Id)">
<div class="card-header d-flex justify-content-between align-items-center">
@if (EditingCategoryId == category.Id)
{
<div class="d-flex gap-2 align-items-center w-100">
<InputText class="form-control form-control-sm" @bind-Value="CategoryRenameName" />
<button type="button" class="btn btn-sm btn-primary" @onclick="() => SaveCategoryRenameAsync(category.Id)">Save</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="CancelCategoryRename">Cancel</button>
</div>
}
else
{
<strong>@category.Name</strong>
<div class="d-flex gap-2">
<button type="button"
class="btn btn-sm btn-outline-secondary"
@onclick="() => StartCategoryRename(category)">
Rename
</button>
<button type="button"
class="btn btn-sm btn-outline-danger"
@onclick="() => RemoveCategoryAsync(category.Id)"
disabled="@(ItemCategories.Count <= 1)">
Remove
</button>
</div>
}
</div>
<div class="card-body p-0 @GetCategoryItemsDropClass(category.Id)"
@ondragover="() => OnCategoryItemsDragOver(category.Id)"
@ondragover:preventDefault="true"
@ondrop:stopPropagation="true"
@ondrop:preventDefault="true"
@ondrop="() => OnCategoryItemsDropAsync(category.Id)">
@if (category.Items.Count == 0)
{
<div class="p-3 text-muted">Drop items here.</div>
}
else
{
<table class="table table-striped mb-0">
<thead>
<tr>
<th>Name</th>
<th>Desired Qty</th>
<th>Participation</th>
<th>Purchased by / Contributed by</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in category.Items)
{
<tr id="item-row-@item.Id"
class="@GetItemDropClass(category.Id, item.Id!.Value)"
draggable="true"
@ondragstart:stopPropagation="true"
@ondragstart="() => OnItemDragStart(item.Id!.Value)"
@ondragend="OnItemDragEnd"
@ondragover:stopPropagation="true"
@ondragover="@(args => OnItemDragOverAsync(category.Id, item.Id!.Value, args))"
@ondragover:preventDefault="true"
@ondrop:stopPropagation="true"
@ondrop:preventDefault="true"
@ondrop="() => OnItemDropAsync(category.Id, item.Id!.Value)">
<td>@item.Name</td>
<td>@item.DesiredQuantity</td>
<td>@(item.ParticipationAllowed ? "Yes" : "No")</td>
<td>
@if (item.Purchasers.Count > 0)
{
<div class="mb-1">
<small><strong>Purchased:</strong></small>
<div class="small">
@foreach (var purchaser in item.Purchasers)
{
<div>@purchaser.DisplayName (@purchaser.Quantity)</div>
}
</div>
</div>
}
@if (item.Contributors.Count > 0)
{
<div>
<small><strong>Contributed:</strong></small>
<div class="small">
@foreach (var contributor in item.Contributors)
{
<div>@contributor.DisplayName (@contributor.Amount.ToString("0.00"))</div>
}
</div>
</div>
}
</td>
<td>
<button class="btn btn-sm btn-outline-secondary me-2" @onclick="() => EditItem(item)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteItemAsync(item.Id)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
</section>
}
<div class="category-list-end-drop-zone @GetCategoryListEndDropClass()"
@ondragover="OnCategoryListEndDragOver"
@ondragover:preventDefault="true"
@ondrop:preventDefault="true"
@ondrop="OnCategoryListEndDropAsync">
</div>
</div>
</section>
</div>
@@ -296,6 +405,39 @@ else
</section>
</div>
<!-- Addresses Tab -->
<div class="tab-pane fade @(GetTabPaneClass("addresses"))" id="addresses-content" role="tabpanel" aria-labelledby="addresses-tab">
<section class="mb-4">
<h2>User addresses</h2>
@if (AccessibleUserAddresses.Count == 0)
{
<p class="text-muted">No users found yet.</p>
}
else
{
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Address</th>
</tr>
</thead>
<tbody>
@foreach (var user in AccessibleUserAddresses)
{
<tr>
<td>@user.DisplayName</td>
<td>@(string.IsNullOrWhiteSpace(user.Email) ? "-" : user.Email)</td>
<td>@(string.IsNullOrWhiteSpace(user.Address) ? "-" : user.Address)</td>
</tr>
}
</tbody>
</table>
}
</section>
</div>
<!-- Admins Tab -->
<div class="tab-pane fade @(GetTabPaneClass("admins"))" id="admins-content" role="tabpanel" aria-labelledby="admins-tab">
<section class="mb-4">
@@ -4,6 +4,7 @@ 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;
@@ -20,11 +21,18 @@ public partial class RegistryAdmin : ComponentBase
[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 IReadOnlyList<RegistryItemEditModel> Items { get; private set; } = [];
protected IReadOnlyList<RegistryItemCategoryEditModel> 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<RegistryAdminDisplayModel> Admins { get; private set; } = [];
protected IReadOnlyList<RegistryAccessibleUserAddressViewModel> AccessibleUserAddresses { get; private set; } = [];
protected bool IsAuthorized { get; private set; }
protected bool IsSmtpConfigured { get; private set; }
protected string? InviteEmail { get; set; }
@@ -41,6 +49,15 @@ public partial class RegistryAdmin : ComponentBase
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()
{
@@ -122,13 +139,22 @@ public partial class RegistryAdmin : ComponentBase
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
DesiredQuantity = 1,
CategoryId = ItemCategories.FirstOrDefault()?.Id
};
Items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
}
protected async Task DeleteItemAsync(Guid? itemId)
@@ -138,15 +164,19 @@ public partial class RegistryAdmin : ComponentBase
return;
}
ItemManagementMessage = null;
await RegistryService.DeleteRegistryItemAsync(RegistryId, itemId.Value, CancellationToken.None).ConfigureAwait(false);
Items = await RegistryService.GetRegistryItemsAsync(RegistryId, 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,
@@ -161,6 +191,391 @@ public partial class RegistryAdmin : ComponentBase
};
}
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))
@@ -268,11 +683,55 @@ public partial class RegistryAdmin : ComponentBase
}
Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
Items = await RegistryService.GetRegistryItemsAsync(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
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<RegistryItemEditModel>)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<bool> IsPointerInBottomHalfAsync(string elementId, double clientY)
{
try
{
return await JSRuntime.InvokeAsync<bool>("birthListDrag.isPointerInBottomHalf", elementId, clientY).ConfigureAwait(false);
}
catch (JSException)
{
return false;
}
catch (InvalidOperationException)
{
return false;
}
}
}
@@ -158,3 +158,61 @@ h3 {
font-size: 0.8rem;
}
}
/* Category grouping and drag-drop */
.category-groups {
display: flex;
flex-direction: column;
gap: 1rem;
}
.category-group {
border: 1px solid #dee2e6;
}
.category-group .card-header {
background-color: #f8f9fa;
}
.category-group [draggable="true"] {
cursor: move;
}
.category-group tbody tr[draggable="true"]:active {
opacity: 0.6;
}
/* Drop indicators */
.category-group.drop-target-category {
border-color: #0d6efd;
box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.2);
}
.drop-target-category-before {
box-shadow: inset 0 3px 0 #0d6efd;
}
.drop-target-category-after {
box-shadow: inset 0 -3px 0 #0d6efd;
}
.drop-target-item-end {
border-bottom: 3px solid #0d6efd;
}
.table tbody tr.drop-target-item-before {
box-shadow: inset 0 3px 0 #0d6efd;
}
.table tbody tr.drop-target-item-after {
box-shadow: inset 0 -3px 0 #0d6efd;
}
.category-list-end-drop-zone {
min-height: 18px;
border-radius: 0.375rem;
}
.category-list-end-drop-zone.drop-target-category-list-end {
box-shadow: inset 0 -3px 0 #0d6efd;
}
@@ -40,148 +40,165 @@ else
</div>
}
<div class="item-grid">
@foreach (var item in Registry.Items)
<div class="category-list">
@foreach (var category in Registry.Categories)
{
<article class="card item-card @(item.IsGiven ? "item-given" : "")">
@if (!string.IsNullOrWhiteSpace(item.PictureUrl))
var isCollapsed = IsCategoryCollapsed(category.Id);
<section class="category-section">
<button type="button" class="btn btn-outline-secondary category-toggle" @onclick="() => ToggleCategoryCollapse(category.Id)">
<span class="bi @(isCollapsed ? "bi-chevron-right" : "bi-chevron-down")"></span>
<span>@category.Name</span>
<span class="badge bg-secondary">@category.Items.Count</span>
</button>
@if (!isCollapsed)
{
<img class="card-img-top item-image" src="@item.PictureUrl" alt="@item.Name" />
}
<div class="card-body">
<div class="card-header-badges">
<h5 class="card-title">@item.Name</h5>
@if (item.PreferSecondHand == true)
<div class="item-grid mt-3">
@foreach (var item in category.Items)
{
<span class="badge bg-info">Second-hand preferred</span>
}
else if (item.PreferSecondHand == null)
{
<span class="badge bg-warning">Second-hand optional</span>
<article class="card item-card @(item.IsGiven ? "item-given" : "")">
@if (!string.IsNullOrWhiteSpace(item.PictureUrl))
{
<img class="card-img-top item-image" src="@item.PictureUrl" alt="@item.Name" />
}
<div class="card-body">
<div class="card-header-badges">
<h5 class="card-title">@item.Name</h5>
@if (item.PreferSecondHand == true)
{
<span class="badge bg-info">Second-hand preferred</span>
}
else if (item.PreferSecondHand == null)
{
<span class="badge bg-warning">Second-hand optional</span>
}
</div>
@if (!string.IsNullOrWhiteSpace(item.Description))
{
<p class="card-text item-description-text">@item.Description</p>
}
@if (item.DesiredQuantity > 1)
{
<p class="mb-1"><strong>Qty:</strong> @item.PurchasedQuantity/@item.DesiredQuantity purchased</p>
}
@if (item.PriceAmount.HasValue)
{
<p class="mb-1"><strong>Price:</strong> @item.PriceAmount.Value.ToString("0.00") @item.CurrencyCode</p>
}
@if (item.ParticipationAllowed && GetParticipationTotalAmount(item).HasValue)
{
<p class="mb-2"><strong>Participation:</strong> €@item.MoneyFulfilledAmount.ToString("0.00") out of €@GetParticipationTotalAmount(item)!.Value.ToString("0.00") fulfilled</p>
}
@if (item.Purchasers.Count > 0 || item.Contributors.Count > 0)
{
<div class="contributors-section mt-2 mb-2">
@if (item.Purchasers.Count > 0)
{
<div class="mb-2">
@if (item.CanViewPurchasers && Registry.IsAdmin)
{
<strong class="text-sm">Purchased by:</strong>
<div class="contributor-list">
@foreach (var purchaser in item.Purchasers)
{
<span class="contributor-badge">@purchaser.DisplayName (@purchaser.Quantity)</span>
}
</div>
}
else if (item.CanViewPurchasers && item.CurrentUserPurchasedQuantity > 0)
{
<strong class="text-sm">Purchased by:</strong>
<div class="contributor-list">
<span class="contributor-badge">You (@item.CurrentUserPurchasedQuantity)</span>
@if (item.Purchasers.Count > 1)
{
<span class="contributor-badge">and @(item.Purchasers.Count - 1) other @(item.Purchasers.Count - 1 == 1 ? "person" : "people")</span>
}
</div>
}
else
{
<span class="text-muted small">Purchased</span>
}
</div>
}
@if (item.Contributors.Count > 0)
{
<div class="mb-2">
@if (Registry.IsAdmin)
{
<strong class="text-sm">Contributed by:</strong>
<div class="contributor-list">
@foreach (var contributor in item.Contributors)
{
<span class="contributor-badge">@contributor.DisplayName (@contributor.Amount.ToString("0.00") @item.CurrencyCode)</span>
}
</div>
}
else if (!string.IsNullOrWhiteSpace(Registry.CurrentUserId))
{
var currentUserContribution = item.Contributors.FirstOrDefault(x => x.UserId == Registry.CurrentUserId);
if (currentUserContribution is not null)
{
<strong class="text-sm">Contributed by:</strong>
<div class="contributor-list">
<span class="contributor-badge">@currentUserContribution.DisplayName (@currentUserContribution.Amount.ToString("0.00") @item.CurrencyCode)</span>
@if (item.Contributors.Count > 1)
{
<span class="contributor-badge">and others</span>
}
</div>
}
else
{
<span class="text-muted small">Contributed</span>
}
}
else
{
<span class="text-muted small">Contributed</span>
}
</div>
}
</div>
}
@if (!IsAuthenticated)
{
<button class="btn btn-outline-primary btn-sm" @onclick="() => LoginRedirect()">Login to purchase</button>
}
else
{
<div class="d-flex gap-2 flex-wrap">
@if (!string.IsNullOrWhiteSpace(item.ProductUrl) && item.CurrentUserPurchasedQuantity == 0)
{
<button class="btn btn-primary btn-sm" @onclick="() => OpenPurchasePrompt(item.Id, openTab: true)">Purchase</button>
}
@if (item.CurrentUserPurchasedQuantity == 0)
{
<button class="btn btn-success btn-sm" @onclick="() => OpenPurchasePrompt(item.Id)">Mark purchased</button>
}
@if (item.CurrentUserPurchasedQuantity > 0 || (Registry.IsAdmin && item.Purchasers.Count > 0))
{
@if (item.CurrentUserPurchasedQuantity > 0)
{
<span class="badge bg-success me-2">You purchased @item.CurrentUserPurchasedQuantity</span>
}
<button class="btn btn-warning btn-sm" @onclick="() => UnmarkPurchaseAsync(item.Id)">Unmark purchase</button>
}
@if (item.ParticipationAllowed)
{
<button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionPrompt(item.Id)">Partially fulfill</button>
}
</div>
}
</div>
</article>
}
</div>
@if (!string.IsNullOrWhiteSpace(item.Description))
{
<p class="card-text item-description-text">@item.Description</p>
}
@if (item.DesiredQuantity > 1)
{
<p class="mb-1"><strong>Qty:</strong> @item.PurchasedQuantity/@item.DesiredQuantity purchased</p>
}
@if (item.PriceAmount.HasValue)
{
<p class="mb-1"><strong>Price:</strong> @item.PriceAmount.Value.ToString("0.00") @item.CurrencyCode</p>
}
@if (item.ParticipationAllowed && GetParticipationTotalAmount(item).HasValue)
{
<p class="mb-2"><strong>Participation:</strong> €@item.MoneyFulfilledAmount.ToString("0.00") out of €@GetParticipationTotalAmount(item)!.Value.ToString("0.00") fulfilled</p>
}
@if (item.Purchasers.Count > 0 || item.Contributors.Count > 0)
{
<div class="contributors-section mt-2 mb-2">
@if (item.Purchasers.Count > 0)
{
<div class="mb-2">
@if (item.CanViewPurchasers && Registry.IsAdmin)
{
<strong class="text-sm">Purchased by:</strong>
<div class="contributor-list">
@foreach (var purchaser in item.Purchasers)
{
<span class="contributor-badge">@purchaser.DisplayName (@purchaser.Quantity)</span>
}
</div>
}
else if (item.CanViewPurchasers && item.CurrentUserPurchasedQuantity > 0)
{
<strong class="text-sm">Purchased by:</strong>
<div class="contributor-list">
<span class="contributor-badge">You (@item.CurrentUserPurchasedQuantity)</span>
@if (item.Purchasers.Count > 1)
{
<span class="contributor-badge">and @(item.Purchasers.Count - 1) other @(item.Purchasers.Count - 1 == 1 ? "person" : "people")</span>
}
</div>
}
else
{
<span class="text-muted small">Purchased</span>
}
</div>
}
@if (item.Contributors.Count > 0)
{
<div class="mb-2">
@if (Registry.IsAdmin)
{
<strong class="text-sm">Contributed by:</strong>
<div class="contributor-list">
@foreach (var contributor in item.Contributors)
{
<span class="contributor-badge">@contributor.DisplayName (@contributor.Amount.ToString("0.00") @item.CurrencyCode)</span>
}
</div>
}
else if (!string.IsNullOrWhiteSpace(Registry.CurrentUserId))
{
var currentUserContribution = item.Contributors.FirstOrDefault(x => x.UserId == Registry.CurrentUserId);
if (currentUserContribution is not null)
{
<strong class="text-sm">Contributed by:</strong>
<div class="contributor-list">
<span class="contributor-badge">@currentUserContribution.DisplayName (@currentUserContribution.Amount.ToString("0.00") @item.CurrencyCode)</span>
@if (item.Contributors.Count > 1)
{
<span class="contributor-badge">and others</span>
}
</div>
}
else
{
<span class="text-muted small">Contributed</span>
}
}
else
{
<span class="text-muted small">Contributed</span>
}
</div>
}
</div>
}
@if (!IsAuthenticated)
{
<button class="btn btn-outline-primary btn-sm" @onclick="() => LoginRedirect()">Login to purchase</button>
}
else
{
<div class="d-flex gap-2 flex-wrap">
@if (!string.IsNullOrWhiteSpace(item.ProductUrl) && item.CurrentUserPurchasedQuantity == 0)
{
<button class="btn btn-primary btn-sm" @onclick="() => OpenPurchasePrompt(item.Id, openTab: true)">Purchase</button>
}
@if (item.CurrentUserPurchasedQuantity == 0)
{
<button class="btn btn-success btn-sm" @onclick="() => OpenPurchasePrompt(item.Id)">Mark purchased</button>
}
@if (item.CurrentUserPurchasedQuantity > 0 || (Registry.IsAdmin && item.Purchasers.Count > 0))
{
@if (item.CurrentUserPurchasedQuantity > 0)
{
<span class="badge bg-success me-2">You purchased @item.CurrentUserPurchasedQuantity</span>
}
<button class="btn btn-warning btn-sm" @onclick="() => UnmarkPurchaseAsync(item.Id)">Unmark purchase</button>
}
@if (item.ParticipationAllowed)
{
<button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionPrompt(item.Id)">Partially fulfill</button>
}
</div>
}
</div>
</article>
}
</section>
}
</div>
</div>
@@ -32,12 +32,20 @@ public partial class RegistryPublic : ComponentBase
protected ContributionPaymentMethodType? SelectedPaymentMethod { get; set; }
protected string? SelectedPaymentQrCodeUrl { get; private set; }
private readonly HashSet<Guid> _collapsedCategoryIds = [];
protected override async Task OnParametersSetAsync()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
IsAuthenticated = !string.IsNullOrWhiteSpace(userId);
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
if (Registry is not null)
{
var categoryIds = Registry.Categories.Select(x => x.Id).ToHashSet();
_collapsedCategoryIds.RemoveWhere(id => !categoryIds.Contains(id));
}
if (Registry is not null && !string.IsNullOrWhiteSpace(userId))
{
await RegistryService.LogUserActionAsync(Registry.Id, userId, UserActionType.RegistryLinkOpened, null, null, CancellationToken.None).ConfigureAwait(false);
@@ -274,6 +282,23 @@ public partial class RegistryPublic : ComponentBase
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
}
protected void ToggleCategoryCollapse(Guid categoryId)
{
if (_collapsedCategoryIds.Contains(categoryId))
{
_collapsedCategoryIds.Remove(categoryId);
}
else
{
_collapsedCategoryIds.Add(categoryId);
}
}
protected bool IsCategoryCollapsed(Guid categoryId)
{
return _collapsedCategoryIds.Contains(categoryId);
}
protected static decimal? GetParticipationTotalAmount(RegistryPublicItemViewModel item)
{
ArgumentNullException.ThrowIfNull(item);
@@ -141,3 +141,30 @@
.purchaser-item .btn {
margin-left: 0.5rem;
}
.category-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.category-section {
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 0.75rem;
background-color: #fff;
}
.category-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
font-weight: 600;
text-align: left;
}
.category-toggle span.bi {
margin-right: 0.25rem;
}