Merge branch 'feature/setup' into 'dev'

Feature/setup

Closes #12

See merge request leon.hoppe/hopframe!16
This commit was merged in pull request #54.
This commit is contained in:
2025-01-18 12:30:40 +00:00
48 changed files with 2608 additions and 12 deletions

View File

@@ -1,32 +1,143 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="AutoGeneratedRunConfigurationManager">
<projectFile profileName="http">HopFrame.Testing/HopFrame.Testing.csproj</projectFile>
<projectFile profileName="https">HopFrame.Testing/HopFrame.Testing.csproj</projectFile>
<projectFile profileName="http">testing/HopFrame.Testing/HopFrame.Testing.csproj</projectFile>
</component>
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="0648788e-7696-4e60-bf12-5d5601f33d8c" name="Changes" comment="" /> <list default="true" id="0648788e-7696-4e60-bf12-5d5601f33d8c" name="Changes" comment="Added text area support and DI support for modifier functions">
<change beforePath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.HopFrame/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" beforeDir="false" afterPath="$PROJECT_DIR$/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" /> <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" /> <option name="LAST_RESOLUTION" value="IGNORE" />
</component> </component>
<component name="DpaMonitoringSettings">
<option name="firstShow" value="false" />
</component>
<component name="FileTemplateManagerImpl">
<option name="RECENT_TEMPLATES">
<list>
<option value="CSS File" />
</list>
</option>
</component>
<component name="Git.Settings"> <component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="dev" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component> </component>
<component name="ProjectColorInfo"><![CDATA[{ <component name="GitLabMergeRequestFiltersHistory">{
"associatedIndex": 3 &quot;lastFilter&quot;: {
}]]></component> &quot;state&quot;: &quot;OPENED&quot;,
&quot;assignee&quot;: {
&quot;type&quot;: &quot;org.jetbrains.plugins.gitlab.mergerequest.ui.filters.GitLabMergeRequestsFiltersValue.MergeRequestsMemberFilterValue.MergeRequestsAssigneeFilterValue&quot;,
&quot;username&quot;: &quot;leon.hoppe&quot;,
&quot;fullname&quot;: &quot;Leon Hoppe&quot;
}
}
}</component>
<component name="GitLabMergeRequestsSettings">{
&quot;selectedUrlAndAccountId&quot;: {
&quot;first&quot;: &quot;https://git.leon-hoppe.de/leon.hoppe/hopframe.git&quot;,
&quot;second&quot;: &quot;2d65fdcb-5f13-45ad-a7ba-91dd4a88d6e4&quot;
}
}</component>
<component name="HighlightingSettingsPerFile">
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/26c9a2fb5243863babc926e4be763daf4128d4f97c4a769cdce1e2e3e5c532/FluentButton.razor.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/2751d5afefca5424bfc4b21347f581372f7a739c0ae4df661ea557fcb97ef20/EnumExtensions.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/60e7b22380df80ef6fefe43138047f49ec6eff4b25c12b42ce3d6ed5aac/MethodInvokerCommon.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/87c584767b46b5fd42769be76547105558e6690f785614efddca134b2d682/Type.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/adcd2c45092dd8e4fc412325c8adb75d6e7d8b3e90a9523f167583fb9c60/ServiceCollectionExtensions.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/b3ccb66df3646cb51df73ad51716136ebd2eefb4edb1308dd52a7e999582d59e/IBindableColumn.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/d858ddb35a8e36df5573b7612542f9ad50f426b8ab43818587d1ac65fab14829/DatabaseGeneratedAttribute.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/ece8533187fe96ce67b3ef1c9cc3502ef8da5510aadb132a9b21c5605d7c2119/PropertyColumn.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/ee4d234452e240d83e3de396c2e85cbf9ac9fb9add618b955eea196c81aaf8/IDialogContentComponent.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/ff37d54b3bf4d2756237fb789635831532603376e940f63d634b869d26d74c/Regular16.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/src/HopFrame.Core/Config/PropertyConfig.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/src/HopFrame.Core/Config/TableConfig.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/src/HopFrame.Core/Services/Implementations/ContextExplorer.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/src/HopFrame.Core/Services/Implementations/TableManager.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor" root0="SKIP_HIGHLIGHTING" root1="FORCE_HIGHLIGHTING" root2="FORCE_HIGHLIGHTING" />
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" root0="SKIP_HIGHLIGHTING" root1="FORCE_HIGHLIGHTING" root2="FORCE_HIGHLIGHTING" />
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor" root0="SKIP_HIGHLIGHTING" root1="FORCE_HIGHLIGHTING" root2="FORCE_HIGHLIGHTING" />
<setting file="mock://C:/Users/leon/Documents/Projekte/HopFrame/testing/HopFrame.Testing/Program.cs" root0="SKIP_HIGHLIGHTING" />
</component>
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 3
}</component>
<component name="ProjectId" id="2raEUZtlphkj04rfRNtlQw6fPMU" /> <component name="ProjectId" id="2raEUZtlphkj04rfRNtlQw6fPMU" />
<component name="ProjectLevelVcsManager" settingsEditedManually="true" /> <component name="ProjectLevelVcsManager" settingsEditedManually="true">
<ConfirmationsSetting value="2" id="Add" />
</component>
<component name="ProjectViewState"> <component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;.NET Launch Settings Profile.HopFrame.Testing.executor&quot;: &quot;Run&quot;,
"nodejs_package_manager_path": "npm", &quot;.NET Launch Settings Profile.HopFrame.Testing: https.executor&quot;: &quot;Run&quot;,
"settings.editor.selected.configurable": "preferences.environmentSetup", &quot;.NET Project.HopFrame.Testing.executor&quot;: &quot;Run&quot;,
"vue.rearranger.settings.migration": "true" &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;feature/setup&quot;,
&quot;list.type.of.created.stylesheet&quot;: &quot;CSS&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;preferences.environmentSetup&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
} }
}]]></component> }</component>
<component name="RunManager" selected=".NET Launch Settings Profile.HopFrame.Testing: https">
<configuration name="HopFrame.Testing: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/testing/HopFrame.Testing/HopFrame.Testing.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net9.0" />
<option name="LAUNCH_PROFILE_NAME" value="http" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="HopFrame.Testing: https" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/testing/HopFrame.Testing/HopFrame.Testing.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net9.0" />
<option name="LAUNCH_PROFILE_NAME" value="https" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<list>
<item itemvalue=".NET Launch Settings Profile.HopFrame.Testing: https" />
<item itemvalue=".NET Launch Settings Profile.HopFrame.Testing: http" />
</list>
</component>
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" /> <component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
<component name="TaskManager"> <component name="TaskManager">
<task active="true" id="Default" summary="Default task"> <task active="true" id="Default" summary="Default task">
@@ -36,14 +147,155 @@
<option name="presentableId" value="Default" /> <option name="presentableId" value="Default" />
<updated>1736788802057</updated> <updated>1736788802057</updated>
<workItem from="1736788803805" duration="46000" /> <workItem from="1736788803805" duration="46000" />
<workItem from="1736788853462" duration="780000" />
<workItem from="1736845367516" duration="283000" />
<workItem from="1736845655122" duration="165000" />
<workItem from="1736845825812" duration="14084000" />
<workItem from="1736863626597" duration="7027000" />
<workItem from="1736875984621" duration="8464000" />
<workItem from="1736884461354" duration="1075000" />
<workItem from="1736962119221" duration="8119000" />
<workItem from="1737021098746" duration="21112000" />
<workItem from="1737047730756" duration="7678000" />
<workItem from="1737120164342" duration="9351000" />
<workItem from="1737199714142" duration="2964000" />
</task> </task>
<task id="LOCAL-00001" summary="Added basic configuration">
<option name="closed" value="true" />
<created>1736850899254</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1736850899254</updated>
</task>
<task id="LOCAL-00002" summary="Added admin page navigation">
<option name="closed" value="true" />
<created>1736855209077</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1736855209077</updated>
</task>
<task id="LOCAL-00003" summary="Added database loading logic">
<option name="closed" value="true" />
<created>1736859917232</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1736859917232</updated>
</task>
<task id="LOCAL-00004" summary="Started working on listing page">
<option name="closed" value="true" />
<created>1736885531216</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1736885531216</updated>
</task>
<task id="LOCAL-00005" summary="Added entry saving support">
<option name="closed" value="true" />
<created>1736970238802</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1736970238802</updated>
</task>
<task id="LOCAL-00006" summary="Added reload button and animation">
<option name="closed" value="true" />
<created>1737023058093</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1737023058093</updated>
</task>
<task id="LOCAL-00007" summary="Added relation picker dialog">
<option name="closed" value="true" />
<created>1737035288104</created>
<option name="number" value="00007" />
<option name="presentableId" value="LOCAL-00007" />
<option name="project" value="LOCAL" />
<updated>1737035288104</updated>
</task>
<task id="LOCAL-00008" summary="Added automatic relation mapping">
<option name="closed" value="true" />
<created>1737037853482</created>
<option name="number" value="00008" />
<option name="presentableId" value="LOCAL-00008" />
<option name="project" value="LOCAL" />
<updated>1737037853482</updated>
</task>
<task id="LOCAL-00009" summary="Added property validation">
<option name="closed" value="true" />
<created>1737040612038</created>
<option name="number" value="00009" />
<option name="presentableId" value="LOCAL-00009" />
<option name="project" value="LOCAL" />
<updated>1737040612038</updated>
</task>
<task id="LOCAL-00010" summary="Added creation/modification confirmation">
<option name="closed" value="true" />
<created>1737040946489</created>
<option name="number" value="00010" />
<option name="presentableId" value="LOCAL-00010" />
<option name="project" value="LOCAL" />
<updated>1737040946489</updated>
</task>
<task id="LOCAL-00011" summary="Removed Template">
<option name="closed" value="true" />
<created>1737042229086</created>
<option name="number" value="00011" />
<option name="presentableId" value="LOCAL-00011" />
<option name="project" value="LOCAL" />
<updated>1737042229086</updated>
</task>
<task id="LOCAL-00012" summary="Added policy validation, ordering and virtual listing properties">
<option name="closed" value="true" />
<created>1737055409534</created>
<option name="number" value="00012" />
<option name="presentableId" value="LOCAL-00012" />
<option name="project" value="LOCAL" />
<updated>1737055409535</updated>
</task>
<task id="LOCAL-00013" summary="Added n -&gt; m relation support">
<option name="closed" value="true" />
<created>1737129518866</created>
<option name="number" value="00013" />
<option name="presentableId" value="LOCAL-00013" />
<option name="project" value="LOCAL" />
<updated>1737129518866</updated>
</task>
<task id="LOCAL-00014" summary="Added text area support and DI support for modifier functions">
<option name="closed" value="true" />
<created>1737202192471</created>
<option name="number" value="00014" />
<option name="presentableId" value="LOCAL-00014" />
<option name="project" value="LOCAL" />
<updated>1737202192471</updated>
</task>
<option name="localTasksCounter" value="15" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" /> <option name="version" value="3" />
</component> </component>
<component name="UnityCheckinConfiguration" checkUnsavedScenes="true" />
<component name="UnityProjectConfiguration" hasMinimizedUI="false" /> <component name="UnityProjectConfiguration" hasMinimizedUI="false" />
<component name="VcsManagerConfiguration"> <component name="VcsManagerConfiguration">
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" /> <option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
<MESSAGE value="Added basic configuration" />
<MESSAGE value="Added admin page navigation" />
<MESSAGE value="Added database loading logic" />
<MESSAGE value="Started working on listing page" />
<MESSAGE value="Added entry saving support" />
<MESSAGE value="Added reload button and animation" />
<MESSAGE value="Added relation picker dialog" />
<MESSAGE value="Added automatic relation mapping" />
<MESSAGE value="Added property validation" />
<MESSAGE value="Added creation/modification confirmation" />
<MESSAGE value="Removed Template" />
<MESSAGE value="Added policy validation, ordering and virtual listing properties" />
<MESSAGE value="Added n -&gt; m relation support" />
<MESSAGE value="Added text area support and DI support for modifier functions" />
<option name="LAST_COMMIT_MESSAGE" value="Added text area support and DI support for modifier functions" />
</component> </component>
</project> </project>

