Quellcode durchsuchen

Added markdown editor / viewer

Tim Jones vor 1 Monat
Ursprung
Commit
dbc21c8006

+ 46 - 0
RackPeek.Domain/Persistence/Yaml/NotesStringYamlConverter.cs

@@ -0,0 +1,46 @@
+using YamlDotNet.Core;
+using YamlDotNet.Core.Events;
+using YamlDotNet.Serialization;
+
+public sealed class NotesStringYamlConverter : IYamlTypeConverter
+{
+    public bool Accepts(Type type) => type == typeof(string);
+    public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer)
+    {
+        var scalar = parser.Consume<Scalar>();
+        return scalar.Value;
+    }
+
+    public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer)
+    {
+        if (value is null)
+        {
+            emitter.Emit(new Scalar(
+                AnchorName.Empty,
+                TagName.Empty,
+                "",
+                ScalarStyle.Plain,
+                true,
+                true));
+            return;
+        }
+
+        var s = (string)value;
+
+        if (s.Contains('\n'))
+        {
+            // Literal block style (|)
+            emitter.Emit(new Scalar(
+                AnchorName.Empty,
+                TagName.Empty,
+                s,
+                ScalarStyle.Literal,
+                true,
+                false));
+        }
+        else
+        {
+            emitter.Emit(new Scalar(s));
+        }
+    }
+}

+ 3 - 0
RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs

@@ -83,6 +83,7 @@ public sealed class YamlResourceCollection(
 
             var serializer = new SerializerBuilder()
                 .WithNamingConvention(CamelCaseNamingConvention.Instance)
+                .WithTypeConverter(new NotesStringYamlConverter())
                 .Build();
 
             var payload = new OrderedDictionary
@@ -110,6 +111,7 @@ public sealed class YamlResourceCollection(
             .WithNamingConvention(CamelCaseNamingConvention.Instance)
             .WithCaseInsensitivePropertyMatching()
             .WithTypeConverter(new StorageSizeYamlConverter())
+            .WithTypeConverter(new NotesStringYamlConverter())
             .WithTypeDiscriminatingNodeDeserializer(options =>
             {
                 options.AddKeyValueTypeDiscriminator<Resource>("kind", new Dictionary<string, Type>
@@ -163,6 +165,7 @@ public sealed class YamlResourceCollection(
 
         var serializer = new SerializerBuilder()
             .WithNamingConvention(CamelCaseNamingConvention.Instance)
+            .WithTypeConverter(new NotesStringYamlConverter())
             .Build();
 
         var yaml = serializer.Serialize(resource);

+ 7 - 1
RackPeek.Domain/Resources/Hardware/AccessPoints/UpdateAccessPointUseCase.cs

@@ -8,7 +8,8 @@ public class UpdateAccessPointUseCase(IHardwareRepository repository) : IUseCase
     public async Task ExecuteAsync(
         string name,
         string? model = null,
-        double? speed = null
+        double? speed = null,
+        string? notes = null
     )
     {
         // ToDo validate / normalize all inputs
@@ -31,6 +32,11 @@ public class UpdateAccessPointUseCase(IHardwareRepository repository) : IUseCase
             ap.Speed = speed.Value;
         }
 
+        if (notes != null)
+        {
+            ap.Notes = notes;
+        }
+
         await repository.UpdateAsync(ap);
     }
 }

+ 6 - 2
RackPeek.Domain/Resources/Hardware/Desktops/UpdateDesktopUseCase.cs

@@ -9,7 +9,8 @@ public class UpdateDesktopUseCase(IHardwareRepository repository) : IUseCase
         string name,
         string? model = null,
         int? ramGb = null,
-        int? ramMts = null
+        int? ramMts = null,
+        string? notes = null
     )
     {
         // ToDo validate / normalize all inputs
@@ -37,7 +38,10 @@ public class UpdateDesktopUseCase(IHardwareRepository repository) : IUseCase
             desktop.Ram ??= new Ram();
             desktop.Ram.Mts = ramMts.Value;
         }
-
+        if (notes != null)
+        {
+            desktop.Notes = notes;
+        }
         await repository.UpdateAsync(desktop);
     }
 }

+ 6 - 2
RackPeek.Domain/Resources/Hardware/Firewalls/UpdateFirewallUseCase.cs

@@ -9,7 +9,8 @@ public class UpdateFirewallUseCase(IHardwareRepository repository) : IUseCase
         string name,
         string? model = null,
         bool? managed = null,
-        bool? poe = null
+        bool? poe = null,
+        string? notes = null
     )
     {
         // ToDo validate / normalize all inputs
@@ -29,7 +30,10 @@ public class UpdateFirewallUseCase(IHardwareRepository repository) : IUseCase
 
         if (poe.HasValue)
             firewallResource.Poe = poe.Value;
-
+        if (notes != null)
+        {
+            firewallResource.Notes = notes;
+        }
         await repository.UpdateAsync(firewallResource);
     }
 }

