Merge branch 'FruitBank_v0.0.7.0' into FruitBank_v0.0.8.0

This commit is contained in:
Loretta 2026-03-28 16:21:51 +01:00
commit 56475da43b
6 changed files with 361 additions and 28 deletions

View File

@ -0,0 +1,36 @@
@typeparam TItem
<div class="mg-card-view-container" style="@ContainerStyle">
@if (ShowFilterPanel && FilterPanel is not null)
{
<div class="mg-card-filter-panel">
@FilterPanel
</div>
}
@if (Data is { Count: > 0 })
{
<div class="mg-card-scroll-area">
<div class="mg-card-grid @CssClass"
style="--cols-xs: @ColumnCountXs; --cols-sm: @ColumnCountSm; --cols-lg: @ColumnCountLg;">
@foreach (var item in PagedItems)
{
<div id="@GetCardElementId(item)"
class="mg-card @CardCssClass"
@onclick="() => OnCardClickInternal(item)"
style="@(OnCardClick.HasDelegate ? "cursor: pointer;" : "")">
@CardTemplate(item)
</div>
}
</div>
</div>
@if (ShowPager && Data.Count > PageSize)
{
<DxPager PageCount="@((int)Math.Ceiling((double)Data.Count / PageSize))"
ActivePageIndex="_activePageIndex"
ActivePageIndexChanged="OnActivePageIndexChanged"
CssClass="mt-2" />
}
}
</div>

View File

@ -0,0 +1,163 @@
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace AyCode.Blazor.Components.Components.CardViews;
/// <summary>
/// Generic card view component that displays items in a responsive CSS Grid layout
/// with optional pagination and scroll-to-item support.
/// </summary>
/// <typeparam name="TItem">The type of data item displayed in each card.</typeparam>
public partial class MgCardView<TItem> : ComponentBase
{
[Inject] private IJSRuntime JSRuntime { get; set; } = null!;
/// <summary>
/// The collection of items to display as cards.
/// </summary>
[Parameter, EditorRequired]
public IReadOnlyList<TItem> Data { get; set; } = [];
/// <summary>
/// Template for rendering each card's content.
/// </summary>
[Parameter, EditorRequired]
public RenderFragment<TItem> CardTemplate { get; set; } = null!;
/// <summary>
/// Fired when a card is clicked/tapped.
/// </summary>
[Parameter]
public EventCallback<TItem> OnCardClick { get; set; }
/// <summary>
/// Number of columns on extra-small screens (below 576px). Default: 1.
/// </summary>
[Parameter]
public int ColumnCountXs { get; set; } = 1;
/// <summary>
/// Number of columns on small screens (576768px). Default: 2.
/// </summary>
[Parameter]
public int ColumnCountSm { get; set; } = 2;
/// <summary>
/// Number of columns on medium+ screens (769px+). Default: 3.
/// </summary>
[Parameter]
public int ColumnCountLg { get; set; } = 3;
/// <summary>
/// Whether to show the pager below the cards. Default: false.
/// </summary>
[Parameter]
public bool ShowPager { get; set; }
/// <summary>
/// Number of items per page when paging is enabled. Default: 12.
/// </summary>
[Parameter]
public int PageSize { get; set; } = 12;
/// <summary>
/// Additional CSS class for the card view container.
/// </summary>
[Parameter]
public string? CssClass { get; set; }
/// <summary>
/// Additional CSS class applied to each individual card wrapper.
/// </summary>
[Parameter]
public string? CardCssClass { get; set; }
/// <summary>
/// Height of the card view container (e.g., "500px", "70vh"). When set, the component uses its own scroll area.
/// </summary>
[Parameter]
public string? Height { get; set; }
/// <summary>
/// Whether to show the filter panel above the cards. Default: false.
/// </summary>
[Parameter]
public bool ShowFilterPanel { get; set; }
/// <summary>
/// Custom content for the filter panel. Rendered above the card grid when ShowFilterPanel is true.
/// </summary>
[Parameter]
public RenderFragment? FilterPanel { get; set; }
/// <summary>
/// Item to scroll into view after render. Set to null to disable.
/// </summary>
[Parameter]
public TItem? ScrollToItem { get; set; }
/// <summary>
/// Key selector for identifying items (e.g., item => item.Id). Required when ScrollToItem is used.
/// </summary>
[Parameter]
public Func<TItem, object>? ItemKeySelector { get; set; }
private int _activePageIndex;
private object? _lastScrolledKey;
private string? ContainerStyle => Height is not null ? $"height: {Height};" : null;
private IReadOnlyList<TItem> PagedItems
{
get
{
if (!ShowPager)
return Data;
return Data
.Skip(_activePageIndex * PageSize)
.Take(PageSize)
.ToList();
}
}
private async Task OnCardClickInternal(TItem item)
{
if (OnCardClick.HasDelegate)
await OnCardClick.InvokeAsync(item);
}
private void OnActivePageIndexChanged(int newPageIndex)
{
_activePageIndex = newPageIndex;
}
/// <summary>
/// Generates a stable DOM element id for a card item using the key selector.
/// </summary>
private string? GetCardElementId(TItem item)
{
return ItemKeySelector is null ? null : $"mg-card-{ItemKeySelector(item)}";
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (ScrollToItem is not null && ItemKeySelector is not null)
{
var key = ItemKeySelector(ScrollToItem);
if (!Equals(key, _lastScrolledKey))
{
_lastScrolledKey = key;
var elementId = $"mg-card-{key}";
try
{
await JSRuntime.InvokeVoidAsync("MgCardView.scrollToElement", elementId);
}
catch (JSException)
{
// JS might not be loaded yet
}
}
}
}
}