View File

@@ -1,8 +1,37 @@
Microsoft Visual Studio Solution File, Format Version 12.00 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
EndGlobalSection 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 EndGlobal

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore;
namespace HopFrame.Core.Config;
public class DbContextConfig {
public Type ContextType { get; }
public List<TableConfig> 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<TDbContext>(DbContextConfig config) {
public DbContextConfig InnerConfig { get; } = config;
public DbContextConfig<TDbContext> Table<TModel>(Action<TableConfig<TModel>> configurator) where TModel : class {
var table = Table<TModel>();
configurator.Invoke(table);
return this;
}
public TableConfig<TModel> Table<TModel>() where TModel : class {
var table = InnerConfig.Tables.Single(table => table.TableType == typeof(TModel));
return new TableConfig<TModel>(table);
}
}

View File

@@ -0,0 +1,42 @@
using HopFrame.Core.Services;
using Microsoft.EntityFrameworkCore;
namespace HopFrame.Core.Config;
public class HopFrameConfig {
public List<DbContextConfig> 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<TDbContext>(Action<DbContextConfig<TDbContext>> configurator) where TDbContext : DbContext {
var context = AddDbContext<TDbContext>();
configurator.Invoke(context);
return this;
}
public DbContextConfig<TDbContext> AddDbContext<TDbContext>() where TDbContext : DbContext {
var context = new DbContextConfig(typeof(TDbContext));
InnerConfig.Contexts.Add(context);
return new DbContextConfig<TDbContext>(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;
}
}

View File

@@ -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<object, IServiceProvider, string>? Formatter { get; set; }
public Func<object, IServiceProvider, string>? EnumerableFormatter { get; set; }
public Func<string, IServiceProvider, object>? Parser { get; set; }
public Func<object?, IServiceProvider, Task<IEnumerable<string>>>? 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<TProp>(PropertyConfig config) {
public PropertyConfig InnerConfig { get; } = config;
public PropertyConfig<TProp> SetDisplayName(string displayName) {
InnerConfig.Name = displayName;
return this;
}
public PropertyConfig<TProp> List(bool list) {
InnerConfig.List = list;
InnerConfig.Searchable = !list;
return this;
}
public PropertyConfig<TProp> IsSortable(bool sortable) {
InnerConfig.Sortable = sortable;
return this;
}
public PropertyConfig<TProp> IsSearchable(bool searchable) {
InnerConfig.Searchable = searchable;
return this;
}
public PropertyConfig<TProp> SetDisplayedProperty<TInnerProp>(Expression<Func<TProp, TInnerProp>> propertyExpression) {
InnerConfig.DisplayedProperty = TableConfig<TProp>.GetPropertyInfo(propertyExpression);
return this;
}
public PropertyConfig<TProp> Format(Func<TProp, IServiceProvider, string> formatter) {
InnerConfig.Formatter = (obj, provider) => formatter.Invoke((TProp)obj, provider);
return this;
}
public PropertyConfig<TProp> FormatEach<TInnerProp>(Func<TInnerProp, IServiceProvider, string> formatter) {
InnerConfig.EnumerableFormatter = (obj, provider) => formatter.Invoke((TInnerProp)obj, provider);
return this;
}
public PropertyConfig<TProp> SetParser(Func<string, IServiceProvider, TProp> parser) {
InnerConfig.Parser = (str, provider) => parser.Invoke(str, provider)!;
return this;
}
public PropertyConfig<TProp> SetEditable(bool editable) {
InnerConfig.Editable = editable;
return this;
}
public PropertyConfig<TProp> SetCreatable(bool creatable) {
InnerConfig.Creatable = creatable;
return this;
}
public PropertyConfig<TProp> DisplayValue(bool display) {
InnerConfig.DisplayValue = display;
return this;
}
public PropertyConfig<TProp> IsTextArea(bool textField) {
InnerConfig.TextArea = textField;
return this;
}
public PropertyConfig<TProp> SetTextAreaRows(int rows) {
InnerConfig.TextAreaRows = rows;
return this;
}
public PropertyConfig<TProp> SetValidator(Func<TProp?, IServiceProvider, IEnumerable<string>> validator) {
InnerConfig.Validator = (obj, provider) => Task.FromResult(validator.Invoke((TProp?)obj, provider));
return this;
}
public PropertyConfig<TProp> SetValidator(Func<TProp?, IServiceProvider, Task<IEnumerable<string>>> validator) {
InnerConfig.Validator = (obj, provider) => validator.Invoke((TProp?)obj, provider);
return this;
}
public PropertyConfig<TProp> SetOrderIndex(int index) {
InnerConfig.Order = index;
return this;
}
}

View File

@@ -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<PropertyConfig> 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<TModel>(TableConfig config) {
public TableConfig InnerConfig { get; } = config;
public TableConfig<TModel> Ignore() {
InnerConfig.Ignored = true;
return this;
}
public PropertyConfig<TProp> Property<TProp>(Expression<Func<TModel, TProp>> propertyExpression) {
var info = GetPropertyInfo(propertyExpression);
var prop = InnerConfig.Properties
.Single(prop => prop.Info.Name == info.Name);
return new PropertyConfig<TProp>(prop);
}
public TableConfig<TModel> Property<TProp>(Expression<Func<TModel, TProp>> propertyExpression, Action<PropertyConfig<TProp>> configurator) {
var prop = Property(propertyExpression);
configurator.Invoke(prop);
return this;
}
public PropertyConfig<string> AddListingProperty(string name, Func<TModel, IServiceProvider, string> 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<string>(prop);
}
public TableConfig<TModel> AddListingProperty(string name, Func<TModel, IServiceProvider, string> template, Action<PropertyConfig<string>> configurator) {
var prop = AddListingProperty(name, template);
configurator.Invoke(prop);
return this;
}
public TableConfig<TModel> SetDisplayName(string name) {
InnerConfig.DisplayName = name;
return this;
}
public TableConfig<TModel> SetDescription(string description) {
InnerConfig.Description = description;
return this;
}
public TableConfig<TModel> SetOrderIndex(int index) {
InnerConfig.Order = index;
return this;
}
public TableConfig<TModel> SetViewPolicy(string policy) {
InnerConfig.ViewPolicy = policy;
return this;
}
public TableConfig<TModel> SetUpdatePolicy(string policy) {
InnerConfig.UpdatePolicy = policy;
return this;
}
public TableConfig<TModel> SetCreatePolicy(string policy) {
InnerConfig.CreatePolicy = policy;
return this;
}
public TableConfig<TModel> SetDeletePolicy(string policy) {
InnerConfig.DeletePolicy = policy;
return this;
}
internal static PropertyInfo GetPropertyInfo<TSource, TProperty>(Expression<Func<TSource, TProperty>> 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;
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
</ItemGroup>
</Project>

View File

@@ -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<IContextExplorer, ContextExplorer>();
services.TryAddTransient<IHopFrameAuthHandler, DefaultAuthHandler>();
return services;
}
}

View File

@@ -0,0 +1,10 @@
using HopFrame.Core.Config;
namespace HopFrame.Core.Services;
public interface IContextExplorer {
public IEnumerable<TableConfig> GetTables();
public TableConfig? GetTable(string tableDisplayName);
public TableConfig? GetTable(Type tableEntity);
public ITableManager? GetTableManager(string tablePropertyName);
}

View File

@@ -0,0 +1,6 @@
namespace HopFrame.Core.Services;
public interface IHopFrameAuthHandler {
public Task<bool> IsAuthenticatedAsync(string? policy);
public Task<string> GetCurrentUserDisplayNameAsync();
}

View File

@@ -0,0 +1,16 @@
using System.Reflection;
using HopFrame.Core.Config;
namespace HopFrame.Core.Services;
public interface ITableManager {
public IQueryable<object> LoadPage(int page, int perPage = 20);
public Task<(IEnumerable<object>, int)> Search(string searchTerm, int page = 0, int perPage = 20);
public Task<int> 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);
}

View File

@@ -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<ContextExplorer> logger) : IContextExplorer {
public IEnumerable<TableConfig> 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;
}
}