+ 6 - 2
RackPeek.Domain/Resources/Hardware/Laptops/UpdateLaptopUseCase.cs

@@ -9,7 +9,8 @@ public class UpdateLaptopUseCase(IHardwareRepository repository) : IUseCase
         string name,
         string? model = null,
         int? ramGb = null,
-        int? ramMts = null
+        int? ramMts = null,
+        string? notes = null
     )
     {
         // ToDo validate / normalize all inputs
@@ -37,7 +38,10 @@ public class UpdateLaptopUseCase(IHardwareRepository repository) : IUseCase
             laptop.Ram ??= new Ram();
             laptop.Ram.Mts = ramMts.Value;
         }
-
+        if (notes != null)
+        {
+            laptop.Notes = notes;
+        }
         await repository.UpdateAsync(laptop);
     }
 }

+ 6 - 2
RackPeek.Domain/Resources/Hardware/Routers/UpdateRouterUseCase.cs

@@ -9,7 +9,8 @@ public class UpdateRouterUseCase(IHardwareRepository repository) : IUseCase
         string name,
         string? model = null,
         bool? managed = null,
-        bool? poe = null
+        bool? poe = null,
+        string? notes = null
     )
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase
@@ -30,7 +31,10 @@ public class UpdateRouterUseCase(IHardwareRepository repository) : IUseCase
 
         if (poe.HasValue)
             routerResource.Poe = poe.Value;
-
+        if (notes != null)
+        {
+            routerResource.Notes = notes;
+        }
         await repository.UpdateAsync(routerResource);
     }
 }

+ 6 - 2
RackPeek.Domain/Resources/Hardware/Servers/UpdateServerUseCase.cs

@@ -9,7 +9,8 @@ public class UpdateServerUseCase(IHardwareRepository repository) : IUseCase
         string name,
         int? ramGb = null,
         int? ramMts = null,
-        bool? ipmi = null
+        bool? ipmi = null,
+        string? notes = null
     )
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
@@ -38,7 +39,10 @@ public class UpdateServerUseCase(IHardwareRepository repository) : IUseCase
 
         // ---- IPMI ----
         if (ipmi.HasValue) server.Ipmi = ipmi.Value;
-
+        if (notes != null)
+        {
+            server.Notes = notes;
+        }
         await repository.UpdateAsync(server);
     }
 }

+ 6 - 2
RackPeek.Domain/Resources/Hardware/Switches/UpdateSwitchUseCase.cs

@@ -9,7 +9,8 @@ public class UpdateSwitchUseCase(IHardwareRepository repository) : IUseCase
         string name,
         string? model = null,
         bool? managed = null,
-        bool? poe = null
+        bool? poe = null,
+        string? notes = null
     )
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
@@ -30,7 +31,10 @@ public class UpdateSwitchUseCase(IHardwareRepository repository) : IUseCase
 
         if (poe.HasValue)
             switchResource.Poe = poe.Value;
-
+        if (notes != null)
+        {
+            switchResource.Notes = notes;
+        }
         await repository.UpdateAsync(switchResource);
     }
 }

+ 6 - 2
RackPeek.Domain/Resources/Hardware/UpsUnits/UpdateUpsUseCase.cs

@@ -8,7 +8,8 @@ public class UpdateUpsUseCase(IHardwareRepository repository) : IUseCase
     public async Task ExecuteAsync(
         string name,
         string? model = null,
-        int? va = null
+        int? va = null,
+        string? notes = null
     )
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
@@ -26,7 +27,10 @@ public class UpdateUpsUseCase(IHardwareRepository repository) : IUseCase
 
         if (va.HasValue)
             ups.Va = va.Value;
-
+        if (notes != null)
+        {
+            ups.Notes = notes;
+        }
         await repository.UpdateAsync(ups);
     }
 }

+ 1 - 0
RackPeek.Domain/Resources/Resource.cs

@@ -22,6 +22,7 @@ public abstract class Resource
     public required string Name { get; set; }
 
     public Dictionary<string, string>? Tags { get; set; }
