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 @@ +//------------------------------------------------------------------------------ +// <auto-generated> +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// </auto-generated> +//------------------------------------------------------------------------------ + +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 @@ +<root> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>1.3</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <data name="Category_General" xml:space="preserve"> + <value>General</value> + </data> + <data name="Category_Chat" xml:space="preserve"> + <value>Chat</value> + </data> + <data name="Category_GameDatabase" xml:space="preserve"> + <value>Database (Game)</value> + </data> + <data name="Category_Logging" xml:space="preserve"> + <value>Logging</value> + </data> + <data name="Category_LoggingDatabase" xml:space="preserve"> + <value>Database (Logging)</value> + </data> + <data name="Category_Metrics" xml:space="preserve"> + <value>Metrics</value> + </data> + <data name="Category_Packets" xml:space="preserve"> + <value>Packets</value> + </data> + <data name="Category_PlayerDatabase" xml:space="preserve"> + <value>Database (Player)</value> + </data> + <data name="Category_Security" xml:space="preserve"> + <value>Security</value> + </data> + <data name="Category_SmtpSettings" xml:space="preserve"> + <value>Email</value> + </data> + <data name="Category_Combat" xml:space="preserve"> + <value>Combat</value> + </data> + <data name="Category_Equipment" xml:space="preserve"> + <value>Equipment</value> + </data> + <data name="Category_Passability" xml:space="preserve"> + <value>Passability</value> + </data> + <data name="Category_Map" xml:space="preserve"> + <value>Map</value> + </data> + <data name="Category_Player" xml:space="preserve"> + <value>Player</value> + </data> + <data name="Category_Party" xml:space="preserve"> + <value>Party</value> + </data> + <data name="Category_Loot" xml:space="preserve"> + <value>Loot</value> + </data> + <data name="Category_Processing" xml:space="preserve"> + <value>Processing</value> + </data> + <data name="Category_Sprites" xml:space="preserve"> + <value>Sprites</value> + </data> + <data name="Category_Npc" xml:space="preserve"> + <value>NPC</value> + </data> + <data name="Category_Quest" xml:space="preserve"> + <value>Quest</value> + </data> + <data name="Category_Guild" xml:space="preserve"> + <value>Guild</value> + </data> + <data name="Category_Bank" xml:space="preserve"> + <value>Bank</value> + </data> + <data name="Category_Instancing" xml:space="preserve"> + <value>Instancing</value> + </data> + <data name="Category_Items" xml:space="preserve"> + <value>Items</value> + </data> +</root> \ 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 @@ +<root> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>1.3</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <data name="Category_General" xml:space="preserve"> + <value>Generale</value> + </data> + <data name="Category_SmtpSettings" xml:space="preserve"> + <value>Email</value> + </data> + <data name="Category_Security" xml:space="preserve"> + <value>Sicurezza</value> + </data> + <data name="Category_Player" xml:space="preserve"> + <value>Giocatori</value> + </data> +</root> \ 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 @@ +<?xml version="1.0" encoding="utf-8"?> + +<root> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:element name="root" msdata:IsDataSet="true"> + + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>1.3</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <data name="Category_General" xml:space="preserve"> + <value>General</value> + </data> + <data name="Category_Logging" xml:space="preserve"> + <value>Logging</value> + </data> + <data name="Category_Metrics" xml:space="preserve"> + <value>Metrics</value> + </data> + <data name="Category_GameDatabase" xml:space="preserve"> + <value>Database (Game)</value> + </data> + <data name="Category_LoggingDatabase" xml:space="preserve"> + <value>Database (Logging)</value> + </data> + <data name="Category_PlayerDatabase" xml:space="preserve"> + <value>Database (Player)</value> + </data> + <data name="Category_Security" xml:space="preserve"> + <value>Security</value> + </data> + <data name="Category_SmtpSettings" xml:space="preserve"> + <value>Email</value> + </data> + <data name="Category_Chat" xml:space="preserve"> + <value>Chat</value> + </data> + <data name="Category_Packets" xml:space="preserve"> + <value>Packets</value> + </data> + <data name="Category_Combat" xml:space="preserve"> + <value>Combat</value> + </data> + <data name="Category_Equipment" xml:space="preserve"> + <value>Equipment</value> + </data> + <data name="Category_Passability" xml:space="preserve"> + <value>Passability</value> + </data> + <data name="Category_Map" xml:space="preserve"> + <value>Map</value> + </data> + <data name="Category_Player" xml:space="preserve"> + <value>Player</value> + </data> + <data name="Category_Party" xml:space="preserve"> + <value>Party</value> + </data> + <data name="Category_Loot" xml:space="preserve"> + <value>Loot</value> + </data> + <data name="Category_Processing" xml:space="preserve"> + <value>Processing</value> + </data> + <data name="Category_Sprites" xml:space="preserve"> + <value>Sprites</value> + </data> + <data name="Category_Npc" xml:space="preserve"> + <value>NPC</value> + </data> + <data name="Category_Quest" xml:space="preserve"> + <value>Quest</value> + </data> + <data name="Category_Guild" xml:space="preserve"> + <value>Guild</value> + </data> + <data name="Category_Bank" xml:space="preserve"> + <value>Bank</value> + </data> + <data name="Category_Instancing" xml:space="preserve"> + <value>Instancing</value> + </data> + <data name="Category_Items" xml:space="preserve"> + <value>Items</value> + </data> +</root> \ 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 @@ +//------------------------------------------------------------------------------ +// <auto-generated> +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// </auto-generated> +//------------------------------------------------------------------------------ + +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 @@ +<?xml version="1.0" encoding="utf-8"?> + +<root> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:element name="root" msdata:IsDataSet="true"> + + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>1.3</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <data name="VariableDescriptor_EditorTable" xml:space="preserve"> + <value /> + </data> +</root> \ 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 @@ <ProjectReference Include="..\Intersect.Framework\Intersect.Framework.csproj" /> </ItemGroup> + <ItemGroup> + <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> + <_Parameter1>Intersect Client</_Parameter1> + </AssemblyAttribute> + <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> + <_Parameter1>Intersect.Client.Core</_Parameter1> + </AssemblyAttribute> + <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> + <_Parameter1>Intersect Server</_Parameter1> + </AssemblyAttribute> + <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> + <_Parameter1>Intersect.Server.Core</_Parameter1> + </AssemblyAttribute> + </ItemGroup> + <ItemGroup> <PackageReference Include="Ceras" Version="4.1.7" /> <PackageReference Include="K4os.Compression.LZ4" Version="1.3.6" /> @@ -24,4 +39,28 @@ <PackageReference Include="System.IO.FileSystem" Version="4.3.0" /> </ItemGroup> + <ItemGroup> + <EmbeddedResource Update="GameObjects\DescriptorStrings.resx"> + <Generator>ResXFileCodeGenerator</Generator> + <LastGenOutput>DescriptorStrings.Designer.cs</LastGenOutput> + </EmbeddedResource> + <EmbeddedResource Update="Config\OptionsStrings.resx"> + <Generator>ResXFileCodeGenerator</Generator> + <LastGenOutput>OptionsStrings.Designer.cs</LastGenOutput> + </EmbeddedResource> + </ItemGroup> + + <ItemGroup> + <Compile Update="GameObjects\DescriptorStrings.Designer.cs"> + <DesignTime>True</DesignTime> + <AutoGen>True</AutoGen> + <DependentUpon>DescriptorStrings.resx</DependentUpon> + </Compile> + <Compile Update="Config\OptionsStrings.Designer.cs"> + <DesignTime>True</DesignTime> + <AutoGen>True</AutoGen> + <DependentUpon>OptionsStrings.resx</DependentUpon> + </Compile> + </ItemGroup> + </Project> 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<TValue>(this Type type) => typeof(IEnumerable<TValue>).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 @@ +<!--suppress CssUnresolvedCustomProperty --> +<template id="custom-element-tab-content"> + <style> + :host > .content { + display: contents; + } + </style> + <div class="content" role="tabpanel" aria-labelledby="manifests" aria-hidden="true"> + <slot></slot> + </div> +</template> + +<template id="custom-element-tab-set"> + <style> + :host { + display: block; + } + + :host > .container { + display: flex; + flex-direction: column; + } + + :host([nav-style=side]) > .container { + flex-direction: row; + } + + :host > .container > .triggers { + flex-direction: row; + } + + :host([nav-style=side]) > .container > .triggers { + display: flex; + flex-direction: column; + max-height: 100%; + overflow-y: auto; + } + + .container, + input, + label, + ::slotted(tab-content) { + border-collapse: var(--tabs-border-collapse, collapse); + } + + .container { + border: var(--tabs-border-width_container, var(--tabs-border-width, 0)) solid var(--tabs-border-color_container, var(--tabs-border-color)); + } + + input.trigger { + display: none; + } + + input.trigger+label { + background-color: var(--tabs-trigger-color); + border-style: solid; + border-width: 0 0.1em; + border-color: var(--tabs-trigger-color-selected, #bbb); + display: inline-flex; + padding: var(--tabs-trigger-padding, 0.5em 2em); + } + + input.trigger:checked+label, + input.trigger+label:hover { + background-color: var(--tabs-trigger-color-selected, #bbb); + } + + :host([nav-style=side]) input.trigger+label { + border-width: 0.1em 0 0 0; + padding: var(--tabs-trigger-padding, 0.5em 1em); + } + + input.trigger+label:hover { + filter: brightness(125%); + } + + input.trigger+label>svg { + max-height: 1.25lh; + } + + input.trigger+label>svg.material { + fill: currentColor; + margin-right: 0.5em; + min-width: calc(min(1.5em, 24px)); + } + + input.trigger+label>span { + min-width: fit-content; + } + + svg.hidden { + display: none; + } + + ::slotted(tab-content) { + border-top: var(--tabs-trigger-border-width, 0.0625rem) solid var(--tabs-trigger-color-selected, #bbb); + display: none; + flex: 1; + padding: 1em; + } + + ::slotted(tab-content.selected) { + display: block; + } + </style> + <div class="container" role="tablist" data-tab-group="asset-management"> + <template id="trigger"> + <input type="radio" class="trigger" name="tabs"/> + <label role="tab" aria-controls="panel-browse" tabindex="0"> + <svg class="material hidden" viewBox="0 0 24 24" aria-hidden="true"> + <use/> + </svg> + <span>Tab Label</span> + </label> + </template> + <div class="triggers"></div> + <slot></slot> + </div> +</template> + +<script src="~/js/components/tabset/tabset.js"></script> \ 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<DeveloperFeature> 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") + +<article class="page"> + <h1>Server Config</h1> + + <form> + <tab-set nav-style="side" style="--tabs-trigger-color: var(--theme-bg-accent); --tabs-trigger-color-selected: var(--theme-bg-accent-hover); --tabs-trigger-border-width: 0.125em;"> + @foreach (var (groupKey, propertyInfos) in ServerSettingsIndexModel.PropertyGroups) + { + <tab-content tab-id="@groupKey" tab-label="@OptionsStrings.ResourceManager.GetStringWithFallback(groupKey, fallbackToResourceName: true)" + @*tab-icon="/material/notification/account_tree/materialiconsoutlined/24px.svg#root"*@> + @foreach (var propertyInfo in propertyInfos) + { + <partial name="_Property.partial" model="@(Model.GetModelFor(propertyInfo))"/> + } + </tab-content> + } + </tab-set> + @* <partial name="_Type.partial" model="@(new TypePartialPageModel(Model.Logger, target: Options.Instance.DeepClone(), typeof(Options), isEditing: true, isRoot: true))"/> *@ + </form> +</article> \ 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<ServerSettingsIndexModel> logger) : PageModel +{ + private const string CategoryGeneral = "Category_General"; + + public readonly ILogger<ServerSettingsIndexModel> 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<string, PropertyInfo[]> 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<Options> 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<TInfoType>( + 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<PropertyInfo>( + 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<Type>( + logger: logger, + target: target, + info: type, + isEditing: isEditing, + parentId: parentId + ) +{ + + public List<MemberInfo> 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)) +{ + <span class="@Model.ClassString"> + <label for="@Model.OwnId">@Model.Info.Name</label> + @if (Model.Info.PropertyType.IsIntegral()) + { + <input id="@Model.OwnId" name="@Model.OwnId" disabled=@Model.IsReadOnly readonly=@Model.IsReadOnly type="number" value="@Model.Info.GetValue(Model.Target)" /> + } + else if (Model.Info.PropertyType.IsFloatingPoint()) + { + <input id="@Model.OwnId" name="@Model.OwnId" disabled=@Model.IsReadOnly readonly=@Model.IsReadOnly type="number" value="@Model.Info.GetValue(Model.Target)" /> + } + else if (Model.Info.PropertyType == typeof(string)) + { + <input id="@Model.OwnId" name="@Model.OwnId" disabled=@Model.IsReadOnly readonly=@Model.IsReadOnly type="@(Model.Info.IsPassword() ? "password" : "text")" + value="@Model.Info.GetValue(Model.Target)" /> + } + else if (Model.Info.PropertyType == typeof(bool)) + { + <input id="@Model.OwnId" name="@Model.OwnId" disabled=@Model.IsReadOnly type="checkbox" + checked=@Model.Info.GetValue(Model.Target)/> + } + else + { + <span>STUB_@Model.Info.PropertyType.GetName(qualified: true)</span> + } + </span> +} +else if (Model.Info.PropertyType.IsEnum) +{ + @if (Model.Info.PropertyType.IsBitflags()) + { + <span class="@Model.ClassString"> + <label for="@Model.OwnId">@Model.Info.Name</label> + STUB_BITFLAGS + </span> + } + else + { + <span class="@Model.ClassString"> + <label for="@Model.OwnId">@Model.Info.Name</label> + <select id="@Model.OwnId" name="@Model.OwnId" disabled=@Model.IsReadOnly> + @foreach (var enumValue in Model.Info.PropertyType.GetValues(excludeIgnored: true)) + { + <option value="@enumValue" selected=@(Model.Info.GetValue(Model.Target)?.Equals(enumValue) ?? false)>@enumValue</option> + } + </select> + </span> + } +} +else if (typeof(IEnumerable<>).ExtendedBy(Model.Info.PropertyType)) +{ + <span class="@Model.ClassString"> + <label for="@Model.OwnId">@Model.Info.Name</label> + STUB_ENUMERABLE + </span> +} +else if (Model.IsRoot) +{ + <partial name="_Type.partial" model="@(new TypePartialPageModel(Model.Logger, target: Model.Info.GetValue(Model.Target), Model.Info.PropertyType, isEditing: Model.IsEditing, parentId: Model.OwnId, sectionName: Model.Info.Name, isRoot: true))"/> +} +else +{ + <span class="@Model.ClassString"> + <partial name="_Type.partial" model="@(new TypePartialPageModel(Model.Logger, target: Model.Info.GetValue(Model.Target), Model.Info.PropertyType, isEditing: Model.IsEditing, parentId: Model.OwnId, sectionName: Model.Info.Name))"/> + </span> +} \ 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<string> 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) +{ + <partial name="_TypeBody.partial" model="@(new TypeBodyPartialPageModel(Model.Logger, target: Model.Target, Model.Type, isEditing: Model.IsEditing && !Model.Type.IsReadOnly(), parentId: Model.ParentId))"/> +} +else +{ + <fieldset data-id="@Model.OwnId"> + <legend>@(Model.SectionName ?? Model.Info.Name)</legend> + <partial name="_TypeBody.partial" model="@(new TypeBodyPartialPageModel(Model.Logger, target: Model.Target, Model.Type, isEditing: Model.IsEditing && !Model.Type.IsReadOnly(), parentId: Model.ParentId))"/> + </fieldset> +} \ 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); + <span>Unsupported member field: @(string.IsNullOrWhiteSpace(Model.OwnId) ? fieldInfo.Name : $"{Model.OwnId}.{fieldInfo.Name}")</span> + break; + case PropertyInfo propertyInfo: + <partial name="_Property.partial" model="@(new PropertyPartialPageModel(Model.Logger, target: Model.Target, Model.OwnId, propertyInfo, isEditing: Model.IsEditing))"/> + break; + default: + Model.Logger.LogDebug("Unsupported member: {MemberType} {MemberName}", memberInfo.MemberType, memberInfo.Name); + <span>Unsupported member @memberInfo.MemberType: @(string.IsNullOrWhiteSpace(Model.OwnId) ? memberInfo.Name : $"{Model.OwnId}.{memberInfo.Name}")</span> + 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 `<input />` 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 `<input />` 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);