View File

@@ -0,0 +1,10 @@
namespace HopFrame.Core.Services.Implementations;
internal sealed class DefaultAuthHandler : IHopFrameAuthHandler {
public Task<bool> IsAuthenticatedAsync(string? policy) {
return Task.FromResult(true);
}
public Task<string> GetCurrentUserDisplayNameAsync() {
return Task.FromResult(string.Empty);
}
}

View File

@@ -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<TModel>(DbContext context, TableConfig config, IContextExplorer explorer, IServiceProvider provider) : ITableManager where TModel : class {
public IQueryable<object> LoadPage(int page, int perPage = 20) {
var table = context.Set<TModel>();
var data = IncludeForgeinKeys(table);
return data
.Skip(page * perPage)
.Take(perPage);
}
public Task<(IEnumerable<object>, int)> Search(string searchTerm, int page = 0, int perPage = 20) {
var table = context.Set<TModel>();
var all = IncludeForgeinKeys(table)
.AsEnumerable()
.Where(item => ItemSearched(item, searchTerm))
.ToList();
return Task.FromResult((
(IEnumerable<object>)all.Skip(page * perPage).Take(perPage),
(int)Math.Ceiling(all.Count / (double)perPage)));
}
public async Task<int> TotalPages(int perPage = 20) {
var table = context.Set<TModel>();
return (int)Math.Ceiling(await table.CountAsync() / (double)perPage);
}
public async Task DeleteItem(object item) {
var table = context.Set<TModel>();
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<TModel>();
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<object>().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<TModel> IncludeForgeinKeys(IQueryable<TModel> query) {
var pendingQuery = query;
foreach (var property in config.Properties.Where(prop => prop.IsRelation)) {
pendingQuery = pendingQuery.Include(property.Info.Name);
}
return pendingQuery;
}
}

View File

@@ -0,0 +1,376 @@
@implements IDialogContentComponent<EditorDialogData>
@rendermode InteractiveServer
@using System.Collections
@using HopFrame.Core.Config
@using HopFrame.Core.Services
@using HopFrame.Web.Models
@using HopFrame.Web.Helpers
<FluentDialogBody>
@foreach (var property in Content.Config.Properties.Where(prop => !prop.IsListingProperty).OrderBy(prop => prop.Order)) {
if (!_currentlyEditing && !property.Creatable) continue;
<div style="margin-bottom: 20px">
@if (property.IsRelation) {
<div style="display: flex; gap: 5px; align-items: flex-end">
<div style="flex-grow: 1">
@if (property.IsEnumerable) {
<FluentLabel Style="margin-bottom: calc(var(--design-unit) * 1px)">@property.Name</FluentLabel>
<div class="hopframe-listview">
<FluentOverflow Style="width: 100%">
@foreach (var item in GetPropertyValue<IEnumerable>(property) ?? Enumerable.Empty<object>()) {
<FluentOverflowItem><FluentBadge>@(GetPropertyValue<string>(property, item))</FluentBadge></FluentOverflowItem>
}
</FluentOverflow>
</div>
}
else {
<FluentTextField
Label="@property.Name"
Value="@(GetPropertyValue<string>(property))"
Required="@property.IsRequired"
ReadOnly="true"
Style="width: 100%" />
}
</div>
<div style="display: flex; gap: 5px; margin-bottom: 4px">
@if (!property.IsRequired) {
<FluentButton OnClick="async () => await SetPropertyValue(property, null, InputType.Relation)" Disabled="@(_currentlyEditing && !property.Editable)">
<FluentIcon Value="@(new Icons.Regular.Size20.Dismiss())" Color="Color.Neutral" />
</FluentButton>
}
<FluentButton OnClick="async () => await OpenRelationalPicker(property)" Disabled="@(_currentlyEditing && !property.Editable)">
<FluentIcon Value="@(new Icons.Regular.Size20.Open())" Color="Color.Neutral" />
</FluentButton>
</div>
</div>
}
else if (property.Info.PropertyType.IsNumeric()) {
<FluentNumberField
TValue="double"
Label="@property.Name"
Value="GetPropertyValue<double>(property)"
Style="width: 100%;"
Disabled="@(_currentlyEditing && !property.Editable)"
Required="@property.IsRequired"
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Number))" />
}
else if (Type.GetTypeCode(property.Info.PropertyType) == TypeCode.Boolean) {
<FluentSwitch
Label="@property.Name"
Value="GetPropertyValue<bool>(property)"
Disabled="@(_currentlyEditing && !property.Editable)"
Required="@property.IsRequired"
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Switch))" />
}
else if (Type.GetTypeCode(property.Info.PropertyType) == TypeCode.DateTime) {
<div style="display: flex; gap: 5px">
<div style="display: flex; flex-direction: column; width: 100%">
<FluentDatePicker
Label="@property.Name"
Value="GetPropertyValue<DateTime>(property)"
Disabled="@(_currentlyEditing && !property.Editable)"
Required="@property.IsRequired"
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Date))" />
</div>
<div style="display: flex; flex-direction: column; justify-content: flex-end">
<FluentTimePicker
Value="GetPropertyValue<DateTime>(property)"
Disabled="@(_currentlyEditing && !property.Editable)"
Required="@property.IsRequired"
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Time))" />
</div>
</div>
}
else if (property.Info.PropertyType == typeof(DateOnly)) {
<FluentDatePicker
Label="@property.Name"
Value="GetPropertyValue<DateOnly>(property).ToDateTime(TimeOnly.MinValue)"
Style="width: 100%"
Disabled="@(_currentlyEditing && !property.Editable)"
Required="@property.IsRequired"
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Date))" />
}
else if (property.Info.PropertyType == typeof(TimeOnly)) {
<FluentTimePicker
Label="@property.Name"
Value="GetPropertyValue<TimeOnly>(property).ToDateTime()"
Style="width: 100%"
Disabled="@(_currentlyEditing && !property.Editable)"
Required="@property.IsRequired"
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Time))" />
}
else if (property.Info.PropertyType.IsEnum) {
<FluentSelect
TOption="string"
Label="@property.Name"
Items="Enum.GetNames(property.Info.PropertyType)"
Value="@(GetPropertyValue<string>(property))"
Style="width: 100%"
Height="250px"
Disabled="@(_currentlyEditing && !property.Editable)"
Required="@property.IsRequired"
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Enum))" />
}
else if (property.Info.PropertyType.IsNullableEnum()) {
<div style="display: flex; gap: 5px; align-items: flex-end">
<div style="flex-grow: 1">
<FluentSelect
TOption="string"
Label="@property.Name"
Items="@(["", ..Enum.GetNames(Nullable.GetUnderlyingType(property.Info.PropertyType)!)])"
Value="@(GetPropertyValue<string>(property))"
Style="width: 100%"
Height="250px"
Disabled="@(_currentlyEditing && !property.Editable)"
Required="@property.IsRequired"
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Enum))" />
</div>
<div style="display: flex; gap: 5px">
<FluentButton OnClick="async () => await SetPropertyValue(property, null, InputType.Enum)" Disabled="@(_currentlyEditing && !property.Editable)">
<FluentIcon Value="@(new Icons.Regular.Size20.Dismiss())" Color="Color.Neutral" />
</FluentButton>
</div>
</div>
}
else if (property.TextArea) {
<FluentTextArea
Label="@property.Name"
Value="@(GetPropertyValue<string>(property))"
Style="width: 100%;"
Rows="@property.TextAreaRows"
Disabled="@(_currentlyEditing && !property.Editable)"
Required="@property.IsRequired"
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Text))" />
}
else {
<FluentTextField
Label="@property.Name"
Value="@(GetPropertyValue<string>(property))"
Style="width: 100%;"
Disabled="@(_currentlyEditing && !property.Editable)"
Required="@property.IsRequired"
ValueChanged="@(async v => await SetPropertyValue(property, v, InputType.Text))" />
}
@foreach (var error in _validationErrors[property.Info.Name]) {
<FluentLabel Color="@Color.Error">@error</FluentLabel>
}
</div>
}
</FluentDialogBody>
<FluentToastProvider MaxToastCount="10" />
@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<string, List<string>> _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<TValue>(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<DateTime>(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<DateTime>(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<object>().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<object>();
if (config.IsEnumerable) {
foreach (var o in GetPropertyValue<IEnumerable>(config) ?? Enumerable.Empty<object>()) {
currentValues.Add(o);
}
}
else {
var raw = config.Info.GetValue(Content.CurrentObject);
if (raw is not null)
currentValues.Add(raw);
}
var dialog = await Dialogs.ShowDialogAsync<HopFrameRelationPicker>(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<bool> 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
}
}

View File

@@ -0,0 +1,32 @@
@implements IDialogContentComponent<RelationPickerDialogData>
@rendermode InteractiveServer
@using HopFrame.Web.Models
@using HopFrame.Web.Components.Pages
<FluentDialogBody Style="overflow-x: auto">
<HopFrameTablePage
DisplayActions="false"
DisplaySelection="true"
TableDisplayName="@Content.SourceTable.DisplayName"
PerPage="15"
DialogData="Content"
SelectionMode="@(Content.AllowMultiple ? DataGridSelectMode.Multiple : DataGridSelectMode.Single)"/>
</FluentDialogBody>
@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";
}
}