+    public string? Notes { get; set; }
 
     public static string KindToPlural(string kind)
     {

+ 6 - 1
RackPeek.Domain/Resources/Services/UseCases/UpdateServiceUseCase.cs

@@ -11,7 +11,8 @@ public class UpdateServiceUseCase(IServiceRepository repository, ISystemReposito
         int? port = null,
         string? protocol = null,
         string? url = null,
-        string? runsOn = null
+        string? runsOn = null,
+        string? notes = null
     )
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
@@ -53,6 +54,10 @@ public class UpdateServiceUseCase(IServiceRepository repository, ISystemReposito
             var parentSystem = await systemRepo.GetByNameAsync(runsOn);
             if (parentSystem == null) throw new NotFoundException($"Parent system '{runsOn}' not found.");
             service.RunsOn = runsOn;
+        }        
+        if (notes != null)
+        {
+            service.Notes = notes;
         }
 
         await repository.UpdateAsync(service);

+ 7 - 1
RackPeek.Domain/Resources/SystemResources/UseCases/UpdateSystemUseCase.cs

@@ -11,7 +11,8 @@ public class UpdateSystemUseCase(ISystemRepository repository, IHardwareReposito
         string? os = null,
         int? cores = null,
         int? ram = null,
-        string? runsOn = null
+        string? runsOn = null,
+        string? notes = null
     )
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
@@ -41,6 +42,11 @@ public class UpdateSystemUseCase(ISystemRepository repository, IHardwareReposito
         if (ram.HasValue)
             system.Ram = ram.Value;
 
+        if (notes != null)
+        {
+            system.Notes = notes;
+        }
+        
         if (!string.IsNullOrWhiteSpace(runsOn))
         {
             ThrowIfInvalid.ResourceName(runsOn);

+ 30 - 8
Shared.Rcl/AccessPoints/AccessPointCardComponent.razor

@@ -64,8 +64,8 @@
             @if (_isEditing)
             {
                 <input class="w-full px-3 py-2 rounded-md
-                              bg-zinc-800 text-zinc-100
-                              border border-zinc-600"
+                          bg-zinc-800 text-zinc-100
+                          border border-zinc-600"
                        @bind="_edit.Model" />
             }
             else if (!string.IsNullOrWhiteSpace(AccessPoint.Model))
@@ -83,8 +83,8 @@
                 <input type="number"
                        step="0.1"
                        class="w-full px-3 py-2 rounded-md
-                              bg-zinc-800 text-zinc-100
-                              border border-zinc-600"
+                          bg-zinc-800 text-zinc-100
+                          border border-zinc-600"
                        @bind="_edit.Speed" />
             }
             else if (AccessPoint.Speed is not null)
@@ -95,7 +95,26 @@
             }
         </div>
 
+        <!-- NOTES — FULL WIDTH -->
+        <div class="md:col-span-2">
+            <div class="text-zinc-400 mb-1">Notes</div>
+
+            @if (_isEditing)
+            {
+                <MarkdownEditor
+                    @bind-Value="_edit.Notes"
+                    ShowSaveButton="false" />
+            }
+            else
+            {
+                <MarkdownViewer
+                    Value="@AccessPoint.Notes"
+                    ShowEditButton="false" />
+            }
+        </div>
+
     </div>
+
 </div>
 
 <ConfirmModal
@@ -145,7 +164,6 @@
         _edit = AccessPointEditModel.From(AccessPoint);
         _isEditing = true;
     }
-
     async Task Save()
     {
         _isEditing = false;
@@ -153,11 +171,12 @@
         await UpdateUseCase.ExecuteAsync(
             AccessPoint.Name,
             _edit.Model,
-            _edit.Speed);
+            _edit.Speed,
+            _edit.Notes);
 
-        // update local view model
         AccessPoint.Model = _edit.Model;
         AccessPoint.Speed = _edit.Speed;
+        AccessPoint.Notes = _edit.Notes; 
     }
 
     void Cancel()
@@ -206,14 +225,17 @@
     {
         public string? Model { get; set; }
         public double? Speed { get; set; }
+        public string? Notes { get; set; }
 
         public static AccessPointEditModel From(AccessPoint ap)
         {
             return new AccessPointEditModel
             {
                 Model = ap.Model,
-                Speed = ap.Speed
+                Speed = ap.Speed,
+                Notes = ap.Notes
             };
         }
     }
+
 }

+ 52 - 0
Shared.Rcl/Components/MarkdownEditor.razor

@@ -0,0 +1,52 @@
+<div class="border border-zinc-800 rounded p-4 bg-zinc-900">
+
+    @if (ShowActionButtons)
+    {
+        <div class="flex justify-end gap-3 mb-2 text-xs">
+            <button class="text-emerald-400 hover:text-emerald-300 transition"
+                    @onclick="HandleSave">
+                Save
+            </button>
+
+            <button class="text-zinc-500 hover:text-zinc-300 transition"
+                    @onclick="HandleCancel">
+                Cancel
+            </button>
+        </div>
+    }
+
+    <textarea
+        class="w-full h-64 bg-zinc-950 text-zinc-200 border border-zinc-700 rounded p-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
+        value="@Value"
+        @oninput="HandleInput">
+    </textarea>
+
+</div>
+
+@code {
+    [Parameter] public string? Value { get; set; }
+    [Parameter] public EventCallback<string?> ValueChanged { get; set; }
+
+    [Parameter] public bool ShowActionButtons { get; set; } = true;
+
+    [Parameter] public EventCallback OnSave { get; set; }
+    [Parameter] public EventCallback OnCancel { get; set; }
+
+    async Task HandleInput(ChangeEventArgs e)
+    {
+        Value = e.Value?.ToString();
+        await ValueChanged.InvokeAsync(Value);
+    }
+
+    async Task HandleSave()
+    {
+        if (OnSave.HasDelegate)
+            await OnSave.InvokeAsync();
+    }
+
+    async Task HandleCancel()
+    {
+        if (OnCancel.HasDelegate)
+            await OnCancel.InvokeAsync();
+    }
+}

