diff --git a/AyCode.Blazor.Components/Components/CardViews/MgCardView.razor b/AyCode.Blazor.Components/Components/CardViews/MgCardView.razor new file mode 100644 index 0000000..b1c4d9a --- /dev/null +++ b/AyCode.Blazor.Components/Components/CardViews/MgCardView.razor @@ -0,0 +1,36 @@ +@typeparam TItem + +
+ @if (ShowFilterPanel && FilterPanel is not null) + { +
+ @FilterPanel +
+ } + + @if (Data is { Count: > 0 }) + { +
+
+ @foreach (var item in PagedItems) + { +
+ @CardTemplate(item) +
+ } +
+
+ + @if (ShowPager && Data.Count > PageSize) + { + + } + } +
diff --git a/AyCode.Blazor.Components/Components/CardViews/MgCardView.razor.cs b/AyCode.Blazor.Components/Components/CardViews/MgCardView.razor.cs new file mode 100644 index 0000000..e3e9133 --- /dev/null +++ b/AyCode.Blazor.Components/Components/CardViews/MgCardView.razor.cs @@ -0,0 +1,163 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace AyCode.Blazor.Components.Components.CardViews; + +/// +/// Generic card view component that displays items in a responsive CSS Grid layout +/// with optional pagination and scroll-to-item support. +/// +/// The type of data item displayed in each card. +public partial class MgCardView : ComponentBase +{ + [Inject] private IJSRuntime JSRuntime { get; set; } = null!; + + /// + /// The collection of items to display as cards. + /// + [Parameter, EditorRequired] + public IReadOnlyList Data { get; set; } = []; + + /// + /// Template for rendering each card's content. + /// + [Parameter, EditorRequired] + public RenderFragment CardTemplate { get; set; } = null!; + + /// + /// Fired when a card is clicked/tapped. + /// + [Parameter] + public EventCallback OnCardClick { get; set; } + + /// + /// Number of columns on extra-small screens (below 576px). Default: 1. + /// + [Parameter] + public int ColumnCountXs { get; set; } = 1; + + /// + /// Number of columns on small screens (576–768px). Default: 2. + /// + [Parameter] + public int ColumnCountSm { get; set; } = 2; + + /// + /// Number of columns on medium+ screens (769px+). Default: 3. + /// + [Parameter] + public int ColumnCountLg { get; set; } = 3; + + /// + /// Whether to show the pager below the cards. Default: false. + /// + [Parameter] + public bool ShowPager { get; set; } + + /// + /// Number of items per page when paging is enabled. Default: 12. + /// + [Parameter] + public int PageSize { get; set; } = 12; + + /// + /// Additional CSS class for the card view container. + /// + [Parameter] + public string? CssClass { get; set; } + + /// + /// Additional CSS class applied to each individual card wrapper. + /// + [Parameter] + public string? CardCssClass { get; set; } + + /// + /// Height of the card view container (e.g., "500px", "70vh"). When set, the component uses its own scroll area. + /// + [Parameter] + public string? Height { get; set; } + + /// + /// Whether to show the filter panel above the cards. Default: false. + /// + [Parameter] + public bool ShowFilterPanel { get; set; } + + /// + /// Custom content for the filter panel. Rendered above the card grid when ShowFilterPanel is true. + /// + [Parameter] + public RenderFragment? FilterPanel { get; set; } + + /// + /// Item to scroll into view after render. Set to null to disable. + /// + [Parameter] + public TItem? ScrollToItem { get; set; } + + /// + /// Key selector for identifying items (e.g., item => item.Id). Required when ScrollToItem is used. + /// + [Parameter] + public Func? ItemKeySelector { get; set; } + + private int _activePageIndex; + private object? _lastScrolledKey; + + private string? ContainerStyle => Height is not null ? $"height: {Height};" : null; + + private IReadOnlyList 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; + } + + /// + /// Generates a stable DOM element id for a card item using the key selector. + /// + 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 + } + } + } + } +} diff --git a/AyCode.Blazor.Components/Components/CardViews/MgCardView.razor.css b/AyCode.Blazor.Components/Components/CardViews/MgCardView.razor.css new file mode 100644 index 0000000..048e9cb --- /dev/null +++ b/AyCode.Blazor.Components/Components/CardViews/MgCardView.razor.css @@ -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; +} diff --git a/AyCode.Blazor.Components/Components/Grids/MgGridDataColumn.cs b/AyCode.Blazor.Components/Components/Grids/MgGridDataColumn.cs index 93b0746..630b6f2 100644 --- a/AyCode.Blazor.Components/Components/Grids/MgGridDataColumn.cs +++ b/AyCode.Blazor.Components/Components/Grids/MgGridDataColumn.cs @@ -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; /// /// Extended DxGridDataColumn with additional parameters for InfoPanel support. /// -public class MgGridDataColumn : DxGridDataColumn +public partial class MgGridDataColumn : DxGridDataColumn { + private static readonly ConcurrentDictionary<(Type Type, string Property), Func?> SAccessorCache = new(); + private string? _urlLink; private bool _isInitialized; + private TemplatePart[]? _templateParts; /// /// 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(); + }; + } + + /// + /// Represents a parsed segment of a URL template: either a literal string or a property placeholder. + /// + internal readonly record struct TemplatePart(string Value, bool IsProperty); + + [GeneratedRegex(@"\{([^}]+)\}")] + private static partial Regex TemplateRegex(); + + /// + /// Parses a URL template into literal and property placeholder segments. + /// + internal static TemplatePart[] ParseTemplate(string template) + { + var parts = new List(); + 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]; + } + + /// + /// Builds a URL from pre-parsed template parts using cached compiled property accessors. + /// + 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(); } /// /// 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 for hot paths. /// 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? 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>(body, param).Compile(); } } diff --git a/AyCode.Blazor.Components/Components/Grids/MgGridWithInfoPanel.razor b/AyCode.Blazor.Components/Components/Grids/MgGridWithInfoPanel.razor index 90441e9..ef89176 100644 --- a/AyCode.Blazor.Components/Components/Grids/MgGridWithInfoPanel.razor +++ b/AyCode.Blazor.Components/Components/Grids/MgGridWithInfoPanel.razor @@ -187,7 +187,7 @@ @GridContent - @if (ChildContent != null) { diff --git a/AyCode.Blazor.Components/wwwroot/js/mgCardView.js b/AyCode.Blazor.Components/wwwroot/js/mgCardView.js new file mode 100644 index 0000000..7aa0512 --- /dev/null +++ b/AyCode.Blazor.Components/wwwroot/js/mgCardView.js @@ -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' }); + } + } +};