View File

@@ -0,0 +1,46 @@
@using HopFrame.Core.Config
@using HopFrame.Core.Services
@using Microsoft.Extensions.DependencyInjection
@inherits LayoutComponentBase
<link rel="stylesheet" type='text/css' href="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/devicon.min.css" />
<link rel="stylesheet" href="/_content/HopFrame.Web/hopframe.css"/>
<FluentDesignTheme Mode="DesignThemeModes.Dark" />
<FluentLayout Class="hopframe-outer">
<HopFrameNavigation />
<FluentStack Orientation="Orientation.Horizontal" Width="100%" Class="hopframe-main">
<HopFrameSideMenu />
<FluentBodyContent>
<div class="hopframe-content">
@Body
</div>
</FluentBodyContent>
</FluentStack>
<FluentFooter>
<a href="https://git.leon-hoppe.de/leon.hoppe/hopframe" target="_blank">Documentation and source code</a>
<FluentSpacer/>
<a href="https://git.leon-hoppe.de/leon.hoppe/" target="_blank" style="margin-right: 0.25rem; text-decoration: none">
<i class="devicon-gitlab-plain"></i>
</a>
<a href="https://github.com/leonhoppe" target="_blank" style="text-decoration: none">
<i class="devicon-github-original"></i>
</a>
</FluentFooter>
</FluentLayout>
@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);
}
}
}

