From cb0ab21aca482cd3a60595272bf6a1127001d4ad Mon Sep 17 00:00:00 2001 From: lodicolo Date: Mon, 27 Jan 2025 15:00:17 -0500 Subject: [PATCH] wip: Server settings display and editing (#2510) * add tab-set web component partial * wip Server Settings editor * progress on form organization, vertical tabs * more server settings work, mostly localization * disable server settings access because it's not complete --- .editorconfig | 1 + .../Config/DatabaseOptions.cs | 1 + .../Config/OptionsStrings.Designer.cs | 114 ++++++ .../Config/OptionsStrings.en.resx | 89 +++++ .../Config/OptionsStrings.it.resx | 26 ++ .../Config/OptionsStrings.resx | 96 +++++ .../GameObjects/DescriptorStrings.Designer.cs | 54 +++ .../GameObjects/DescriptorStrings.resx | 24 ++ .../Intersect.Framework.Core.csproj | 39 ++ .../Reflection/TypeExtensions.cs | 4 + .../Resources/ResourceManagerExtensions.cs | 9 +- .../WebComponents/TabSet/_TabSet.cshtml | 121 ++++++ .../Web/Pages/Developer/Index.cshtml.cs | 9 +- .../Developer/ServerSettings/Index.cshtml | 34 ++ .../Developer/ServerSettings/Index.cshtml.cs | 64 ++++ .../Developer/ServerSettings/Index.cshtml.css | 11 + .../ServerSettings/MemberInfoPageModel.cs | 24 ++ .../ServerSettings/PropertyInfoPageModel.cs | 21 ++ .../Developer/ServerSettings/TypePageModel.cs | 32 ++ .../ServerSettings/_Property.partial.cshtml | 70 ++++ .../_Property.partial.cshtml.cs | 47 +++ .../ServerSettings/_Type.partial.cshtml | 14 + .../ServerSettings/_Type.partial.cshtml.cs | 25 ++ .../ServerSettings/_TypeBody.partial.cshtml | 22 ++ .../_TypeBody.partial.cshtml.cs | 20 + .../Web/Pages/Shared/_Layout.cshtml.css | 1 + Intersect.Server/wwwroot/css/site.css | 39 +- .../wwwroot/js/components/tabset/tabset.js | 350 ++++++++++++++++++ 28 files changed, 1356 insertions(+), 5 deletions(-) create mode 100644 Framework/Intersect.Framework.Core/Config/OptionsStrings.Designer.cs create mode 100644 Framework/Intersect.Framework.Core/Config/OptionsStrings.en.resx create mode 100644 Framework/Intersect.Framework.Core/Config/OptionsStrings.it.resx create mode 100644 Framework/Intersect.Framework.Core/Config/OptionsStrings.resx create mode 100644 Framework/Intersect.Framework.Core/GameObjects/DescriptorStrings.Designer.cs create mode 100644 Framework/Intersect.Framework.Core/GameObjects/DescriptorStrings.resx create mode 100644 Intersect.Server/Web/Pages/Components/WebComponents/TabSet/_TabSet.cshtml create mode 100644 Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml create mode 100644 Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml.cs create mode 100644 Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml.css create mode 100644 Intersect.Server/Web/Pages/Developer/ServerSettings/MemberInfoPageModel.cs create mode 100644 Intersect.Server/Web/Pages/Developer/ServerSettings/PropertyInfoPageModel.cs create mode 100644 Intersect.Server/Web/Pages/Developer/ServerSettings/TypePageModel.cs create mode 100644 Intersect.Server/Web/Pages/Developer/ServerSettings/_Property.partial.cshtml create mode 100644 Intersect.Server/Web/Pages/Developer/ServerSettings/_Property.partial.cshtml.cs create mode 100644 Intersect.Server/Web/Pages/Developer/ServerSettings/_Type.partial.cshtml create mode 100644 Intersect.Server/Web/Pages/Developer/ServerSettings/_Type.partial.cshtml.cs create mode 100644 Intersect.Server/Web/Pages/Developer/ServerSettings/_TypeBody.partial.cshtml create mode 100644 Intersect.Server/Web/Pages/Developer/ServerSettings/_TypeBody.partial.cshtml.cs create mode 100644 Intersect.Server/wwwroot/js/components/tabset/tabset.js diff --git a/.editorconfig b/.editorconfig index 5598b6120f..567d809b49 100644 --- a/.editorconfig +++ b/.editorconfig @@ -151,6 +151,7 @@ resharper_trailing_comma_in_multiline_lists = true resharper_wrap_array_initializer_style = chop_if_long resharper_wrap_before_primary_constructor_declaration_rpar = true resharper_wrap_chained_binary_expressions = chop_if_long +resharper_wrap_chained_method_calls = chop_if_long ############################### # VB Coding Conventions # ############################### diff --git a/Framework/Intersect.Framework.Core/Config/DatabaseOptions.cs b/Framework/Intersect.Framework.Core/Config/DatabaseOptions.cs index 2de57095a8..3a6e6c47bc 100644 --- a/Framework/Intersect.Framework.Core/Config/DatabaseOptions.cs +++ b/Framework/Intersect.Framework.Core/Config/DatabaseOptions.cs @@ -5,6 +5,7 @@ namespace Intersect.Config; +[RequiresRestart] public partial class DatabaseOptions { #if DEBUG diff --git a/Framework/Intersect.Framework.Core/Config/OptionsStrings.Designer.cs b/Framework/Intersect.Framework.Core/Config/OptionsStrings.Designer.cs new file mode 100644 index 0000000000..6d9382468b --- /dev/null +++ b/Framework/Intersect.Framework.Core/Config/OptionsStrings.Designer.cs @@ -0,0 +1,114 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Intersect.Framework.Core.Config { + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class OptionsStrings { + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal OptionsStrings() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Intersect.Framework.Core.Config.OptionsStrings", typeof(OptionsStrings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + internal static string Category_General { + get { + return ResourceManager.GetString("Category_General", resourceCulture); + } + } + + internal static string Category_Logging { + get { + return ResourceManager.GetString("Category_Logging", resourceCulture); + } + } + + internal static string Category_Metrics { + get { + return ResourceManager.GetString("Category_Metrics", resourceCulture); + } + } + + internal static string Category_GameDatabase { + get { + return ResourceManager.GetString("Category_GameDatabase", resourceCulture); + } + } + + internal static string Category_LoggingDatabase { + get { + return ResourceManager.GetString("Category_LoggingDatabase", resourceCulture); + } + } + + internal static string Category_PlayerDatabase { + get { + return ResourceManager.GetString("Category_PlayerDatabase", resourceCulture); + } + } + + internal static string Category_Security { + get { + return ResourceManager.GetString("Category_Security", resourceCulture); + } + } + + internal static string Category_SmtpSettings { + get { + return ResourceManager.GetString("Category_SmtpSettings", resourceCulture); + } + } + + internal static string Category_Chat { + get { + return ResourceManager.GetString("Category_Chat", resourceCulture); + } + } + + internal static string Category_Packets { + get { + return ResourceManager.GetString("Category_Packets", resourceCulture); + } + } + + internal static string Category_Combat { + get { + return ResourceManager.GetString("Category_Combat", resourceCulture); + } + } + } +} diff --git a/Framework/Intersect.Framework.Core/Config/OptionsStrings.en.resx b/Framework/Intersect.Framework.Core/Config/OptionsStrings.en.resx new file mode 100644 index 0000000000..a5dee20842 --- /dev/null +++ b/Framework/Intersect.Framework.Core/Config/OptionsStrings.en.resx @@ -0,0 +1,89 @@ + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + General + + + Chat + + + Database (Game) + + + Logging + + + Database (Logging) + + + Metrics + + + Packets + + + Database (Player) + + + Security + + + Email + + + Combat + + + Equipment + + + Passability + + + Map + + + Player + + + Party + + + Loot + + + Processing + + + Sprites + + + NPC + + + Quest + + + Guild + + + Bank + + + Instancing + + + Items + + \ No newline at end of file diff --git a/Framework/Intersect.Framework.Core/Config/OptionsStrings.it.resx b/Framework/Intersect.Framework.Core/Config/OptionsStrings.it.resx new file mode 100644 index 0000000000..346a82c29d --- /dev/null +++ b/Framework/Intersect.Framework.Core/Config/OptionsStrings.it.resx @@ -0,0 +1,26 @@ + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Generale + + + Email + + + Sicurezza + + + Giocatori + + \ No newline at end of file diff --git a/Framework/Intersect.Framework.Core/Config/OptionsStrings.resx b/Framework/Intersect.Framework.Core/Config/OptionsStrings.resx new file mode 100644 index 0000000000..9d35009392 --- /dev/null +++ b/Framework/Intersect.Framework.Core/Config/OptionsStrings.resx @@ -0,0 +1,96 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + General + + + Logging + + + Metrics + + + Database (Game) + + + Database (Logging) + + + Database (Player) + + + Security + + + Email + + + Chat + + + Packets + + + Combat + + + Equipment + + + Passability + + + Map + + + Player + + + Party + + + Loot + + + Processing + + + Sprites + + + NPC + + + Quest + + + Guild + + + Bank + + + Instancing + + + Items + + \ No newline at end of file diff --git a/Framework/Intersect.Framework.Core/GameObjects/DescriptorStrings.Designer.cs b/Framework/Intersect.Framework.Core/GameObjects/DescriptorStrings.Designer.cs new file mode 100644 index 0000000000..6e74f2a7d5 --- /dev/null +++ b/Framework/Intersect.Framework.Core/GameObjects/DescriptorStrings.Designer.cs @@ -0,0 +1,54 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Intersect.Framework.Core.GameObjects { + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class DescriptorStrings { + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal DescriptorStrings() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Intersect.Framework.Core.GameObjects.DescriptorStrings", typeof(DescriptorStrings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + internal static string VariableDescriptor_EditorTable { + get { + return ResourceManager.GetString("VariableDescriptor_EditorTable", resourceCulture); + } + } + } +} diff --git a/Framework/Intersect.Framework.Core/GameObjects/DescriptorStrings.resx b/Framework/Intersect.Framework.Core/GameObjects/DescriptorStrings.resx new file mode 100644 index 0000000000..092d36615d --- /dev/null +++ b/Framework/Intersect.Framework.Core/GameObjects/DescriptorStrings.resx @@ -0,0 +1,24 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + \ No newline at end of file diff --git a/Framework/Intersect.Framework.Core/Intersect.Framework.Core.csproj b/Framework/Intersect.Framework.Core/Intersect.Framework.Core.csproj index 5bf630af26..8407e1aebb 100644 --- a/Framework/Intersect.Framework.Core/Intersect.Framework.Core.csproj +++ b/Framework/Intersect.Framework.Core/Intersect.Framework.Core.csproj @@ -8,6 +8,21 @@ + + + <_Parameter1>Intersect Client + + + <_Parameter1>Intersect.Client.Core + + + <_Parameter1>Intersect Server + + + <_Parameter1>Intersect.Server.Core + + + @@ -24,4 +39,28 @@ + + + ResXFileCodeGenerator + DescriptorStrings.Designer.cs + + + ResXFileCodeGenerator + OptionsStrings.Designer.cs + + + + + + True + True + DescriptorStrings.resx + + + True + True + OptionsStrings.resx + + + diff --git a/Framework/Intersect.Framework/Reflection/TypeExtensions.cs b/Framework/Intersect.Framework/Reflection/TypeExtensions.cs index 29dbe96598..58628c9af0 100755 --- a/Framework/Intersect.Framework/Reflection/TypeExtensions.cs +++ b/Framework/Intersect.Framework/Reflection/TypeExtensions.cs @@ -55,6 +55,10 @@ public static string[] GetMappedColumnNames(this Type type) .ToArray(); } + public static bool IsEnumerable(this Type type) => typeof(IEnumerable<>).ExtendedBy(type); + + public static bool IsEnumerable(this Type type) => typeof(IEnumerable).ExtendedBy(type); + public static bool IsIntegral(this Type type) => type == typeof(int) || type == typeof(long) || diff --git a/Framework/Intersect.Framework/Resources/ResourceManagerExtensions.cs b/Framework/Intersect.Framework/Resources/ResourceManagerExtensions.cs index df983ccef1..75a37c6b7a 100644 --- a/Framework/Intersect.Framework/Resources/ResourceManagerExtensions.cs +++ b/Framework/Intersect.Framework/Resources/ResourceManagerExtensions.cs @@ -8,10 +8,13 @@ public static class ResourceManagerExtensions public static string? GetStringWithFallback( this ResourceManager resourceManager, string name, - CultureInfo? cultureInfo + CultureInfo? cultureInfo = null, + bool fallbackToResourceName = false ) { - while (cultureInfo != null && cultureInfo.LCID != CultureInfo.InvariantCulture.LCID) + cultureInfo ??= CultureInfo.CurrentCulture; + + while (cultureInfo.LCID != CultureInfo.InvariantCulture.LCID) { var value = resourceManager.GetString(name, cultureInfo); if (!string.IsNullOrWhiteSpace(value)) @@ -22,6 +25,6 @@ public static class ResourceManagerExtensions cultureInfo = cultureInfo.Parent; } - return null; + return fallbackToResourceName ? name : null; } } \ No newline at end of file diff --git a/Intersect.Server/Web/Pages/Components/WebComponents/TabSet/_TabSet.cshtml b/Intersect.Server/Web/Pages/Components/WebComponents/TabSet/_TabSet.cshtml new file mode 100644 index 0000000000..15d161083a --- /dev/null +++ b/Intersect.Server/Web/Pages/Components/WebComponents/TabSet/_TabSet.cshtml @@ -0,0 +1,121 @@ + + + + + + \ No newline at end of file diff --git a/Intersect.Server/Web/Pages/Developer/Index.cshtml.cs b/Intersect.Server/Web/Pages/Developer/Index.cshtml.cs index 14dc50897f..80931b445d 100644 --- a/Intersect.Server/Web/Pages/Developer/Index.cshtml.cs +++ b/Intersect.Server/Web/Pages/Developer/Index.cshtml.cs @@ -6,7 +6,14 @@ namespace Intersect.Server.Web.Pages.Developer; [Authorize(Policy = "Developer")] public class DeveloperIndexModel : PageModel { - public static readonly DeveloperFeature[] AllFeatures = []; + public static readonly DeveloperFeature[] AllFeatures = [ + new( + "ServerSettings", + "/Developer/ServerSettings", + () => "STUBServerSettings", + context => false + ), + ]; public IEnumerable EnabledFeatures => AllFeatures.Where(feature => feature.EnablementProvider?.Invoke(HttpContext) ?? true); diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml b/Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml new file mode 100644 index 0000000000..1940256bf0 --- /dev/null +++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml @@ -0,0 +1,34 @@ +@page "/Developer/ServerSettings" +@using System.Reflection +@using Intersect.Framework.Core.Config +@using Intersect.Framework.Reflection +@using Intersect.Framework.Resources +@using Microsoft.AspNetCore.Html +@using Microsoft.AspNetCore.Mvc.TagHelpers +@model ServerSettingsIndexModel + +@{ + ViewData["Title"] = DeveloperWebResources.DeveloperPortal; +} + +@await Html.PartialAsync("~/Web/Pages/Components/WebComponents/TabSet/_TabSet.cshtml") + +
+

Server Config

+ +
+ + @foreach (var (groupKey, propertyInfos) in ServerSettingsIndexModel.PropertyGroups) + { + + @foreach (var propertyInfo in propertyInfos) + { + + } + + } + + @* *@ + +
\ No newline at end of file diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml.cs b/Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml.cs new file mode 100644 index 0000000000..865fd294a1 --- /dev/null +++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml.cs @@ -0,0 +1,64 @@ +using System.Reflection; +using Intersect.Framework.Reflection; +using Microsoft.AspNetCore.JsonPatch; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Intersect.Server.Web.Pages.Developer.ServerSettings; + +public class ServerSettingsIndexModel(ILogger logger) : PageModel +{ + private const string CategoryGeneral = "Category_General"; + + public readonly ILogger Logger = logger; + + private static string GetGroupKey(PropertyInfo propertyInfo) + { + if (propertyInfo.PropertyType.IsValueType) + { + return CategoryGeneral; + } + + if (propertyInfo.PropertyType == typeof(string)) + { + return CategoryGeneral; + } + + // ReSharper disable once ConvertIfStatementToReturnStatement + if (propertyInfo.PropertyType.IsEnumerable()) + { + return CategoryGeneral; + } + + return $"Category_{propertyInfo.Name}"; + } + + public static readonly Dictionary PropertyGroups = typeof(Options) + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(propertyInfo => !propertyInfo.IsIgnored()) + .GroupBy(GetGroupKey) + .ToDictionary(group => group.Key, group => group.ToArray()); + + public Options Target { get; set; } = Options.Instance.DeepClone(); + + public void OnGet() + { + + } + + public void OnPush(JsonPatchDocument optionsChanges) + { + + } + + public PropertyPartialPageModel GetModelFor(PropertyInfo propertyInfo) => + new( + Logger, + target: Target, + parentId: null, + propertyInfo, + isEditing: true + ) + { + IsRoot = true, + }; +} \ No newline at end of file diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml.css b/Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml.css new file mode 100644 index 0000000000..c370d66ba3 --- /dev/null +++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml.css @@ -0,0 +1,11 @@ +::deep form { + width: 100%; +} + +::deep form tab-content.selected, +::deep span.field > fieldset { + display: grid; + grid-gap: 0.5em; + grid-template-columns: minmax(min-content, 20em) minmax(min-content, 20em); + grid-auto-rows: min-content; +} diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/MemberInfoPageModel.cs b/Intersect.Server/Web/Pages/Developer/ServerSettings/MemberInfoPageModel.cs new file mode 100644 index 0000000000..f46707e486 --- /dev/null +++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/MemberInfoPageModel.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Intersect.Server.Web.Pages.Developer.ServerSettings; + +public abstract class MemberInfoPageModel( + ILogger logger, + object target, + TInfoType info, + bool isEditing = false, + string? parentId = null +) : PageModel +{ + public TInfoType Info { get; } = info; + + public bool IsEditing { get; } = isEditing; + + public ILogger Logger { get; } = logger; + + public abstract string OwnId { get; } + + public string? ParentId { get; } = parentId; + + public object Target { get; } = target; +} \ No newline at end of file diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/PropertyInfoPageModel.cs b/Intersect.Server/Web/Pages/Developer/ServerSettings/PropertyInfoPageModel.cs new file mode 100644 index 0000000000..79d1550a82 --- /dev/null +++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/PropertyInfoPageModel.cs @@ -0,0 +1,21 @@ +using System.Reflection; + +namespace Intersect.Server.Web.Pages.Developer.ServerSettings; + +public abstract class PropertyInfoPageModel( + ILogger logger, + object target, + string? parentId, + PropertyInfo propertyInfo, + bool isEditing = false +) + : MemberInfoPageModel( + logger: logger, + target: target, + info: propertyInfo, + isEditing: isEditing, + parentId: parentId + ) +{ + public override string OwnId => ParentId == null ? Info.Name : string.Join('.', ParentId, Info.Name); +} \ No newline at end of file diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/TypePageModel.cs b/Intersect.Server/Web/Pages/Developer/ServerSettings/TypePageModel.cs new file mode 100644 index 0000000000..23bdefd95a --- /dev/null +++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/TypePageModel.cs @@ -0,0 +1,32 @@ +using System.Reflection; +using Intersect.Framework.Reflection; + +namespace Intersect.Server.Web.Pages.Developer.ServerSettings; + +public abstract class TypePageModel( + ILogger logger, + object target, + Type type, + bool isEditing = false, + string? parentId = null +) + : MemberInfoPageModel( + logger: logger, + target: target, + info: type, + isEditing: isEditing, + parentId: parentId + ) +{ + + public List Members => + ((MemberInfo[]) + [ + ..Type.GetProperties(BindingFlags.Instance | BindingFlags.Public), + ..Type.GetFields(BindingFlags.Instance | BindingFlags.Public) + ]).Where(memberInfo => !memberInfo.IsIgnored()).ToList(); + + public override string OwnId => ParentId; + + public Type Type { get; } = type; +} \ No newline at end of file diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/_Property.partial.cshtml b/Intersect.Server/Web/Pages/Developer/ServerSettings/_Property.partial.cshtml new file mode 100644 index 0000000000..b6ce5167cb --- /dev/null +++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/_Property.partial.cshtml @@ -0,0 +1,70 @@ +@using Intersect.Framework.Reflection +@model PropertyPartialPageModel + +@if (Model.Info.PropertyType.IsPrimitive || Model.Info.PropertyType == typeof(string)) +{ + + + @if (Model.Info.PropertyType.IsIntegral()) + { + + } + else if (Model.Info.PropertyType.IsFloatingPoint()) + { + + } + else if (Model.Info.PropertyType == typeof(string)) + { + + } + else if (Model.Info.PropertyType == typeof(bool)) + { + + } + else + { + STUB_@Model.Info.PropertyType.GetName(qualified: true) + } + +} +else if (Model.Info.PropertyType.IsEnum) +{ + @if (Model.Info.PropertyType.IsBitflags()) + { + + + STUB_BITFLAGS + + } + else + { + + + + + } +} +else if (typeof(IEnumerable<>).ExtendedBy(Model.Info.PropertyType)) +{ + + + STUB_ENUMERABLE + +} +else if (Model.IsRoot) +{ + +} +else +{ + + + +} \ No newline at end of file diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/_Property.partial.cshtml.cs b/Intersect.Server/Web/Pages/Developer/ServerSettings/_Property.partial.cshtml.cs new file mode 100644 index 0000000000..09575b090c --- /dev/null +++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/_Property.partial.cshtml.cs @@ -0,0 +1,47 @@ +using System.Reflection; +using Intersect.Framework.Annotations; +using Intersect.Framework.Reflection; + +namespace Intersect.Server.Web.Pages.Developer.ServerSettings; + +public class PropertyPartialPageModel( + ILogger logger, + object target, + string? parentId, + PropertyInfo propertyInfo, + bool isEditing = false +) + : PropertyInfoPageModel( + logger: logger, + target: target, + parentId: parentId, + propertyInfo: propertyInfo, + isEditing: isEditing + ) +{ + public bool IsRoot { get; init; } + + public string ClassString + { + get + { + List classes = ["field"]; + if (IsEditing && !Info.PropertyType.IsReadOnly()) + { + classes.Add("editing"); + } + else + { + classes.Add("display"); + } + if (RequiresRestartAttribute.RequiresRestart(Info)) + { + classes.Add("requires-restart"); + } + + return string.Join(' ', classes); + } + } + + public bool IsReadOnly => !IsEditing || Info.IsReadOnly(); +} \ No newline at end of file diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/_Type.partial.cshtml b/Intersect.Server/Web/Pages/Developer/ServerSettings/_Type.partial.cshtml new file mode 100644 index 0000000000..1d42ac70f3 --- /dev/null +++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/_Type.partial.cshtml @@ -0,0 +1,14 @@ +@using Intersect.Framework.Reflection +@model TypePartialPageModel + +@if (Model.IsRoot) +{ + +} +else +{ +
+ @(Model.SectionName ?? Model.Info.Name) + +
+} \ No newline at end of file diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/_Type.partial.cshtml.cs b/Intersect.Server/Web/Pages/Developer/ServerSettings/_Type.partial.cshtml.cs new file mode 100644 index 0000000000..06d8fb292e --- /dev/null +++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/_Type.partial.cshtml.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Intersect.Server.Web.Pages.Developer.ServerSettings; + +public class TypePartialPageModel( + ILogger logger, + object target, + Type type, + bool isEditing = false, + string? parentId = null, + bool isRoot = false, + string? sectionName = null +) + : TypePageModel( + logger: logger, + target: target, + type: type, + isEditing: isEditing, + parentId: parentId + ) +{ + public bool IsRoot { get; } = isRoot; + + public string? SectionName { get; } = sectionName; +} \ No newline at end of file diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/_TypeBody.partial.cshtml b/Intersect.Server/Web/Pages/Developer/ServerSettings/_TypeBody.partial.cshtml new file mode 100644 index 0000000000..d000883a9d --- /dev/null +++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/_TypeBody.partial.cshtml @@ -0,0 +1,22 @@ +@using System.Reflection +@using Intersect.Framework.Reflection + +@model TypeBodyPartialPageModel + +@foreach (var memberInfo in Model.Members) +{ + switch (memberInfo) + { + case FieldInfo fieldInfo: + Model.Logger.LogDebug("Unsupported member field: {FieldType} {FieldName}", fieldInfo.FieldType.GetName(qualified: true), fieldInfo.Name); + Unsupported member field: @(string.IsNullOrWhiteSpace(Model.OwnId) ? fieldInfo.Name : $"{Model.OwnId}.{fieldInfo.Name}") + break; + case PropertyInfo propertyInfo: + + break; + default: + Model.Logger.LogDebug("Unsupported member: {MemberType} {MemberName}", memberInfo.MemberType, memberInfo.Name); + Unsupported member @memberInfo.MemberType: @(string.IsNullOrWhiteSpace(Model.OwnId) ? memberInfo.Name : $"{Model.OwnId}.{memberInfo.Name}") + break; + } +} \ No newline at end of file diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/_TypeBody.partial.cshtml.cs b/Intersect.Server/Web/Pages/Developer/ServerSettings/_TypeBody.partial.cshtml.cs new file mode 100644 index 0000000000..b586974a3a --- /dev/null +++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/_TypeBody.partial.cshtml.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Intersect.Server.Web.Pages.Developer.ServerSettings; + +public class TypeBodyPartialPageModel( + ILogger logger, + object target, + Type type, + bool isEditing = false, + string? parentId = null +) + : TypePageModel( + logger: logger, + target: target, + type: type, + isEditing: isEditing, + parentId: parentId + ) +{ +} \ No newline at end of file diff --git a/Intersect.Server/Web/Pages/Shared/_Layout.cshtml.css b/Intersect.Server/Web/Pages/Shared/_Layout.cshtml.css index 506874e04c..22b19c13ff 100644 --- a/Intersect.Server/Web/Pages/Shared/_Layout.cshtml.css +++ b/Intersect.Server/Web/Pages/Shared/_Layout.cshtml.css @@ -67,4 +67,5 @@ main { display: flex; flex-direction: column; align-items: center; + width: 100%; } diff --git a/Intersect.Server/wwwroot/css/site.css b/Intersect.Server/wwwroot/css/site.css index ac814a1fc0..02d22f369f 100644 --- a/Intersect.Server/wwwroot/css/site.css +++ b/Intersect.Server/wwwroot/css/site.css @@ -302,7 +302,44 @@ form span { /*border: 1px solid #bbb;*/ display: block; position: relative; - padding-top: 1.5em; + margin-top: 1.5em; +} + +form span.field { + margin-top: 0.5em; +} + +form span:first-of-type, +form span.field:first-of-type { + margin-top: initial; +} + +form span.field { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / 3; +} + +form span.field > label { + display: inline-flex; + justify-content: end; + align-items: center; +} + +form span.field > label+* { + text-align: start; +} + +form span.field > label+input[type=checkbox] { + width: min-content; +} + +fieldset { + padding: 0 0.5em 0.5em; +} + +form span.field > fieldset { + grid-column: 1 / 3; } form span > input { diff --git a/Intersect.Server/wwwroot/js/components/tabset/tabset.js b/Intersect.Server/wwwroot/js/components/tabset/tabset.js new file mode 100644 index 0000000000..3c4ce1ea9a --- /dev/null +++ b/Intersect.Server/wwwroot/js/components/tabset/tabset.js @@ -0,0 +1,350 @@ +class TabContentVisibleEvent extends CustomEvent { + static NAME = 'tabcontentvisible'; + + constructor() { + super(TabContentVisibleEvent.NAME, { + detail: {} + }); + } +} + +class TabContentElement extends HTMLElement { + static observedAttributes = [ + 'tab-icon', + 'tab-id', + 'tab-label' + ]; + + /** @type {HTMLElement} */ + #contentContainer; + + /** @type {boolean} */ + #selected = false; + + /** @type {string?} */ + #tabIcon; + + /** @type {string?} */ + #tabId; + + /** @type {string?} */ + #tabLabel; + + /** @type {boolean} */ + get selected() { + return this.#selected; + } + + /** @param {boolean} value */ + set selected(value) { + if (this.#selected === value) { + return; + } + + this.#selected = typeof value === 'boolean' ? value : Boolean(value); + this.#contentContainer?.setAttribute('aria-hidden', !this.#selected); + if (value) { + this.classList.add('selected'); + this.dispatchEvent(new TabContentVisibleEvent()); + } else { + this.classList.remove('selected'); + } + } + + /** @type {string} */ + get tabIcon() { + return this.#tabIcon; + } + + /** @param {string?} value */ + set tabIcon(value) { + this.#tabIcon = value; + } + + /** @type {string} */ + get tabId() { + return this.#tabId; + } + + /** @param {string?} value */ + set tabId(value) { + this.#tabId = value; + } + + /** @type {string} */ + get tabLabel() { + return this.#tabLabel; + } + + /** @param {string?} value */ + set tabLabel(value) { + this.#tabLabel = value; + } + + constructor() { + super(); + + this.attachShadow({ + mode: 'open' + }); + } + + #render() { + /** @type {HTMLTemplateElement} */ + const template = document.querySelector('template#custom-element-tab-content'); + this.shadowRoot.appendChild(template.content.cloneNode(true)); + + this.#contentContainer = this.shadowRoot.querySelector('div.content'); + this.#contentContainer.setAttribute('aria-hidden', !this.#selected); + } + + connectedCallback() { + this.#render(); + } + + disconnectedCallback() { + } + + adoptedCallback() { + } + + attributeChangedCallback(name, oldValue, newValue) { + if (oldValue === newValue) { + return; + } + + const camelName = kebabToCamelCase(name); + switch (name) { + case 'tab-icon': + case 'tab-id': + case 'tab-label': + this[camelName] = newValue; + break; + + default: + this[name] = newValue; + break; + } + } +} + +customElements.define('tab-content', TabContentElement); + +class TabSetElement extends HTMLElement { + static observedAttributes = [ + 'nav-style' + ]; + + /** @type {HTMLElement} */ + #elementContainer; + + /** @type {HTMLElement} */ + #triggerContainer; + + /** @type {HTMLTemplateElement} */ + #triggerTemplate; + + /** @type {HTMLSlotElement} */ + #slotContents; + + /** @type {TabContentElement[]} */ + #contentElements = []; + + /** @type {number} */ + #selectedIndex = 0; + + constructor() { + super(); + + this.attachShadow({ + mode: 'open' + }); + } + + get selectedIndex() { + return this.#selectedIndex; + } + + /** + * + * @param {number} value + */ + set selectedIndex(value) { + if (this.#selectedIndex === value) { + return; + } + + const currentTab = this.#contentElements[this.#selectedIndex]; + if (currentTab !== undefined) { + const { tabId } = currentTab; + currentTab.selected = false; + const trigger = this.#triggerContainer.querySelector(`input[data-tab-id="${tabId}"]`); + if (trigger !== null) { + trigger.checked = false; + } + } + + this.#selectedIndex = value; + + const selectedTab = this.#contentElements[this.#selectedIndex]; + if (selectedTab !== undefined) { + const { tabId } = selectedTab; + selectedTab.selected = true; + const trigger = this.#triggerContainer.querySelector(`input[data-tab-id="${tabId}"]`); + if (trigger !== null) { + trigger.checked = true; + } + } + } + + #render() { + /** @type {HTMLTemplateElement} */ + const template = document.querySelector('template#custom-element-tab-set'); + this.shadowRoot.appendChild(template.content.cloneNode(true)); + + this.#elementContainer = this.shadowRoot.querySelector('div.container'); + this.#triggerContainer = this.shadowRoot.querySelector('div.triggers'); + this.#triggerTemplate = this.shadowRoot.querySelector('template#trigger'); + + this.#slotContents = this.shadowRoot.querySelector('slot:not([name])'); + + const onSlotChanged = () => { + /** @type {TabContentElement[]} */ + const tabs = this.#slotContents.assignedElements().filter(e => e instanceof TabContentElement); + + /** @type {string[]} */ + const tabIds = []; + + for (const tab of tabs) { + const tabId = tab.tabId?.trim(); + const tabIcon = tab.tabIcon?.trim(); + + if (typeof tabId !== 'string' || tabId.length < 1) { + console.error('Invalid tab, `tab-id` attribute is not set', tab); + continue; + } + + const isSelected = this.#selectedIndex === tabIds.length; + + tabIds.push(tabId); + + /** @type {HTMLInputElement | null} */ + let input = this.#triggerContainer.querySelector(`input[data-tab-id="${tab.tabId}"]`); + + /** @type {HTMLLabelElement | null} */ + let label = this.#triggerContainer.querySelector(`label[data-tab-id="${tab.tabId}"]`); + + if (input === null || label === null) { + if (input !== null) { + this.#triggerContainer.removeChild(input); + } + + if (label !== null) { + this.#triggerContainer.removeChild(label); + } + + /** @type {DocumentFragment} */ + const nodes = this.#triggerTemplate.content.cloneNode(true); + input = nodes.querySelector('input'); + label = nodes.querySelector('label'); + + input.setAttribute('data-tab-id', tabId); + label.setAttribute('data-tab-id', tabId); + + input.id = `trigger-${tabId}`; + label.id = `label-${tabId}`; + label.htmlFor = input.id; + + const [lastTabId] = tabIds.slice(-1); + const [lastTab] = lastTabId === undefined ? [null] : [...this.#triggerContainer.querySelectorAll(`[data-tab-id="${lastTabId}"]`)]; + const nextSibling = lastTab?.nextSibling; + this.#triggerContainer.insertBefore(input, nextSibling); + this.#triggerContainer.insertBefore(label, nextSibling); + + input.addEventListener('change', ({ target }) => { + if (target?.tagName !== 'INPUT') { + console.error('Invalid target, expected an `` element', target); + return; + } + + /** @type {string | null} */ + const selectedTabId = target.getAttribute('data-tab-id')?.trim(); + if (typeof selectedTabId !== 'string' || selectedTabId.length < 1) { + console.error('Invalid target, the `` has no `data-tab-id` attribute', target); + return; + } + + const nextSelectedIndex = this.#contentElements.findIndex(e => e.tabId === selectedTabId); + console.info(`Selecting tab ${nextSelectedIndex}`); + this.#contentElements[this.#selectedIndex]?.classList.remove('selected'); + this.#selectedIndex = nextSelectedIndex; + this.#contentElements[this.#selectedIndex]?.classList.add('selected'); + }); + + label.addEventListener('keypress', evt => { + if (evt.key === ' ' || evt.key === 'Enter') { + const { target } = evt; + const tabId = target.getAttribute('data-tab-id'); + const tabIndex = this.#contentElements.findIndex(tab => tab.tabId === tabId); + console.debug(`Selecting tab ${tabIndex} (${tabId}) with the keyboard`); + this.selectedIndex = tabIndex; + } + }); + } + + if (typeof tabIcon === 'string' && tabIcon.length > 0) { + const iconUse = label.querySelector('use'); + iconUse.setAttribute('href', tabIcon); + iconUse.parentElement.classList.remove('hidden'); + } + + input.checked = isSelected; + + label.querySelector('span').textContent = tab.tabLabel?.trim(); + + tab.id = `panel-${tabId}`; + tab.setAttribute('aria-labelledby', label.id); + tab.selected = isSelected; + + label.setAttribute('aria-controls', tab.id); + } + + for (const element of this.#triggerContainer.children) { + switch (element.tagName) { + case 'INPUT': + case 'LABEL': + if (!tabIds.includes(element.getAttribute('data-tab-id'))) { + this.#triggerContainer.removeChild(element); + } + break; + } + } + + this.#contentElements = tabs; + }; + + this.#slotContents.addEventListener('slotchange', onSlotChanged); + } + + connectedCallback() { + this.#render(); + } + + disconnectedCallback() { + } + + adoptedCallback() { + } + + attributeChangedCallback(name, oldValue, newValue) { + if (oldValue !== newValue) { + if (name === 'selected-index') { + this.selectedIndex = newValue; + } else { + this[name] = newValue; + } + } + } +} + +customElements.define('tab-set', TabSetElement);