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
+
+
+
\ 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
+{
+
+}
\ 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);