View File

@@ -0,0 +1,45 @@
@using System.Text
@using HopFrame.Core.Config
@using HopFrame.Core.Services
<FluentHeader Class="hopframe-header">
<a href="/admin" style="text-decoration: none; color: @Color.Neutral.ToAttributeValue();">HopFrame</a>
@if (Config.DisplayUserInfo) {
<FluentPersona Name="@_displayName" Initials="@_initials" ImageSize="32px" TextPosition="TextPosition.Start" Style="margin-left: auto"/>
}
</FluentHeader>
@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();
}
}

View File

@@ -0,0 +1,39 @@
@using HopFrame.Core.Config
@using HopFrame.Core.Services
<FluentAppBar Orientation="Orientation.Vertical" PopoverShowSearch="false" Style="background-color: var(--neutral-layer-2); height: auto">
<FluentAppBarItem Href="/admin"
Match="NavLinkMatch.All"
IconActive="new Icons.Filled.Size24.Grid()"
IconRest="new Icons.Regular.Size24.Grid()"
Text="Dashboard"
Style="margin-top: 0.25rem"/>
<br>
@foreach (var table in _tables.OrderBy(t => t.Order).Select(t => t.DisplayName)) {
<FluentAppBarItem Href="@("/admin/" + table.ToLower())"
Match="NavLinkMatch.All"
IconActive="new Icons.Filled.Size24.Database()"
IconRest="new Icons.Regular.Size24.Database()"
Text="@table"
Style="margin-top: 0.25rem"/>
}
</FluentAppBar>
@inject IContextExplorer Explorer
@inject IHopFrameAuthHandler Handler
@code {
private readonly List<TableConfig> _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);
}
}
}