+ 53 - 0
Shared.Rcl/Components/MarkdownViewer.razor

@@ -0,0 +1,53 @@
+@using Markdig
+
+<div class="border border-zinc-800 rounded p-4 bg-zinc-900">
+
+    @if (ShowEditButton)
+    {
+        <div class="flex justify-end mb-2 text-xs">
+            <button class="text-blue-400 hover:text-blue-300 transition"
+                    @onclick="HandleEdit">
+                Edit
+            </button>
+        </div>
+    }
+
+    @if (string.IsNullOrWhiteSpace(Value))
+    {
+        <div class="text-sm text-zinc-500 italic">
+            No notes
+        </div>
+    }
+    else
+    {
+        <div class="prose prose-invert max-w-none text-sm">
+            @((MarkupString)_html)
+        </div>
+    }
+
+</div>
+
+@code {
+    [Parameter] public string? Value { get; set; }
+    [Parameter] public bool ShowEditButton { get; set; }
+    [Parameter] public EventCallback OnEdit { get; set; }
+
+    private string _html = "";
+
+    private static readonly MarkdownPipeline Pipeline =
+        new MarkdownPipelineBuilder()
+            .UseAdvancedExtensions()
+            
+            .Build();
+
+    protected override void OnParametersSet()
+    {
+        _html = Markdown.ToHtml(Value ?? "", Pipeline);
+    }
+
+    private async Task HandleEdit()
+    {
+        if (OnEdit.HasDelegate)
+            await OnEdit.InvokeAsync();
+    }
+}

+ 57 - 2
Shared.Rcl/Desktops/DesktopCardComponent.razor

@@ -33,7 +33,6 @@
     <div class="flex justify-between items-center mb-3">
         <div class="text-zinc-100 hover:text-emerald-300">
             <NavLink href="@($"resources/hardware/{Desktop.Name}")" class="block">
-
                 @Desktop.Name
             </NavLink>
         </div>
@@ -218,6 +217,27 @@
         </div>
 
     </div>
+    
+    <div class="md:col-span-2">
+        <div class="text-zinc-400 mb-1">Notes</div>
+
+        @if (!_editingNotes)
+        {
+            <MarkdownViewer
+                Value="@Desktop.Notes"
+                ShowEditButton="true"
+                OnEdit="BeginNotesEdit" />
+        }
+        else
+        {
+            <MarkdownEditor
+                @bind-Value="_notesDraft"
+                ShowActionButtons="true"
+                OnSave="SaveNotes"
+                OnCancel="CancelNotesEdit" />
+        }
+    </div>
+    
 </div>
 <CpuModal
     IsOpen="@_cpuModalOpen"
@@ -557,4 +577,39 @@
         Nav.NavigateTo($"resources/hardware/{newName}");
     }
 
-}
+}
+
+@code
+{
+    bool _editingNotes;
+    string? _notesDraft;
+
+    void BeginNotesEdit()
+    {
+        _editingNotes = true;
+        _notesDraft = Desktop.Notes; // draft buffer
+    }
+
+    void CancelNotesEdit()
+    {
+        _editingNotes = false;
+        _notesDraft = null; // discard
+    }
+
+    async Task SaveNotes()
+    {
+        _editingNotes = false;
+
+        await UpdateDesktopUseCase.ExecuteAsync(
+            Desktop.Name,
+            Desktop.Model,
+            Desktop.Ram?.Size,
+            Desktop.Ram?.Mts,
+            _notesDraft);
+
+        Desktop = await GetDesktopUseCase.ExecuteAsync(Desktop.Name);
+        _notesDraft = null;
+    }
+
+    
+}

+ 1 - 0
Shared.Rcl/Shared.Rcl.csproj

@@ -14,6 +14,7 @@
     </ItemGroup>
 
     <ItemGroup>
+        <PackageReference Include="Markdig" Version="0.45.0" />
         <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.0"/>
         <PackageReference Include="Spectre.Console.Cli" Version="0.53.1" />
         <PackageReference Include="Spectre.Console.Testing" Version="0.54.0" />