View File

@ -0,0 +1,52 @@
.mg-card-view-container {
display: flex;
flex-direction: column;
overflow: hidden;
}
.mg-card-scroll-area {
flex: 1;
overflow-y: auto;
padding-right: 4px;
}
.mg-card-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(var(--cols-xs, 1), 1fr);
}
@media (min-width: 576px) {
.mg-card-grid {
grid-template-columns: repeat(var(--cols-sm, 2), 1fr);
}
}
@media (min-width: 769px) {
.mg-card-grid {
grid-template-columns: repeat(var(--cols-lg, 3), 1fr);
}
}
.mg-card {
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 16px;
background-color: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s ease, transform 0.15s ease;
height: 100%;
}
.mg-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.mg-card-filter-panel {
margin-bottom: 12px;
padding: 12px 16px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
}

View File

@ -1,6 +1,8 @@
using DevExpress.Blazor;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using System.Collections.Concurrent;
using System.Linq.Expressions;
using System.Text;
using System.Text.RegularExpressions;
namespace AyCode.Blazor.Components.Components.Grids;
@ -8,10 +10,13 @@ namespace AyCode.Blazor.Components.Components.Grids;
/// <summary>
/// Extended DxGridDataColumn with additional parameters for InfoPanel support.
/// </summary>
public class MgGridDataColumn : DxGridDataColumn
public partial class MgGridDataColumn : DxGridDataColumn
{
private static readonly ConcurrentDictionary<(Type Type, string Property), Func<object, object?>?> SAccessorCache = new();
private string? _urlLink;
private bool _isInitialized;
private TemplatePart[]? _templateParts;
/// <summary>
/// Whether this column should be visible in the InfoPanel. Default is true.
@ -42,6 +47,7 @@ public class MgGridDataColumn : DxGridDataColumn
set
{
if (_urlLink == value) return;
_urlLink = value;
if (_isInitialized) UpdateCellDisplayTemplate();
}
@ -56,41 +62,99 @@ public class MgGridDataColumn : DxGridDataColumn
private void UpdateCellDisplayTemplate()
{
if (!string.IsNullOrWhiteSpace(_urlLink))
if (string.IsNullOrWhiteSpace(_urlLink)) return;
_templateParts = ParseTemplate(_urlLink);
var parts = _templateParts;
CellDisplayTemplate = context => builder =>
{
CellDisplayTemplate = context => builder =>
{
var url = BuildUrlFromTemplate(_urlLink, context.DataItem);
builder.OpenElement(0, "a");
builder.AddAttribute(1, "href", url);
builder.AddAttribute(2, "target", "_blank");
builder.AddAttribute(3, "style", "text-decoration: none;");
builder.AddContent(4, context.DisplayText);
builder.CloseElement();
};
var url = BuildUrl(parts, context.DataItem);
builder.OpenElement(0, "a");
builder.AddAttribute(1, "href", url);
builder.AddAttribute(2, "target", "_blank");
builder.AddAttribute(3, "style", "text-decoration: underline; color: inherit;");
builder.AddContent(4, context.DisplayText);
builder.CloseElement();
};
}
/// <summary>
/// Represents a parsed segment of a URL template: either a literal string or a property placeholder.
/// </summary>
internal readonly record struct TemplatePart(string Value, bool IsProperty);
[GeneratedRegex(@"\{([^}]+)\}")]
private static partial Regex TemplateRegex();
/// <summary>
/// Parses a URL template into literal and property placeholder segments.
/// </summary>
internal static TemplatePart[] ParseTemplate(string template)
{
var parts = new List<TemplatePart>();
var lastIndex = 0;
foreach (Match match in TemplateRegex().Matches(template))
{
if (match.Index > lastIndex) parts.Add(new TemplatePart(template[lastIndex..match.Index], IsProperty: false));
parts.Add(new TemplatePart(match.Groups[1].Value, IsProperty: true));
lastIndex = match.Index + match.Length;
}
if (lastIndex < template.Length) parts.Add(new TemplatePart(template[lastIndex..], IsProperty: false));
return [.. parts];
}
/// <summary>
/// Builds a URL from pre-parsed template parts using cached compiled property accessors.
/// </summary>
internal static string BuildUrl(TemplatePart[] parts, object? dataItem)
{
if (dataItem is null || parts.Length == 0)
return string.Empty;
var type = dataItem.GetType();
var sb = new StringBuilder(parts.Length * 16);
foreach (var part in parts)
{
if (!part.IsProperty)
{
sb.Append(part.Value);
continue;
}
var accessor = SAccessorCache.GetOrAdd((type, part.Value), static key => CompileAccessor(key.Type, key.Property));
if (accessor is not null) sb.Append(accessor(dataItem)?.ToString() ?? string.Empty);
else sb.Append('{').Append(part.Value).Append('}');
}
return sb.ToString();
}
/// <summary>
/// Replaces {property} placeholders in the template with values from the data item.
/// Exposed for unit testing.
/// Convenience overload that parses the template on each call — prefer pre-parsed <see cref="BuildUrl"/> for hot paths.
/// </summary>
internal static string BuildUrlFromTemplate(string template, object? dataItem)
{
if (dataItem == null) return template;
return dataItem is null ? template : BuildUrl(ParseTemplate(template), dataItem);
}
return Regex.Replace(template, "{([^}]+)}", match =>
{
var propName = match.Groups[1].Value;
private static Func<object, object?>? CompileAccessor(Type type, string propertyName)
{
var prop = type.GetProperty(propertyName);
if (prop is null) return null;
//TODO: delegate-et kéne használni és cache-elni egy dictionary-ba! - J.
var prop = dataItem.GetType().GetProperty(propName);
if (prop != null)
{
var value = prop.GetValue(dataItem);
return value?.ToString() ?? string.Empty;
}
return match.Value;
});
var param = Expression.Parameter(typeof(object), "obj");
var body = Expression.Convert(
Expression.Property(Expression.Convert(param, type), prop),
typeof(object));
return Expression.Lambda<Func<object, object?>>(body, param).Compile();
}
}

View File

@ -187,7 +187,7 @@
<DxSplitterPane>
@GridContent
</DxSplitterPane>
<DxSplitterPane Size="@_currentInfoPanelSize" MinSize="0px" MaxSize="100%" AllowCollapse="false" CssClass="mg-info-panel-pane"
<DxSplitterPane Size="@_currentInfoPanelSize" MinSize="0px" MaxSize="100%" AllowCollapse="true" CssClass="mg-info-panel-pane"
SizeChanged="OnInfoPanelSizeChanged">
@if (ChildContent != null)
{

View File

@ -0,0 +1,18 @@
// MgCardView - Scroll handling
window.MgCardView = {
scrollToElement: function (elementId) {
const element = document.getElementById(elementId);
if (!element) return;
// Find the closest scroll container
const scrollArea = element.closest('.mg-card-scroll-area');
if (scrollArea) {
const containerRect = scrollArea.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const offset = elementRect.top - containerRect.top - (containerRect.height - elementRect.height) / 2;
scrollArea.scrollBy({ top: offset, behavior: 'smooth' });
} else {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
};