View File

@@ -0,0 +1,45 @@
@page "/admin"
@using HopFrame.Core.Config
@using HopFrame.Core.Services
@layout HopFrameLayout
<PageTitle>HopFrame</PageTitle>
<div style="padding: 1.5rem 1.5rem;">
<h2>Tables</h2>
<FluentStack Orientation="Orientation.Horizontal" Wrap="true" Style="margin-top: 1.5rem">
@foreach (var table in _tables.OrderBy(t => t.Order)) {
<FluentCard Width="350px" Height="200px" Style="display: flex; flex-direction: column; background-color: var(--neutral-layer-1)">
<h3 style="margin-bottom: 0;">@table.DisplayName</h3>
<FluentLabel Typo="Typography.Body" Color="Color.Info" Style="margin-bottom: 0.5rem">@table.ViewPolicy</FluentLabel>
<span>@table.Description</span>
<FluentSpacer />
<div style="display: flex">
<FluentSpacer/>
<a href="@("/admin/" + table.DisplayName.ToLower())" style="display: inline-block">
<FluentButton>Open</FluentButton>
</a>
</div>
</FluentCard>
}
</FluentStack>
</div>
@inject IContextExplorer Explorer
@inject IHopFrameAuthHandler Handler
@code {
private readonly List<TableConfig> _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);
}
}
}

