diff --git a/.idea/.idea.HopFrame/.idea/workspace.xml b/.idea/.idea.HopFrame/.idea/workspace.xml index 57c430c..934a352 100644 --- a/.idea/.idea.HopFrame/.idea/workspace.xml +++ b/.idea/.idea.HopFrame/.idea/workspace.xml @@ -1,32 +1,143 @@ + + HopFrame.Testing/HopFrame.Testing.csproj + HopFrame.Testing/HopFrame.Testing.csproj + testing/HopFrame.Testing/HopFrame.Testing.csproj + + + - + + + + + + + + + + - + { + "lastFilter": { + "state": "OPENED", + "assignee": { + "type": "org.jetbrains.plugins.gitlab.mergerequest.ui.filters.GitLabMergeRequestsFiltersValue.MergeRequestsMemberFilterValue.MergeRequestsAssigneeFilterValue", + "username": "leon.hoppe", + "fullname": "Leon Hoppe" + } + } +} + { + "selectedUrlAndAccountId": { + "first": "https://git.leon-hoppe.de/leon.hoppe/hopframe.git", + "second": "2d65fdcb-5f13-45ad-a7ba-91dd4a88d6e4" + } +} + + + + + + + + + + + + + + + + + + + + + + { + "associatedIndex": 3 +} - + + + - { + "keyToString": { + ".NET Launch Settings Profile.HopFrame.Testing.executor": "Run", + ".NET Launch Settings Profile.HopFrame.Testing: https.executor": "Run", + ".NET Project.HopFrame.Testing.executor": "Run", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.git.unshallow": "true", + "git-widget-placeholder": "feature/setup", + "list.type.of.created.stylesheet": "CSS", + "node.js.detected.package.eslint": "true", + "node.js.detected.package.tslint": "true", + "node.js.selected.package.eslint": "(autodetect)", + "node.js.selected.package.tslint": "(autodetect)", + "nodejs_package_manager_path": "npm", + "settings.editor.selected.configurable": "preferences.environmentSetup", + "vue.rearranger.settings.migration": "true" } -}]]> +} + + + + + + + + + + @@ -36,14 +147,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/HopFrame.sln b/HopFrame.sln index a55ff74..d622867 100644 --- a/HopFrame.sln +++ b/HopFrame.sln @@ -1,8 +1,37 @@  Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{7E4AAFB3-9762-4F42-86DF-5A3194FDC243}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Core", "src\HopFrame.Core\HopFrame.Core.csproj", "{4BFE21C2-EAAC-4662-8B97-500836651B2A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Web", "src\HopFrame.Web\HopFrame.Web.csproj", "{8E59F398-184A-47C9-AAA2-3E0FFD775ABF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Testing", "Testing", "{9EB7FDBD-49C2-4872-9666-6F7AEBA541B2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HopFrame.Testing", "testing\HopFrame.Testing\HopFrame.Testing.csproj", "{58490069-51DF-454C-8B54-7FB7D4BDFF81}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {4BFE21C2-EAAC-4662-8B97-500836651B2A} = {7E4AAFB3-9762-4F42-86DF-5A3194FDC243} + {8E59F398-184A-47C9-AAA2-3E0FFD775ABF} = {7E4AAFB3-9762-4F42-86DF-5A3194FDC243} + {58490069-51DF-454C-8B54-7FB7D4BDFF81} = {9EB7FDBD-49C2-4872-9666-6F7AEBA541B2} + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4BFE21C2-EAAC-4662-8B97-500836651B2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4BFE21C2-EAAC-4662-8B97-500836651B2A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4BFE21C2-EAAC-4662-8B97-500836651B2A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4BFE21C2-EAAC-4662-8B97-500836651B2A}.Release|Any CPU.Build.0 = Release|Any CPU + {8E59F398-184A-47C9-AAA2-3E0FFD775ABF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E59F398-184A-47C9-AAA2-3E0FFD775ABF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E59F398-184A-47C9-AAA2-3E0FFD775ABF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E59F398-184A-47C9-AAA2-3E0FFD775ABF}.Release|Any CPU.Build.0 = Release|Any CPU + {58490069-51DF-454C-8B54-7FB7D4BDFF81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {58490069-51DF-454C-8B54-7FB7D4BDFF81}.Debug|Any CPU.Build.0 = Debug|Any CPU + {58490069-51DF-454C-8B54-7FB7D4BDFF81}.Release|Any CPU.ActiveCfg = Release|Any CPU + {58490069-51DF-454C-8B54-7FB7D4BDFF81}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection EndGlobal diff --git a/src/HopFrame.Core/Config/DbContextConfig.cs b/src/HopFrame.Core/Config/DbContextConfig.cs new file mode 100644 index 0000000..584a5b8 --- /dev/null +++ b/src/HopFrame.Core/Config/DbContextConfig.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; + +namespace HopFrame.Core.Config; + +public class DbContextConfig { + public Type ContextType { get; } + public List Tables { get; } = new(); + + public DbContextConfig(Type context) { + ContextType = context; + + foreach (var property in ContextType.GetProperties()) { + if (!property.PropertyType.IsGenericType) continue; + var innerType = property.PropertyType.GenericTypeArguments.First(); + var setType = typeof(DbSet<>).MakeGenericType(innerType); + if (property.PropertyType != setType) continue; + + var table = new TableConfig(this, innerType, property.Name, Tables.Count); + Tables.Add(table); + } + } +} + +public class DbContextConfig(DbContextConfig config) { + public DbContextConfig InnerConfig { get; } = config; + + public DbContextConfig Table(Action> configurator) where TModel : class { + var table = Table(); + configurator.Invoke(table); + return this; + } + + public TableConfig Table() where TModel : class { + var table = InnerConfig.Tables.Single(table => table.TableType == typeof(TModel)); + return new TableConfig(table); + } + +} diff --git a/src/HopFrame.Core/Config/HopFrameConfig.cs b/src/HopFrame.Core/Config/HopFrameConfig.cs new file mode 100644 index 0000000..4ac3bb4 --- /dev/null +++ b/src/HopFrame.Core/Config/HopFrameConfig.cs @@ -0,0 +1,42 @@ +using HopFrame.Core.Services; +using Microsoft.EntityFrameworkCore; + +namespace HopFrame.Core.Config; + +public class HopFrameConfig { + public List Contexts { get; } = new(); + public bool DisplayUserInfo { get; set; } = true; + public string? BasePolicy { get; set; } + public string? LoginPageRewrite { get; set; } +} + +public class HopFrameConfigurator(HopFrameConfig config) { + public HopFrameConfig InnerConfig { get; } = config; + + public HopFrameConfigurator AddDbContext(Action> configurator) where TDbContext : DbContext { + var context = AddDbContext(); + configurator.Invoke(context); + return this; + } + + public DbContextConfig AddDbContext() where TDbContext : DbContext { + var context = new DbContextConfig(typeof(TDbContext)); + InnerConfig.Contexts.Add(context); + return new DbContextConfig(context); + } + + public HopFrameConfigurator DisplayUserInfo(bool display) { + InnerConfig.DisplayUserInfo = display; + return this; + } + + public HopFrameConfigurator SetBasePolicy(string basePolicy) { + InnerConfig.BasePolicy = basePolicy; + return this; + } + + public HopFrameConfigurator SetLoginPage(string url) { + InnerConfig.LoginPageRewrite = url; + return this; + } +} diff --git a/src/HopFrame.Core/Config/PropertyConfig.cs b/src/HopFrame.Core/Config/PropertyConfig.cs new file mode 100644 index 0000000..09d7b69 --- /dev/null +++ b/src/HopFrame.Core/Config/PropertyConfig.cs @@ -0,0 +1,114 @@ +using System.Collections; +using System.Linq.Expressions; +using System.Reflection; + +namespace HopFrame.Core.Config; + +public class PropertyConfig(PropertyInfo info, TableConfig table, int nthProperty) { + public PropertyInfo Info { get; } = info; + public TableConfig Table { get; } = table; + public string Name { get; set; } = info.Name; + public bool List { get; set; } = true; + public bool Sortable { get; set; } = true; + public bool Searchable { get; set; } = true; + public PropertyInfo? DisplayedProperty { get; set; } + public Func? Formatter { get; set; } + public Func? EnumerableFormatter { get; set; } + public Func? Parser { get; set; } + public Func>>? Validator { get; set; } + public bool Editable { get; set; } = true; + public bool Creatable { get; set; } = true; + public bool DisplayValue { get; set; } = true; + public bool TextArea { get; set; } + public int TextAreaRows { get; set; } = 16; + public bool IsRelation { get; set; } + public bool IsRequired { get; set; } + public bool IsEnumerable { get; set; } + public bool IsListingProperty { get; set; } + public int Order { get; set; } = nthProperty; +} + +public class PropertyConfig(PropertyConfig config) { + public PropertyConfig InnerConfig { get; } = config; + + public PropertyConfig SetDisplayName(string displayName) { + InnerConfig.Name = displayName; + return this; + } + + public PropertyConfig List(bool list) { + InnerConfig.List = list; + InnerConfig.Searchable = !list; + return this; + } + + public PropertyConfig IsSortable(bool sortable) { + InnerConfig.Sortable = sortable; + return this; + } + + public PropertyConfig IsSearchable(bool searchable) { + InnerConfig.Searchable = searchable; + return this; + } + + public PropertyConfig SetDisplayedProperty(Expression> propertyExpression) { + InnerConfig.DisplayedProperty = TableConfig.GetPropertyInfo(propertyExpression); + return this; + } + + public PropertyConfig Format(Func formatter) { + InnerConfig.Formatter = (obj, provider) => formatter.Invoke((TProp)obj, provider); + return this; + } + + public PropertyConfig FormatEach(Func formatter) { + InnerConfig.EnumerableFormatter = (obj, provider) => formatter.Invoke((TInnerProp)obj, provider); + return this; + } + + public PropertyConfig SetParser(Func parser) { + InnerConfig.Parser = (str, provider) => parser.Invoke(str, provider)!; + return this; + } + + public PropertyConfig SetEditable(bool editable) { + InnerConfig.Editable = editable; + return this; + } + + public PropertyConfig SetCreatable(bool creatable) { + InnerConfig.Creatable = creatable; + return this; + } + + public PropertyConfig DisplayValue(bool display) { + InnerConfig.DisplayValue = display; + return this; + } + + public PropertyConfig IsTextArea(bool textField) { + InnerConfig.TextArea = textField; + return this; + } + + public PropertyConfig SetTextAreaRows(int rows) { + InnerConfig.TextAreaRows = rows; + return this; + } + + public PropertyConfig SetValidator(Func> validator) { + InnerConfig.Validator = (obj, provider) => Task.FromResult(validator.Invoke((TProp?)obj, provider)); + return this; + } + + public PropertyConfig SetValidator(Func>> validator) { + InnerConfig.Validator = (obj, provider) => validator.Invoke((TProp?)obj, provider); + return this; + } + + public PropertyConfig SetOrderIndex(int index) { + InnerConfig.Order = index; + return this; + } +} diff --git a/src/HopFrame.Core/Config/TableConfig.cs b/src/HopFrame.Core/Config/TableConfig.cs new file mode 100644 index 0000000..ec0850d --- /dev/null +++ b/src/HopFrame.Core/Config/TableConfig.cs @@ -0,0 +1,144 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq.Expressions; +using System.Reflection; + +namespace HopFrame.Core.Config; + +public class TableConfig { + public Type TableType { get; } + public string PropertyName { get; } + public string DisplayName { get; set; } + public string? Description { get; set; } + public DbContextConfig ContextConfig { get; } + public bool Ignored { get; set; } + public int Order { get; set; } + internal bool Seeded { get; set; } + + public string? ViewPolicy { get; set; } + public string? CreatePolicy { get; set; } + public string? UpdatePolicy { get; set; } + public string? DeletePolicy { get; set; } + + public List Properties { get; } = new(); + + public TableConfig(DbContextConfig config, Type tableType, string propertyName, int nthTable) { + TableType = tableType; + PropertyName = propertyName; + ContextConfig = config; + DisplayName = PropertyName; + Order = nthTable; + + var properties = tableType.GetProperties(); + for (var i = 0; i < properties.Length; i++) { + var info = properties[i]; + var propConfig = new PropertyConfig(info, this, i); + + if (info.GetCustomAttributes(true).Any(a => a is DatabaseGeneratedAttribute)) { + propConfig.Creatable = false; + propConfig.Editable = false; + } + + if (info.GetCustomAttributes(true).Any(a => a is KeyAttribute)) { + propConfig.Editable = false; + } + + Properties.Add(propConfig); + } + } +} + +public class TableConfig(TableConfig config) { + public TableConfig InnerConfig { get; } = config; + + public TableConfig Ignore() { + InnerConfig.Ignored = true; + return this; + } + + public PropertyConfig Property(Expression> propertyExpression) { + var info = GetPropertyInfo(propertyExpression); + var prop = InnerConfig.Properties + .Single(prop => prop.Info.Name == info.Name); + return new PropertyConfig(prop); + } + + public TableConfig Property(Expression> propertyExpression, Action> configurator) { + var prop = Property(propertyExpression); + configurator.Invoke(prop); + return this; + } + + public PropertyConfig AddListingProperty(string name, Func template) { + var prop = new PropertyConfig(InnerConfig.Properties.First().Info, InnerConfig, InnerConfig.Properties.Count); + prop.Name = name; + prop.IsListingProperty = true; + prop.Formatter = (obj, provider) => template.Invoke((TModel)obj, provider); + InnerConfig.Properties.Add(prop); + return new PropertyConfig(prop); + } + + public TableConfig AddListingProperty(string name, Func template, Action> configurator) { + var prop = AddListingProperty(name, template); + configurator.Invoke(prop); + return this; + } + + public TableConfig SetDisplayName(string name) { + InnerConfig.DisplayName = name; + return this; + } + + public TableConfig SetDescription(string description) { + InnerConfig.Description = description; + return this; + } + + public TableConfig SetOrderIndex(int index) { + InnerConfig.Order = index; + return this; + } + + public TableConfig SetViewPolicy(string policy) { + InnerConfig.ViewPolicy = policy; + return this; + } + + public TableConfig SetUpdatePolicy(string policy) { + InnerConfig.UpdatePolicy = policy; + return this; + } + + public TableConfig SetCreatePolicy(string policy) { + InnerConfig.CreatePolicy = policy; + return this; + } + + public TableConfig SetDeletePolicy(string policy) { + InnerConfig.DeletePolicy = policy; + return this; + } + + internal static PropertyInfo GetPropertyInfo(Expression> propertyLambda) { + if (propertyLambda.Body is not MemberExpression member) { + throw new ArgumentException($"Expression '{propertyLambda}' refers to a method, not a property."); + } + + if (member.Member is not PropertyInfo propInfo) { + throw new ArgumentException($"Expression '{propertyLambda}' refers to a field, not a property."); + } + + Type type = typeof(TSource); + if (propInfo.ReflectedType != null && type != propInfo.ReflectedType && + !type.IsSubclassOf(propInfo.ReflectedType)) { + throw new ArgumentException($"Expression '{propertyLambda}' refers to a property that is not from type {type}."); + } + + if (propInfo.Name is null) + throw new ArgumentException($"Expression '{propertyLambda}' refers a not existing property."); + + return propInfo; + } + + +} diff --git a/src/HopFrame.Core/HopFrame.Core.csproj b/src/HopFrame.Core/HopFrame.Core.csproj new file mode 100644 index 0000000..ae907a6 --- /dev/null +++ b/src/HopFrame.Core/HopFrame.Core.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/src/HopFrame.Core/ServiceCollectionExtensions.cs b/src/HopFrame.Core/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..b53ae3c --- /dev/null +++ b/src/HopFrame.Core/ServiceCollectionExtensions.cs @@ -0,0 +1,16 @@ +using HopFrame.Core.Services; +using HopFrame.Core.Services.Implementations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace HopFrame.Core; + +public static class ServiceCollectionExtensions { + + public static IServiceCollection AddHopFrameServices(this IServiceCollection services) { + services.AddTransient(); + services.TryAddTransient(); + return services; + } + +} \ No newline at end of file diff --git a/src/HopFrame.Core/Services/IContextExplorer.cs b/src/HopFrame.Core/Services/IContextExplorer.cs new file mode 100644 index 0000000..ee306be --- /dev/null +++ b/src/HopFrame.Core/Services/IContextExplorer.cs @@ -0,0 +1,10 @@ +using HopFrame.Core.Config; + +namespace HopFrame.Core.Services; + +public interface IContextExplorer { + public IEnumerable GetTables(); + public TableConfig? GetTable(string tableDisplayName); + public TableConfig? GetTable(Type tableEntity); + public ITableManager? GetTableManager(string tablePropertyName); +} \ No newline at end of file diff --git a/src/HopFrame.Core/Services/IHopFrameAuthHandler.cs b/src/HopFrame.Core/Services/IHopFrameAuthHandler.cs new file mode 100644 index 0000000..f883c7a --- /dev/null +++ b/src/HopFrame.Core/Services/IHopFrameAuthHandler.cs @@ -0,0 +1,6 @@ +namespace HopFrame.Core.Services; + +public interface IHopFrameAuthHandler { + public Task IsAuthenticatedAsync(string? policy); + public Task GetCurrentUserDisplayNameAsync(); +} \ No newline at end of file diff --git a/src/HopFrame.Core/Services/ITableManager.cs b/src/HopFrame.Core/Services/ITableManager.cs new file mode 100644 index 0000000..9d035fa --- /dev/null +++ b/src/HopFrame.Core/Services/ITableManager.cs @@ -0,0 +1,16 @@ +using System.Reflection; +using HopFrame.Core.Config; + +namespace HopFrame.Core.Services; + +public interface ITableManager { + public IQueryable LoadPage(int page, int perPage = 20); + public Task<(IEnumerable, int)> Search(string searchTerm, int page = 0, int perPage = 20); + public Task TotalPages(int perPage = 20); + public Task DeleteItem(object item); + public Task EditItem(object item); + public Task AddItem(object item); + public Task RevertChanges(object item); + + public string DisplayProperty(object? item, PropertyConfig prop, object? value = null); +} \ No newline at end of file diff --git a/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs b/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs new file mode 100644 index 0000000..cbb2f16 --- /dev/null +++ b/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using HopFrame.Core.Config; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace HopFrame.Core.Services.Implementations; + +internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider provider, ILogger logger) : IContextExplorer { + public IEnumerable GetTables() { + foreach (var context in config.Contexts) { + foreach (var table in context.Tables) { + if (table.Ignored) continue; + yield return table; + } + } + } + + public TableConfig? GetTable(string tableDisplayName) { + foreach (var context in config.Contexts) { + var table = context.Tables.FirstOrDefault(table => table.DisplayName.Equals(tableDisplayName, StringComparison.CurrentCultureIgnoreCase)); + if (table is null) continue; + + SeedTableData(table); + return table; + } + + return null; + } + + public TableConfig? GetTable(Type tableEntity) { + foreach (var context in config.Contexts) { + var table = context.Tables.FirstOrDefault(table => table.TableType == tableEntity); + if (table is null) continue; + + SeedTableData(table); + return table; + } + + return null; + } + + public ITableManager? GetTableManager(string tablePropertyName) { + foreach (var context in config.Contexts) { + var table = context.Tables.FirstOrDefault(table => table.PropertyName == tablePropertyName); + if (table is null) continue; + + var dbContext = provider.GetService(context.ContextType) as DbContext; + if (dbContext is null) return null; + + var type = typeof(TableManager<>).MakeGenericType(table.TableType); + return Activator.CreateInstance(type, dbContext, table, this, provider) as ITableManager; + } + + return null; + } + + private void SeedTableData(TableConfig table) { + if (table.Seeded) return; + var dbContext = (provider.GetService(table.ContextConfig.ContextType) as DbContext)!; + var entity = dbContext.Model.FindEntityType(table.TableType)!; + + foreach (var propertyConfig in table.Properties) { + if (propertyConfig.IsListingProperty) continue; + var prop = entity.FindProperty(propertyConfig.Info.Name); + if (prop is not null) continue; + var nav = entity.FindNavigation(propertyConfig.Info.Name); + if (nav is null) continue; + propertyConfig.IsRelation = true; + propertyConfig.IsRequired = nav.ForeignKey.IsRequired; + propertyConfig.IsEnumerable = nav.IsCollection; + } + + foreach (var property in entity.GetProperties()) { + var propConfig = table.Properties + .Where(prop => !prop.IsListingProperty) + .SingleOrDefault(prop => prop.Info == property.PropertyInfo); + if (propConfig is null) continue; + propConfig.IsRequired = !property.IsNullable; + } + + logger.LogInformation("Extracted information for table '" + table.PropertyName + "'"); + table.Seeded = true; + } + +} \ No newline at end of file diff --git a/src/HopFrame.Core/Services/Implementations/DefaultAuthHandler.cs b/src/HopFrame.Core/Services/Implementations/DefaultAuthHandler.cs new file mode 100644 index 0000000..19db0dc --- /dev/null +++ b/src/HopFrame.Core/Services/Implementations/DefaultAuthHandler.cs @@ -0,0 +1,10 @@ +namespace HopFrame.Core.Services.Implementations; + +internal sealed class DefaultAuthHandler : IHopFrameAuthHandler { + public Task IsAuthenticatedAsync(string? policy) { + return Task.FromResult(true); + } + public Task GetCurrentUserDisplayNameAsync() { + return Task.FromResult(string.Empty); + } +} \ No newline at end of file diff --git a/src/HopFrame.Core/Services/Implementations/TableManager.cs b/src/HopFrame.Core/Services/Implementations/TableManager.cs new file mode 100644 index 0000000..33c74e7 --- /dev/null +++ b/src/HopFrame.Core/Services/Implementations/TableManager.cs @@ -0,0 +1,121 @@ +using System.Collections; +using System.ComponentModel.DataAnnotations; +using HopFrame.Core.Config; +using Microsoft.EntityFrameworkCore; + +namespace HopFrame.Core.Services.Implementations; + +internal sealed class TableManager(DbContext context, TableConfig config, IContextExplorer explorer, IServiceProvider provider) : ITableManager where TModel : class { + + public IQueryable LoadPage(int page, int perPage = 20) { + var table = context.Set(); + var data = IncludeForgeinKeys(table); + return data + .Skip(page * perPage) + .Take(perPage); + } + + public Task<(IEnumerable, int)> Search(string searchTerm, int page = 0, int perPage = 20) { + var table = context.Set(); + var all = IncludeForgeinKeys(table) + .AsEnumerable() + .Where(item => ItemSearched(item, searchTerm)) + .ToList(); + + return Task.FromResult(( + (IEnumerable)all.Skip(page * perPage).Take(perPage), + (int)Math.Ceiling(all.Count / (double)perPage))); + } + + public async Task TotalPages(int perPage = 20) { + var table = context.Set(); + return (int)Math.Ceiling(await table.CountAsync() / (double)perPage); + } + + public async Task DeleteItem(object item) { + var table = context.Set(); + table.Remove((item as TModel)!); + await context.SaveChangesAsync(); + } + + public async Task EditItem(object item) { + await context.SaveChangesAsync(); + } + + public async Task AddItem(object item) { + var table = context.Set(); + await table.AddAsync((TModel)item); + await context.SaveChangesAsync(); + } + + public async Task RevertChanges(object item) { + await context.Entry((TModel)item).ReloadAsync(); + } + + private bool ItemSearched(TModel item, string searchTerm) { + foreach (var property in config.Properties) { + if (!property.Searchable) continue; + var value = property.Info.GetValue(item); + if (value is null) continue; + + var strValue = value.ToString(); + if (strValue?.Contains(searchTerm) == true) + return true; + } + + return false; + } + + public string DisplayProperty(object? item, PropertyConfig prop, object? value = null) { + if (item is null) return string.Empty; + + if (prop.IsListingProperty) + return prop.Formatter!.Invoke(item, provider); + + var propValue = value ?? prop.Info.GetValue(item); + if (propValue is null) + return string.Empty; + + if (prop.Formatter is not null) { + return prop.Formatter.Invoke(propValue, provider); + } + + if (prop.IsEnumerable) { + if (value is not null) { + if (prop.EnumerableFormatter is not null) { + return prop.EnumerableFormatter.Invoke(value, provider); + } + + return value.ToString() ?? string.Empty; + } + + return (propValue as IEnumerable)!.OfType().Count().ToString(); + } + + if (prop.DisplayedProperty is null) { + var key = prop.Info.PropertyType + .GetProperties() + .FirstOrDefault(p => p.GetCustomAttributes(true).Any(a => a is KeyAttribute)); + + return key?.GetValue(propValue)?.ToString() ?? propValue.ToString() ?? string.Empty; + } + + var innerConfig = explorer.GetTable(propValue.GetType()); + var innerProp = innerConfig!.Properties + .SingleOrDefault(p => p.Info == prop.DisplayedProperty && !p.IsListingProperty); + + if (innerProp is null) return propValue.ToString() ?? string.Empty; + return DisplayProperty(propValue, innerProp); + } + + private IQueryable IncludeForgeinKeys(IQueryable query) { + var pendingQuery = query; + + foreach (var property in config.Properties.Where(prop => prop.IsRelation)) { + pendingQuery = pendingQuery.Include(property.Info.Name); + } + + return pendingQuery; + } + +} \ No newline at end of file diff --git a/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor b/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor new file mode 100644 index 0000000..041929d --- /dev/null +++ b/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor @@ -0,0 +1,376 @@ +@implements IDialogContentComponent +@rendermode InteractiveServer + +@using System.Collections +@using HopFrame.Core.Config +@using HopFrame.Core.Services +@using HopFrame.Web.Models +@using HopFrame.Web.Helpers + + + @foreach (var property in Content.Config.Properties.Where(prop => !prop.IsListingProperty).OrderBy(prop => prop.Order)) { + if (!_currentlyEditing && !property.Creatable) continue; + +
+ @if (property.IsRelation) { +
+
+ @if (property.IsEnumerable) { + @property.Name +
+ + @foreach (var item in GetPropertyValue(property) ?? Enumerable.Empty()) { + @(GetPropertyValue(property, item)) + } + + + } + else { + + } + +
+ @if (!property.IsRequired) { + + + + } + + + +
+ + } + else if (property.Info.PropertyType.IsNumeric()) { + + } + else if (Type.GetTypeCode(property.Info.PropertyType) == TypeCode.Boolean) { + + } + else if (Type.GetTypeCode(property.Info.PropertyType) == TypeCode.DateTime) { +
+
+ +
+
+ +
+
+ } + else if (property.Info.PropertyType == typeof(DateOnly)) { + + } + else if (property.Info.PropertyType == typeof(TimeOnly)) { + + } + else if (property.Info.PropertyType.IsEnum) { + + } + else if (property.Info.PropertyType.IsNullableEnum()) { +
+
+ +
+
+ + + +
+
+ } + else if (property.TextArea) { + + } + else { + + } + + @foreach (var error in _validationErrors[property.Info.Name]) { + @error + } + + } + + + + +@inject IContextExplorer Explorer +@inject IDialogService Dialogs +@inject IHopFrameAuthHandler Handler +@inject IToastService Toasts +@inject IServiceProvider Provider + +@code { + [Parameter] + public required EditorDialogData Content { get; set; } + + [CascadingParameter] + public required FluentDialog Dialog { get; set; } + + private bool _currentlyEditing; + private ITableManager? _manager; + private readonly Dictionary> _validationErrors = new(); + + protected override void OnInitialized() { + _currentlyEditing = Content.CurrentObject is not null; + Dialog.Instance.Parameters.Title = (_currentlyEditing ? "Edit " : "Add ") + Content.Config.TableType.Name; + Dialog.Instance.Parameters.Width = "500px"; + Dialog.Instance.Parameters.PrimaryAction = "Save"; + Dialog.Instance.Parameters.ValidateDialogAsync = ValidateInputs; + _manager = Explorer.GetTableManager(Content.Config.PropertyName); + Content.CurrentObject ??= Activator.CreateInstance(Content.Config.TableType); + + foreach (var property in Content.Config.Properties) { + if (property.IsListingProperty) continue; + _validationErrors.Add(property.Info.Name, []); + } + } + + private TValue? GetPropertyValue(PropertyConfig config, object? listItem = null) { + if (!config.DisplayValue) return default; + if (Content.CurrentObject is null) return default; + + if (listItem is not null) { + return (TValue)(object)_manager!.DisplayProperty(Content.CurrentObject, config, listItem); + } + + var value = config.Info.GetValue(Content.CurrentObject); + + if (value is null) + return default; + + if (typeof(TValue).IsAssignableFrom(config.Info.PropertyType)) + return (TValue)value; + + if (typeof(TValue) == typeof(string)) + return (TValue)(object)_manager!.DisplayProperty(Content.CurrentObject, config); + + return (TValue)Convert.ChangeType(value, typeof(TValue)); + } + + private async Task SetPropertyValue(PropertyConfig config, object? value, InputType senderType) { + if (!await Handler.IsAuthenticatedAsync(_currentlyEditing ? Content.Config.UpdatePolicy : Content.Config.CreatePolicy)) + return; + + object? result = null; + var needsOverride = true; + + if (value is not null && config.Parser is null) { + switch (senderType) { + case InputType.Number: + result = Convert.ChangeType(value, config.Info.PropertyType); + break; + + case InputType.Text: + if (config.Info.PropertyType == typeof(Guid)) { + var success = Guid.TryParse((string)value, out var guid); + if (success) result = guid; + else Toasts.ShowError($"'{value}' is not a valid guid"); + break; + } + + result = Convert.ToString(value); + break; + + case InputType.Switch: + result = Convert.ToBoolean(value); + break; + + case InputType.Enum: + var type = Nullable.GetUnderlyingType(config.Info.PropertyType); + if (type is not null && string.IsNullOrEmpty((string)value)) break; + type ??= config.Info.PropertyType; + result = Enum.Parse(type, (string)value); + break; + + case InputType.Date: + if (config.Info.PropertyType == typeof(DateTime)) { + var newDate = (DateTime)value; + var dateTime = GetPropertyValue(config); + result = new DateTime(newDate.Year, newDate.Month, newDate.Day, dateTime.Hour, dateTime.Minute, dateTime.Second, dateTime.Millisecond, dateTime.Microsecond); + } + else result = DateOnly.FromDateTime((DateTime)value); + break; + + case InputType.Time: + if (config.Info.PropertyType == typeof(DateTime)) { + var newTime = (DateTime)value; + var dateTime = GetPropertyValue(config); + result = new DateTime(dateTime.Year, dateTime.Month, dateTime.Day, newTime.Hour, newTime.Minute, newTime.Second, newTime.Millisecond, newTime.Microsecond); + } + else result = TimeOnly.FromDateTime((DateTime)value); + break; + + case InputType.Relation: + if (!config.IsEnumerable) + result = ((IEnumerable)value).OfType().FirstOrDefault(); + else { + needsOverride = false; + + if (!typeof(IList).IsAssignableFrom(config.Info.PropertyType)) { + throw new ArgumentException($"Invalid type of '{config.Name}' property in '{config.Table.DisplayName}' table, only list types are supported on enumerable relations."); + } + + var asList = (IList)config.Info.GetValue(Content.CurrentObject)!; + asList.Clear(); + foreach (var element in (IEnumerable)value) { + asList.Add(element); + } + } + break; + + default: + throw new ArgumentOutOfRangeException(nameof(senderType), senderType, null); + } + } + + if (config.Parser is not null && result is not null) { + result = config.Parser(result.ToString()!, Provider); + } + + if (needsOverride) + config.Info.SetValue(Content.CurrentObject, result); + } + + private async Task OpenRelationalPicker(PropertyConfig config) { + if (!await Handler.IsAuthenticatedAsync(_currentlyEditing ? Content.Config.UpdatePolicy : Content.Config.CreatePolicy)) + return; + + var relationType = config.Info.PropertyType; + if (config.IsEnumerable) { + relationType = config.Info.PropertyType.GetGenericArguments().First(); + } + + var relationTable = Explorer.GetTable(relationType); + if (relationTable is null) return; + + var currentValues = new List(); + if (config.IsEnumerable) { + foreach (var o in GetPropertyValue(config) ?? Enumerable.Empty()) { + currentValues.Add(o); + } + } + else { + var raw = config.Info.GetValue(Content.CurrentObject); + if (raw is not null) + currentValues.Add(raw); + } + + var dialog = await Dialogs.ShowDialogAsync(new RelationPickerDialogData(relationTable, currentValues, config.IsEnumerable), new DialogParameters()); + var result = await dialog.Result; + if (result.Cancelled) return; + + var data = (RelationPickerDialogData)result.Data!; + await SetPropertyValue(config, data.SelectedObjects, InputType.Relation); + } + + private async Task ValidateInputs() { + if (!await Handler.IsAuthenticatedAsync(_currentlyEditing ? Content.Config.UpdatePolicy : Content.Config.CreatePolicy)) + return false; + + foreach (var property in Content.Config.Properties) { + if (property.IsListingProperty) continue; + + var errorList = _validationErrors[property.Info.Name]; + errorList.Clear(); + var value = property.Info.GetValue(Content.CurrentObject); + + if (property.Validator is not null) { + errorList.AddRange(await property.Validator.Invoke(value, Provider)); + continue; + } + + if (value is null && property.IsRequired) + errorList.Add($"{property.Name} is required"); + } + + StateHasChanged(); + var valid = _validationErrors + .Select(err => err.Value.Count) + .All(c => c == 0); + + if (!valid) return false; + var dialog = await Dialogs.ShowConfirmationAsync($"Do you really want to {(_currentlyEditing ? "edit" : "create")} this entry?"); + var result = await dialog.Result; + return !result.Cancelled; + } + + private enum InputType { + Number, + Switch, + Date, + Time, + Enum, + Text, + Relation + } +} diff --git a/src/HopFrame.Web/Components/Dialogs/HopFrameRelationPicker.razor b/src/HopFrame.Web/Components/Dialogs/HopFrameRelationPicker.razor new file mode 100644 index 0000000..d201ccd --- /dev/null +++ b/src/HopFrame.Web/Components/Dialogs/HopFrameRelationPicker.razor @@ -0,0 +1,32 @@ +@implements IDialogContentComponent +@rendermode InteractiveServer + +@using HopFrame.Web.Models +@using HopFrame.Web.Components.Pages + + + + + +@code { + + [Parameter] + public required RelationPickerDialogData Content { get; set; } + + [CascadingParameter] + public required FluentDialog Dialog { get; set; } + + protected override void OnInitialized() { + Dialog.Instance.Parameters.Title = $"Select {Content.SourceTable.TableType.Name}"; + Dialog.Instance.Parameters.Width = "90vw"; + Dialog.Instance.Parameters.Height = "90vh"; + Dialog.Instance.Parameters.PrimaryAction = "Assign"; + } + +} \ No newline at end of file diff --git a/src/HopFrame.Web/Components/Layout/HopFrameLayout.razor b/src/HopFrame.Web/Components/Layout/HopFrameLayout.razor new file mode 100644 index 0000000..87b9751 --- /dev/null +++ b/src/HopFrame.Web/Components/Layout/HopFrameLayout.razor @@ -0,0 +1,46 @@ +@using HopFrame.Core.Config +@using HopFrame.Core.Services +@using Microsoft.Extensions.DependencyInjection +@inherits LayoutComponentBase + + + + + + + + + + + +
+ @Body +
+
+
+ + Documentation and source code + + + + + + + + +
+ +@inject IHopFrameAuthHandler Handler +@inject HopFrameConfig Config +@inject NavigationManager Navigator + +@code { + + protected override async Task OnInitializedAsync() { + var authorized = await Handler.IsAuthenticatedAsync(Config.BasePolicy); + if (!authorized) { + Navigator.NavigateTo((Config.LoginPageRewrite ?? "/login") + "?redirect=/" + Navigator.ToBaseRelativePath(Navigator.Uri), true); + } + } + +} diff --git a/src/HopFrame.Web/Components/Layout/HopFrameNavigation.razor b/src/HopFrame.Web/Components/Layout/HopFrameNavigation.razor new file mode 100644 index 0000000..09f4e82 --- /dev/null +++ b/src/HopFrame.Web/Components/Layout/HopFrameNavigation.razor @@ -0,0 +1,45 @@ +@using System.Text +@using HopFrame.Core.Config +@using HopFrame.Core.Services + + + HopFrame + + @if (Config.DisplayUserInfo) { + + } + + + +@inject HopFrameConfig Config +@inject IHopFrameAuthHandler Handler + +@code { + + private string? _displayName; + private string? _initials; + + protected override async Task OnInitializedAsync() { + if (Config.DisplayUserInfo) { + _displayName = await Handler.GetCurrentUserDisplayNameAsync(); + _initials = GetInitials(_displayName); + } + } + + private static string GetInitials(string input) { + if (string.IsNullOrEmpty(input)) + return string.Empty; + + StringBuilder initials = new StringBuilder(); + string[] words = input.Split([' ', '.', '_'], StringSplitOptions.RemoveEmptyEntries); + + foreach (string word in words) { + if (!string.IsNullOrEmpty(word) && char.IsLetter(word[0])) + initials.Append(word[0]); + } + + return initials.ToString().ToUpper(); + } + + +} diff --git a/src/HopFrame.Web/Components/Layout/HopFrameSideMenu.razor b/src/HopFrame.Web/Components/Layout/HopFrameSideMenu.razor new file mode 100644 index 0000000..d839091 --- /dev/null +++ b/src/HopFrame.Web/Components/Layout/HopFrameSideMenu.razor @@ -0,0 +1,39 @@ +@using HopFrame.Core.Config +@using HopFrame.Core.Services + + + + +
+ + @foreach (var table in _tables.OrderBy(t => t.Order).Select(t => t.DisplayName)) { + + } +
+ +@inject IContextExplorer Explorer +@inject IHopFrameAuthHandler Handler + +@code { + + private readonly List _tables = []; + + protected override async Task OnInitializedAsync() { + foreach (var table in Explorer.GetTables()) { + if (table.Ignored) continue; + if (!await Handler.IsAuthenticatedAsync(table.ViewPolicy)) continue; + _tables.Add(table); + } + } + +} diff --git a/src/HopFrame.Web/Components/Pages/HopFrameHome.razor b/src/HopFrame.Web/Components/Pages/HopFrameHome.razor new file mode 100644 index 0000000..6be85aa --- /dev/null +++ b/src/HopFrame.Web/Components/Pages/HopFrameHome.razor @@ -0,0 +1,45 @@ +@page "/admin" +@using HopFrame.Core.Config +@using HopFrame.Core.Services +@layout HopFrameLayout + +HopFrame + +
+

Tables

+ + + @foreach (var table in _tables.OrderBy(t => t.Order)) { + +

@table.DisplayName

+ @table.ViewPolicy + @table.Description + +
+ + + + Open + +
+
+ } +
+
+ +@inject IContextExplorer Explorer +@inject IHopFrameAuthHandler Handler + +@code { + + private readonly List _tables = []; + + protected override async Task OnInitializedAsync() { + foreach (var table in Explorer.GetTables()) { + if (table.Ignored) continue; + if (!await Handler.IsAuthenticatedAsync(table.ViewPolicy)) continue; + _tables.Add(table); + } + } + +} \ No newline at end of file diff --git a/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor b/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor new file mode 100644 index 0000000..64ce7bd --- /dev/null +++ b/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor @@ -0,0 +1,286 @@ +@page "/admin/{TableDisplayName}" +@layout HopFrameLayout +@rendermode InteractiveServer +@implements IDisposable + +@using HopFrame.Core.Config +@using HopFrame.Core.Services +@using HopFrame.Web.Models +@using Microsoft.JSInterop +@using Microsoft.EntityFrameworkCore + +@if (!DisplaySelection) { + @_config?.DisplayName +} + + + +
+ +

@_config?.DisplayName

+ @if (!DisplaySelection) { + + Refresh + + } + + + + + @if (_hasCreatePolicy && DisplayActions) { + Add Entry + } +
+ + +
+
+ + @if (DisplaySelection) { + + } + + @foreach (var property in _config!.Properties.Where(prop => prop.List).OrderBy(prop => prop.Order)) { + + } + + @if (DisplayActions && (_hasDeletePolicy || _hasUpdatePolicy)) { + var dataIndex = 0; + + + @{ var currentElement = _currentlyDisplayedModels.ElementAtOrDefault(dataIndex); } + + @if (_hasUpdatePolicy) { + + + + } + + @if (_hasDeletePolicy) { + + + + } + + @{ + dataIndex++; + dataIndex %= 20; + } + + } + +
+
+ + @if (_totalPages > 1) { +
+ + + + + Page + + + + of @_totalPages + + + + +
+ } +
+ + + +@inject IContextExplorer Explorer +@inject NavigationManager Navigator +@inject IJSRuntime Js +@inject IDialogService Dialogs +@inject IHopFrameAuthHandler Handler + +@code { + + [Parameter] + public required string TableDisplayName { get; set; } + + [Parameter] + public bool DisplaySelection { get; set; } + + [Parameter] + public bool DisplayActions { get; set; } = true; + + [Parameter] + public RelationPickerDialogData? DialogData { get; set; } + + [Parameter] + public DataGridSelectMode SelectionMode { get; set; } = DataGridSelectMode.Single; + + [Parameter] + public int PerPage { get; set; } = 20; + + private TableConfig? _config; + private ITableManager? _manager; + + private object[] _currentlyDisplayedModels = []; + private int _currentPage; + private int _totalPages; + private string? _searchTerm; + private bool _loading; + + private bool _hasUpdatePolicy; + private bool _hasDeletePolicy; + private bool _hasCreatePolicy; + + private SelectColumn? _selectColumn; + private bool _allSelected; + + protected override void OnInitialized() { + _config ??= Explorer.GetTable(TableDisplayName); + + if (_config is null || (_config.Ignored && DialogData is null)) { + Navigator.NavigateTo("/admin", true); + } + } + + protected override async Task OnInitializedAsync() { + if (!await Handler.IsAuthenticatedAsync(_config?.ViewPolicy)) { + Navigator.NavigateTo("/admin", true); + return; + } + + _hasUpdatePolicy = await Handler.IsAuthenticatedAsync(_config?.UpdatePolicy); + _hasDeletePolicy = await Handler.IsAuthenticatedAsync(_config?.DeletePolicy); + _hasCreatePolicy = await Handler.IsAuthenticatedAsync(_config?.CreatePolicy); + + _manager ??= Explorer.GetTableManager(_config!.PropertyName); + _currentlyDisplayedModels = await _manager!.LoadPage(_currentPage, PerPage).ToArrayAsync(); + _totalPages = await _manager.TotalPages(PerPage); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) { + try { + await Js.InvokeVoidAsync("removeBg"); + } + catch (Exception) { + // ignored + } + } + + public void Dispose() { + _searchCancel.Dispose(); + } + + private CancellationTokenSource _searchCancel = new(); + private async Task OnSearch(ChangeEventArgs eventArgs) { + await _searchCancel.CancelAsync(); + _searchTerm = eventArgs.Value?.ToString(); + if (_searchTerm is null) return; + _searchCancel = new(); + + await Task.Delay(500, _searchCancel.Token); + await Reload(); + } + + private async Task Reload() { + _loading = true; + if (!string.IsNullOrEmpty(_searchTerm)) { + (var query, _totalPages) = await _manager!.Search(_searchTerm, 0, PerPage); + _currentlyDisplayedModels = query.ToArray(); + } + else { + await OnInitializedAsync(); + } + _loading = false; + } + + private async Task ChangePage(int page) { + if (page < 0 || page > _totalPages - 1) return; + _currentPage = page; + await Reload(); + } + + private async Task DeleteEntry(object element) { + if (!await Handler.IsAuthenticatedAsync(_config?.DeletePolicy)) { + Navigator.NavigateTo("/admin", true); + return; + } + + var dialog = await Dialogs.ShowConfirmationAsync("Do you really want to delete this entry?"); + var result = await dialog.Result; + if (result.Cancelled) return; + + await _manager!.DeleteItem(element); + await Reload(); + } + + private async Task CreateOrEdit(object? element) { + if (!await Handler.IsAuthenticatedAsync(element is null ? _config?.CreatePolicy : _config?.UpdatePolicy)) { + Navigator.NavigateTo("/admin", true); + return; + } + + var panel = await Dialogs.ShowPanelAsync(new EditorDialogData(_config!, element), new DialogParameters { + TrapFocus = false + }); + var result = await panel.Result; + var data = result.Data as EditorDialogData; + + if (result.Cancelled) { + if (data?.CurrentObject is not null) + await _manager!.RevertChanges(data.CurrentObject); + return; + } + + if (element is null) + await _manager!.AddItem(data!.CurrentObject!); + else + await _manager!.EditItem(data!.CurrentObject!); + + await Reload(); + } + + private void SelectItem(object item, bool selected) { + if (!selected) + DialogData?.SelectedObjects.Remove(item); + else DialogData?.SelectedObjects.Add(item); + } + + private void SelectAll() { + var selected = _currentlyDisplayedModels.Any(obj => DialogData?.SelectedObjects.Contains(obj) != true); + foreach (var displayedModel in _currentlyDisplayedModels) { + SelectItem(displayedModel, selected); + } + + _allSelected = selected; + } +} \ No newline at end of file diff --git a/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor.css b/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor.css new file mode 100644 index 0000000..f482c66 --- /dev/null +++ b/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor.css @@ -0,0 +1,22 @@ +h3 { + margin: 0; +} + +.hopframe-paginator { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + margin-top: auto; + padding-top: 20px; + margin-bottom: 20px; +} + +.hopframe-radio { + width: 30px; + height: 44px; + display: grid; + place-items: center; + border-bottom: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-divider-rest); +} diff --git a/src/HopFrame.Web/Helpers/TypeExtensions.cs b/src/HopFrame.Web/Helpers/TypeExtensions.cs new file mode 100644 index 0000000..9ad43bf --- /dev/null +++ b/src/HopFrame.Web/Helpers/TypeExtensions.cs @@ -0,0 +1,23 @@ +namespace HopFrame.Web.Helpers; + +internal static class TypeExtensions { + public static bool IsNumeric(this Type o) { + if (o.IsEnum) return false; + switch (Type.GetTypeCode(o)) { + case TypeCode.Byte: + case TypeCode.SByte: + case TypeCode.UInt16: + case TypeCode.UInt32: + case TypeCode.UInt64: + case TypeCode.Int16: + case TypeCode.Int32: + case TypeCode.Int64: + case TypeCode.Decimal: + case TypeCode.Double: + case TypeCode.Single: + return true; + default: + return false; + } + } +} diff --git a/src/HopFrame.Web/HopFrame.Web.csproj b/src/HopFrame.Web/HopFrame.Web.csproj new file mode 100644 index 0000000..c137722 --- /dev/null +++ b/src/HopFrame.Web/HopFrame.Web.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + + + diff --git a/src/HopFrame.Web/Models/EditorDialogData.cs b/src/HopFrame.Web/Models/EditorDialogData.cs new file mode 100644 index 0000000..a3a8e4b --- /dev/null +++ b/src/HopFrame.Web/Models/EditorDialogData.cs @@ -0,0 +1,8 @@ +using HopFrame.Core.Config; + +namespace HopFrame.Web.Models; + +public sealed class EditorDialogData(TableConfig config, object? current = null) { + public object? CurrentObject { get; set; } = current; + public TableConfig Config { get; } = config; +} diff --git a/src/HopFrame.Web/Models/RelationPickerDialogData.cs b/src/HopFrame.Web/Models/RelationPickerDialogData.cs new file mode 100644 index 0000000..a58a244 --- /dev/null +++ b/src/HopFrame.Web/Models/RelationPickerDialogData.cs @@ -0,0 +1,9 @@ +using HopFrame.Core.Config; + +namespace HopFrame.Web.Models; + +public sealed class RelationPickerDialogData(TableConfig sourceTable, List current, bool multiple) { + public List SelectedObjects { get; set; } = current; + public TableConfig SourceTable { get; init; } = sourceTable; + public bool AllowMultiple { get; set; } = multiple; +} \ No newline at end of file diff --git a/src/HopFrame.Web/ServiceCollectionExtensions.cs b/src/HopFrame.Web/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..ef238e8 --- /dev/null +++ b/src/HopFrame.Web/ServiceCollectionExtensions.cs @@ -0,0 +1,23 @@ +using HopFrame.Core; +using HopFrame.Core.Config; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace HopFrame.Web; + +public static class ServiceCollectionExtensions { + + public static IServiceCollection AddHopFrame(this IServiceCollection services, Action configurator, LibraryConfiguration? fluentUiLibraryConfiguration = null) { + var config = new HopFrameConfig(); + configurator.Invoke(new HopFrameConfigurator(config)); + return AddHopFrame(services, config, fluentUiLibraryConfiguration); + } + + public static IServiceCollection AddHopFrame(this IServiceCollection services, HopFrameConfig config, LibraryConfiguration? fluentUiLibraryConfiguration = null) { + services.AddSingleton(config); + services.AddHopFrameServices(); + services.AddFluentUIComponents(fluentUiLibraryConfiguration); + return services; + } + +} \ No newline at end of file diff --git a/src/HopFrame.Web/_Imports.razor b/src/HopFrame.Web/_Imports.razor new file mode 100644 index 0000000..1c2fd1f --- /dev/null +++ b/src/HopFrame.Web/_Imports.razor @@ -0,0 +1,12 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.FluentUI.AspNetCore.Components +@using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons +@using Microsoft.FluentUI.AspNetCore.Components.Extensions +@using HopFrame.Web.Components.Layout +@using HopFrame.Web.Components.Dialogs diff --git a/src/HopFrame.Web/wwwroot/hopframe.css b/src/HopFrame.Web/wwwroot/hopframe.css new file mode 100644 index 0000000..c713a09 --- /dev/null +++ b/src/HopFrame.Web/wwwroot/hopframe.css @@ -0,0 +1,42 @@ +.hopframe-header { + background-color: var(--neutral-layer-4) !important; + border-bottom: calc(var(--stroke-width) * 2px) solid var(--accent-fill-rest) !important; + color: var(--neutral-foreground-rest) !important; +} + +.hopframe-content { + align-self: stretch !important; + width: 100%; +} + +.hopframe-main { + min-height: calc(100dvh - 86px); + color: var(--neutral-foreground-rest); + align-items: stretch !important; + column-gap: 0 !important; +} + +.hopframe-toolbar { + width: 100%; + padding: 0.5rem 1.5rem; +} + +.hopframe-listview { + background: padding-box linear-gradient(var(--neutral-fill-input-rest), var(--neutral-fill-input-rest)), border-box var(--neutral-stroke-input-rest); + border: calc(var(--stroke-width) * 1px) solid transparent; + border-radius: calc(var(--control-corner-radius) * 1px); + padding: 0 calc(var(--design-unit) * 2px + 1px); + margin-bottom: 4px; + display: flex; + align-items: center; + width: 100%; + height: 32px; +} + +.hopframe-content .empty-content-row.empty-content-cell { + border: none !important; +} + +fluent-option { + background: transparent !important; +} diff --git a/testing/HopFrame.Testing/Components/App.razor b/testing/HopFrame.Testing/Components/App.razor new file mode 100644 index 0000000..ccbffb9 --- /dev/null +++ b/testing/HopFrame.Testing/Components/App.razor @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/testing/HopFrame.Testing/Components/Layout/MainLayout.razor b/testing/HopFrame.Testing/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..989cce9 --- /dev/null +++ b/testing/HopFrame.Testing/Components/Layout/MainLayout.razor @@ -0,0 +1,26 @@ +@inherits LayoutComponentBase + + + + HopFrame.Testing + + + + +
+ @Body +
+
+
+ + Documentation and demos + + About Blazor + +
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
\ No newline at end of file diff --git a/testing/HopFrame.Testing/Components/Layout/NavMenu.razor b/testing/HopFrame.Testing/Components/Layout/NavMenu.razor new file mode 100644 index 0000000..68ed2db --- /dev/null +++ b/testing/HopFrame.Testing/Components/Layout/NavMenu.razor @@ -0,0 +1,19 @@ +@rendermode InteractiveServer + + + +@code { + private bool expanded = true; +} \ No newline at end of file diff --git a/testing/HopFrame.Testing/Components/Pages/Counter.razor b/testing/HopFrame.Testing/Components/Pages/Counter.razor new file mode 100644 index 0000000..e4f070e --- /dev/null +++ b/testing/HopFrame.Testing/Components/Pages/Counter.razor @@ -0,0 +1,21 @@ +@page "/counter" +@rendermode InteractiveServer + +Counter + +

Counter

+ +
+ Current count: @currentCount +
+ +Click me + +@code { + private int currentCount = 0; + + private void IncrementCount() { + currentCount++; + } + +} \ No newline at end of file diff --git a/testing/HopFrame.Testing/Components/Pages/Error.razor b/testing/HopFrame.Testing/Components/Pages/Error.razor new file mode 100644 index 0000000..06de831 --- /dev/null +++ b/testing/HopFrame.Testing/Components/Pages/Error.razor @@ -0,0 +1,35 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) { +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; + +} \ No newline at end of file diff --git a/testing/HopFrame.Testing/Components/Pages/Home.razor b/testing/HopFrame.Testing/Components/Pages/Home.razor new file mode 100644 index 0000000..0a43ff8 --- /dev/null +++ b/testing/HopFrame.Testing/Components/Pages/Home.razor @@ -0,0 +1,65 @@ +@page "/" +@using HopFrame.Testing.Models +@using Microsoft.EntityFrameworkCore + +Home + +

Hello, world!

+ +Welcome to your new Fluent Blazor app. + +@inject DatabaseContext Context + +@code { + + protected override async Task OnInitializedAsync() { + User? user = null; + for (int i = 0; i < 100; i++) { + var first = GenerateName(Random.Shared.Next(4, 6)); + var last = GenerateName(Random.Shared.Next(4, 6)); + var username = $"{first}.{last}"; + + user = new() { + Email = $"{username}-{Random.Shared.Next(0, 20)}@gmail.com", + Id = Guid.CreateVersion7(), + FirstName = first, + LastName = last, + Username = username, + Password = GenerateName(Random.Shared.Next(8, 16)) + }; + + Context.Users.Add(user); + } + + await Context.SaveChangesAsync(); + + Context.Posts.Add(new() { + Caption = "Cool Post", + Content = "This post is cool", + Author = user + }); + + await Context.SaveChangesAsync(); + } + + public static string GenerateName(int len) { + Random r = new Random(); + string[] consonants = { "b", "c", "d", "f", "g", "h", "j", "k", "l", "m", "l", "n", "p", "q", "r", "s", "sh", "zh", "t", "v", "w", "x" }; + string[] vowels = { "a", "e", "i", "o", "u", "ae", "y" }; + string Name = ""; + Name += consonants[r.Next(consonants.Length)].ToUpper(); + Name += vowels[r.Next(vowels.Length)]; + int b = 2; //b tells how many times a new letter has been added. It's 2 right now because the first two letters are already in the name. + while (b < len) { + Name += consonants[r.Next(consonants.Length)]; + b++; + Name += vowels[r.Next(vowels.Length)]; + b++; + } + + return Name; + } + +} + using HopFrame.Testing.Models; + using System.Runtime.InteropServices; diff --git a/testing/HopFrame.Testing/Components/Pages/Weather.razor b/testing/HopFrame.Testing/Components/Pages/Weather.razor new file mode 100644 index 0000000..b88da70 --- /dev/null +++ b/testing/HopFrame.Testing/Components/Pages/Weather.razor @@ -0,0 +1,48 @@ +@page "/weather" +@attribute [StreamRendering] + +Weather + +

Weather

+ +

This component demonstrates showing data.

+ +@if (forecasts == null) { +

+ Loading... +

+} +else { + + + + + + + +} + +@code { + private IQueryable? forecasts; + + protected override async Task OnInitializedAsync() { + // Simulate asynchronous loading to demonstrate streaming rendering + await Task.Delay(500); + + var startDate = DateOnly.FromDateTime(DateTime.Now); + var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; + forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast { + Date = startDate.AddDays(index), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = summaries[Random.Shared.Next(summaries.Length)] + }).AsQueryable(); + } + + private class WeatherForecast { + public DateOnly Date { get; set; } + public int TemperatureC { get; set; } + public string? Summary { get; set; } + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + } + +} \ No newline at end of file diff --git a/testing/HopFrame.Testing/Components/Routes.razor b/testing/HopFrame.Testing/Components/Routes.razor new file mode 100644 index 0000000..ae94e9e --- /dev/null +++ b/testing/HopFrame.Testing/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/testing/HopFrame.Testing/Components/_Imports.razor b/testing/HopFrame.Testing/Components/_Imports.razor new file mode 100644 index 0000000..eae1177 --- /dev/null +++ b/testing/HopFrame.Testing/Components/_Imports.razor @@ -0,0 +1,12 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.FluentUI.AspNetCore.Components +@using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons +@using Microsoft.JSInterop +@using HopFrame.Testing +@using HopFrame.Testing.Components \ No newline at end of file diff --git a/testing/HopFrame.Testing/DatabaseContext.cs b/testing/HopFrame.Testing/DatabaseContext.cs new file mode 100644 index 0000000..7abb091 --- /dev/null +++ b/testing/HopFrame.Testing/DatabaseContext.cs @@ -0,0 +1,19 @@ +using HopFrame.Testing.Models; +using Microsoft.EntityFrameworkCore; + +namespace HopFrame.Testing; + +public class DatabaseContext(DbContextOptions options) : DbContext(options) { + + public DbSet Users { get; set; } + public DbSet Posts { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity() + .HasOne(p => p.Author) + .WithMany(u => u.Posts) + .OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/testing/HopFrame.Testing/HopFrame.Testing.csproj b/testing/HopFrame.Testing/HopFrame.Testing.csproj new file mode 100644 index 0000000..aa6580f --- /dev/null +++ b/testing/HopFrame.Testing/HopFrame.Testing.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + diff --git a/testing/HopFrame.Testing/Models/Post.cs b/testing/HopFrame.Testing/Models/Post.cs new file mode 100644 index 0000000..63a35b4 --- /dev/null +++ b/testing/HopFrame.Testing/Models/Post.cs @@ -0,0 +1,30 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace HopFrame.Testing.Models; + +public class Post { + [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; set; } + + [MaxLength(255)] + public required string Caption { get; set; } + + public required string? Content { get; set; } + + [ForeignKey("author")] + public virtual required User Author { get; set; } + + public bool Published { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.Now; + + public DateOnly Created { get; set; } + + public TimeOnly At { get; set; } + + public ListSortDirection Type { get; set; } + + public TypeCode? TypeCode { get; set; } +} \ No newline at end of file diff --git a/testing/HopFrame.Testing/Models/User.cs b/testing/HopFrame.Testing/Models/User.cs new file mode 100644 index 0000000..22ea5de --- /dev/null +++ b/testing/HopFrame.Testing/Models/User.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace HopFrame.Testing.Models; + +public class User { + [Key] + public required Guid Id { get; init; } = Guid.CreateVersion7(); + public required string Email { get; init; } + public string? Username { get; set; } + public string? Password { get; set; } + public string? FirstName { get; set; } + public string? LastName { get; set; } + + public virtual List Posts { get; set; } = new(); + + public override string ToString() { + return Username; + } +} \ No newline at end of file diff --git a/testing/HopFrame.Testing/Program.cs b/testing/HopFrame.Testing/Program.cs new file mode 100644 index 0000000..a771dc8 --- /dev/null +++ b/testing/HopFrame.Testing/Program.cs @@ -0,0 +1,99 @@ +using System.Collections; +using HopFrame.Testing; +using Microsoft.FluentUI.AspNetCore.Components; +using HopFrame.Testing.Components; +using HopFrame.Testing.Models; +using HopFrame.Web; +using HopFrame.Web.Components.Pages; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); +builder.Services.AddFluentUIComponents(); + +builder.Services.AddDbContext(options => { + options.UseInMemoryDatabase("testing"); +}); + +builder.Services.AddHopFrame(options => { + options.DisplayUserInfo(false); + options.AddDbContext(context => { + context.Table(table => { + table.Property(u => u.Password) + .SetParser((pwd, _) => pwd + "-edited"); + + table.Property(u => u.FirstName) + .List(false); + + table.Property(u => u.LastName) + .List(false); + + table.Property(u => u.Id) + .IsSortable(false) + .SetOrderIndex(3); + + table.AddListingProperty("Name", (user, _) => $"{user.FirstName} {user.LastName}") + .SetOrderIndex(2); + + table.SetDisplayName("Benutzer"); + table.SetDescription("This table is used for user data store and user authentication"); + + table.SetViewPolicy("policy"); + + table.Property(u => u.Posts) + .FormatEach((post, _) => post.Caption); + }); + + context.Table() + .Property(p => p.Author) + .Format((user, _) => $"{user.FirstName} {user.LastName}"); + + context.Table() + .Property(p => p.Id) + .SetDisplayName("ID"); + + context.Table() + .Property(p => p.CreatedAt); + + context.Table() + .Property(p => p.Content) + .IsTextArea(true) + /*.Validator(input => { + var errors = new List(); + + if (input is null) + errors.Add("Value cannot be null"); + + if (input?.Length > 10) + errors.Add("Value can only be 10 characters long"); + + return errors; + })*/; + + context.Table() + .SetOrderIndex(-1); + }); +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) { + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseHttpsRedirection(); + +app.UseAntiforgery(); + +app.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddAdditionalAssemblies(typeof(HopFrameHome).Assembly); + +app.Run(); \ No newline at end of file diff --git a/testing/HopFrame.Testing/Properties/launchSettings.json b/testing/HopFrame.Testing/Properties/launchSettings.json new file mode 100644 index 0000000..4132fdd --- /dev/null +++ b/testing/HopFrame.Testing/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5221", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7180;http://localhost:5221", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/testing/HopFrame.Testing/appsettings.Development.json b/testing/HopFrame.Testing/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/testing/HopFrame.Testing/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/testing/HopFrame.Testing/appsettings.json b/testing/HopFrame.Testing/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/testing/HopFrame.Testing/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/testing/HopFrame.Testing/wwwroot/app.css b/testing/HopFrame.Testing/wwwroot/app.css new file mode 100644 index 0000000..288d428 --- /dev/null +++ b/testing/HopFrame.Testing/wwwroot/app.css @@ -0,0 +1,191 @@ +@import '/_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css'; + +body { + --body-font: "Segoe UI Variable", "Segoe UI", sans-serif; + font-family: var(--body-font); + font-size: var(--type-ramp-base-font-size); + line-height: var(--type-ramp-base-line-height); + margin: 0; +} + +.navmenu-icon { + display: none; +} + +.main { + min-height: calc(100dvh - 86px); + color: var(--neutral-foreground-rest); + align-items: stretch !important; +} + +.body-content { + align-self: stretch; + height: calc(100dvh - 86px) !important; + display: flex; +} + +.content { + padding: 0.5rem 1.5rem; + align-self: stretch !important; + width: 100%; +} + +.manage { + width: 100dvw; +} + +footer { + background: var(--neutral-layer-4); + color: var(--neutral-foreground-rest); + align-items: center; + padding: 10px 10px; +} + + footer a { + color: var(--neutral-foreground-rest); + text-decoration: none; + } + + footer a:focus { + outline: 1px dashed; + outline-offset: 3px; + } + + footer a:hover { + text-decoration: underline; + } + +.alert { + border: 1px dashed var(--accent-fill-rest); + padding: 5px; +} + + +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; + margin: 20px 0; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::before { + content: "An error has occurred. " + } + +.loading-progress { + position: relative; + display: block; + width: 8rem; + height: 8rem; + margin: 20vh auto 1rem auto; +} + + .loading-progress circle { + fill: none; + stroke: #e0e0e0; + stroke-width: 0.6rem; + transform-origin: 50% 50%; + transform: rotate(-90deg); + } + + .loading-progress circle:last-child { + stroke: #1b6ec2; + stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; + transition: stroke-dasharray 0.05s ease-in-out; + } + +.loading-progress-text { + position: absolute; + text-align: center; + font-weight: bold; + inset: calc(20vh + 3.25rem) 0 auto 0.2rem; +} + + .loading-progress-text:after { + content: var(--blazor-load-percentage-text, "Loading"); + } + +code { + color: #c02d76; +} + +@media (max-width: 600px) { + .header-gutters { + margin: 0.5rem 3rem 0.5rem 1.5rem !important; + } + + [dir="rtl"] .header-gutters { + margin: 0.5rem 1.5rem 0.5rem 3rem !important; + } + + .main { + flex-direction: column !important; + row-gap: 0 !important; + } + + nav.sitenav { + width: 100%; + height: 100%; + } + + #main-menu { + width: 100% !important; + } + + #main-menu > div:first-child:is(.expander) { + display: none; + } + + .navmenu { + width: 100%; + } + + #navmenu-toggle { + appearance: none; + } + + #navmenu-toggle ~ nav { + display: none; + } + + #navmenu-toggle:checked ~ nav { + display: block; + } + + .navmenu-icon { + cursor: pointer; + z-index: 10; + display: block; + position: absolute; + top: 15px; + left: unset; + right: 20px; + width: 20px; + height: 20px; + border: none; + } + + [dir="rtl"] .navmenu-icon { + left: 20px; + right: unset; + } +} diff --git a/testing/HopFrame.Testing/wwwroot/favicon.ico b/testing/HopFrame.Testing/wwwroot/favicon.ico new file mode 100644 index 0000000..e189d8e Binary files /dev/null and b/testing/HopFrame.Testing/wwwroot/favicon.ico differ