View File

@@ -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) {
<PageTitle>@_config?.DisplayName</PageTitle>
}
<FluentDialogProvider />
<div style="display: flex; flex-direction: column; height: 100%">
<FluentToolbar Class="hopframe-toolbar">
<h3>@_config?.DisplayName</h3>
@if (!DisplaySelection) {
<FluentButton
IconStart="@(new Icons.Regular.Size16.ArrowClockwise())"
OnClick="Reload"
Loading="_loading"
Style="margin-left: 10px">
Refresh
</FluentButton>
}
<FluentSpacer />
<FluentSearch @oninput="OnSearch" @onchange="OnSearch" Style="width: 350px" />
@if (_hasCreatePolicy && DisplayActions) {
<FluentButton OnClick="async () => { await CreateOrEdit(null); }">Add Entry</FluentButton>
}
</FluentToolbar>
<FluentProgress Visible="_loading" Width="100%" />
<div style="display: flex; overflow-y: auto; flex-grow: 1">
<div style="flex-grow: 1">
<FluentDataGrid Items="_currentlyDisplayedModels.AsQueryable()">
@if (DisplaySelection) {
<SelectColumn
TGridItem="object"
SelectMode="SelectionMode"
SelectFromEntireRow="true"
SelectedItems="DialogData?.SelectedObjects.ToArray()"
OnSelect="data => SelectItem(data.Item, data.Selected)"
SelectAllChanged="SelectAll"
SelectAll="_allSelected"
Style="min-width: max-content; height: 44px; display: grid; align-items: center" @ref="_selectColumn" />
}
@foreach (var property in _config!.Properties.Where(prop => prop.List).OrderBy(prop => prop.Order)) {
<PropertyColumn
Title="@property.Name" Property="o => _manager!.DisplayProperty(o, property, null)"
Style="min-width: max-content; height: 44px;"
Sortable="@property.Sortable"/>
}
@if (DisplayActions && (_hasDeletePolicy || _hasUpdatePolicy)) {
var dataIndex = 0;
<TemplateColumn Title="Actions" Align="@Align.End" Style="min-height: 44px; min-width: max-content">
@{ var currentElement = _currentlyDisplayedModels.ElementAtOrDefault(dataIndex); }
@if (_hasUpdatePolicy) {
<FluentButton aria-label="Edit entry" OnClick="async () => { await CreateOrEdit(currentElement); }">
<FluentIcon Value="@(new Icons.Regular.Size16.Edit())"/>
</FluentButton>
}
@if (_hasDeletePolicy) {
<FluentButton aria-label="Delete entry" OnClick="async () => { await DeleteEntry(currentElement!); }">
<FluentIcon Value="@(new Icons.Regular.Size16.Delete())" Color="Color.Warning"/>
</FluentButton>
}
@{
dataIndex++;
dataIndex %= 20;
}
</TemplateColumn>
}
</FluentDataGrid>
</div>
</div>
@if (_totalPages > 1) {
<div class="hopframe-paginator">
<FluentButton BackgroundColor="transparent" OnClick="async () => await ChangePage(_currentPage - 1)">
<FluentIcon Value="@(new Icons.Regular.Size20.ArrowPrevious())" Color="Color.Neutral" />
</FluentButton>
<span>Page</span>
<FluentSelect TOption="int"
Items="Enumerable.Range(0, _totalPages)"
OptionValue="@(p => p.ToString())"
OptionText="@(p => (p + 1).ToString())"
ValueChanged="async s => await ChangePage(Convert.ToInt32(s))"
Width="max-content" SelectedOption="@_currentPage"/>
<span>of @_totalPages</span>
<FluentButton BackgroundColor="transparent" OnClick="async () => await ChangePage(_currentPage + 1)">
<FluentIcon Value="@(new Icons.Regular.Size20.ArrowNext())" Color="Color.Neutral" />
</FluentButton>
</div>
}
</div>
<script>
function removeBg() {
const elements = document.querySelectorAll(".col-sort-button");
const style = new CSSStyleSheet();
style.replaceSync(".control { background: none !important; }");
elements.forEach(e => e?.shadowRoot?.adoptedStyleSheets?.push(style));
}
removeBg();
</script>
@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<object>? _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<HopFrameEditor>(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;
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<SupportedPlatform Include="browser"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="9.0.0"/>
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" Version="4.11.2" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="4.11.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HopFrame.Core\HopFrame.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -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;
}

View File

@@ -0,0 +1,9 @@
using HopFrame.Core.Config;
namespace HopFrame.Web.Models;
public sealed class RelationPickerDialogData(TableConfig sourceTable, List<object> current, bool multiple) {
public List<object> SelectedObjects { get; set; } = current;
public TableConfig SourceTable { get; init; } = sourceTable;
public bool AllowMultiple { get; set; } = multiple;
}

View File

@@ -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<HopFrameConfigurator> 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;
}
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="/"/>
<link rel="stylesheet" href="@Assets["app.css"]"/>
<link rel="stylesheet" href="@Assets["HopFrame.Testing.styles.css"]"/>
<ImportMap/>
<link rel="icon" type="image/x-icon" href="favicon.ico"/>
<HeadOutlet/>
</head>
<body>
<Routes/>
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

@@ -0,0 +1,26 @@
@inherits LayoutComponentBase
<FluentLayout>
<FluentHeader>
HopFrame.Testing
</FluentHeader>
<FluentStack Class="main" Orientation="Orientation.Horizontal" Width="100%">
<NavMenu/>
<FluentBodyContent Class="body-content">
<div class="content">
@Body
</div>
</FluentBodyContent>
</FluentStack>
<FluentFooter>
<a href="https://www.fluentui-blazor.net" target="_blank">Documentation and demos</a>
<FluentSpacer/>
<a href="https://learn.microsoft.com/en-us/aspnet/core/blazor" target="_blank">About Blazor</a>
</FluentFooter>
</FluentLayout>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>

View File

@@ -0,0 +1,19 @@
@rendermode InteractiveServer
<div class="navmenu">
<input type="checkbox" title="Menu expand/collapse toggle" id="navmenu-toggle" class="navmenu-icon"/>
<label for="navmenu-toggle" class="navmenu-icon">
<FluentIcon Value="@(new Icons.Regular.Size20.Navigation())" Color="Color.Fill"/>
</label>
<nav class="sitenav" aria-labelledby="main-menu">
<FluentNavMenu Id="main-menu" Collapsible="true" Width="250" Title="Navigation menu" @bind-Expanded="expanded" CustomToggle="true">
<FluentNavLink Href="/" Match="NavLinkMatch.All" Icon="@(new Icons.Regular.Size20.Home())" IconColor="Color.Accent">Home</FluentNavLink>
<FluentNavLink Href="counter" Icon="@(new Icons.Regular.Size20.NumberSymbolSquare())" IconColor="Color.Accent">Counter</FluentNavLink>
<FluentNavLink Href="weather" Icon="@(new Icons.Regular.Size20.WeatherPartlyCloudyDay())" IconColor="Color.Accent">Weather</FluentNavLink>
</FluentNavMenu>
</nav>
</div>
@code {
private bool expanded = true;
}

View File

@@ -0,0 +1,21 @@
@page "/counter"
@rendermode InteractiveServer
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<div role="status" style="padding-bottom: 1em;">
Current count: <FluentBadge Appearance="Appearance.Neutral">@currentCount</FluentBadge>
</div>
<FluentButton Appearance="Appearance.Accent" @onclick="IncrementCount">Click me</FluentButton>
@code {
private int currentCount = 0;
private void IncrementCount() {
currentCount++;
}
}

View File

@@ -0,0 +1,35 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId) {
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@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;
}

View File

@@ -0,0 +1,65 @@
@page "/"
@using HopFrame.Testing.Models
@using Microsoft.EntityFrameworkCore
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
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;

View File

@@ -0,0 +1,48 @@
@page "/weather"
@attribute [StreamRendering]
<PageTitle>Weather</PageTitle>
<h1>Weather</h1>
<p>This component demonstrates showing data.</p>
@if (forecasts == null) {
<p>
<em>Loading...</em>
</p>
}
else {
<!-- This page is rendered in SSR mode, so the FluentDataGrid component does not offer any interactivity (like sorting). -->
<FluentDataGrid Id="weathergrid" Items="@forecasts" GridTemplateColumns="1fr 1fr 1fr 2fr" TGridItem="WeatherForecast">
<PropertyColumn Title="Date" Property="@(c => c!.Date)" Align="Align.Start"/>
<PropertyColumn Title="Temp. (C)" Property="@(c => c!.TemperatureC)" Align="Align.Center"/>
<PropertyColumn Title="Temp. (F)" Property="@(c => c!.TemperatureF)" Align="Align.Center"/>
<PropertyColumn Title="Summary" Property="@(c => c!.Summary)" Align="Align.End"/>
</FluentDataGrid>
}
@code {
private IQueryable<WeatherForecast>? 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);
}
}

View File

@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
</Found>
</Router>

View File

@@ -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

View File

@@ -0,0 +1,19 @@
using HopFrame.Testing.Models;
using Microsoft.EntityFrameworkCore;
namespace HopFrame.Testing;
public class DatabaseContext(DbContextOptions<DatabaseContext> options) : DbContext(options) {
public DbSet<User> Users { get; set; }
public DbSet<Post> Posts { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) {
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Post>()
.HasOne<User>(p => p.Author)
.WithMany(u => u.Posts)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.0" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" Version="4.11.2" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="4.11.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\HopFrame.Web\HopFrame.Web.csproj" />
</ItemGroup>
</Project>

View File

@@ -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; }
}

View File

@@ -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<Post> Posts { get; set; } = new();
public override string ToString() {
return Username;
}
}

View File

@@ -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<DatabaseContext>(options => {
options.UseInMemoryDatabase("testing");
});
builder.Services.AddHopFrame(options => {
options.DisplayUserInfo(false);
options.AddDbContext<DatabaseContext>(context => {
context.Table<User>(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, _) => post.Caption);
});
context.Table<Post>()
.Property(p => p.Author)
.Format((user, _) => $"{user.FirstName} {user.LastName}");
context.Table<Post>()
.Property(p => p.Id)
.SetDisplayName("ID");
context.Table<Post>()
.Property(p => p.CreatedAt);
context.Table<Post>()
.Property(p => p.Content)
.IsTextArea(true)
/*.Validator(input => {
var errors = new List<string>();
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<Post>()
.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<App>()
.AddInteractiveServerRenderMode()
.AddAdditionalAssemblies(typeof(HopFrameHome).Assembly);
app.Run();

View File

@@ -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"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB