Initial commit

This commit is contained in:
2026-07-03 16:37:12 +07:00
commit 63b8c1ea8b
1931 changed files with 640587 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
using Microsoft.AspNetCore.SignalR.Client;
namespace RobotNet10.Components.Clients;
public abstract class HubClient
{
public event Func<HubConnectionState, Task>? ConnectionStateChanged;
public bool IsConnected => Connection.State == HubConnectionState.Connected;
protected HubConnection Connection { get; }
private readonly ManualResetEvent connectedWaitHandler = new(false);
protected HubClient(Uri url)
{
Connection = new HubConnectionBuilder()
.WithUrl(url)
.WithAutomaticReconnect(new HubClientRepeatRetryPolicy(TimeSpan.FromSeconds(3)))
.Build();
Connection.Closed += Connection_Closed;
Connection.Reconnected += Connection_Reconnected;
}
private Task Connection_Closed(Exception? arg)
{
return ConnectionStateChanged?.Invoke(Connection.State) ?? Task.CompletedTask;
}
private Task Connection_Reconnected(string? arg)
{
return ConnectionStateChanged?.Invoke(Connection.State) ?? Task.CompletedTask;
}
public virtual async Task StartAsync()
{
if (Connection.State == HubConnectionState.Disconnected)
{
await Connection.StartAsync();
ConnectionStateChanged?.Invoke(Connection.State);
connectedWaitHandler.Set();
}
}
public void WaitForConnected() => connectedWaitHandler.WaitOne();
public virtual async Task StopAsync()
{
if (Connection.State != HubConnectionState.Disconnected)
{
await Connection.StopAsync();
ConnectionStateChanged?.Invoke(Connection.State);
}
}
public class HubClientRepeatRetryPolicy(TimeSpan repeatSpan) : IRetryPolicy
{
private readonly TimeSpan RepeatTimeSpan = repeatSpan;
public TimeSpan? NextRetryDelay(RetryContext retryContext) => RepeatTimeSpan;
}
}

View File

@@ -0,0 +1,26 @@
<div class="@WidthClass @HeightClass position-relative overflow-hidden">
<div class="w-100 h-100 position-absolute top-0 start-0 @OverflowX @OverflowY @Class">
@ChildContent
</div>
</div>
@code{
[Parameter]
public RenderFragment? ChildContent { get; set; }
[Parameter]
public string WidthClass { get; set; } = "w-100";
[Parameter]
public string HeightClass { get; set; } = "h-100";
[Parameter]
public string OverflowX { get; set; } = "overflow-x-hidden";
[Parameter]
public string OverflowY { get; set; } = "overflow-y-hidden";
[Parameter]
public string Class { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,25 @@
@using Microsoft.AspNetCore.Components.Web
<button class="icon-button @DisabledClass" @onclick="HandleClick" title="@Title" disabled="@Disabled">
<span class="mdi @IconClass"></span>
</button>
@code {
[Parameter] public string Icon { get; set; } = string.Empty;
[Parameter] public string? Title { get; set; }
[Parameter] public EventCallback<MouseEventArgs> OnClick { get; set; }
[Parameter] public bool Disabled { get; set; }
private string IconClass => $"mdi-{Icon} mdi-18px"; /* Giảm icon size để phù hợp với button nhỏ hơn */
private string DisabledClass => Disabled ? "disabled" : string.Empty;
private async Task HandleClick(MouseEventArgs e)
{
if (Disabled || !OnClick.HasDelegate)
{
return;
}
await OnClick.InvokeAsync(e);
}
}

View File

@@ -0,0 +1,43 @@
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px; /* Giảm từ 24px xuống 20px */
height: 20px; /* Giảm từ 24px xuống 20px */
padding: 0;
margin: 0 1px; /* Giảm margin từ 2px xuống 1px */
border: none;
background-color: transparent;
color: #858585;
cursor: pointer;
transition: color 0.15s ease, background-color 0.15s ease;
border-radius: 2px;
flex-shrink: 0; /* Không cho phép shrink */
}
.icon-button:hover {
background-color: #2a2d2e;
color: #cccccc;
}
.icon-button:active {
background-color: #3e3e42;
color: #ffffff;
}
.icon-button:focus {
outline: none;
}
.icon-button.disabled,
.icon-button:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.icon-button span {
display: inline-block;
line-height: 1;
}

View File

@@ -0,0 +1,54 @@
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@inherits LayoutComponentBase
@inject IJSRuntime JS
@inject OptionLayout Options
<div class="w-100 h-100 d-flex flex-column overflow-hidden">
<div class="d-flex flex-row justify-content-between position-relative" style="height: 50px; background-color: #233871">
<div class="h-100 d-flex align-items-center" onclick="robotnet.components.toggleSidebar()">
<img class="ms-1" src="_content/RobotNet10.Components/images/logoLight.svg" alt="PhenikaaX" style="height: 35px;" />
</div>
<div class="w-100 h-100 position-absolute pe-none">
<div class="h-100 mx-auto pe-none" style="width: fit-content;">
<div class="h-100 d-flex align-items-center">
<span class="text-white" style="font-size: 36px;">
@Options.AppName
</span>
</div>
</div>
</div>
<AuthorizeView>
<Authorized>
<div class="d-flex flex-row align-items-center">
<div>
<span class="mdi mdi-account mdi-36px text-white "></span>
</div>
<MudText Class="text-white name" Typo="Typo.subtitle1">@context.User.Identity?.Name</MudText>
<MudSpacer />
<form action="Account/Logout" method="post">
<AntiforgeryToken />
<input type="hidden" name="returnUrl" value="" />
<MudIconButton Class="text-white" ButtonType="@ButtonType.Submit" Icon="@Icons.Material.Filled.Logout" />
</form>
</div>
</Authorized>
</AuthorizeView>
</div>
<div class="w-100 flex-grow-1 d-flex flex-row">
<NavMenu />
<main>
<div>
@Body
</div>
</main>
</div>
</div>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>

View File

@@ -0,0 +1,34 @@
main {
flex-grow: 1;
height: 100%;
position: relative;
}
main > div {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}

View File

@@ -0,0 +1,46 @@
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.Extensions.Options
@using Microsoft.JSInterop
@using RobotNet10.Components
@inject IJSRuntime JS
@inject OptionLayout Options
<div class="sidebar">
<DivContainer HeightClass="flex-grow-1" OverflowY="overflow-y-auto" Class="d-flex flex-column">
<AuthorizeView>
<Authorized>
@foreach (var nav in Options.NavModels)
{
<div class="nav-item px-3">
<NavLink class="nav-link" href="@nav.Path" Match="@nav.Match">
<div class="d-flex align-items-center">
<div class="nav-icon">
<span class="mdi @nav.Icon mdi-36px" aria-hidden="true"></span>
</div>
<span class="nav-label">@nav.Label</span>
</div>
</NavLink>
</div>
}
</Authorized>
</AuthorizeView>
</DivContainer>
<div class="copyright d-flex align-items-center justify-content-end">
<span class="text-white" style="font-size: 10px; text-align: end; margin-right: 5px;">
© Copyright 2025 Phenikaa X <br />v@(Options.Version) | All Rights Reserved
</span>
</div>
</div>
@code {
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("robotnet.components.loadToggleSidebarState");
}
}
}

View File

@@ -0,0 +1,76 @@
.sidebar {
background-image: linear-gradient(180deg, #e06e2e 0%, #3a0647 70%);
height: 100%;
width: 250px;
display: flex;
flex-direction: column;
transition: width 0.3s;
}
.sidebar.collapsed {
width: 74px;
}
.sidebar.collapsed .copyright {
display: none !important;
}
/*.sidebar .user {
display: flex;
flex-direction: row;
align-items: center;
}
.sidebar.collapsed .user > div {
display: none !important;
}*/
.sidebar.collapsed .nav-label {
display: none;
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
overflow: hidden;
}
.nav-item .nav-label {
font-size: 18px;
text-wrap: nowrap;
}
.nav-item .nav-icon {
height: 48px;
width: 48px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep a {
color: #d7d7d7;
border-radius: 4px;
height: 48px;
display: flex;
align-items: center;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep a:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}

View File

@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Components.Routing;
namespace RobotNet10.Components;
public record NavModel(string Icon, string Path, string Label, NavLinkMatch Match);
public class OptionLayout()
{
public string AppName { get; set; } = "RobotNet";
public string Version { get; set; } = "";
public NavModel[] NavModels { get; set; } = [];
}

View File

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Components.Web;
namespace RobotNet10.Components;
public static class RenderMode
{
public readonly static InteractiveWebAssemblyRenderMode InteractiveWebAssemblyNoPrerender = new(prerender: false);
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.3" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
<PackageReference Include="MudBlazor" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\RobotNet10.ScriptEngine.Shared\RobotNet10.ScriptEngine.Shared.csproj" />
<ProjectReference Include="..\..\Shared\RobotNet10.Shared\RobotNet10.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,5 @@
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@using Microsoft.JSInterop
@using MudBlazor
@using RobotNet10.Components

View File

@@ -0,0 +1,15 @@
{
"version": "3.0",
"defaultProvider": "cdnjs",
"libraries": [
{
"library": "bootstrap@5.3.8",
"destination": "wwwroot/lib/bootstrap/"
},
{
"provider": "jsdelivr",
"library": "@mdi/font@7.4.47",
"destination": "wwwroot/lib/mdi/font/"
}
]
}

View File

@@ -0,0 +1,81 @@
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
src: url(KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmZiArmlw.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
src: url(KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmQiArmlw.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
src: url(KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmYiArmlw.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
src: url(KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmXiArmlw.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* math */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
src: url(KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVnoiArmlw.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
src: url(KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVn6iArmlw.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
src: url(KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmbiArmlw.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
src: url(KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmaiArmlw.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
src: url(KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAo.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

View File

@@ -0,0 +1,5 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path d="M31.81,1.79,21.29,14.32l-2.48-3a2.6,2.6,0,0,1,0-3.34l4.45-5.31a2.64,2.64,0,0,1,2-.93Z" fill="#e06e2e"/>
<path d="M.19,1.8,10.71,14.34l2.48-2.95a2.6,2.6,0,0,0,0-3.34L8.75,2.74a2.66,2.66,0,0,0-2-.94Z" fill="#e06e2e"/>
<path d="M32,30.21H25.36a2.61,2.61,0,0,1-2-.92L16.5,21.1a.65.65,0,0,0-1,0L8.63,29.29a2.58,2.58,0,0,1-2,.92H0L12.07,15.83,14,13.57a2.67,2.67,0,0,1,4.08,0l1.89,2.26Z" fill="#233871"/>
</svg>

After

Width:  |  Height:  |  Size: 514 B

View File

@@ -0,0 +1,10 @@
<svg
id="Layer_1"
data-name="Layer 1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
>
<path d="M31.81,1.79,21.29,14.32l-2.48-3a2.6,2.6,0,0,1,0-3.34l4.45-5.31a2.64,2.64,0,0,1,2-.93Z" fill="#e06e2e"/>
<path d="M.19,1.8,10.71,14.34l2.48-2.95a2.6,2.6,0,0,0,0-3.34L8.75,2.74a2.66,2.66,0,0,0-2-.94Z" fill="#e06e2e"/>
<path d="M32,30.21H25.36a2.61,2.61,0,0,1-2-.92L16.5,21.1a.65.65,0,0,0-1,0L8.63,29.29a2.58,2.58,0,0,1-2,.92H0L12.07,15.83,14,13.57a2.67,2.67,0,0,1,4.08,0l1.89,2.26Z" fill="#233871"/>
</svg>

After

Width:  |  Height:  |  Size: 532 B

View File

@@ -0,0 +1,48 @@
<svg
id="Layer_1"
data-name="Layer 1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 370.84 59.64"
>
<path
d="M66.68,0,44.62,26.28,39.43,20.1a5.45,5.45,0,0,1,0-7L48.75,2A5.53,5.53,0,0,1,53,0Z"
fill="#e06e2e"
/>
<path
d="M.4,0,22.46,26.31l5.19-6.18a5.45,5.45,0,0,0,0-7L18.33,2a5.53,5.53,0,0,0-4.22-2Z"
fill="#e06e2e"
/>
<path
d="M67.08,59.59H53.15A5.44,5.44,0,0,1,49,57.65L34.58,40.49a1.36,1.36,0,0,0-2.09,0L18.09,57.65a5.46,5.46,0,0,1-4.17,1.94H0L25.31,29.43l4-4.72a5.57,5.57,0,0,1,8.54,0l4,4.72Z"
fill="#ffffff"
/>
<path
d="M98.34,24.3A12.87,12.87,0,0,1,104,42.84a12.29,12.29,0,0,1-5.61,4.56A21.47,21.47,0,0,1,89.74,49H81.17v7.93a2.72,2.72,0,0,1-2.72,2.73H74.13V22.72H89.74a21.64,21.64,0,0,1,8.6,1.58m-1.93,17a6.52,6.52,0,0,0,2.39-5.43,6.54,6.54,0,0,0-2.39-5.43q-2.39-1.91-7-1.9H81.17V43.18h8.25q4.6,0,7-1.9"
fill="#ffffff"
/>
<path
d="M145.86,25.44V56.91a2.72,2.72,0,0,1-2.72,2.73h-4.33V43.81H119.18V59.64h-4.32a2.73,2.73,0,0,1-2.73-2.73V25.44a2.72,2.72,0,0,1,2.73-2.72h4.32V38h19.63V22.72h4.33a2.72,2.72,0,0,1,2.72,2.72"
fill="#ffffff"
/>
<path
d="M183,56.58v3h-24.8a3.6,3.6,0,0,1-3.6-3.6V26.36a3.6,3.6,0,0,1,3.6-3.6h24.05v3a2.73,2.73,0,0,1-2.73,2.73H161.62v9.57h18.29V43.7H161.62V53.86h18.65A2.72,2.72,0,0,1,183,56.58"
fill="#ffffff"
/>
<path
d="M222.73,22.76V59.59h-4.52a2.71,2.71,0,0,1-2.09-1l-20.06-24V59.59h-7V22.76h4.51a2.72,2.72,0,0,1,2.09,1l20.07,24V22.76Z"
fill="#ffffff"
/>
<rect x="231.85" y="22.76" width="7.03" height="36.83" fill="#ffffff" />
<path
d="M261,44.18l-6,6v9.42h-7V22.76h7V41.65L273,23.57a2.71,2.71,0,0,1,1.93-.81h6.77L265.75,39.23l16.88,20.36h-7a2.72,2.72,0,0,1-2.06-.94Z"
fill="#ffffff"
/>
<path
d="M315.59,51.07H296.65l-3,6.89a2.74,2.74,0,0,1-2.5,1.63h-5.47l16.08-34.74A3.6,3.6,0,0,1,305,22.76h2.32a3.61,3.61,0,0,1,3.27,2.08l16.13,34.75h-5.59A2.72,2.72,0,0,1,318.66,58Zm-2.33-5.37-7.14-16.1L299,45.7Z"
fill="#ffffff"
/>
<path
d="M359.69,51.07H340.76l-3,6.89a2.71,2.71,0,0,1-2.49,1.63h-5.47l16.07-34.74a3.62,3.62,0,0,1,3.27-2.09h2.33a3.59,3.59,0,0,1,3.26,2.08l16.13,34.75h-5.59A2.7,2.7,0,0,1,362.77,58Zm-2.32-5.37-7.14-16.1-7.09,16.1Z"
fill="#ffffff"
/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,36 @@
window.robotnet = {
localStorageHelper: {
getItem: function (key) {
return localStorage.getItem(key);
},
setItem: function (key, value) {
localStorage.setItem(key, value);
},
removeItem: function (key) {
localStorage.removeItem(key);
},
clear: function () {
localStorage.clear();
}
},
components: {
loadToggleSidebarState: function () {
let toggle = localStorage.getItem("navMenuCollapsed");
if (toggle === "collapsed") {
let sidebar = document.querySelector(".sidebar");
sidebar.classList.add("collapsed");
}
},
toggleSidebar: function () {
let sidebar = document.querySelector(".sidebar");
sidebar.classList.toggle("collapsed");
let toggle = localStorage.getItem("navMenuCollapsed");
if (toggle === "collapsed") {
localStorage.setItem("navMenuCollapsed", "");
}
else {
localStorage.setItem("navMenuCollapsed", "collapsed");
}
},
},
};

View File

@@ -0,0 +1,15 @@
@import url("lib/bootstrap/css/bootstrap.min.css");
@import url("lib/mdi/font/css/materialdesignicons.min.css");
@import url("fonts/fonts.googleapis.com.css");
@import url("/_content/MudBlazor/MudBlazor.min.css");
@import url("/_content/BlazorMonaco/lib/monaco-editor/min/vs/editor/editor.main.css");
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
width: 100vw;
height: 100vh;
}
.mud-popover-provider > div {
position: fixed;
}

View File

@@ -0,0 +1,457 @@
@inject IDialogService DialogService
@inject ISnackbar Snackbar
<MudStack Spacing="4">
<!-- Header -->
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudStack Spacing="1">
<MudText Typo="Typo.h6">@(State.SelectedConfig?.ConfigType ?? "Unknown")</MudText>
@if (!string.IsNullOrEmpty(State.SelectedConfig?.Description))
{
<MudText Typo="Typo.body2" Color="Color.Secondary">
@State.SelectedConfig.Description
</MudText>
}
</MudStack>
<MudStack Row="true" Spacing="2">
@if (_hasUnsavedChanges)
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Variant="Variant.Text">
Unsaved changes
</MudChip>
}
<MudButton StartIcon="@Icons.Material.Filled.Save"
Variant="Variant.Filled"
Color="Color.Primary"
OnClick="HandleSave"
Disabled="@(State.IsSaving || !_hasUnsavedChanges)">
Save
</MudButton>
<MudButton StartIcon="@Icons.Material.Filled.Edit"
Variant="Variant.Outlined"
Color="Color.Primary"
OnClick="OpenEditConfigDialog"
Disabled="@(State.IsSaving || Disabled)">
Edit Config
</MudButton>
<MudButton StartIcon="@Icons.Material.Filled.FileDownload"
Variant="Variant.Outlined"
Color="Color.Info"
OnClick="@(() => OnExport.InvokeAsync(State.SelectedConfig!.Id))">
Export
</MudButton>
<MudButton StartIcon="@Icons.Material.Filled.Delete"
Variant="Variant.Outlined"
Color="Color.Error"
OnClick="@(() => OnDelete.InvokeAsync(State.SelectedConfig!.Id))"
Disabled="@(Disabled)">
Delete
</MudButton>
</MudStack>
</MudStack>
<!-- Config Info -->
<MudPaper Class="pa-3" Elevation="1">
<MudGrid>
<MudItem xs="12" md="6">
<MudStack Spacing="1">
<MudText Typo="Typo.caption" Color="Color.Secondary">CONFIG INFO</MudText>
<MudText Typo="Typo.body2">
<strong>Config Type:</strong> @(State.SelectedConfig?.ConfigType ?? "Unknown")
</MudText>
<MudText Typo="Typo.body2">
<strong>Created:</strong> @(State.SelectedConfig?.CreatedAt.ToString("yyyy-MM-dd HH:mm") ?? "N/A")
</MudText>
<MudText Typo="Typo.body2">
<strong>Updated:</strong> @(State.SelectedConfig?.UpdatedAt.ToString("yyyy-MM-dd HH:mm") ?? "N/A")
</MudText>
</MudStack>
</MudItem>
<MudItem xs="12" md="6">
<MudStack Spacing="1">
<MudText Typo="Typo.caption" Color="Color.Secondary">VARIABLES</MudText>
<MudText Typo="Typo.body2">
<strong>Total Variables:</strong> @(State.SelectedConfig?.Variables.Count ?? 0)
</MudText>
</MudStack>
</MudItem>
</MudGrid>
</MudPaper>
<!-- Variables Editor -->
<MudPaper Class="pa-4" Elevation="1">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-3">
<MudText Typo="Typo.h6">Variables</MudText>
<MudButton StartIcon="@Icons.Material.Filled.Add"
Variant="Variant.Outlined"
Color="Color.Success"
Size="Size.Small"
OnClick="OpenAddVariableDialog"
Disabled="@(Disabled)">
Add Variable
</MudButton>
</MudStack>
@if (State.SelectedConfig?.Variables.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center" Class="mt-4">
No variables defined
</MudText>
}
else
{
<MudTable Items="@(State.SelectedConfig?.Variables ?? new List<ConfigVariableModel>())"
Hover="true"
Dense="true"
Elevation="0"
FixedHeader="true"
Height="calc(100vh - 575px)">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Type</MudTh>
<MudTh>Value</MudTh>
<MudTh>Min</MudTh>
<MudTh>Max</MudTh>
<MudTh>Actions</MudTh>
</HeaderContent>
<RowTemplate>
@{
bool canEditThisVariable = _variableEditPermissions.TryGetValue(context.Name, out var canEdit) && canEdit;
}
<MudTd DataLabel="Name">
<MudText Typo="Typo.body2" Style="font-weight: bold;">
@context.Name
</MudText>
</MudTd>
<MudTd DataLabel="Type">
<MudChip T="string" Size="Size.Small" Color="Color.Primary">
@context.Type
</MudChip>
</MudTd>
<MudTd DataLabel="Value">
@{
var variableName = context.Name;
}
<VariableEditor Variable="@context"
OnValueChanged="@((value) => OnVariableValueChanged(variableName, value))"
Disabled="@(!canEditThisVariable)" />
</MudTd>
<MudTd DataLabel="Min">
@if (context.Min.HasValue)
{
<MudText Typo="Typo.body2">@context.Min</MudText>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
}
</MudTd>
<MudTd DataLabel="Max">
@if (context.Max.HasValue)
{
<MudText Typo="Typo.body2">@context.Max</MudText>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
}
</MudTd>
<MudTd DataLabel="Actions">
<MudStack Row="true" Spacing="1">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Color="Color.Primary"
Size="Size.Small"
OnClick="@(() => OpenEditVariableDialog(context))"
Disabled="@(!canEditThisVariable)">
<MudTooltip>Edit Variable</MudTooltip>
</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="@(() => OnRemoveVariable(context.Name))"
Disabled="@(Disabled)">
<MudTooltip>Delete Variable</MudTooltip>
</MudIconButton>
</MudStack>
</MudTd>
</RowTemplate>
</MudTable>
}
</MudPaper>
</MudStack>
@code {
[Parameter]
public ConfigManagerState State { get; set; } = null!;
[Parameter]
public EventCallback OnSave { get; set; }
[Parameter]
public EventCallback<Guid> OnDelete { get; set; }
[Parameter]
public EventCallback<Guid> OnExport { get; set; }
[Parameter]
public bool Disabled { get; set; }
private Dictionary<string, bool> _variableEditPermissions = new();
private Dictionary<string, object?> _originalValues = new(); // Store original values for reset
private bool _hasUnsavedChanges = false;
private Guid? _lastConfigId = null;
protected override async Task OnParametersSetAsync()
{
// Reset when config changes
if (State.SelectedConfig?.Id != _lastConfigId)
{
ResetLocalState();
_lastConfigId = State.SelectedConfig?.Id;
await LoadVariablePermissionsAsync();
}
}
private void ResetLocalState()
{
_originalValues.Clear();
_variableEditPermissions.Clear();
_hasUnsavedChanges = false;
// Store original values
if (State.SelectedConfig != null)
{
foreach (var variable in State.SelectedConfig.Variables)
{
_originalValues[variable.Name] = variable.Value;
}
}
}
private async Task LoadVariablePermissionsAsync()
{
_variableEditPermissions.Clear();
if (State.SelectedConfig != null)
{
foreach (var variable in State.SelectedConfig.Variables)
{
_variableEditPermissions[variable.Name] = await State.CanEditVariableAsync(variable);
}
}
}
private async Task OnVariableValueChanged(string variableName, object? value)
{
// Check permission before updating
if (State.SelectedConfig == null)
return;
var variable = State.SelectedConfig.Variables.FirstOrDefault(v => v.Name == variableName);
if (variable == null)
return;
var canEdit = await State.CanEditVariableAsync(variable);
if (!canEdit)
{
Snackbar.Add("You do not have permission to edit this variable", Severity.Warning);
StateHasChanged(); // Refresh to show original value
return;
}
// Update local state only (don't save to backend yet)
variable.Value = value;
_hasUnsavedChanges = true;
StateHasChanged();
}
private async Task HandleSave()
{
if (State.SelectedConfig == null || !_hasUnsavedChanges)
return;
try
{
// Save all variables to backend
await State.UpdateConfigAsync(State.SelectedConfig.Variables, State.SelectedConfig.Description);
// Reset local state after successful save
ResetLocalState();
Snackbar.Add("Config saved successfully", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add(HttpErrorHelper.GetErrorMessage(ex), Severity.Error);
}
}
private async Task OnRemoveVariable(string variableName)
{
var result = await DialogService.ShowMessageBoxAsync(
"Remove Variable",
$"Are you sure you want to remove variable '{variableName}'?",
yesText: "Remove",
cancelText: "Cancel");
if (result == true)
{
try
{
if (State.SelectedConfig == null)
return;
// Remove variable from local state only (don't save to backend yet)
var variable = State.SelectedConfig.Variables.FirstOrDefault(v => v.Name == variableName);
if (variable != null)
{
State.SelectedConfig.Variables.Remove(variable);
// Remove from original values tracking
_originalValues.Remove(variableName);
_hasUnsavedChanges = true;
StateHasChanged();
Snackbar.Add($"Variable '{variableName}' removed (click Save to persist)", Severity.Info);
}
}
catch (Exception ex)
{
Snackbar.Add(HttpErrorHelper.GetErrorMessage(ex), Severity.Error);
}
}
}
private async Task OpenAddVariableDialog()
{
if (State.SelectedConfig == null)
return;
var parameters = new DialogParameters<AddVariableDialog>
{
{ x => x.OnAdd, EventCallback.Factory.Create<ConfigVariableModel>(this, HandleAddVariable) }
};
var options = new DialogOptions
{
CloseOnEscapeKey = true,
MaxWidth = MaxWidth.Medium,
FullWidth = true
};
await DialogService.ShowAsync<AddVariableDialog>("Add Variable", parameters, options);
}
private async Task HandleAddVariable(ConfigVariableModel variable)
{
try
{
if (State.SelectedConfig == null)
return;
// Add variable to local state only (don't save to backend yet)
State.SelectedConfig.Variables.Add(variable);
// Store original value for new variable (null means it's new)
_originalValues[variable.Name] = null;
_variableEditPermissions[variable.Name] = await State.CanEditVariableAsync(variable);
_hasUnsavedChanges = true;
StateHasChanged();
Snackbar.Add($"Variable '{variable.Name}' added (click Save to persist)", Severity.Info);
}
catch (Exception ex)
{
Snackbar.Add(HttpErrorHelper.GetErrorMessage(ex), Severity.Error);
}
}
private async Task OpenEditConfigDialog()
{
if (State.SelectedConfig == null)
return;
var parameters = new DialogParameters<EditConfigDialog>
{
{ x => x.Config, State.SelectedConfig },
{ x => x.OnSave, EventCallback.Factory.Create<ConfigFileModel>(this, HandleEditConfig) }
};
var options = new DialogOptions
{
CloseOnEscapeKey = true,
MaxWidth = MaxWidth.Medium,
FullWidth = true
};
await DialogService.ShowAsync<EditConfigDialog>("Edit Config", parameters, options);
}
private async Task HandleEditConfig(ConfigFileModel config)
{
try
{
await State.UpdateConfigAsync(description: config.Description);
Snackbar.Add("Config updated successfully", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add(HttpErrorHelper.GetErrorMessage(ex), Severity.Error);
}
}
private async Task OpenEditVariableDialog(ConfigVariableModel variable)
{
if (State.SelectedConfig == null)
return;
var parameters = new DialogParameters<EditVariableDialog>
{
{ x => x.Variable, variable },
{ x => x.OnSave, EventCallback.Factory.Create<ConfigVariableModel>(this, HandleEditVariable) }
};
var options = new DialogOptions
{
CloseOnEscapeKey = true,
MaxWidth = MaxWidth.Medium,
FullWidth = true
};
await DialogService.ShowAsync<EditVariableDialog>("Edit Variable", parameters, options);
}
private async Task HandleEditVariable(ConfigVariableModel variable)
{
try
{
if (State.SelectedConfig == null)
return;
// Find and update the variable in the config
var existingVariable = State.SelectedConfig.Variables.FirstOrDefault(v => v.Name == variable.Name);
if (existingVariable == null)
{
Snackbar.Add($"Variable '{variable.Name}' not found", Severity.Error);
return;
}
// Update variable properties (local state only)
existingVariable.Type = variable.Type;
existingVariable.Value = variable.Value;
existingVariable.Min = variable.Min;
existingVariable.Max = variable.Max;
existingVariable.Roles = variable.Roles;
existingVariable.EnumValues = variable.EnumValues;
// Refresh permission in case Roles changed
_variableEditPermissions[variable.Name] = await State.CanEditVariableAsync(existingVariable);
// Mark as having unsaved changes
_hasUnsavedChanges = true;
StateHasChanged();
Snackbar.Add($"Variable '{variable.Name}' updated (click Save to persist)", Severity.Info);
}
catch (Exception ex)
{
Snackbar.Add(HttpErrorHelper.GetErrorMessage(ex), Severity.Error);
}
}
}

View File

@@ -0,0 +1,36 @@
<MudStack Spacing="2">
<MudText Typo="Typo.h6">Configurations</MudText>
@if (State.Configs.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center" Class="mt-4">
No configs found
</MudText>
}
else
{
@foreach (var config in State.Configs)
{
<MudCard Elevation="@(State.SelectedConfig?.Id == config.Id ? 4 : 1)"
Class="mb-2 cursor-pointer"
Style="@($"background-color: {(State.SelectedConfig?.Id == config.Id ? "var(--mud-palette-primary-lighten)" : "transparent")};")"
@onclick="@(() => OnConfigSelected.InvokeAsync(config))">
<MudCardContent>
<MudStack Spacing="1">
<MudText Typo="Typo.subtitle1" Style="font-weight: bold;">
@config.ConfigType
</MudText>
</MudStack>
</MudCardContent>
</MudCard>
}
}
</MudStack>
@code {
[Parameter]
public ConfigManagerState State { get; set; } = null!;
[Parameter]
public EventCallback<ConfigFileMetadataModel> OnConfigSelected { get; set; }
}

View File

@@ -0,0 +1,320 @@
@inject ConfigManagerState State
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject IJSRuntime JSRuntime
@inject HttpClient HttpClient
@inject NavigationManager NavigationManager
@implements IDisposable
<!-- Toolbar -->
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-4">
<MudPaper Class="pa-4 mb-4" MinHeight="80px">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="3">
<MudText Typo="Typo.h5">Config Manager</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<!-- Search Box -->
<MudTextField Value="@searchText"
Placeholder="Search configs..."
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Margin="Margin.Dense"
Style="min-width: 250px;"
Immediate="false"
T="string"
ValueChanged="OnSearchChanged"
Clearable="true" />
<!-- Import Button -->
<MudButton StartIcon="@Icons.Material.Filled.FileUpload"
Variant="Variant.Filled"
Color="Color.Primary"
OnClick="OpenImportDialog"
Disabled="@(State.IsLoading || !_canEditConfig)">
Import
</MudButton>
<!-- Create Button -->
<MudButton StartIcon="@Icons.Material.Filled.Add"
Variant="Variant.Filled"
Color="Color.Success"
OnClick="OpenCreateDialog"
Disabled="@(State.IsLoading || !_canEditConfig)">
Create
</MudButton>
</MudStack>
</MudStack>
</MudPaper>
<!-- Error Message -->
@if (!string.IsNullOrEmpty(State.ErrorMessage))
{
<MudAlert Severity="Severity.Error" Class="mb-4" ShowCloseIcon="true" CloseIconClicked="ClearError">
@State.ErrorMessage
</MudAlert>
}
<!-- Loading State -->
@if (State.IsLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-4" />
}
<!-- Main Content -->
<MudGrid Spacing="3">
<!-- Left Panel: Config List -->
<MudItem xs="12" md="4">
<MudPaper Elevation="2" Class="pa-4" Style="height: calc(100vh - 250px); overflow-y: auto;">
<ConfigListPanel State="@State" OnConfigSelected="OnConfigSelected" />
</MudPaper>
</MudItem>
<!-- Right Panel: Config Editor -->
<MudItem xs="12" md="8">
<MudPaper Elevation="2" Class="pa-4" Style="height: calc(100vh - 250px); overflow: hidden;">
@if (State.SelectedConfig != null)
{
<ConfigEditorPanel State="@State"
OnSave="@(EventCallback.Factory.Create(this, OnSaveConfig))"
OnDelete="@(EventCallback.Factory.Create<Guid>(this, OnDeleteConfig))"
OnExport="@(EventCallback.Factory.Create<Guid>(this, OnExportConfig))"
Disabled="!_canEditConfig" />
}
else
{
<MudStack AlignItems="AlignItems.Center" Justify="Justify.Center" Style="height: 100%;">
<MudIcon Icon="@Icons.Material.Filled.Settings" Size="Size.Large" Color="Color.Secondary" Style="font-size: 100px;" />
<MudText Typo="Typo.h6" Color="Color.Secondary" Align="Align.Center" Class="mt-4">
Select a config to view and edit
</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center">
Choose a config from the list on the left
</MudText>
</MudStack>
}
</MudPaper>
</MudItem>
</MudGrid>
</MudContainer>
@code {
private const string ApiPath = "api/configs";
private string? searchText;
private Timer? _searchTimer;
private bool _canEditConfig = true;
protected override async Task OnInitializedAsync()
{
State.OnStateChanged += StateChanged;
await State.LoadConfigsAsync();
await LoadPermissionsAsync();
}
private async Task LoadPermissionsAsync()
{
_canEditConfig = await State.CanEditConfigAsync();
}
private void StateChanged()
{
InvokeAsync(StateHasChanged);
}
private async Task OnSearchChanged(string? value)
{
searchText = value;
// Debounce search
_searchTimer?.Dispose();
_searchTimer = new Timer(async _ =>
{
await InvokeAsync(async () =>
{
await State.LoadConfigsAsync(searchText);
});
}, null, 500, Timeout.Infinite);
}
private async Task OnConfigSelected(ConfigFileMetadataModel config)
{
if (config.Id == State.SelectedConfig?.Id) return;
await State.SelectConfigAsync(config);
}
private async Task OpenImportDialog()
{
var parameters = new DialogParameters<ImportConfigDialog>
{
{ x => x.OnImport, EventCallback.Factory.Create<ConfigFileModel>(this, HandleImport) }
};
var options = new DialogOptions
{
CloseOnEscapeKey = true,
MaxWidth = MaxWidth.Medium,
FullWidth = true
};
await DialogService.ShowAsync<ImportConfigDialog>("Import Config", parameters, options);
}
private async Task OpenCreateDialog()
{
var parameters = new DialogParameters<CreateConfigDialog>
{
{ x => x.OnCreate, EventCallback.Factory.Create<ConfigFileModel>(this, HandleCreate) }
};
var options = new DialogOptions
{
CloseOnEscapeKey = true,
MaxWidth = MaxWidth.Medium,
FullWidth = true
};
await DialogService.ShowAsync<CreateConfigDialog>("Create Config", parameters, options);
}
private async Task HandleImport(ConfigFileModel config)
{
Snackbar.Add($"Config '{config.ConfigType}' imported successfully", Severity.Success);
await State.LoadConfigsAsync(State.SearchQuery);
}
private async Task HandleCreate(ConfigFileModel config)
{
Snackbar.Add($"Config '{config.ConfigType}' created successfully", Severity.Success);
await State.LoadConfigsAsync(State.SearchQuery);
}
private async Task OnSaveConfig()
{
try
{
await State.UpdateConfigAsync();
Snackbar.Add("Config saved successfully", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add(HttpErrorHelper.GetErrorMessage(ex), Severity.Error);
}
}
private async Task OnDeleteConfig(Guid id)
{
var result = await DialogService.ShowMessageBoxAsync(
"Delete Config",
"Are you sure you want to delete this config?",
yesText: "Delete",
cancelText: "Cancel");
if (result == true)
{
try
{
await State.DeleteConfigAsync(id);
Snackbar.Add("Config deleted successfully", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add(HttpErrorHelper.GetErrorMessage(ex), Severity.Error);
}
}
}
private async Task OnExportConfig(Guid id)
{
try
{
// Get config type for filename
var configType = State.SelectedConfig?.ConfigType ?? State.Configs.FirstOrDefault(c => c.Id == id)?.ConfigType;
var fileName = !string.IsNullOrEmpty(configType) ? $"{configType}.config.json" : "config.config.json";
// Build export URL
var baseUrl = NavigationManager.BaseUri.TrimEnd('/');
var exportUrl = $"{baseUrl}/{ApiPath}/{id}/export";
// Try to download directly from URL first (simpler and more reliable)
try
{
await DownloadFileFromUrl(exportUrl, fileName);
Snackbar.Add("Config exported successfully", Severity.Success);
return;
}
catch (Microsoft.JSInterop.JSException)
{
// Fallback to stream method if URL download fails
}
// Fallback: Get stream from API
var stream = await State.ExportConfigAsync(id);
await DownloadFile(stream, fileName);
Snackbar.Add("Config exported successfully", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add(HttpErrorHelper.GetErrorMessage(ex), Severity.Error);
}
}
private async Task DownloadFileFromUrl(string url, string fileName)
{
// Use JavaScript to download file directly from URL
await JSRuntime.InvokeVoidAsync("downloadFileFromUrl", url, fileName);
}
private async Task DownloadFile(Stream stream, string fileName)
{
try
{
// Ensure stream position is at the beginning
if (stream.CanSeek && stream.Position != 0)
{
stream.Position = 0;
}
// Verify stream is readable
if (!stream.CanRead)
{
throw new InvalidOperationException("Stream is not readable");
}
// Verify stream has data
if (stream.Length == 0)
{
throw new InvalidOperationException("Stream is empty");
}
// Create stream reference - this will keep the stream alive until JS reads it
var streamRef = new DotNetStreamReference(stream);
await JSRuntime.InvokeVoidAsync("downloadFileFromStream", fileName, streamRef);
// Note: DotNetStreamReference will dispose the stream after JS reads it
}
catch (Microsoft.JSInterop.JSException jsEx)
{
var errorMsg = jsEx.Message.Contains("downloadFileFromStream") || jsEx.Message.Contains("is not defined")
? "JavaScript function not found. Please ensure downloadFile.js is loaded in your HTML."
: $"JavaScript error: {jsEx.Message}";
Snackbar.Add(errorMsg, Severity.Error);
}
catch (Exception ex)
{
var errorMsg = HttpErrorHelper.GetErrorMessage(ex);
Snackbar.Add(errorMsg, Severity.Error);
}
}
private void ClearError()
{
State.ClearError();
}
public void Dispose()
{
State.OnStateChanged -= StateChanged;
_searchTimer?.Dispose();
}
}

View File

@@ -0,0 +1,228 @@
@inject ISnackbar Snackbar
@inject ConfigManagerState State
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudTextField @bind-Value="variableName"
Label="Variable Name"
Required="true"
HelperText="Unique name for the variable"
Variant="Variant.Outlined" />
<MudSelect T="string"
@bind-Value="variableType"
Label="Type"
Required="true"
Variant="Variant.Outlined">
<MudSelectItem Value="@("string")">String</MudSelectItem>
<MudSelectItem Value="@("int")">Int</MudSelectItem>
<MudSelectItem Value="@("double")">Double</MudSelectItem>
<MudSelectItem Value="@("bool")">Bool</MudSelectItem>
<MudSelectItem Value="@("enum")">Enum</MudSelectItem>
<MudSelectItem Value="@("object")">Object</MudSelectItem>
<MudSelectItem Value="@("array")">Array</MudSelectItem>
</MudSelect>
@if (variableType == "int" || variableType == "double")
{
<MudGrid>
<MudItem xs="6">
<MudNumericField T="double?"
@bind-Value="minValue"
Label="Min Value"
Variant="Variant.Outlined"
HelperText="Optional minimum value" />
</MudItem>
<MudItem xs="6">
<MudNumericField T="double?"
@bind-Value="maxValue"
Label="Max Value"
Variant="Variant.Outlined"
HelperText="Optional maximum value" />
</MudItem>
</MudGrid>
}
@if (variableType == "enum")
{
<MudStack Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Secondary">
Enum Values (one per line)
</MudText>
<MudTextField @bind-Value="enumValuesText"
Label="Enum Values"
Required="true"
Lines="3"
Variant="Variant.Outlined"
HelperText="Enter one value per line" />
</MudStack>
}
@if (variableType == "string")
{
<MudTextField @bind-Value="stringValue"
Label="Default Value"
Variant="Variant.Outlined"
HelperText="Optional default value" />
}
else if (variableType == "int")
{
<MudNumericField T="int?"
@bind-Value="intValue"
Label="Default Value"
Variant="Variant.Outlined"
Min="@((int?)minValue)"
Max="@((int?)maxValue)"
HelperText="Optional default value" />
}
else if (variableType == "double")
{
<MudNumericField T="double?"
@bind-Value="doubleValue"
Label="Default Value"
Variant="Variant.Outlined"
Min="@minValue"
Max="@maxValue"
HelperText="Optional default value" />
}
else if (variableType == "bool")
{
<MudSwitch T="bool"
@bind-Value="boolValue"
Label="Default Value"
Color="Color.Primary" />
}
else if (variableType == "enum")
{
<MudSelect T="string"
@bind-Value="enumValue"
Label="Default Value"
Variant="Variant.Outlined"
HelperText="Select default enum value">
@if (!string.IsNullOrEmpty(enumValuesText))
{
@foreach (var val in GetEnumValues())
{
<MudSelectItem Value="@val">@val</MudSelectItem>
}
}
</MudSelect>
}
else if (variableType == "object" || variableType == "array")
{
<MudTextField @bind-Value="jsonValue"
Label="Default Value (JSON)"
Lines="3"
Variant="Variant.Outlined"
HelperText="Enter valid JSON" />
}
<MudTextField @bind-Value="roles"
Label="Roles"
Variant="Variant.Outlined"
HelperText="Optional roles (comma-separated)" />
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error">@errorMessage</MudAlert>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(!IsValid() || isAdding)">
@if (isAdding)
{
<MudProgressCircular Class="ms-n1" Size="Size.Small" Indeterminate="true" />
<MudText Class="ms-2">Adding...</MudText>
}
else
{
<span>Add</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code
{
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public EventCallback<ConfigVariableModel> OnAdd { get; set; } = default;
private string variableName = string.Empty;
private string variableType = "string";
private string? roles;
private string? stringValue;
private int? intValue;
private double? doubleValue;
private bool boolValue;
private string? enumValue;
private string? enumValuesText;
private string? jsonValue;
private double? minValue;
private double? maxValue;
private string? errorMessage;
private bool isAdding = false;
private bool IsValid() => VariableDialogHelper.IsValid(variableName, variableType, enumValuesText, jsonValue);
private List<string> GetEnumValues() => VariableDialogHelper.GetEnumValues(enumValuesText);
private object? GetDefaultValue() => VariableDialogHelper.GetDefaultValue(variableType, stringValue, intValue, doubleValue, boolValue, enumValue, jsonValue);
private void Cancel() => MudDialog.Cancel();
private async Task Submit()
{
if (!IsValid())
{
errorMessage = "Please fill in all required fields correctly";
return;
}
isAdding = true;
errorMessage = null;
try
{
// Check if variable name already exists
if (State.SelectedConfig?.Variables.Any(v =>
v.Name.Equals(variableName, StringComparison.OrdinalIgnoreCase)) == true)
{
errorMessage = $"Variable '{variableName}' already exists in this config";
isAdding = false;
return;
}
var variable = new ConfigVariableModel
{
Name = variableName,
Type = variableType,
Value = GetDefaultValue(),
Min = (variableType == "int" || variableType == "double") ? minValue : null,
Max = (variableType == "int" || variableType == "double") ? maxValue : null,
Roles = roles ?? string.Empty,
EnumValues = variableType == "enum" ? GetEnumValues() : null
};
await OnAdd.InvokeAsync(variable);
MudDialog.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
errorMessage = HttpErrorHelper.GetErrorMessage(ex);
Snackbar.Add(errorMessage, Severity.Error);
}
finally
{
isAdding = false;
}
}
}

View File

@@ -0,0 +1,102 @@
@inject ISnackbar Snackbar
@inject ConfigManagerState State
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudTextField @bind-Value="configType"
Label="Config Type"
Required="true"
HelperText="Unique identifier for the config (e.g., MQTTBrokerConfig)"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="description"
Label="Description"
Lines="3"
HelperText="Optional description"
Variant="Variant.Outlined" />
<MudText Typo="Typo.body2" Color="Color.Secondary">
Note: Variables can be added after creating the config.
</MudText>
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error">@errorMessage</MudAlert>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Success"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(!IsValid() || isCreating)">
@if (isCreating)
{
<MudProgressCircular Class="ms-n1" Size="Size.Small" Indeterminate="true" />
<MudText Class="ms-2">Creating...</MudText>
}
else
{
<span>Create</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public EventCallback<ConfigFileModel> OnCreate { get; set; } = default;
private string configType = string.Empty;
private string? description;
private string? errorMessage;
private bool isCreating = false;
private bool IsValid()
{
return !string.IsNullOrWhiteSpace(configType);
}
private void Cancel() => MudDialog.Cancel();
private async Task Submit()
{
if (!IsValid())
return;
isCreating = true;
errorMessage = null;
try
{
// Check if config type already exists
var exists = await State.ConfigTypeExistsAsync(configType);
if (exists)
{
errorMessage = $"Config with type '{configType}' already exists";
isCreating = false;
return;
}
// Create config with empty variables
var config = await State.CreateConfigAsync(configType, new List<ConfigVariableModel>(), description);
await OnCreate.InvokeAsync(config);
MudDialog.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
errorMessage = HttpErrorHelper.GetErrorMessage(ex);
Snackbar.Add(errorMessage, Severity.Error);
}
finally
{
isCreating = false;
}
}
}

View File

@@ -0,0 +1,85 @@
@inject ISnackbar Snackbar
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudTextField Value="@Config.ConfigType"
Label="Config Type"
Disabled="true"
Variant="Variant.Outlined"
HelperText="Config Type cannot be changed" />
<MudTextField @bind-Value="description"
Label="Description"
Lines="3"
HelperText="Optional description"
Variant="Variant.Outlined" />
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error">@errorMessage</MudAlert>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@isSaving">
@if (isSaving)
{
<MudProgressCircular Class="ms-n1" Size="Size.Small" Indeterminate="true" />
<MudText Class="ms-2">Saving...</MudText>
}
else
{
<span>Save</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public ConfigFileModel Config { get; set; } = null!;
[Parameter]
public EventCallback<ConfigFileModel> OnSave { get; set; }
private string? description;
private string? errorMessage;
private bool isSaving = false;
protected override void OnParametersSet()
{
description = Config.Description;
}
private void Cancel() => MudDialog.Cancel();
private async Task Submit()
{
isSaving = true;
errorMessage = null;
try
{
Config.Description = description;
await OnSave.InvokeAsync(Config);
MudDialog.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
errorMessage = HttpErrorHelper.GetErrorMessage(ex);
Snackbar.Add(errorMessage, Severity.Error);
}
finally
{
isSaving = false;
}
}
}

View File

@@ -0,0 +1,269 @@
@inject ISnackbar Snackbar
@inject ConfigManagerState State
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudTextField Value="@variableName"
Label="Variable Name"
Disabled="true"
Variant="Variant.Outlined"
HelperText="Variable name cannot be changed" />
<MudSelect T="string"
Value="@variableType"
Label="Type"
Disabled="true"
Variant="Variant.Outlined"
HelperText="Type cannot be changed">
<MudSelectItem Value="@("string")">String</MudSelectItem>
<MudSelectItem Value="@("int")">Int</MudSelectItem>
<MudSelectItem Value="@("double")">Double</MudSelectItem>
<MudSelectItem Value="@("bool")">Bool</MudSelectItem>
<MudSelectItem Value="@("enum")">Enum</MudSelectItem>
<MudSelectItem Value="@("object")">Object</MudSelectItem>
<MudSelectItem Value="@("array")">Array</MudSelectItem>
</MudSelect>
@if (variableType == "int" || variableType == "double")
{
<MudGrid>
<MudItem xs="6">
<MudNumericField T="double?"
@bind-Value="minValue"
Label="Min Value"
Variant="Variant.Outlined"
HelperText="Optional minimum value" />
</MudItem>
<MudItem xs="6">
<MudNumericField T="double?"
@bind-Value="maxValue"
Label="Max Value"
Variant="Variant.Outlined"
HelperText="Optional maximum value" />
</MudItem>
</MudGrid>
}
@if (variableType == "enum")
{
<MudStack Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Secondary">
Enum Values (one per line)
</MudText>
<MudTextField @bind-Value="enumValuesText"
Label="Enum Values"
Required="true"
Lines="3"
Variant="Variant.Outlined"
HelperText="Enter one value per line" />
</MudStack>
}
@if (variableType == "string")
{
<MudTextField @bind-Value="stringValue"
Label="Default Value"
Variant="Variant.Outlined"
HelperText="Optional default value" />
}
else if (variableType == "int")
{
<MudNumericField T="int?"
@bind-Value="intValue"
Label="Default Value"
Variant="Variant.Outlined"
Min="@((int?)minValue)"
Max="@((int?)maxValue)"
HelperText="Optional default value" />
}
else if (variableType == "double")
{
<MudNumericField T="double?"
@bind-Value="doubleValue"
Label="Default Value"
Variant="Variant.Outlined"
Min="@minValue"
Max="@maxValue"
HelperText="Optional default value" />
}
else if (variableType == "bool")
{
<MudSwitch T="bool"
@bind-Value="boolValue"
Label="Default Value"
Color="Color.Primary" />
}
else if (variableType == "enum")
{
<MudSelect T="string"
@bind-Value="enumValue"
Label="Default Value"
Variant="Variant.Outlined"
HelperText="Select default enum value">
@if (!string.IsNullOrEmpty(enumValuesText))
{
@foreach (var val in GetEnumValues())
{
<MudSelectItem Value="@val">@val</MudSelectItem>
}
}
</MudSelect>
}
else if (variableType == "object" || variableType == "array")
{
<MudTextField @bind-Value="jsonValue"
Label="Default Value (JSON)"
Lines="3"
Variant="Variant.Outlined"
HelperText="Enter valid JSON" />
}
<MudTextField @bind-Value="roles"
Label="Roles"
Variant="Variant.Outlined"
HelperText="Optional roles (comma-separated)" />
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error">@errorMessage</MudAlert>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(!IsValid() || isSaving)">
@if (isSaving)
{
<MudProgressCircular Class="ms-n1" Size="Size.Small" Indeterminate="true" />
<MudText Class="ms-2">Saving...</MudText>
}
else
{
<span>Save</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code
{
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public ConfigVariableModel Variable { get; set; } = null!;
[Parameter]
public EventCallback<ConfigVariableModel> OnSave { get; set; } = default;
private string variableName = string.Empty;
private string variableType = "string";
private string? roles;
private string? stringValue;
private int? intValue;
private double? doubleValue;
private bool boolValue;
private string? enumValue;
private string? enumValuesText;
private string? jsonValue;
private double? minValue;
private double? maxValue;
private string? errorMessage;
private bool isSaving = false;
protected override void OnParametersSet()
{
// Initialize from existing variable
variableName = Variable.Name;
variableType = Variable.Type;
roles = Variable.Roles;
minValue = Variable.Min;
maxValue = Variable.Max;
enumValuesText = Variable.EnumValues != null ? string.Join("\n", Variable.EnumValues) : null;
// Initialize default value based on type
switch (Variable.Type.ToLower())
{
case "string":
stringValue = Variable.Value?.ToString() ?? string.Empty;
break;
case "int":
if (Variable.Value is int i)
intValue = i;
else if (int.TryParse(Variable.Value?.ToString(), out var parsedInt))
intValue = parsedInt;
break;
case "double":
if (Variable.Value is double d)
doubleValue = d;
else if (double.TryParse(Variable.Value?.ToString(), out var parsedDouble))
doubleValue = parsedDouble;
break;
case "bool":
if (Variable.Value is bool b)
boolValue = b;
else if (bool.TryParse(Variable.Value?.ToString(), out var parsedBool))
boolValue = parsedBool;
break;
case "enum":
enumValue = Variable.Value?.ToString();
break;
case "object":
case "array":
jsonValue = Variable.Value?.ToString() ?? string.Empty;
break;
}
}
private bool IsValid() => VariableDialogHelper.IsValid(variableName, variableType, enumValuesText, jsonValue);
private List<string> GetEnumValues() => VariableDialogHelper.GetEnumValues(enumValuesText);
private object? GetDefaultValue() => VariableDialogHelper.GetDefaultValue(variableType, stringValue, intValue, doubleValue, boolValue, enumValue, jsonValue);
private void Cancel() => MudDialog.Cancel();
private async Task Submit()
{
if (!IsValid())
{
errorMessage = "Please fill in all required fields correctly";
return;
}
isSaving = true;
errorMessage = null;
try
{
var variable = new ConfigVariableModel
{
Name = variableName,
Type = variableType,
Value = GetDefaultValue(),
Min = (variableType == "int" || variableType == "double") ? minValue : null,
Max = (variableType == "int" || variableType == "double") ? maxValue : null,
Roles = roles ?? string.Empty,
EnumValues = variableType == "enum" ? GetEnumValues() : null
};
await OnSave.InvokeAsync(variable);
MudDialog.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
errorMessage = HttpErrorHelper.GetErrorMessage(ex);
Snackbar.Add(errorMessage, Severity.Error);
}
finally
{
isSaving = false;
}
}
}

View File

@@ -0,0 +1,193 @@
@inject ISnackbar Snackbar
@inject ConfigManagerState State
@using System.Text.Json
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudTextField @bind-Value="configType"
Label="Config Type"
Required="true"
HelperText="Unique identifier for the config (e.g., MQTTBrokerConfig)"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="description"
Label="Description"
Lines="3"
HelperText="Optional description"
Variant="Variant.Outlined" />
<MudFileUpload T="IBrowserFile"
Accept=".json"
FilesChanged="OnFileSelected"
MaximumFileCount="1">
<CustomContent>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Upload"
FullWidth="true"
OnClick="@context.OpenFilePickerAsync">
@if (selectedFile != null)
{
<span>@selectedFile.Name</span>
}
else
{
<span>Select JSON File</span>
}
</MudButton>
</CustomContent>
</MudFileUpload>
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error">@errorMessage</MudAlert>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(!IsValid() || isImporting)">
@if (isImporting)
{
<MudProgressCircular Class="ms-n1" Size="Size.Small" Indeterminate="true" />
<MudText Class="ms-2">Importing...</MudText>
}
else
{
<span>Import</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public EventCallback<ConfigFileModel> OnImport { get; set; } = default;
private string configType = string.Empty;
private string? description;
private IBrowserFile? selectedFile;
private string? errorMessage;
private bool isImporting = false;
private bool IsValid()
{
return !string.IsNullOrWhiteSpace(configType) && selectedFile != null;
}
private async Task OnFileSelected(IBrowserFile? file)
{
selectedFile = file;
errorMessage = null;
// If file is selected, try to parse it and extract ConfigType and Description
if (file != null)
{
try
{
await ParseFileAndFillForm(file);
}
catch (Exception ex)
{
// Don't show error if parsing fails - user can still manually enter the fields
// Just log or ignore
System.Diagnostics.Debug.WriteLine($"Failed to parse file: {ex.Message}");
}
}
StateHasChanged();
}
private async Task ParseFileAndFillForm(IBrowserFile file)
{
using var stream = file.OpenReadStream();
using var reader = new StreamReader(stream);
var json = await reader.ReadToEndAsync();
// Parse JSON to check if it has ConfigType and Description
using var jsonDoc = JsonDocument.Parse(json);
var root = jsonDoc.RootElement;
// Check if it's new format (object with metadata)
if (root.ValueKind == JsonValueKind.Object)
{
// Try to get ConfigType
if (root.TryGetProperty("configType", out var configTypeProp) &&
configTypeProp.ValueKind == JsonValueKind.String)
{
var fileConfigType = configTypeProp.GetString();
// Only fill if field is empty
if (string.IsNullOrWhiteSpace(configType) && !string.IsNullOrWhiteSpace(fileConfigType))
{
configType = fileConfigType;
}
}
// Try to get Description
if (root.TryGetProperty("description", out var descProp))
{
string? fileDescription = null;
if (descProp.ValueKind == JsonValueKind.String)
{
fileDescription = descProp.GetString();
}
else if (descProp.ValueKind == JsonValueKind.Null)
{
fileDescription = null;
}
// Only fill if field is empty
if (string.IsNullOrWhiteSpace(description) && !string.IsNullOrWhiteSpace(fileDescription))
{
description = fileDescription;
}
}
}
}
private void Cancel() => MudDialog.Cancel();
private async Task Submit()
{
if (!IsValid() || selectedFile == null)
return;
isImporting = true;
errorMessage = null;
try
{
// Check if config type already exists
var exists = await State.ConfigTypeExistsAsync(configType);
if (exists)
{
errorMessage = $"Config with type '{configType}' already exists";
isImporting = false;
return;
}
// Read file stream
using var stream = selectedFile.OpenReadStream();
var config = await State.ImportConfigAsync(stream, selectedFile.Name, configType, description);
await OnImport.InvokeAsync(config);
MudDialog.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
errorMessage = HttpErrorHelper.GetErrorMessage(ex);
Snackbar.Add(errorMessage, Severity.Error);
}
finally
{
isImporting = false;
}
}
}

View File

@@ -0,0 +1,80 @@
namespace RobotNet10.CustomConfigurationEditor.Components.ConfigManager.Dialogs;
/// <summary>
/// Shared helper methods cho AddVariableDialog và EditVariableDialog
/// </summary>
internal static class VariableDialogHelper
{
/// <summary>
/// Validate variable input fields
/// </summary>
public static bool IsValid(string? variableName, string? variableType, string? enumValuesText, string? jsonValue)
{
if (string.IsNullOrWhiteSpace(variableName))
return false;
if (string.IsNullOrWhiteSpace(variableType))
return false;
// Validate enum
if (variableType == "enum")
{
if (string.IsNullOrWhiteSpace(enumValuesText))
return false;
var values = GetEnumValues(enumValuesText);
if (values.Count == 0)
return false;
}
// Validate JSON for object/array
if (variableType == "object" || variableType == "array")
{
if (!string.IsNullOrWhiteSpace(jsonValue))
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(jsonValue);
}
catch (System.Text.Json.JsonException)
{
return false;
}
}
}
return true;
}
/// <summary>
/// Parse enum values từ text (one per line)
/// </summary>
public static List<string> GetEnumValues(string? enumValuesText)
{
if (string.IsNullOrWhiteSpace(enumValuesText))
return [];
return enumValuesText.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(v => v.Trim())
.Where(v => !string.IsNullOrWhiteSpace(v))
.ToList();
}
/// <summary>
/// Lấy default value theo variable type
/// </summary>
public static object? GetDefaultValue(string? variableType, string? stringValue, int? intValue, double? doubleValue, bool boolValue, string? enumValue, string? jsonValue)
{
return variableType switch
{
"string" => stringValue ?? string.Empty,
"int" => intValue,
"double" => doubleValue,
"bool" => boolValue,
"enum" => enumValue,
"object" => jsonValue,
"array" => jsonValue,
_ => null
};
}
}

View File

@@ -0,0 +1,188 @@
@switch (Variable.Type.ToLower())
{
case "string":
<MudTextField Value="@stringValue"
Variant="Variant.Outlined"
Margin="Margin.Dense"
T="string"
Disabled="@Disabled"
ValueChanged="HandleStringValueChanged" />
break;
case "int":
<MudNumericField T="int?"
Value="@intValue"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Disabled="@Disabled"
Min="@(Variable.Min.HasValue ? (int?)Variable.Min.Value : null)"
Max="@(Variable.Max.HasValue ? (int?)Variable.Max.Value : null)"
ValueChanged="HandleIntValueChanged" />
break;
case "double":
<MudNumericField T="double?"
Value="@doubleValue"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Disabled="@Disabled"
Min="@Variable.Min"
Max="@Variable.Max"
ValueChanged="HandleDoubleValueChanged" />
break;
case "bool":
<MudSwitch T="bool"
Value="@boolValue"
Color="Color.Primary"
Disabled="@Disabled"
ValueChanged="HandleBoolValueChanged" />
break;
case "enum":
<MudSelect T="string"
Value="@enumValue"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Dense="true"
Disabled="@Disabled"
ValueChanged="HandleEnumValueChanged">
@if (Variable.EnumValues != null)
{
@foreach (var enumVal in Variable.EnumValues)
{
<MudSelectItem Value="@enumVal">@enumVal</MudSelectItem>
}
}
</MudSelect>
break;
case "object":
case "array":
<MudTextField Value="@jsonValue"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Lines="3"
T="string"
Disabled="@Disabled"
ValueChanged="HandleJsonValueChanged"
Placeholder="Enter JSON..." />
break;
default:
<MudTextField Value="@stringValue"
Variant="Variant.Outlined"
Margin="Margin.Dense"
T="string"
Disabled="@Disabled"
ValueChanged="@HandleStringValueChanged" />
break;
}
@code {
[Parameter]
public ConfigVariableModel Variable { get; set; } = null!;
[Parameter]
public EventCallback<object?> OnValueChanged { get; set; }
[Parameter]
public bool Disabled { get; set; } = false;
private string? stringValue;
private int? intValue;
private double? doubleValue;
private bool boolValue;
private string? enumValue;
private string? jsonValue;
protected override void OnParametersSet()
{
// Initialize values based on type
switch (Variable.Type.ToLower())
{
case "string":
stringValue = Variable.Value?.ToString() ?? string.Empty;
break;
case "int":
if (Variable.Value is int i)
intValue = i;
else if (int.TryParse(Variable.Value?.ToString(), out var parsedInt))
intValue = parsedInt;
break;
case "double":
if (Variable.Value is double d)
doubleValue = d;
else if (double.TryParse(Variable.Value?.ToString(), out var parsedDouble))
doubleValue = parsedDouble;
break;
case "bool":
if (Variable.Value is bool b)
boolValue = b;
else if (bool.TryParse(Variable.Value?.ToString(), out var parsedBool))
boolValue = parsedBool;
break;
case "enum":
enumValue = Variable.Value?.ToString();
break;
case "object":
case "array":
jsonValue = Variable.Value?.ToString() ?? string.Empty;
break;
}
}
private async Task HandleStringValueChanged(string value)
{
stringValue = value;
await OnValueChanged.InvokeAsync(value);
}
private async Task HandleIntValueChanged(int? value)
{
intValue = value;
await OnValueChanged.InvokeAsync(value);
}
private async Task HandleDoubleValueChanged(double? value)
{
doubleValue = value;
await OnValueChanged.InvokeAsync(value);
}
private async Task HandleBoolValueChanged(bool value)
{
boolValue = value;
await OnValueChanged.InvokeAsync(value);
}
private async Task HandleEnumValueChanged(string value)
{
enumValue = value;
await OnValueChanged.InvokeAsync(value);
}
private async Task HandleJsonValueChanged(string value)
{
jsonValue = value;
object? convertedValue = value;
// For JSON types, try to parse
if (!string.IsNullOrEmpty(value))
{
try
{
// Validate JSON
using var doc = System.Text.Json.JsonDocument.Parse(value);
convertedValue = value;
}
catch (System.Text.Json.JsonException)
{
// Invalid JSON, but still pass it through
convertedValue = value;
}
}
await OnValueChanged.InvokeAsync(convertedValue);
}
}

View File

@@ -0,0 +1,14 @@
namespace RobotNet10.CustomConfigurationEditor.Models;
/// <summary>
/// Model cho ConfigFileMetadata trong frontend (list view)
/// </summary>
public class ConfigFileMetadataModel
{
public Guid Id { get; set; }
public string ConfigType { get; set; } = string.Empty; // Tên định danh duy nhất
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public string? Description { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace RobotNet10.CustomConfigurationEditor.Models;
/// <summary>
/// Model cho ConfigFile trong frontend
/// </summary>
public class ConfigFileModel
{
public Guid Id { get; set; }
public string ConfigType { get; set; } = string.Empty; // Tên định danh duy nhất
public List<ConfigVariableModel> Variables { get; set; } = new();
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public string? Description { get; set; }
}

View File

@@ -0,0 +1,18 @@
namespace RobotNet10.CustomConfigurationEditor.Models;
/// <summary>
/// Model cho ConfigVariable trong frontend
/// </summary>
public class ConfigVariableModel
{
public string Name { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty; // "string", "int", "double", "bool", "object", "array", "enum"
public object? Value { get; set; }
// Optional properties
public double? Min { get; set; } // Cho int và double (0 nếu không dùng)
public double? Max { get; set; } // Cho int và double (0 nếu không dùng)
public string Roles { get; set; } = string.Empty; // Roles string (có thể empty)
public List<string>? EnumValues { get; set; } // Cho type Enum - danh sách các giá trị cho phép
}

View File

@@ -0,0 +1,562 @@
# RobotNet10.CustomConfigurationEditor
Component Blazor để quản lý cấu hình với giao diện người dùng, tích hợp với `RobotNet10.CustomConfiguration` backend.
## 📋 Mục lục
- [Tính năng](#tính-năng)
- [Cài đặt](#cài-đặt)
- [Sử dụng Component](#sử-dụng-component)
- [Sử dụng Services](#sử-dụng-services)
- [Components](#components)
- [Models](#models)
- [Ví dụ](#ví-dụ)
## ✨ Tính năng
- 🎨 **Giao diện đẹp**: Sử dụng MudBlazor components
- 📋 **Danh sách Configs**: Hiển thị và tìm kiếm configs
- ✏️ **Editor**: Chỉnh sửa config và variables
- 📤 **Import/Export**: Import và export config files
- 🔍 **Tìm kiếm**: Tìm kiếm configs theo tên hoặc ConfigType
-**Validation**: Validation real-time khi chỉnh sửa
- 🎯 **Type-aware Editor**: Editor tự động thay đổi theo type của variable
## 🚀 Cài đặt
### 1. Thêm Project Reference
```xml
<ItemGroup>
<ProjectReference Include="..\Components\RobotNet10.CustomConfigurationEditor\RobotNet10.CustomConfigurationEditor.csproj" />
</ItemGroup>
```
### 2. Đăng ký Services
Trong `Program.cs` hoặc `Client/Program.cs`:
```csharp
using RobotNet10.CustomConfigurationEditor.Services.API;
using RobotNet10.CustomConfigurationEditor.Services.State;
// HttpClient (nếu chưa có)
builder.Services.AddScoped(sp => new HttpClient
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});
// MudBlazor (nếu chưa có)
builder.Services.AddMudServices();
// CustomConfiguration Services
builder.Services.AddScoped<ConfigApiService>();
builder.Services.AddScoped<ConfigManagerState>();
```
### 3. Copy JavaScript File
Copy `wwwroot/js/downloadFile.js` vào `wwwroot/js/` của project frontend và thêm vào `index.html`:
```html
<script src="js/downloadFile.js"></script>
```
### 4. Thêm Using
Trong `_Imports.razor`:
```razor
@using RobotNet10.CustomConfigurationEditor.Components.ConfigManager
@using RobotNet10.CustomConfigurationEditor.Services.API
@using RobotNet10.CustomConfigurationEditor.Services.State
```
## 📖 Sử dụng Component
### Cách 1: Sử dụng Component trực tiếp
Tạo page mới:
```razor
@page "/config-manager"
@using RobotNet10.CustomConfigurationEditor.Components.ConfigManager
<PageTitle>Configuration Manager</PageTitle>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
<ConfigManagerComponent />
</MudContainer>
```
### Cách 2: Sử dụng trong Layout
Thêm vào navigation menu:
```razor
<MudNavLink Href="/config-manager" Match="NavLinkMatch.All">
<MudIcon Icon="@Icons.Material.Filled.Settings" />
Configuration Manager
</MudNavLink>
```
## 🔧 Sử dụng Services
### ConfigApiService
Service để gọi API backend:
```csharp
@inject ConfigApiService ApiService
@code {
protected override async Task OnInitializedAsync()
{
// Lấy tất cả configs
var configs = await ApiService.GetAllConfigsAsync();
// Lấy config theo ID
var config = await ApiService.GetConfigByIdAsync(id);
// Lấy config theo ConfigType
var mqttConfig = await ApiService.GetConfigByTypeAsync("MQTTBrokerConfig");
// Tạo config mới
var newConfig = await ApiService.CreateConfigAsync(
configType: "MyConfig",
variables: variables,
description: "My config"
);
// Cập nhật config
await ApiService.UpdateConfigAsync(id, variables, description);
// Xóa config
await ApiService.DeleteConfigAsync(id);
// Import config
using var stream = file.OpenReadStream();
var imported = await ApiService.ImportConfigAsync(stream, file.Name, "MyConfig");
// Export config
var exportStream = await ApiService.ExportConfigAsync(id);
// Cập nhật variable
await ApiService.UpdateVariableAsync(id, "port", 8080);
// Thêm variable
await ApiService.AddVariableAsync(id, newVariable);
// Xóa variable
await ApiService.RemoveVariableAsync(id, "variableName");
}
}
```
### ConfigManagerState
State management service với events:
```csharp
@inject ConfigManagerState State
<MudButton OnClick="LoadConfigs">Load Configs</MudButton>
@if (State.IsLoading)
{
<MudProgressLinear />
}
@if (!string.IsNullOrEmpty(State.ErrorMessage))
{
<MudAlert Severity="Severity.Error">@State.ErrorMessage</MudAlert>
}
@code {
protected override async Task OnInitializedAsync()
{
// Subscribe to state changes
State.OnStateChanged += StateChanged;
// Load configs
await State.LoadConfigsAsync();
}
private async Task LoadConfigs()
{
await State.LoadConfigsAsync();
}
private void StateChanged()
{
StateHasChanged();
}
public void Dispose()
{
State.OnStateChanged -= StateChanged;
}
}
```
## 🧩 Components
### ConfigManagerComponent
Component chính để quản lý configs.
**Sử dụng:**
```razor
<ConfigManagerComponent />
```
**Tính năng:**
- Toolbar với search, import, create buttons
- Split view: List panel và Editor panel
- Error handling và notifications
### ConfigListPanel
Panel hiển thị danh sách configs.
**Parameters:**
- `State`: ConfigManagerState
- `OnConfigSelected`: EventCallback khi chọn config
**Sử dụng:**
```razor
<ConfigListPanel
State="@State"
OnConfigSelected="@(EventCallback.Factory.Create<ConfigFileMetadataModel>(this, OnConfigSelected))" />
```
### ConfigEditorPanel
Panel để chỉnh sửa config.
**Parameters:**
- `State`: ConfigManagerState
- `OnSave`: EventCallback khi save
- `OnDelete`: EventCallback khi delete
- `OnExport`: EventCallback khi export
**Sử dụng:**
```razor
<ConfigEditorPanel
State="@State"
OnSave="@(EventCallback.Factory.Create(this, OnSave))"
OnDelete="@(EventCallback.Factory.Create<Guid>(this, OnDelete))"
OnExport="@(EventCallback.Factory.Create<Guid>(this, OnExport))" />
```
### VariableEditor
Component để chỉnh sửa một variable, tự động thay đổi input type theo variable type.
**Parameters:**
- `Variable`: ConfigVariableModel
- `OnValueChanged`: EventCallback khi value thay đổi
**Sử dụng:**
```razor
<VariableEditor
Variable="@variable"
OnValueChanged="@(EventCallback.Factory.Create<object?>(this, OnValueChanged))" />
```
**Hỗ trợ types:**
- `string`: MudTextField
- `int`: MudNumericField với Min/Max
- `double`: MudNumericField với Min/Max
- `bool`: MudSwitch
- `enum`: MudSelect với EnumValues
- `object`: MudTextField multiline (JSON)
- `array`: MudTextField multiline (JSON)
### Dialogs
#### ImportConfigDialog
Dialog để import config từ file.
```razor
var dialog = await DialogService.ShowAsync<ImportConfigDialog>("Import Config");
var result = await dialog.Result;
```
#### ExportConfigDialog
Dialog để export config.
```razor
var dialog = await DialogService.ShowAsync<ExportConfigDialog>("Export Config");
```
#### CreateConfigDialog
Dialog để tạo config mới.
```razor
var dialog = await DialogService.ShowAsync<CreateConfigDialog>("Create Config");
```
#### EditConfigDialog
Dialog để chỉnh sửa metadata của config.
```razor
var dialog = await DialogService.ShowAsync<EditConfigDialog>("Edit Config");
```
## 📦 Models
### ConfigFileModel
```csharp
public class ConfigFileModel
{
public Guid Id { get; set; }
public string ConfigType { get; set; } = string.Empty;
public List<ConfigVariableModel> Variables { get; set; } = new();
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public string? Description { get; set; }
}
```
### ConfigFileMetadataModel
```csharp
public class ConfigFileMetadataModel
{
public Guid Id { get; set; }
public string ConfigType { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public string? Description { get; set; }
}
```
### ConfigVariableModel
```csharp
public class ConfigVariableModel
{
public string Name { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty; // "string", "int", "double", "bool", "enum", "object", "array"
public object? Value { get; set; }
public double? Min { get; set; }
public double? Max { get; set; }
public string? Roles { get; set; }
public List<string>? EnumValues { get; set; }
}
```
## 💡 Ví dụ
### Ví dụ 1: Custom Page với State
```razor
@page "/my-configs"
@inject ConfigManagerState State
@inject ISnackbar Snackbar
<PageTitle>My Configs</PageTitle>
<MudContainer>
<MudText Typo="Typo.h4" Class="mb-4">My Configurations</MudText>
@if (State.IsLoading)
{
<MudProgressLinear />
}
<MudGrid>
@foreach (var config in State.Configs)
{
<MudItem xs="12" md="6" lg="4">
<MudCard>
<MudCardContent>
<MudText Typo="Typo.h6">@config.ConfigType</MudText>
<MudText Typo="Typo.body2">@config.Description</MudText>
</MudCardContent>
<MudCardActions>
<MudButton OnClick="@(() => SelectConfig(config))">Select</MudButton>
</MudCardActions>
</MudCard>
</MudItem>
}
</MudGrid>
</MudContainer>
@code {
protected override async Task OnInitializedAsync()
{
State.OnStateChanged += StateChanged;
await State.LoadConfigsAsync();
}
private async Task SelectConfig(ConfigFileMetadataModel config)
{
await State.SelectConfigAsync(config);
Snackbar.Add($"Selected {config.ConfigType}", Severity.Success);
}
private void StateChanged()
{
StateHasChanged();
}
public void Dispose()
{
State.OnStateChanged -= StateChanged;
}
}
```
### Ví dụ 2: Custom Variable Editor
```razor
@inject ConfigManagerState State
<MudTable Items="@State.SelectedConfig?.Variables" Hover="true">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Type</MudTh>
<MudTh>Value</MudTh>
<MudTh>Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Name</MudTd>
<MudTd>@context.Type</MudTd>
<MudTd>
<VariableEditor
Variable="@context"
OnValueChanged="@(EventCallback.Factory.Create<object?>(this, value => OnVariableChanged(context.Name, value)))" />
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(() => DeleteVariable(context.Name))" />
</MudTd>
</RowTemplate>
</MudTable>
@code {
private async Task OnVariableChanged(string name, object? value)
{
await State.UpdateVariableAsync(name, value);
}
private async Task DeleteVariable(string name)
{
await State.RemoveVariableAsync(name);
}
}
```
### Ví dụ 3: Import Config với Custom Logic
```razor
@inject IDialogService DialogService
@inject ConfigManagerState State
@inject ISnackbar Snackbar
<MudButton OnClick="ImportConfig">Import Config</MudButton>
@code {
private async Task ImportConfig()
{
var parameters = new DialogParameters();
var options = new DialogOptions
{
CloseOnEscapeKey = true,
MaxWidth = MaxWidth.Medium,
FullWidth = true
};
var dialog = await DialogService.ShowAsync<ImportConfigDialog>("Import Config", parameters, options);
var result = await dialog.Result;
if (!result.Canceled)
{
Snackbar.Add("Config imported successfully", Severity.Success);
await State.LoadConfigsAsync();
}
}
}
```
## 🎨 Customization
### Thay đổi Theme
Component sử dụng MudBlazor theme. Để custom theme:
```csharp
builder.Services.AddMudServices(config =>
{
config.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.BottomRight;
config.SnackbarConfiguration.VisibleStateDuration = 3000;
});
```
### Custom Styling
Thêm CSS vào `wwwroot/css/app.css`:
```css
.config-manager {
padding: 1rem;
}
.config-list-item {
cursor: pointer;
transition: background-color 0.2s;
}
.config-list-item:hover {
background-color: var(--mud-palette-action-hover);
}
```
## 🔧 Troubleshooting
### Component không hiển thị
**Kiểm tra:**
1. Đã đăng ký services trong `Program.cs`
2. Đã thêm using trong `_Imports.razor`
3. Đã có MudBlazor services
### API calls fail
**Kiểm tra:**
1. HttpClient có `BaseAddress` đúng
2. Backend API đang chạy
3. CORS đã được cấu hình (nếu frontend và backend khác domain)
### Export không hoạt động
**Kiểm tra:**
1. Đã copy `downloadFile.js` vào `wwwroot/js/`
2. Đã thêm script tag vào `index.html`
### State không update
**Kiểm tra:**
1. Đã subscribe `OnStateChanged` event
2. Đã gọi `StateHasChanged()` trong event handler
3. Component implement `IDisposable` và unsubscribe khi dispose
## 📚 Tài liệu tham khảo
- [Backend README](../RobotNet10.CustomConfiguration/README.md)
- [MudBlazor Documentation](https://mudblazor.com/)
- [Blazor Documentation](https://learn.microsoft.com/aspnet/core/blazor/)

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.3" />
</ItemGroup>
<ItemGroup>
<Folder Include="Components\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RobotNet10.Components\RobotNet10.Components.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,232 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using RobotNet10.CustomConfigurationEditor.Models;
namespace RobotNet10.CustomConfigurationEditor.Services.API;
/// <summary>
/// Service cho giao tiếp với Config REST API
/// </summary>
public class ConfigApiService(HttpClient httpClient)
{
private readonly HttpClient _httpClient = httpClient;
private readonly string? _baseUrl = httpClient.BaseAddress?.AbsoluteUri;
private const string ApiPath = "api/configs";
// ==========================================
// CONFIG FILE MANAGEMENT
// ==========================================
/// <summary>
/// Lấy tất cả configs (metadata only)
/// </summary>
public async Task<List<ConfigFileMetadataModel>> GetAllConfigsAsync(string? search = null)
{
var url = $"{_baseUrl}{ApiPath}";
if (!string.IsNullOrEmpty(search))
url += $"?search={Uri.EscapeDataString(search)}";
return await _httpClient.GetFromJsonAsync<List<ConfigFileMetadataModel>>(url) ?? [];
}
/// <summary>
/// Lấy config theo ID
/// </summary>
public async Task<ConfigFileModel?> GetConfigByIdAsync(Guid id)
{
return await _httpClient.GetFromJsonAsync<ConfigFileModel>($"{_baseUrl}{ApiPath}/{id}");
}
/// <summary>
/// Lấy config theo ConfigType
/// </summary>
public async Task<ConfigFileModel?> GetConfigByTypeAsync(string configType)
{
return await _httpClient.GetFromJsonAsync<ConfigFileModel>($"{_baseUrl}{ApiPath}/by-type/{Uri.EscapeDataString(configType)}");
}
/// <summary>
/// Kiểm tra ConfigType có tồn tại không
/// </summary>
public async Task<bool> ConfigTypeExistsAsync(string configType)
{
return await _httpClient.GetFromJsonAsync<bool>($"{_baseUrl}{ApiPath}/exists/{Uri.EscapeDataString(configType)}");
}
/// <summary>
/// Tạo config mới
/// </summary>
public async Task<ConfigFileModel> CreateConfigAsync(string configType, List<ConfigVariableModel> variables, string? description = null)
{
var request = new
{
ConfigType = configType,
Variables = variables,
Description = description
};
var response = await _httpClient.PostAsJsonAsync($"{_baseUrl}{ApiPath}", request);
if (!response.IsSuccessStatusCode)
{
var errorMessage = await HttpErrorHelper.GetErrorMessageAsync(response);
throw new HttpRequestException(errorMessage);
}
return await response.Content.ReadFromJsonAsync<ConfigFileModel>()
?? throw new Exception("Failed to create config. Invalid response from server.");
}
/// <summary>
/// Cập nhật config
/// </summary>
public async Task<ConfigFileModel> UpdateConfigAsync(Guid id, List<ConfigVariableModel>? variables = null, string? description = null)
{
var request = new
{
Variables = variables,
Description = description
};
var response = await _httpClient.PutAsJsonAsync($"{_baseUrl}{ApiPath}/{id}", request);
if (!response.IsSuccessStatusCode)
{
var errorMessage = await HttpErrorHelper.GetErrorMessageAsync(response);
throw new HttpRequestException(errorMessage);
}
return await response.Content.ReadFromJsonAsync<ConfigFileModel>()
?? throw new Exception("Failed to update config. Invalid response from server.");
}
/// <summary>
/// Xóa config
/// </summary>
public async Task DeleteConfigAsync(Guid id)
{
var response = await _httpClient.DeleteAsync($"{_baseUrl}{ApiPath}/{id}");
if (!response.IsSuccessStatusCode)
{
var errorMessage = await HttpErrorHelper.GetErrorMessageAsync(response);
throw new HttpRequestException(errorMessage);
}
}
// ==========================================
// IMPORT/EXPORT
// ==========================================
/// <summary>
/// Import config từ JSON file
/// </summary>
public async Task<ConfigFileModel> ImportConfigAsync(Stream fileStream, string fileName, string configType, string? description = null)
{
using var content = new MultipartFormDataContent();
var streamContent = new StreamContent(fileStream);
streamContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
content.Add(streamContent, "file", fileName);
content.Add(new StringContent(configType), "configType");
if (!string.IsNullOrWhiteSpace(description))
{
content.Add(new StringContent(description), "description");
}
var response = await _httpClient.PostAsync($"{_baseUrl}{ApiPath}/import", content);
if (!response.IsSuccessStatusCode)
{
var errorMessage = await HttpErrorHelper.GetErrorMessageAsync(response);
throw new HttpRequestException(errorMessage);
}
return await response.Content.ReadFromJsonAsync<ConfigFileModel>()
?? throw new Exception("Failed to import config. Invalid response from server.");
}
/// <summary>
/// Export config ra JSON file
/// </summary>
public async Task<Stream> ExportConfigAsync(Guid id)
{
var response = await _httpClient.GetAsync($"{_baseUrl}{ApiPath}/{id}/export");
if (!response.IsSuccessStatusCode)
{
var errorMessage = await HttpErrorHelper.GetErrorMessageAsync(response);
throw new HttpRequestException(errorMessage);
}
try
{
// Read content as byte array first, then create memory stream
var bytes = await response.Content.ReadAsByteArrayAsync();
var memoryStream = new MemoryStream(bytes);
memoryStream.Position = 0; // Reset position to beginning
return memoryStream;
}
catch (Exception ex)
{
throw new HttpRequestException($"Failed to read export data: {HttpErrorHelper.GetErrorMessage(ex)}", ex);
}
}
// ==========================================
// VARIABLE MANAGEMENT
// ==========================================
/// <summary>
/// Cập nhật giá trị của một variable
/// </summary>
public async Task<ConfigFileModel> UpdateVariableAsync(Guid configId, string variableName, object? value)
{
var request = new { Value = value };
var response = await _httpClient.PutAsJsonAsync($"{_baseUrl}{ApiPath}/{configId}/variables/{Uri.EscapeDataString(variableName)}", request);
if (!response.IsSuccessStatusCode)
{
var errorMessage = await HttpErrorHelper.GetErrorMessageAsync(response);
throw new HttpRequestException(errorMessage);
}
return await response.Content.ReadFromJsonAsync<ConfigFileModel>()
?? throw new Exception("Failed to update variable. Invalid response from server.");
}
/// <summary>
/// Thêm variable mới vào config
/// </summary>
public async Task<ConfigFileModel> AddVariableAsync(Guid configId, ConfigVariableModel variable)
{
var response = await _httpClient.PostAsJsonAsync($"{_baseUrl}{ApiPath}/{configId}/variables", variable);
if (!response.IsSuccessStatusCode)
{
var errorMessage = await HttpErrorHelper.GetErrorMessageAsync(response);
throw new HttpRequestException(errorMessage);
}
return await response.Content.ReadFromJsonAsync<ConfigFileModel>()
?? throw new Exception("Failed to add variable. Invalid response from server.");
}
/// <summary>
/// Xóa variable khỏi config
/// </summary>
public async Task<ConfigFileModel> RemoveVariableAsync(Guid configId, string variableName)
{
var response = await _httpClient.DeleteAsync($"{_baseUrl}{ApiPath}/{configId}/variables/{Uri.EscapeDataString(variableName)}");
if (!response.IsSuccessStatusCode)
{
var errorMessage = await HttpErrorHelper.GetErrorMessageAsync(response);
throw new HttpRequestException(errorMessage);
}
return await response.Content.ReadFromJsonAsync<ConfigFileModel>()
?? throw new Exception("Failed to remove variable. Invalid response from server.");
}
}

View File

@@ -0,0 +1,149 @@
using System.Net;
using System.Text.Json;
namespace RobotNet10.CustomConfigurationEditor.Services.API;
/// <summary>
/// Helper class để parse error messages từ HTTP responses
/// </summary>
public static class HttpErrorHelper
{
/// <summary>
/// Extract user-friendly error message từ HttpResponseMessage
/// </summary>
public static async Task<string> GetErrorMessageAsync(HttpResponseMessage response)
{
try
{
// Try to read error message from response body
var content = await response.Content.ReadAsStringAsync();
if (!string.IsNullOrWhiteSpace(content))
{
// Try to parse as JSON error object
try
{
using var doc = JsonDocument.Parse(content);
var root = doc.RootElement;
// Check for common error property names
if (root.TryGetProperty("error", out var errorProp))
{
var errorMsg = errorProp.GetString();
if (!string.IsNullOrWhiteSpace(errorMsg))
return errorMsg;
}
if (root.TryGetProperty("message", out var messageProp))
{
var message = messageProp.GetString();
if (!string.IsNullOrWhiteSpace(message))
return message;
}
if (root.TryGetProperty("errors", out var errorsProp) && errorsProp.ValueKind == JsonValueKind.Array)
{
var errors = errorsProp.EnumerateArray()
.Select(e => e.GetString())
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToList();
if (errors.Count > 0)
return string.Join("; ", errors);
}
// If it's a simple string, return it
if (root.ValueKind == JsonValueKind.String)
{
return root.GetString() ?? GetDefaultMessage(response.StatusCode);
}
}
catch (JsonException)
{
// If JSON parsing fails, check if content is a simple error message
if (content.Length < 500) // Reasonable length for error message
{
return content;
}
}
}
}
catch (Exception ex) when (ex is HttpRequestException or ObjectDisposedException)
{
// Fall through to default message
}
return GetDefaultMessage(response.StatusCode);
}
/// <summary>
/// Extract user-friendly error message từ Exception
/// </summary>
public static string GetErrorMessage(Exception ex)
{
// Check for HttpRequestException
if (ex is HttpRequestException httpEx)
{
// Try to extract meaningful message
var message = httpEx.Message;
// Remove technical details
if (message.Contains("net_http_message_not_success_statuscode"))
{
return "Unable to connect to server. Please check your network connection.";
}
if (message.Contains("timeout"))
{
return "Request timeout. Please try again.";
}
if (message.Contains("connection"))
{
return "Unable to connect to server. Please check your network connection.";
}
return message;
}
// Check for TaskCanceledException (often timeout)
if (ex is TaskCanceledException)
{
return "Request timeout. Please try again.";
}
// Return original message if it's user-friendly
var exMessage = ex.Message;
if (!string.IsNullOrWhiteSpace(exMessage) &&
!exMessage.Contains("net_http") &&
!exMessage.Contains("StatusCode") &&
!exMessage.Contains("Bad Request") &&
exMessage.Length < 200)
{
return exMessage;
}
// Default fallback
return "An error occurred. Please try again or contact the administrator.";
}
/// <summary>
/// Get default error message based on HTTP status code
/// </summary>
private static string GetDefaultMessage(HttpStatusCode statusCode)
{
return statusCode switch
{
HttpStatusCode.BadRequest => "Invalid data. Please check your input.",
HttpStatusCode.Unauthorized => "You do not have permission to perform this action.",
HttpStatusCode.Forbidden => "You do not have access to this resource.",
HttpStatusCode.NotFound => "The requested data was not found.",
HttpStatusCode.Conflict => "Data already exists or conflicts with existing data.",
HttpStatusCode.InternalServerError => "Server error. Please try again later.",
HttpStatusCode.ServiceUnavailable => "Service is temporarily unavailable. Please try again later.",
HttpStatusCode.GatewayTimeout => "Request timeout. Please try again.",
_ => $"Error: {statusCode}. Please try again."
};
}
}

View File

@@ -0,0 +1,582 @@
using Microsoft.AspNetCore.Components.Authorization;
using RobotNet10.CustomConfigurationEditor.Models;
using RobotNet10.CustomConfigurationEditor.Services.API;
using System.Net.Http;
using System.Security.Claims;
using System.Threading;
namespace RobotNet10.CustomConfigurationEditor.Services.State;
/// <summary>
/// State management cho Config Manager
/// </summary>
public class ConfigManagerState(ConfigApiService apiService, AuthenticationStateProvider? authStateProvider = null, string editorRole = "")
{
private readonly ConfigApiService _apiService = apiService;
private readonly AuthenticationStateProvider? _authStateProvider = authStateProvider;
private readonly SemaphoreSlim _stateLock = new(1, 1);
// ===== DATA =====
public List<ConfigFileMetadataModel> Configs { get; private set; } = [];
public ConfigFileModel? SelectedConfig { get; private set; }
// ===== FILTERS & SEARCH =====
public string? SearchQuery { get; set; }
// ===== UI STATE =====
public bool IsLoading { get; private set; }
public bool IsSaving { get; private set; }
public string? ErrorMessage { get; private set; }
// ===== EVENTS =====
public event Action? OnStateChanged;
// ==========================================
// PUBLIC METHODS
// ==========================================
// Role Editor
public string EditorRole { get; } = editorRole;
/// <summary>
/// Load tất cả configs
/// </summary>
public async Task LoadConfigsAsync(string? searchQuery = null)
{
await _stateLock.WaitAsync();
try
{
IsLoading = true;
SearchQuery = searchQuery;
ErrorMessage = null;
NotifyStateChanged();
try
{
Configs = await _apiService.GetAllConfigsAsync(searchQuery);
}
catch (Exception ex)
{
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
Configs = [];
}
IsLoading = false;
NotifyStateChanged();
}
finally
{
_stateLock.Release();
}
}
/// <summary>
/// Load config theo ID
/// </summary>
public async Task LoadConfigByIdAsync(Guid id)
{
await _stateLock.WaitAsync();
try
{
IsLoading = true;
ErrorMessage = null;
NotifyStateChanged();
try
{
SelectedConfig = await _apiService.GetConfigByIdAsync(id);
if (SelectedConfig == null)
{
ErrorMessage = "Config not found";
}
}
catch (Exception ex)
{
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
SelectedConfig = null;
}
IsLoading = false;
NotifyStateChanged();
}
finally
{
_stateLock.Release();
}
}
/// <summary>
/// Load config theo ConfigType
/// </summary>
public async Task LoadConfigByTypeAsync(string configType)
{
await _stateLock.WaitAsync();
try
{
IsLoading = true;
ErrorMessage = null;
NotifyStateChanged();
try
{
SelectedConfig = await _apiService.GetConfigByTypeAsync(configType);
if (SelectedConfig == null)
{
ErrorMessage = "Config not found";
}
}
catch (Exception ex)
{
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
SelectedConfig = null;
}
IsLoading = false;
NotifyStateChanged();
}
finally
{
_stateLock.Release();
}
}
/// <summary>
/// Select config
/// </summary>
public async Task SelectConfigAsync(ConfigFileMetadataModel configMetadata)
{
await LoadConfigByIdAsync(configMetadata.Id);
}
/// <summary>
/// Clear error message
/// </summary>
public void ClearError()
{
ErrorMessage = null;
NotifyStateChanged();
}
/// <summary>
/// Clear selection
/// </summary>
public void ClearSelection()
{
SelectedConfig = null;
NotifyStateChanged();
}
/// <summary>
/// Tạo config mới
/// </summary>
public async Task<ConfigFileModel> CreateConfigAsync(string configType, List<ConfigVariableModel> variables, string? description = null)
{
await _stateLock.WaitAsync();
try
{
IsSaving = true;
ErrorMessage = null;
NotifyStateChanged();
try
{
var config = await _apiService.CreateConfigAsync(configType, variables, description);
await ReloadConfigsInternalAsync();
SelectedConfig = config;
NotifyStateChanged();
return config;
}
catch (Exception ex)
{
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
throw;
}
finally
{
IsSaving = false;
NotifyStateChanged();
}
}
finally
{
_stateLock.Release();
}
}
/// <summary>
/// Cập nhật config
/// </summary>
public async Task UpdateConfigAsync(List<ConfigVariableModel>? variables = null, string? description = null)
{
if (SelectedConfig == null)
{
throw new InvalidOperationException("No config selected");
}
await _stateLock.WaitAsync();
try
{
IsSaving = true;
ErrorMessage = null;
NotifyStateChanged();
try
{
SelectedConfig = await _apiService.UpdateConfigAsync(SelectedConfig.Id, variables, description);
await ReloadConfigsInternalAsync();
NotifyStateChanged();
}
catch (Exception ex)
{
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
throw;
}
finally
{
IsSaving = false;
NotifyStateChanged();
}
}
finally
{
_stateLock.Release();
}
}
/// <summary>
/// Xóa config
/// </summary>
public async Task DeleteConfigAsync(Guid id)
{
await _stateLock.WaitAsync();
try
{
IsSaving = true;
ErrorMessage = null;
NotifyStateChanged();
try
{
await _apiService.DeleteConfigAsync(id);
await ReloadConfigsInternalAsync();
// Clear selection if deleted
if (SelectedConfig?.Id == id)
{
SelectedConfig = null;
NotifyStateChanged();
}
}
catch (Exception ex)
{
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
throw;
}
finally
{
IsSaving = false;
NotifyStateChanged();
}
}
finally
{
_stateLock.Release();
}
}
/// <summary>
/// Import config từ file
/// </summary>
public async Task<ConfigFileModel> ImportConfigAsync(Stream fileStream, string fileName, string configType, string? description = null)
{
await _stateLock.WaitAsync();
try
{
IsSaving = true;
ErrorMessage = null;
NotifyStateChanged();
try
{
var config = await _apiService.ImportConfigAsync(fileStream, fileName, configType, description);
await ReloadConfigsInternalAsync();
SelectedConfig = config;
NotifyStateChanged();
return config;
}
catch (Exception ex)
{
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
throw;
}
finally
{
IsSaving = false;
NotifyStateChanged();
}
}
finally
{
_stateLock.Release();
}
}
/// <summary>
/// Export config ra file
/// </summary>
public async Task<Stream> ExportConfigAsync(Guid id)
{
try
{
return await _apiService.ExportConfigAsync(id);
}
catch (Exception ex)
{
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
throw;
}
}
/// <summary>
/// Cập nhật variable value
/// </summary>
public async Task UpdateVariableAsync(string variableName, object? value)
{
if (SelectedConfig == null)
{
throw new InvalidOperationException("No config selected");
}
await _stateLock.WaitAsync();
try
{
IsSaving = true;
ErrorMessage = null;
NotifyStateChanged();
try
{
SelectedConfig = await _apiService.UpdateVariableAsync(SelectedConfig.Id, variableName, value);
NotifyStateChanged();
}
catch (Exception ex)
{
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
throw;
}
finally
{
IsSaving = false;
NotifyStateChanged();
}
}
finally
{
_stateLock.Release();
}
}
/// <summary>
/// Thêm variable
/// </summary>
public async Task AddVariableAsync(ConfigVariableModel variable)
{
if (SelectedConfig == null)
{
throw new InvalidOperationException("No config selected");
}
await _stateLock.WaitAsync();
try
{
IsSaving = true;
ErrorMessage = null;
NotifyStateChanged();
try
{
SelectedConfig = await _apiService.AddVariableAsync(SelectedConfig.Id, variable);
NotifyStateChanged();
}
catch (Exception ex)
{
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
throw;
}
finally
{
IsSaving = false;
NotifyStateChanged();
}
}
finally
{
_stateLock.Release();
}
}
/// <summary>
/// Xóa variable
/// </summary>
public async Task RemoveVariableAsync(string variableName)
{
if (SelectedConfig == null)
{
throw new InvalidOperationException("No config selected");
}
await _stateLock.WaitAsync();
try
{
IsSaving = true;
ErrorMessage = null;
NotifyStateChanged();
try
{
SelectedConfig = await _apiService.RemoveVariableAsync(SelectedConfig.Id, variableName);
NotifyStateChanged();
}
catch (Exception ex)
{
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
throw;
}
finally
{
IsSaving = false;
NotifyStateChanged();
}
}
finally
{
_stateLock.Release();
}
}
/// <summary>
/// Kiểm tra ConfigType có tồn tại không
/// </summary>
public async Task<bool> ConfigTypeExistsAsync(string configType)
{
try
{
return await _apiService.ConfigTypeExistsAsync(configType);
}
catch (HttpRequestException)
{
return false;
}
}
// ==========================================
// ROLE-BASED PERMISSION CHECKS
// ==========================================
/// <summary>
/// Kiểm tra user hiện tại có quyền chỉnh sửa config không
/// </summary>
public async Task<bool> CanEditConfigAsync()
{
// Nếu EditorRole rỗng, mọi thứ hoạt động bình thường
if (string.IsNullOrWhiteSpace(EditorRole))
{
return true;
}
// Kiểm tra role của user hiện tại
var userRoles = await GetCurrentUserRolesAsync();
return userRoles.Contains(EditorRole, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Kiểm tra user hiện tại có quyền chỉnh sửa variable không
/// </summary>
public async Task<bool> CanEditVariableAsync(ConfigVariableModel variable)
{
// Nếu EditorRole rỗng, mọi thứ hoạt động bình thường
if (string.IsNullOrWhiteSpace(EditorRole))
{
return true;
}
var userRoles = await GetCurrentUserRolesAsync();
// Kiểm tra nếu user có role = EditorRole
if (userRoles.Contains(EditorRole, StringComparer.OrdinalIgnoreCase))
{
return true;
}
// Kiểm tra nếu role của user nằm trong Roles của variable
if (!string.IsNullOrWhiteSpace(variable.Roles))
{
var variableRoles = variable.Roles.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(r => r.Trim())
.Where(r => !string.IsNullOrWhiteSpace(r));
return variableRoles.Any(role => userRoles.Contains(role, StringComparer.OrdinalIgnoreCase));
}
return false;
}
/// <summary>
/// Lấy danh sách roles của user hiện tại
/// </summary>
private async Task<List<string>> GetCurrentUserRolesAsync()
{
if (_authStateProvider == null)
{
return [];
}
try
{
var authState = await _authStateProvider.GetAuthenticationStateAsync();
var user = authState?.User;
if (user == null || user.Identity?.IsAuthenticated != true)
{
return [];
}
// Lấy roles từ claims
var roles = user.Claims
.Where(c => c.Type == ClaimTypes.Role)
.Select(c => c.Value)
.ToList();
return roles;
}
catch (Exception ex) when (ex is InvalidOperationException or NullReferenceException)
{
return [];
}
}
// ==========================================
// PRIVATE METHODS
// ==========================================
/// <summary>
/// Reload configs without acquiring the lock (for use inside locked methods)
/// </summary>
private async Task ReloadConfigsInternalAsync()
{
try
{
Configs = await _apiService.GetAllConfigsAsync(SearchQuery);
}
catch (Exception ex)
{
ErrorMessage = HttpErrorHelper.GetErrorMessage(ex);
Configs = [];
}
}
private void NotifyStateChanged()
{
OnStateChanged?.Invoke();
}
}

View File

@@ -0,0 +1,12 @@
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.Extensions.Configuration
@using Microsoft.Extensions.DependencyInjection
@using Microsoft.JSInterop
@using MudBlazor
@using RobotNet10.CustomConfigurationEditor.Models
@using RobotNet10.CustomConfigurationEditor.Services.API
@using RobotNet10.CustomConfigurationEditor.Services.State
@using RobotNet10.CustomConfigurationEditor.Components.ConfigManager
@using RobotNet10.CustomConfigurationEditor.Components.ConfigManager.Dialogs

View File

@@ -0,0 +1,38 @@
// Function to download file from stream
window.downloadFileFromStream = async (fileName, contentStreamReference) => {
const arrayBuffer = await contentStreamReference.arrayBuffer();
const blob = new Blob([arrayBuffer]);
const url = URL.createObjectURL(blob);
const anchorElement = document.createElement('a');
anchorElement.href = url;
anchorElement.download = fileName ?? '';
anchorElement.click();
anchorElement.remove();
URL.revokeObjectURL(url);
};
// Function to download file directly from URL
window.downloadFileFromUrl = async (url, fileName) => {
try {
const response = await fetch(url);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const anchorElement = document.createElement('a');
anchorElement.href = blobUrl;
anchorElement.download = fileName ?? '';
document.body.appendChild(anchorElement);
anchorElement.click();
document.body.removeChild(anchorElement);
URL.revokeObjectURL(blobUrl);
} catch (error) {
console.error('Error downloading file:', error);
throw error;
}
};

View File

@@ -0,0 +1,198 @@
@using MudBlazor
@using RobotNet10.MapEditor.Shared.DTOs.Node
@using RobotNet10.MapEditor.Services.State
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.AutoFixHigh" />
<MudText Typo="Typo.h6">Auto Format</MudText>
</MudStack>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<!-- Summary -->
<MudAlert Severity="@(AnalysisResult.HasChanges ? Severity.Info : Severity.Success)"
Dense="true" Variant="Variant.Outlined">
<MudText Typo="Typo.body2">@AnalysisResult.Summary</MudText>
</MudAlert>
@if (AnalysisResult.TotalNodes >= 2)
{
<!-- Operation Toggles -->
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
<MudStack Spacing="2">
<MudText Typo="Typo.subtitle2">Operations (Order: Snap → Align → Distribute)</MudText>
<!-- Snap to Grid -->
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudCheckBox T="bool" @bind-Value="Config.EnableSnap"
Label="Snap to Grid" Dense="true" />
<MudNumericField T="double" @bind-Value="Config.SnapGridSize"
Variant="Variant.Outlined" Margin="Margin.Dense"
Min="0.1" Max="2.0" Step="0.1"
Style="width: 100px;"
Adornment="Adornment.End" AdornmentText="m"
Disabled="@(!Config.EnableSnap)" />
</MudStack>
<!-- Align -->
<MudCheckBox T="bool" @bind-Value="Config.EnableAlign"
Label="Align nodes in groups" Dense="true" />
<!-- Distribute -->
<MudCheckBox T="bool" @bind-Value="Config.EnableDistribute"
Label="Distribute nodes evenly (3+ nodes)" Dense="true" />
</MudStack>
</MudPaper>
<!-- Groups List -->
@if (AnalysisResult.Groups.Count > 0)
{
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">
Detected Groups (@AnalysisResult.Groups.Count)
</MudText>
<MudStack Spacing="1">
@foreach (var group in AnalysisResult.Groups)
{
<MudPaper Elevation="0" Class="pa-2"
Style="background-color: var(--mud-palette-background-grey);">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@GetPatternIcon(group.Pattern)"
Size="Size.Small"
Color="@GetPatternColor(group.Pattern)" />
<MudText Typo="Typo.body2">
@group.GetDescription()
</MudText>
</MudStack>
</MudPaper>
}
</MudStack>
}
<!-- Advanced Configuration -->
<MudExpansionPanels Elevation="0">
<MudExpansionPanel Text="Advanced Settings" Dense="true" Expanded="false">
<MudStack Spacing="2">
<MudNumericField T="double" @bind-Value="Config.PatternThreshold"
Label="Line Tolerance (m)"
Variant="Variant.Outlined" Margin="Margin.Dense"
Min="0.1" Max="2.0" Step="0.1"
HelperText="Max deviation from line (lower = stricter)" />
<MudNumericField T="double" @bind-Value="Config.MinLineSpread"
Label="Min Line Length (m)"
Variant="Variant.Outlined" Margin="Margin.Dense"
Min="0.5" Max="10" Step="0.5"
HelperText="Minimum span to form a line group" />
<MudButton Variant="Variant.Text" Color="Color.Primary"
Size="Size.Small" OnClick="ReAnalyze">
Re-analyze with new settings
</MudButton>
</MudStack>
</MudExpansionPanel>
</MudExpansionPanels>
<!-- Operations Preview -->
<MudText Typo="Typo.caption" Color="Color.Secondary">
@GetOperationsPreview()
</MudText>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">
Select at least 2 nodes to use Auto Format.
</MudText>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled"
OnClick="Apply" Disabled="@(!HasOperations)">
Apply
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? MudDialog { get; set; }
[Parameter]
public List<NodeDto> SelectedNodes { get; set; } = new();
private SmartAutoFormatResult AnalysisResult { get; set; } = new();
private SmartAutoFormatConfig Config { get; set; } = new();
private bool HasOperations => Config.EnableSnap || Config.EnableAlign || Config.EnableDistribute;
protected override void OnInitialized()
{
Analyze();
}
private void Analyze()
{
AnalysisResult = SmartAutoFormatAnalyzer.Analyze(SelectedNodes, Config);
}
private void ReAnalyze()
{
Analyze();
StateHasChanged();
Snackbar.Add("Re-analyzed with new settings", Severity.Info);
}
private string GetPatternIcon(GroupPattern pattern) => pattern switch
{
GroupPattern.HorizontalLine => Icons.Material.Filled.HorizontalRule,
GroupPattern.VerticalLine => Icons.Material.Filled.VerticalAlignCenter,
GroupPattern.Scattered => Icons.Material.Filled.ScatterPlot,
GroupPattern.Single => Icons.Material.Filled.FiberManualRecord,
_ => Icons.Material.Filled.Help
};
private Color GetPatternColor(GroupPattern pattern) => pattern switch
{
GroupPattern.HorizontalLine => Color.Primary,
GroupPattern.VerticalLine => Color.Secondary,
GroupPattern.Scattered => Color.Warning,
GroupPattern.Single => Color.Default,
_ => Color.Default
};
private string GetOperationsPreview()
{
var parts = new List<string>();
if (Config.EnableSnap)
parts.Add($"Snap to {Config.SnapGridSize}m grid");
if (Config.EnableAlign && AnalysisResult.AlignCount > 0)
parts.Add($"Align {AnalysisResult.AlignCount} group(s)");
if (Config.EnableDistribute && AnalysisResult.DistributeCount > 0)
parts.Add($"Distribute {AnalysisResult.DistributeCount} group(s)");
return parts.Count > 0
? $"Will: {string.Join(" → ", parts)}"
: "No operations selected";
}
private void Cancel() => MudDialog?.Cancel();
private void Apply()
{
// Return both the analysis result and config for execution
var result = new SmartAutoFormatDialogResult
{
Analysis = AnalysisResult,
Config = Config
};
MudDialog?.Close(DialogResult.Ok(result));
}
}

View File

@@ -0,0 +1,651 @@
@using MudBlazor
@using RobotNet10.MapEditor.Shared.DTOs.Node
@using RobotNet10.MapEditor.Shared.DTOs.Edge
@using RobotNet10.MapEditor.Components.LayoutEditor.RightPanel
@using RobotNet10.MapEditor.Services.State
@inject IDialogService DialogService
@inject ISnackbar Snackbar
<MudPaper Class="editor-toolbar pa-2" Elevation="2">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="toolbar-content">
<!-- Mode Selection Group -->
<MudButtonGroup OverrideStyles="false" Class="mr-2">
<MudTooltip Text="Scanner (Box Select)">
<MudIconButton Icon="@Icons.Material.Filled.SelectAll"
Color="@GetModeColor(EditorMode.Scanner)"
Variant="@GetModeVariant(EditorMode.Scanner)"
Size="Size.Small"
Disabled="@State.IsReadOnly"
OnClick="() => SetMode(EditorMode.Scanner)" />
</MudTooltip>
<MudMenu Icon="@Icons.Material.Filled.Timeline"
Color="@GetCreateEdgeModeColor()"
Variant="@GetCreateEdgeModeVariant()"
Size="Size.Small"
Dense="true"
Disabled="@State.IsReadOnly">
<MudMenuItem OnClick="() => SetMode(EditorMode.CreateEdge1Way)" Disabled="@State.IsReadOnly">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.TrendingFlat" Size="Size.Small" />
<MudText>Create Edge (1-Way)</MudText>
</MudStack>
</MudMenuItem>
<MudMenuItem OnClick="() => SetMode(EditorMode.CreateEdge2Way)" Disabled="@State.IsReadOnly">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.SwapHoriz" Size="Size.Small" />
<MudText>Create Edge (2-Way)</MudText>
</MudStack>
</MudMenuItem>
</MudMenu>
<MudTooltip Text="Select">
<MudIconButton Icon="@Icons.Material.Filled.NearMe"
Color="@GetModeColor(EditorMode.Select)"
Variant="@GetModeVariant(EditorMode.Select)"
Size="Size.Small"
Disabled="@State.IsReadOnly"
OnClick="() => SetMode(EditorMode.Select)" />
</MudTooltip>
</MudButtonGroup>
<MudDivider Vertical="true" FlexItem="true" Class="mx-2" />
<!-- View Controls -->
<MudButtonGroup OverrideStyles="false" Class="mr-2">
<MudTooltip Text="Zoom In">
<MudIconButton Icon="@Icons.Material.Filled.ZoomIn"
Size="Size.Small"
OnClick="ZoomIn" />
</MudTooltip>
<MudTooltip Text="Zoom Out">
<MudIconButton Icon="@Icons.Material.Filled.ZoomOut"
Size="Size.Small"
OnClick="ZoomOut" />
</MudTooltip>
<MudTooltip Text="Fit to Screen">
<MudIconButton Icon="@Icons.Material.Filled.FitScreen"
Size="Size.Small"
OnClick="FitToScreen" />
</MudTooltip>
</MudButtonGroup>
<MudDivider Vertical="true" FlexItem="true" Class="mx-2" />
<!-- Alignment Group -->
<MudButtonGroup OverrideStyles="false" Class="mr-2">
<MudTooltip Text="Align Horizontal Left">
<MudIconButton Icon="@Icons.Material.Filled.AlignHorizontalLeft"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasMultipleNodesSelected)"
OnClick="AlignNodesLeft" />
</MudTooltip>
<MudTooltip Text="Align Horizontal Center">
<MudIconButton Icon="@Icons.Material.Filled.AlignHorizontalCenter"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasMultipleNodesSelected)"
OnClick="AlignNodesCenter" />
</MudTooltip>
<MudTooltip Text="Align Horizontal Right">
<MudIconButton Icon="@Icons.Material.Filled.AlignHorizontalRight"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasMultipleNodesSelected)"
OnClick="AlignNodesRight" />
</MudTooltip>
<MudTooltip Text="Align Vertical Top">
<MudIconButton Icon="@Icons.Material.Filled.AlignVerticalTop"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasMultipleNodesSelected)"
OnClick="AlignNodesTop" />
</MudTooltip>
<MudTooltip Text="Align Vertical Center">
<MudIconButton Icon="@Icons.Material.Filled.AlignVerticalCenter"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasMultipleNodesSelected)"
OnClick="AlignNodesMiddle" />
</MudTooltip>
<MudTooltip Text="Align Vertical Bottom">
<MudIconButton Icon="@Icons.Material.Filled.AlignVerticalBottom"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasMultipleNodesSelected)"
OnClick="AlignNodesBottom" />
</MudTooltip>
<MudTooltip Text="Auto Format">
<MudIconButton Icon="@Icons.Material.Filled.AutoFixHigh"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasMultipleNodesSelected)"
OnClick="OpenAutoFormatDialog" />
</MudTooltip>
</MudButtonGroup>
<!-- Copy/Move -->
<MudButtonGroup OverrideStyles="false" Class="mr-2">
<MudTooltip Text="Copy Selected">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasSelection)"
OnClick="CopySelected" />
</MudTooltip>
<MudTooltip Text="Move Mode">
<MudIconButton Icon="@Icons.Material.Filled.OpenWith"
Size="Size.Small"
Color="@GetModeColor(EditorMode.Move)"
Variant="@GetModeVariant(EditorMode.Move)"
Disabled="@(State.IsReadOnly || !HasNodesSelected)"
OnClick="() => SetMode(EditorMode.Move)" />
</MudTooltip>
</MudButtonGroup>
<MudDivider Vertical="true" FlexItem="true" Class="mx-2" />
<!-- Merge/Split -->
<MudButtonGroup OverrideStyles="false" Class="mr-2">
<MudTooltip Text="Merge Nodes">
<MudIconButton Icon="@Icons.Material.Filled.CallMerge"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasMultipleNodesSelected)"
OnClick="MergeNodes" />
</MudTooltip>
<MudTooltip Text="Split Node">
<MudIconButton Icon="@Icons.Material.Filled.CallSplit"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !HasSingleNodeSelected)"
OnClick="SplitNode" />
</MudTooltip>
</MudButtonGroup>
<MudDivider Vertical="true" FlexItem="true" Class="mx-2" />
<!-- Vehicle Type Selector -->
<MudSelect T="Guid ?" @bind-Value="State.SelectedVehicleTypeId"
@bind-Value:after="State.NotifyStateChanged"
Label="VehicleType"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Dense="true"
Style="width: 150px;">
@foreach (var vt in State.VehicleTypes)
{
<MudSelectItem Value="@((Guid?)vt.Id)">@vt.VehicleTypeName</MudSelectItem>
}
<MudSelectItem Value="@((Guid?)null)">No Vehicle Types</MudSelectItem>
</MudSelect>
<MudDivider Vertical="true" FlexItem="true" Class="mx-2" />
<!-- Actions Group -->
<MudButtonGroup OverrideStyles="false">
<MudTooltip Text="Undo (Ctrl+Z)">
<MudIconButton Icon="@Icons.Material.Filled.Undo"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !State.CanUndo)"
OnClick="() => OnUndo.InvokeAsync()" />
</MudTooltip>
<MudTooltip Text="Redo (Ctrl+Y)">
<MudIconButton Icon="@Icons.Material.Filled.Redo"
Size="Size.Small"
Disabled="@(State.IsReadOnly || !State.CanRedo)"
OnClick="() => OnRedo.InvokeAsync()" />
</MudTooltip>
<MudTooltip Text="Save (Ctrl+S)">
<MudIconButton Icon="@Icons.Material.Filled.Save"
Size="Size.Small"
Color="@(State.HasUnsavedChanges ? Color.Warning : Color.Default)"
Disabled="@State.IsReadOnly"
OnClick="() => OnSave.InvokeAsync()" />
</MudTooltip>
<MudTooltip Text="Delete Selected (Del)">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
Disabled="@(State.IsReadOnly || !HasSelection)"
OnClick="() => OnDelete.InvokeAsync()" />
</MudTooltip>
<MudTooltip Text="Check Layout">
<MudIconButton Icon="@Icons.Material.Filled.FactCheck"
Size="Size.Small"
OnClick="() => OnCheck.InvokeAsync()" />
</MudTooltip>
<MudTooltip Text="Exit">
<MudIconButton Icon="@Icons.Material.Filled.ExitToApp"
Size="Size.Small"
OnClick="() => OnExit.InvokeAsync()" />
</MudTooltip>
</MudButtonGroup>
</MudStack>
</MudPaper>
@code {
[Parameter] public LayoutEditorState State { get; set; } = null!;
[Parameter]
public EventCallback OnUndo { get; set; }
[Parameter]
public EventCallback OnRedo { get; set; }
[Parameter]
public EventCallback OnSave { get; set; }
[Parameter]
public EventCallback OnDelete { get; set; }
[Parameter]
public EventCallback OnCheck { get; set; }
[Parameter]
public EventCallback OnExit { get; set; }
private bool HasSelection => State.SelectedNodeIds.Count > 0 || State.SelectedEdgeIds.Count > 0;
private bool HasNodesSelected => State.SelectedNodeIds.Count > 0;
private bool HasMultipleNodesSelected => State.SelectedNodeIds.Count > 1;
private bool HasSingleNodeSelected => State.SelectedNodeIds.Count == 1;
private void SetMode(EditorMode mode)
{
State.SetMode(mode);
}
private Color GetModeColor(EditorMode mode) =>
State.Mode == mode ? Color.Primary : Color.Default;
private Variant GetModeVariant(EditorMode mode) =>
State.Mode == mode ? Variant.Filled : Variant.Text;
private Color GetCreateEdgeModeColor() =>
State.Mode == EditorMode.CreateEdge1Way || State.Mode == EditorMode.CreateEdge2Way
? Color.Primary : Color.Default;
private Variant GetCreateEdgeModeVariant() =>
State.Mode == EditorMode.CreateEdge1Way || State.Mode == EditorMode.CreateEdge2Way
? Variant.Filled : Variant.Text;
private void ZoomIn()
{
State.ZoomAtCenter(1.2);
}
private void ZoomOut()
{
State.ZoomAtCenter(1.0 / 1.2);
}
private void FitToScreen()
{
State.FitToScreen();
}
private async Task CheckLayout()
{
var issues = new List<string>();
var warnings = new List<string>();
// Get editor settings
var minEdgeLength = State.Level?.EditorSettings?.EdgeMinLengthCreate ?? 0.1;
// 1. Check for isolated nodes (nodes without any edges)
var nodesWithEdges = new HashSet<Guid>();
foreach (var edge in State.Edges)
{
nodesWithEdges.Add(edge.StartNodeId);
nodesWithEdges.Add(edge.EndNodeId);
}
var isolatedNodes = State.Nodes
.Where(n => !nodesWithEdges.Contains(n.Id))
.ToList();
if (isolatedNodes.Count > 0)
{
warnings.Add($"{isolatedNodes.Count} isolated node(s) found (nodes without edges): {string.Join(", ", isolatedNodes.Take(5).Select(n => n.NodeName ?? n.NodeId))}{(isolatedNodes.Count > 5 ? "..." : "")}");
}
// 2. Check edge minimum length
var shortEdges = new List<(EdgeDto Edge, double Length)>();
foreach (var edge in State.Edges)
{
var startNode = State.Nodes.FirstOrDefault(n => n.Id == edge.StartNodeId);
var endNode = State.Nodes.FirstOrDefault(n => n.Id == edge.EndNodeId);
if (startNode != null && endNode != null)
{
var dx = endNode.X - startNode.X;
var dy = endNode.Y - startNode.Y;
var length = Math.Sqrt(dx * dx + dy * dy);
if (length < minEdgeLength)
{
shortEdges.Add((edge, length));
}
}
}
if (shortEdges.Count > 0)
{
issues.Add($"{shortEdges.Count} edge(s) shorter than minimum length ({minEdgeLength:F2}m): {string.Join(", ", shortEdges.Take(5).Select(e => $"{e.Edge.EdgeName ?? e.Edge.EdgeId} ({e.Length:F2}m)"))}{(shortEdges.Count > 5 ? "..." : "")}");
}
// 3. Check for duplicate node positions (nodes too close)
var duplicatePositions = new List<(NodeDto Node1, NodeDto Node2, double Distance)>();
for (int i = 0; i < State.Nodes.Count; i++)
{
for (int j = i + 1; j < State.Nodes.Count; j++)
{
var node1 = State.Nodes[i];
var node2 = State.Nodes[j];
var dx = node2.X - node1.X;
var dy = node2.Y - node1.Y;
var distance = Math.Sqrt(dx * dx + dy * dy);
if (distance < 0.01) // Less than 1cm apart
{
duplicatePositions.Add((node1, node2, distance));
}
}
}
if (duplicatePositions.Count > 0)
{
warnings.Add($"{duplicatePositions.Count} pair(s) of nodes are very close (< 1cm): {string.Join(", ", duplicatePositions.Take(3).Select(p => $"{p.Node1.NodeName ?? p.Node1.NodeId} & {p.Node2.NodeName ?? p.Node2.NodeId}"))}{(duplicatePositions.Count > 3 ? "..." : "")}");
}
// 4. Check for edges with same start and end node
var selfLoops = State.Edges
.Where(e => e.StartNodeId == e.EndNodeId)
.ToList();
if (selfLoops.Count > 0)
{
warnings.Add($"{selfLoops.Count} self-loop edge(s) found (start = end): {string.Join(", ", selfLoops.Take(5).Select(e => e.EdgeName ?? e.EdgeId))}{(selfLoops.Count > 5 ? "..." : "")}");
}
// Display results
if (issues.Count == 0 && warnings.Count == 0)
{
Snackbar.Add("Layout check completed - No issues found", Severity.Success);
}
else
{
var message = new System.Text.StringBuilder();
if (issues.Count > 0)
{
message.AppendLine($"<strong>{issues.Count} Issue(s) Found:</strong>");
foreach (var issue in issues)
{
message.AppendLine($"• {issue}");
}
}
if (warnings.Count > 0)
{
if (issues.Count > 0) message.AppendLine();
message.AppendLine($"<strong>{warnings.Count} Warning(s):</strong>");
foreach (var warning in warnings)
{
message.AppendLine($"• {warning}");
}
}
await DialogService.ShowMessageBoxAsync(
issues.Count > 0 ? "Layout Check - Issues Found" : "Layout Check - Warnings",
message.ToString(),
yesText: "OK",
cancelText: null);
}
}
private async Task AlignNodesLeft()
{
await State.AlignNodesLeftAsync();
Snackbar.Add("Nodes aligned to left", Severity.Success);
}
private async Task AlignNodesCenter()
{
await State.AlignNodesCenterHorizontalAsync();
Snackbar.Add("Nodes aligned to center", Severity.Success);
}
private async Task AlignNodesRight()
{
await State.AlignNodesRightAsync();
Snackbar.Add("Nodes aligned to right", Severity.Success);
}
private async Task AlignNodesTop()
{
await State.AlignNodesTopAsync();
Snackbar.Add("Nodes aligned to top", Severity.Success);
}
private async Task AlignNodesMiddle()
{
await State.AlignNodesCenterVerticalAsync();
Snackbar.Add("Nodes aligned to middle", Severity.Success);
}
private async Task AlignNodesBottom()
{
await State.AlignNodesBottomAsync();
Snackbar.Add("Nodes aligned to bottom", Severity.Success);
}
private async Task OpenAutoFormatDialog()
{
var selectedNodes = State.GetSelectedNodes();
if (selectedNodes.Count < 2)
{
Snackbar.Add("Select at least 2 nodes to auto format", Severity.Warning);
return;
}
var parameters = new DialogParameters<AutoFormatDialog>
{
{ nameof(AutoFormatDialog.SelectedNodes), selectedNodes }
};
var options = new DialogOptions
{
CloseOnEscapeKey = true,
MaxWidth = MaxWidth.Small,
FullWidth = true
};
var dialog = await DialogService.ShowAsync<AutoFormatDialog>(
"Auto Format",
parameters,
options);
var result = await dialog.Result;
if (result == null || result.Canceled)
{
return;
}
if (result.Data is not SmartAutoFormatDialogResult dialogResult)
{
return;
}
var success = await State.SmartAutoFormatNodesAsync(dialogResult.Analysis, dialogResult.Config);
if (success)
{
var analysis = dialogResult.Analysis;
var config = dialogResult.Config;
var parts = new List<string>();
if (config.EnableSnap) parts.Add($"snapped to {config.SnapGridSize}m grid");
if (config.EnableAlign && analysis.AlignCount > 0) parts.Add($"aligned {analysis.AlignCount} group(s)");
if (config.EnableDistribute && analysis.DistributeCount > 0) parts.Add($"distributed {analysis.DistributeCount} group(s)");
var message = parts.Count > 0 ? string.Join(", ", parts) : "completed";
Snackbar.Add($"Auto format: {message}", Severity.Success);
}
else
{
Snackbar.Add(State.ErrorMessage ?? "Failed to format nodes", Severity.Error);
}
}
private async Task CopyNodes()
{
// TODO: Implement copy functionality
Snackbar.Add("Copy functionality not yet implemented", Severity.Info);
await Task.CompletedTask;
}
private async Task MergeNodes()
{
var selectedNodes = State.GetSelectedNodes();
if (selectedNodes.Count < 2)
{
return;
}
// Check distance and show confirmation if needed
var (success, errorMessage, requiresConfirmation) = await State.MergeNodesAsync(selectedNodes, forceConfirm: false);
if (requiresConfirmation)
{
// Show confirmation dialog for distance warning
var confirmResult = await DialogService.ShowMessageBoxAsync(
"Merge Nodes - Distance Warning",
$"{errorMessage}\n\n" +
$"Are you sure you want to proceed with merging these nodes?",
yesText: "Yes, Merge Anyway",
cancelText: "Cancel");
if (confirmResult != true)
{
return;
}
// Retry with force confirm
(success, errorMessage, _) = await State.MergeNodesAsync(selectedNodes, forceConfirm: true);
}
else if (!success)
{
// Show initial confirmation dialog
var result = await DialogService.ShowMessageBoxAsync(
"Merge Nodes",
$"Are you sure you want to merge {selectedNodes.Count} nodes into one?\n\n" +
$"All edges connected to these nodes will be redirected to the new merged node.\n" +
$"The merged node will be placed at the center of the selected nodes.",
yesText: "Merge",
cancelText: "Cancel");
if (result != true)
{
return;
}
// Retry merge
(success, errorMessage, _) = await State.MergeNodesAsync(selectedNodes, forceConfirm: true);
}
if (success)
{
Snackbar.Add($"Successfully merged {selectedNodes.Count} nodes", Severity.Success);
}
else
{
Snackbar.Add(errorMessage ?? State.ErrorMessage ?? "Failed to merge nodes", Severity.Error);
}
}
private async Task SplitNode()
{
var selectedNodes = State.GetSelectedNodes();
if (selectedNodes.Count != 1)
{
return;
}
var nodeToSplit = selectedNodes[0];
var connectedEdges = State.Edges
.Count(e => e.StartNodeId == nodeToSplit.Id || e.EndNodeId == nodeToSplit.Id);
if (connectedEdges < 2)
{
Snackbar.Add("Cannot split node: Node must have at least 2 connected edges", Severity.Warning);
return;
}
// Check if node has station
var hasStation = State.Stations.Any(s => s.InteractionNodes?.Any(i => i.NodeId == nodeToSplit.Id) == true);
Guid? stationNodeId = null;
if (hasStation)
{
// Show dialog to select which new node should receive the station
// We need to predict how many nodes will be created (one per edge)
var dialogParameters = new DialogParameters<SplitNodeStationDialog>
{
{ nameof(SplitNodeStationDialog.EdgeCount), connectedEdges },
{ nameof(SplitNodeStationDialog.NodeName), nodeToSplit.NodeName ?? nodeToSplit.NodeId }
};
var dialogOptions = new DialogOptions
{
CloseOnEscapeKey = true,
MaxWidth = MaxWidth.Small,
FullWidth = true
};
var dialog = await DialogService.ShowAsync<SplitNodeStationDialog>(
"Split Node - Select Station Node",
dialogParameters,
dialogOptions);
var result = await dialog.Result;
if (result == null || result.Canceled)
{
return; // User cancelled
}
// Get the selected node index (0-based)
// Note: Backend will create nodes in order, so we'll need to get the actual node ID after split
// For now, pass null and backend will assign to first node (index 0)
var selectedIndex = result.Data as int?;
stationNodeId = null; // Will be handled by backend based on node creation order
}
else
{
// Show confirmation dialog
var result = await DialogService.ShowMessageBoxAsync(
"Split Node",
$"Are you sure you want to split this node?\n\n" +
$"The node will be split into {connectedEdges} nodes (one for each connected edge).\n" +
$"Each new node will be offset from the original position.",
yesText: "Split",
cancelText: "Cancel");
if (result != true)
{
return;
}
}
var success = await State.SplitNodeAsync(nodeToSplit, stationNodeId);
if (success)
{
Snackbar.Add($"Successfully split node into {connectedEdges} nodes", Severity.Success);
}
else
{
Snackbar.Add(State.ErrorMessage ?? "Failed to split node", Severity.Error);
}
}
private void CopySelected()
{
State.StartCopy();
}
}

View File

@@ -0,0 +1,27 @@
.editor-toolbar {
flex-shrink: 0;
border-bottom: 1px solid var(--mud-palette-lines-default);
background-color: var(--mud-palette-surface);
}
.toolbar-content {
flex-wrap: wrap;
min-height: 48px;
}
::deep .mud-button-group {
gap: 2px;
}
::deep .mud-checkbox {
margin: 0;
}
::deep .mud-checkbox .mud-typography {
font-size: 0.75rem;
}
::deep .mud-input-control {
margin: 0;
}

View File

@@ -0,0 +1,97 @@
@inject ISnackbar Snackbar
@implements IDisposable
@if (State.Mode == EditorMode.Copy && State.CopySourceNodes != null && State.CopySourceNodes.Count > 0 &&
State.CopyOffsetX.HasValue && State.CopyOffsetY.HasValue)
{
var offsetX = State.CopyOffsetX.Value;
var offsetY = State.CopyOffsetY.Value;
<!-- Preview nodes -->
@foreach (var sourceNode in State.CopySourceNodes)
{
var newX = sourceNode.X + offsetX;
var newY = sourceNode.Y + offsetY;
var svg = State.WorldToSvg(newX, newY);
var nodeRadius = 0.25 / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
<circle class="copy-preview-node"
cx="@svg.X.ToString("F2")"
cy="@svg.Y.ToString("F2")"
r="@nodeRadius.ToString("F2")"
fill="rgba(255, 152, 0, 0.3)"
stroke="#ff9800"
stroke-width="0.02"
stroke-dasharray="0.05,0.05" />
}
<!-- Preview edges -->
@if (State.CopySourceEdges != null)
{
@foreach (var sourceEdge in State.CopySourceEdges)
{
var sourceStartNode = State.CopySourceNodes?.FirstOrDefault(n => n.Id == sourceEdge.StartNodeId);
var sourceEndNode = State.CopySourceNodes?.FirstOrDefault(n => n.Id == sourceEdge.EndNodeId);
if (sourceStartNode != null && sourceEndNode != null)
{
var startSvg = State.WorldToSvg(sourceStartNode.X + offsetX, sourceStartNode.Y + offsetY);
var endSvg = State.WorldToSvg(sourceEndNode.X + offsetX, sourceEndNode.Y + offsetY);
<line class="copy-preview-edge"
x1="@startSvg.X.ToString("F2")"
y1="@startSvg.Y.ToString("F2")"
x2="@endSvg.X.ToString("F2")"
y2="@endSvg.Y.ToString("F2")"
stroke="#ff9800"
stroke-width="0.03"
stroke-dasharray="0.1,0.05"
opacity="0.6" />
}
}
}
}
@code {
[CascadingParameter]
public LayoutEditorState State { get; set; } = null!;
public EdgeDto Model { get; set; } = null!;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender) return;
State.OnCreateCopyChanged += StateHasChanged;
}
public void Dispose()
{
State.OnCreateCopyChanged -= StateHasChanged;
}
/// <summary>
/// Start copy drag - called when user clicks to start dragging
/// </summary>
public void StartCopyDrag(double worldX, double worldY)
{
State.CopyStartX = worldX;
State.CopyStartY = worldY;
State.CopyOffsetX = 0;
State.CopyOffsetY = 0;
StateHasChanged();
}
/// <summary>
/// Update copy drag - called during mouse move
/// </summary>
public void UpdateCopyDrag(double worldX, double worldY)
{
if (State.CopyStartX.HasValue && State.CopyStartY.HasValue)
{
State.CopyOffsetX = worldX - State.CopyStartX.Value;
State.CopyOffsetY = worldY - State.CopyStartY.Value;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,114 @@
@implements IDisposable
@{
var startNode = State.Nodes.FirstOrDefault(n => n.Id == Model.StartNodeId);
var endNode = State.Nodes.FirstOrDefault(n => n.Id == Model.EndNodeId);
if (startNode != null && endNode != null)
{
var isSelected = State.SelectedEdgeIds.Contains(Model.Id);
var isEditor = State.Mode == EditorMode.TrajectoryEditor && isSelected && State.SelectedVehicleTypeId == State.EdgeVehicleEditor?.VehicleTypeId;
// Check if this is a reverse edge (2-way edge) to offset it
var hasReverseEdge = State.Edges.Any(e =>
e.Id != Model.Id &&
e.StartNodeId == Model.EndNodeId &&
e.EndNodeId == Model.StartNodeId);
var vehicleProp = Model.VehicleProperties?.FirstOrDefault(prop => prop.VehicleTypeId == State.SelectedVehicleTypeId);
var TrajectoryPath = State.GetTrajectoryPath(startNode, endNode,
vehicleProp?.TrajectoryDegree ?? 1,
vehicleProp?.TrajectoryControlPoint1X,
vehicleProp?.TrajectoryControlPoint1Y,
vehicleProp?.TrajectoryControlPoint2X,
vehicleProp?.TrajectoryControlPoint2Y,
hasReverseEdge);
<path class="edge @(isSelected ? "selected" : "")"
marker-end="@(isSelected ? "url(#arrowhead-selected)" : "url(#arrowhead)")"
stroke="@(isSelected ? "#1976d2" : "#4caf50")"
stroke-width="@(isSelected ? "0.1" : "0.07")"
data-id="@Model.Id"
fill="none"
@onclick="() => HandleEdgeClick(Model.Id)"
@onclick:stopPropagation="true"
d="@TrajectoryPath" />
<!-- Edge name (above center) -->
@if (State.ShowEdgeNames && !string.IsNullOrEmpty(Model.EdgeName))
{
var startSvg = State.WorldToSvg(startNode.X, startNode.Y);
var endSvg = State.WorldToSvg(endNode.X, endNode.Y);
var midX = (startSvg.X + endSvg.X) / 2;
var midY = (startSvg.Y + endSvg.Y) / 2;
// Font size: base size in world coordinates (meters), adjusted for Resolution and ZoomLevel
// Resolution is meters per pixel, so fontSizeSVG = baseFontSizeWorld / (Resolution * ZoomLevel)
var baseFontSizeWorld = 0.4; // meters
var fontSize = baseFontSizeWorld / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
var baseOffsetWorld = 0.08; // meters
var offset = baseOffsetWorld;
@RenderSvgText(Model.EdgeName, midX, midY - offset, fontSize, "edge-name")
}
}
}
@code {
[Parameter]
public LayoutEditorState State { get; set; } = null!;
[Parameter]
public EdgeDto Model { get; set; } = null!;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender) return;
State.OnDraggingNodesChanged += OnDraggingNodesChanged;
}
private void OnDraggingNodesChanged(Guid[] nodeIds)
{
if (nodeIds.Any(n => Model.StartNodeId == n || Model.EndNodeId == n)) StateHasChanged();
}
public void Dispose()
{
State.OnDraggingNodesChanged -= OnDraggingNodesChanged;
}
/// <summary>
/// Render SVG text element (workaround for Blazor text directive conflict)
/// </summary>
private RenderFragment RenderSvgText(string content, double x, double y, double fontSize, string cssClass) => builder =>
{
builder.OpenElement(0, "text");
builder.AddAttribute(1, "class", cssClass);
builder.AddAttribute(2, "x", x.ToString("F2"));
builder.AddAttribute(3, "y", y.ToString("F2"));
builder.AddAttribute(4, "text-anchor", "middle");
builder.AddAttribute(5, "font-size", fontSize.ToString("F3"));
builder.AddAttribute(6, "fill", "#f44336");
builder.AddAttribute(7, "font-weight", "500");
builder.AddAttribute(8, "font-family", "Segoe UI, sans-serif");
builder.AddAttribute(9, "letter-spacing", "-0.02em");
builder.AddAttribute(10, "pointer-events", "none");
builder.AddAttribute(11, "style", "user-select: none;");
builder.AddContent(12, content);
builder.CloseElement();
};
private void HandleEdgeClick(Guid edgeId)
{
// In ReadOnly mode, only allow selection (view mode)
if (State.IsReadOnly)
{
State.SelectEdge(edgeId, false);
return;
}
if (State.Mode == EditorMode.Select)
{
State.SelectEdge(edgeId);
}
}
}

View File

@@ -0,0 +1,26 @@
.edge {
cursor: pointer;
transition: opacity 0.15s;
/*stroke-dasharray: 5 5;*/
/*animation: dash 1s linear infinite;*/
}
.edge:hover {
opacity: 0.8;
}
.edge.selected {
filter: drop-shadow(0 0 2px rgba(25, 118, 210, 0.6));
}
.edge.editor {
visibility: hidden;
}
.edge-name {
pointer-events: none;
user-select: none;
font-family: 'Segoe UI', sans-serif;
letter-spacing: -0.01em;
}

View File

@@ -0,0 +1,123 @@
@inject ISnackbar Snackbar
@if ((State.Mode == EditorMode.CreateEdge1Way || State.Mode == EditorMode.CreateEdge2Way)
&& startNodePreview is not null
&& endNodePreview is not null
&& isCreating)
{
var nodeRadius = 0.25 / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
<!-- Preview start node -->
<circle class="preview-node"
cx="@startNodePreview.Value.X.ToString("F2")"
cy="@startNodePreview.Value.Y.ToString("F2")"
r="@nodeRadius.ToString("F3")"
fill="#ff9800"
fill-opacity="0.5"
stroke="#ff9800"
stroke-width="0.03"
pointer-events="none" />
<!-- Preview end node -->
<circle class="preview-node"
cx="@endNodePreview.Value.X.ToString("F2")"
cy="@endNodePreview.Value.Y.ToString("F2")"
r="@nodeRadius.ToString("F3")"
fill="#ff9800"
fill-opacity="0.5"
stroke="#ff9800"
stroke-width="0.03"
pointer-events="none" />
<!-- Preview line -->
<line class="edge-preview"
x1="@startNodePreview.Value.X.ToString("F2")"
y1="@startNodePreview.Value.Y.ToString("F2")"
x2="@endNodePreview.Value.X.ToString("F2")"
y2="@endNodePreview.Value.Y.ToString("F2")"
stroke="#ff9800"
stroke-width="0.1"
stroke-dasharray="0.2,0.1"
pointer-events="none" />
}
@code {
[CascadingParameter]
public LayoutEditorState State { get; set; } = null!;
private (double X, double Y)? startNodePreview = new();
private (double X, double Y)? endNodePreview = new();
private bool isCreating = false;
public async Task CreateEdge(double svgX, double svgY)
{
if (!isCreating)
{
isCreating = true;
startNodePreview = (svgX, svgY);
endNodePreview = (svgX, svgY);
StateHasChanged();
}
else
{
await HandleCreateEdgeClick(svgX, svgY);
}
}
public void UpdateEdge(double svgX, double svgY)
{
if (!isCreating) return;
endNodePreview = (svgX, svgY);
StateHasChanged();
}
public void CancelCreateEdge()
{
isCreating = false;
startNodePreview = null;
endNodePreview = null;
StateHasChanged();
}
private async Task HandleCreateEdgeClick(double svgX, double svgY)
{
// Disable create edge in ReadOnly mode
if (State.IsReadOnly)
{
Snackbar.Add("Cannot create edge: Layout is read-only (Active)", Severity.Warning);
return;
}
if (!startNodePreview.HasValue)
{
Snackbar.Add("Cannot create edge: Start node is not existed", Severity.Warning);
return;
}
// Convert SVG coordinates to World coordinates
(double worldEndX, double worldEndY) = State.SvgToWorld(svgX, svgY);
(double worldStartX, double worldStartY) = State.SvgToWorld(startNodePreview.Value.X, startNodePreview.Value.Y);
var isTwoWay = State.Mode == EditorMode.CreateEdge2Way;
var success = await State.CreateEdgeAsync(
worldStartX,
worldStartY,
worldEndX,
worldEndY,
isTwoWay);
if (success)
{
Snackbar.Add(isTwoWay ? "Created 2-way edge successfully" : "Created edge successfully", Severity.Success);
}
else
{
Snackbar.Add(State.ErrorMessage ?? "Failed to create edge", Severity.Error);
}
// Reset
CancelCreateEdge();
}
}

View File

@@ -0,0 +1,8 @@
.edge-preview {
pointer-events: none;
}
.preview-node {
pointer-events: none;
animation: pulse-preview 1s infinite;
}

View File

@@ -0,0 +1,123 @@
@inject ISnackbar Snackbar
@implements IDisposable
<marker id="arrowhead-control" markerWidth="5" markerHeight="5" refX="0" refY="1.5" orient="auto-start-reverse">
<polygon class="edge-arrow"
points="0 0, 3 1.5, 0 3"
fill="#ff9800" />
</marker>
@if (State.Mode == EditorMode.TrajectoryEditor && State.EdgeVehicleEditor is not null)
{
var edge = State.Edges.FirstOrDefault(e => e.Id == State.EdgeVehicleEditor.EdgeId);
var startNode = edge != null ? State.Nodes.FirstOrDefault(n => n.Id == edge.StartNodeId) : null;
var endNode = edge != null ? State.Nodes.FirstOrDefault(n => n.Id == edge.EndNodeId) : null;
@if (startNode is not null && endNode is not null)
{
var TrajectoryPath = State.GetTrajectoryPath(startNode, endNode,
State.EdgeVehicleEditor.TrajectoryDegree ?? 1,
State.EdgeVehicleEditor.TrajectoryControlPoint1X,
State.EdgeVehicleEditor.TrajectoryControlPoint1Y,
State.EdgeVehicleEditor.TrajectoryControlPoint2X,
State.EdgeVehicleEditor.TrajectoryControlPoint2Y, false);
<path d="@TrajectoryPath"
stroke="#ff9800"
marker-end="url(#arrowhead-control)"
stroke-width="0.1"
pointer-events="none"
fill="none" />
var nodeRadius = 0.25 / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
@if (State.EdgeVehicleEditor.TrajectoryDegree == 3 && State.EdgeVehicleEditor.TrajectoryControlPoint2X.HasValue && State.EdgeVehicleEditor.TrajectoryControlPoint2Y.HasValue)
{
var controlpoint2 = State.WorldToSvg(State.EdgeVehicleEditor.TrajectoryControlPoint2X.Value, State.EdgeVehicleEditor.TrajectoryControlPoint2Y.Value);
<circle class="preview-control-node"
cx="@controlpoint2.X.ToString("F2")"
cy="@controlpoint2.Y.ToString("F2")"
r="@nodeRadius.ToString("F3")"
fill="#ff9800"
stroke="#ff9800"
stroke-opacity="0.5"
stroke-width="0.03"
marker-end="url(#arrowhead-control)"
@onmousedown="@((e) => HandleControlPointDown(2, e))"
@onmousedown:stopPropagation="true" />
}
@if (State.EdgeVehicleEditor.TrajectoryDegree > 1 && State.EdgeVehicleEditor.TrajectoryControlPoint1X.HasValue && State.EdgeVehicleEditor.TrajectoryControlPoint1Y.HasValue)
{
var controlpoint1 = State.WorldToSvg(State.EdgeVehicleEditor.TrajectoryControlPoint1X.Value, State.EdgeVehicleEditor.TrajectoryControlPoint1Y.Value);
<circle class="preview-control-node"
cx="@controlpoint1.X.ToString("F2")"
cy="@controlpoint1.Y.ToString("F2")"
r="@nodeRadius.ToString("F3")"
fill="#ff9800"
stroke="#ff9800"
stroke-opacity="0.5"
stroke-width="0.03"
@onmousedown="@((e) => HandleControlPointDown(1, e))"
@onmousedown:stopPropagation="true" />
}
}
}
@code {
[CascadingParameter]
public LayoutEditorState State { get; set; } = null!;
private (double X, double Y)? dragControlPointStartWorld;
private int controlPointSelected = -1;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender) return;
State.OnTrajectoryChanged += StateHasChanged;
}
public void Dispose()
{
State.OnTrajectoryChanged -= StateHasChanged;
}
private void HandleControlPointDown(int controlPointNumber, MouseEventArgs e)
{
// Disable trajectory editing in ReadOnly mode
if (State.IsReadOnly)
{
Snackbar.Add("Cannot edit trajectory: Layout is read-only (Active)", Severity.Warning);
return;
}
{
if (controlPointSelected != controlPointNumber && State.Mode == EditorMode.TrajectoryEditor && State.EdgeVehicleEditor != null && e.CtrlKey)
{
if (controlPointNumber == 1 && State.EdgeVehicleEditor.TrajectoryControlPoint1X.HasValue && State.EdgeVehicleEditor.TrajectoryControlPoint1Y.HasValue)
{
dragControlPointStartWorld = (State.EdgeVehicleEditor.TrajectoryControlPoint1X.Value, State.EdgeVehicleEditor.TrajectoryControlPoint1Y.Value);
controlPointSelected = controlPointNumber;
}
else if (controlPointNumber == 2 && State.EdgeVehicleEditor.TrajectoryControlPoint2X.HasValue && State.EdgeVehicleEditor.TrajectoryControlPoint2Y.HasValue)
{
dragControlPointStartWorld = (State.EdgeVehicleEditor.TrajectoryControlPoint2X.Value, State.EdgeVehicleEditor.TrajectoryControlPoint2Y.Value);
controlPointSelected = controlPointNumber;
}
}
}
}
public void Update(double svgX, double svgY)
{
if (!dragControlPointStartWorld.HasValue || State.EdgeVehicleEditor is null) return;
var currentWorld = State.SvgToWorld(svgX, svgY);
if (controlPointSelected == 1) (State.EdgeVehicleEditor.TrajectoryControlPoint1X, State.EdgeVehicleEditor.TrajectoryControlPoint1Y) = currentWorld;
else if (controlPointSelected == 2) (State.EdgeVehicleEditor.TrajectoryControlPoint2X, State.EdgeVehicleEditor.TrajectoryControlPoint2Y) = currentWorld;
State.NotifyTrajectoryChanged();
StateHasChanged();
}
public void Cancel()
{
controlPointSelected = -1;
}
}

View File

@@ -0,0 +1,6 @@
.preview-control-node {
cursor: pointer;
animation: pulse-preview 1s infinite;
}

View File

@@ -0,0 +1,63 @@
@if (State.ShowGrid && State.Level?.EditorSettings != null)
{
var (physicalWidth, physicalHeight) = State.GetPhysicalDimensions();
var settings = State.Level.EditorSettings;
var originX = settings.OriginX;
var originY = settings.OriginY;
<g id="grid-layer" stroke="#999" stroke-width="0.04" opacity="0.7" stroke-dasharray="0.1,0.1">
@* Vertical lines: Grid bắt đầu từ gốc tọa độ World (0, 0) *@
@{
// Image bounds trong World coordinates:
// Top-left của image trong World: (originX, originY + physicalHeight)
// Bottom-right của image trong World: (originX + physicalWidth, originY)
// Vậy X: từ originX đến originX + physicalWidth
var worldMinX = originX;
var worldMaxX = originX + physicalWidth;
// Tính grid line đầu tiên và cuối cùng trong World coordinates
// Grid lines tại các vị trí: 0, ±GridSpacing, ±2*GridSpacing, ...
var firstGridXWorld = Math.Floor(worldMinX / State.GridSpacing) * State.GridSpacing;
var lastGridXWorld = Math.Ceiling(worldMaxX / State.GridSpacing) * State.GridSpacing;
// Vẽ vertical lines
for (double worldX = firstGridXWorld; worldX <= lastGridXWorld; worldX += State.GridSpacing)
{
var svgX = State.WorldToSvg(worldX, 0).X;
// Chỉ vẽ nếu nằm trong image bounds [0, physicalWidth]
if (svgX >= 0 && svgX <= physicalWidth)
{
<line x1="@svgX.ToString("F2")" y1="0" x2="@svgX.ToString("F2")" y2="@physicalHeight.ToString("F2")" />
}
}
}
@* Horizontal lines: Grid bắt đầu từ gốc tọa độ World (0, 0) *@
@{
// Image bounds trong World coordinates cho Y:
// Y: từ originY (bottom) đến originY + physicalHeight (top)
var worldMinY = originY;
var worldMaxY = originY + physicalHeight;
// Tính grid line đầu tiên và cuối cùng trong World coordinates
var firstGridYWorld = Math.Floor(worldMinY / State.GridSpacing) * State.GridSpacing;
var lastGridYWorld = Math.Ceiling(worldMaxY / State.GridSpacing) * State.GridSpacing;
// Vẽ horizontal lines
for (double worldY = firstGridYWorld; worldY <= lastGridYWorld; worldY += State.GridSpacing)
{
var svgY = State.WorldToSvg(0, worldY).Y;
// Chỉ vẽ nếu nằm trong image bounds [0, physicalHeight]
if (svgY >= 0 && svgY <= physicalHeight)
{
<line x1="0" y1="@svgY.ToString("F2")" x2="@physicalWidth.ToString("F2")" y2="@svgY.ToString("F2")" />
}
}
}
</g>
}
@code {
[CascadingParameter]
public LayoutEditorState State { get; set; } = null!;
}

View File

@@ -0,0 +1,30 @@
<marker id="arrowhead" markerWidth="5" markerHeight="5" refX="0" refY="1.5" orient="auto-start-reverse">
<polygon class="edge-arrow"
points="0 0, 3 1.5, 0 3"
fill="#4caf50" />
</marker>
<marker id="arrowhead-selected" markerWidth="5" markerHeight="5" refX="0" refY="1.5" orient="auto-start-reverse">
<polygon class="edge-arrow"
points="0 0, 3 1.5, 0 3"
fill="#1976d2" />
</marker>
<g id="edges-layer">
@{
// Filter edges based on SelectedVehicleTypeId
// If VehicleType is selected: only show edges that have VehicleProperties for that type
// If no VehicleType selected: show all edges
var edgesToRender = State.SelectedVehicleTypeId.HasValue
? State.Edges.Where(e => e.VehicleProperties?.Any(vp => vp.VehicleTypeId == State.SelectedVehicleTypeId.Value) == true).ToList()
: State.Edges.ToList();
}
@foreach (var edge in edgesToRender)
{
<Edge State="State" Model="edge"/>
}
</g>
@code {
[CascadingParameter]
public LayoutEditorState State { get; set; } = null!;
}

View File

@@ -0,0 +1,52 @@
<g id="nodes-layer">
@{
// Filter edges based on SelectedVehicleTypeId
// If VehicleType is selected: only show edges that have VehicleProperties for that type
// If no VehicleType selected: show all edges
var edgesToRender = State.SelectedVehicleTypeId.HasValue
? State.Edges.Where(e => e.VehicleProperties?.Any(vp => vp.VehicleTypeId == State.SelectedVehicleTypeId.Value) == true).ToList()
: State.Edges.ToList();
}
@{
// Filter nodes based on SelectedVehicleTypeId
// If VehicleType is selected: show nodes that have VehicleProperties for that type OR are connected to visible edges
var nodesToRender = new List<NodeDto>();
if (State.SelectedVehicleTypeId.HasValue)
{
// Get nodes with VehicleProperties for selected VehicleType
var nodesWithVehicleType = State.Nodes
.Where(n => n.VehicleProperties?.Any(vp => vp.VehicleTypeId == State.SelectedVehicleTypeId.Value) == true)
.ToList();
// Get nodes that are start/end of visible edges
var visibleEdgeNodeIds = edgesToRender
.SelectMany(e => new[] { e.StartNodeId, e.EndNodeId })
.Distinct()
.ToHashSet();
var nodesConnectedToVisibleEdges = State.Nodes
.Where(n => visibleEdgeNodeIds.Contains(n.Id))
.ToList();
// Combine: nodes with VehicleType OR nodes connected to visible edges
nodesToRender = nodesWithVehicleType
.Union(nodesConnectedToVisibleEdges)
.DistinctBy(n => n.Id)
.ToList();
}
else
{
// No filter: show all nodes
nodesToRender = State.Nodes.ToList();
}
}
@foreach (var node in nodesToRender)
{
<Node State="State" Model="node"/>
}
</g>
@code {
[CascadingParameter]
public LayoutEditorState State { get; set; } = null!;
}

View File

@@ -0,0 +1,174 @@
@implements IDisposable
@{
var svg = State.WorldToSvg(Model.X, Model.Y);
var isSelected = State.SelectedNodeIds.Contains(Model.Id);
var hasStation = State.Stations.Any(s => s.InteractionNodes?.Any(i => i.NodeId == Model.Id) == true);
var nodeRadius = 0.25 / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
<!-- Selection ring -->
@if (isSelected)
{
<circle class="node-selection"
cx="@svg.X.ToString("F2")"
cy="@svg.Y.ToString("F2")"
r="@((nodeRadius * 1.5).ToString("F3"))"
fill="none"
stroke="#1976d2"
stroke-width="0.04"
stroke-dasharray="0.1,0.05" />
}
<!-- Node circle -->
<circle class="node @(isSelected ? "selected" : "") @(hasStation ? "has-station" : "")"
data-id="@Model.Id"
cx="@svg.X.ToString("F2")"
cy="@svg.Y.ToString("F2")"
r="@nodeRadius.ToString("F3")"
fill="@(hasStation ? "#4caf50" : "#2196f3")"
stroke="@(isSelected ? "#1976d2" : "#fff")"
stroke-width="0.03"
style="cursor: pointer; pointer-events: @(State.Mode == EditorMode.CreateEdge1Way || State.Mode == EditorMode.CreateEdge2Way ? "none" : "all");"
@onmousedown="(e) => HandleNodeMouseDown(Model.Id, e)"
@onmousedown:stopPropagation="true"
@onclick="(e) => HandleNodeClick(Model.Id, e)"
@onclick:stopPropagation="true" />
<!-- Node name (below center) -->
@if (State.ShowNodeNames && !string.IsNullOrEmpty(Model.NodeName))
{
// Font size: base size in world coordinates (meters), adjusted for Resolution and ZoomLevel
// Resolution is meters per pixel, so fontSizeSVG = baseFontSizeWorld / (Resolution * ZoomLevel)
var baseFontSizeWorld = 0.3; // meters
var fontSize = baseFontSizeWorld / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
// nodeRadius is already in SVG coordinates, so offset = nodeRadius + (baseOffsetWorld / resolution)
var baseOffsetWorld = 0.2; // meters
var offset = nodeRadius + baseOffsetWorld;
@RenderSvgText(Model.NodeName, svg.X, svg.Y + offset, fontSize, "node-name")
}
}
@code {
[Parameter]
public LayoutEditorState State { get; set; } = null!;
[Parameter]
public NodeDto Model { get; set; } = null!;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender) return;
State.OnDraggingNodesChanged += OnDraggingNodesChanged;
}
private void OnDraggingNodesChanged(Guid[] nodeIds)
{
if (nodeIds.Any(n => Model.Id == n)) StateHasChanged();
}
public void Dispose()
{
State.OnDraggingNodesChanged -= OnDraggingNodesChanged;
}
/// <summary>
/// Render SVG text element (workaround for Blazor text directive conflict)
/// </summary>
private RenderFragment RenderSvgText(string content, double x, double y, double fontSize, string cssClass) => builder =>
{
builder.OpenElement(0, "text");
builder.AddAttribute(1, "class", cssClass);
builder.AddAttribute(2, "x", x.ToString("F2"));
builder.AddAttribute(3, "y", y.ToString("F2"));
builder.AddAttribute(4, "text-anchor", "middle");
builder.AddAttribute(5, "font-size", fontSize.ToString("F3"));
builder.AddAttribute(6, "fill", "#f44336");
builder.AddAttribute(7, "font-weight", "500");
builder.AddAttribute(8, "font-family", "Segoe UI, sans-serif");
builder.AddAttribute(9, "letter-spacing", "-0.02em");
builder.AddAttribute(10, "pointer-events", "none");
builder.AddAttribute(11, "style", "user-select: none;");
builder.AddContent(12, content);
builder.CloseElement();
};
private void HandleNodeClick(Guid nodeId, MouseEventArgs e)
{
// In ReadOnly mode, only allow selection (view mode)
if (State.IsReadOnly)
{
State.SelectNode(nodeId, e.ShiftKey);
return;
}
if (State.Mode == EditorMode.Select)
{
State.SelectNode(nodeId, e.ShiftKey);
}
}
private void HandleNodeMouseDown(Guid nodeId, MouseEventArgs e)
{
// Disable drag in ReadOnly mode
if (State.IsReadOnly) return;
// Handle left button in Select mode (Ctrl+drag) or Move mode (direct drag)
if (e.Button != 0) return;
// In Move mode, allow drag without Ctrl
// In Select mode, require Ctrl for drag
var canDrag = (State.Mode == EditorMode.Move || State.Mode == EditorMode.Select) && e.CtrlKey;
if (!canDrag)
{
return; // Not in a mode that allows dragging
}
// Start drag operation
if (State.Mode == EditorMode.Move || e.CtrlKey)
{
// If node is not selected, select it first
if (!State.SelectedNodeIds.Contains(nodeId))
{
State.SelectNode(nodeId, false); // Select without toggling
}
// Start drag
var node = State.Nodes.FirstOrDefault(n => n.Id == nodeId);
if (node != null)
{
State.IsDraggingNodes = true;
State.DraggedNodeId = nodeId;
State.DragStartWorld = (node.X, node.Y);
// Store original positions of all selected nodes
State.DragNodesOriginalPositions.Clear();
foreach (var selectedId in State.SelectedNodeIds)
{
var selectedNode = State.Nodes.FirstOrDefault(n => n.Id == selectedId);
if (selectedNode != null)
{
State.DragNodesOriginalPositions[selectedId] = (selectedNode.X, selectedNode.Y);
}
}
State.DragEdgesOriginalPositions.Clear();
foreach (var selectedId in State.SelectedEdgeIds)
{
var selectedEdge = State.Edges.FirstOrDefault(n => n.Id == selectedId);
if (selectedEdge != null)
{
if (selectedEdge.VehicleProperties is null) continue;
Dictionary<Guid, (double? CP1X, double? CP1Y, double? CP2X, double? CP2Y)> vehicleOriginCP = [];
foreach (var vehicle in selectedEdge.VehicleProperties)
{
vehicleOriginCP[vehicle.Id] = (vehicle.TrajectoryControlPoint1X, vehicle.TrajectoryControlPoint1Y, vehicle.TrajectoryControlPoint2X, vehicle.TrajectoryControlPoint2Y);
}
State.DragEdgesOriginalPositions[selectedId] = vehicleOriginCP;
}
}
}
}
}
}

View File

@@ -0,0 +1,30 @@
/* Nodes */
.node {
cursor: pointer;
transition: opacity 0.15s;
}
.node:hover {
opacity: 0.8;
}
.node.selected {
filter: drop-shadow(0 0 3px rgba(25, 118, 210, 0.8));
}
.node.has-station {
/* Green for station nodes */
}
.node-name {
pointer-events: none;
user-select: none;
font-family: 'Segoe UI', sans-serif;
letter-spacing: -0.01em;
}
.node-selection {
pointer-events: none;
animation: pulse 1.5s infinite;
}

View File

@@ -0,0 +1,19 @@
<marker id="originvector" markerWidth="2.4" markerHeight="2.4" refX="0.4" refY="2">
<line x1="0" y1="2" x2="2" y2="2" stroke="red" stroke-width="0.15" />
<path d="M 2 2.2 L 2.4 2 L 2 1.8 Z" fill="red" stroke-width="0" />
<line x1="0.4" y1="2.4" x2="0.4" y2="0.4" stroke="blue" stroke-width="0.15" />
<path d="M 0.6 0.4 L 0.4 0 L 0.2 0.4 Z" fill="blue" stroke-width="0" />
</marker>
@if (State.Level is not null && State.Level.EditorSettings != null)
{
var (_, physicalHeight) = State.GetPhysicalDimensions();
var svgOriginY = physicalHeight + State.Level.EditorSettings.OriginY;
var width = 0.4 / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
<line x1="@(-State.Level.EditorSettings.OriginX)" y1="@(svgOriginY)" x2="@(-State.Level.EditorSettings.OriginX)" y2="@(svgOriginY)" fill="none" marker-end="url(#originvector)" stroke-width="@width" />
}
@code {
[CascadingParameter]
public LayoutEditorState State { get; set; } = null!;
}

View File

@@ -0,0 +1,161 @@
<!-- Layer 6: Box Select Rectangle -->
@if (isBoxSelecting && boxSelectStart.HasValue && boxSelectEnd.HasValue)
{
var x = Math.Min(boxSelectStart.Value.X, boxSelectEnd.Value.X);
var y = Math.Min(boxSelectStart.Value.Y, boxSelectEnd.Value.Y);
var w = Math.Abs(boxSelectEnd.Value.X - boxSelectStart.Value.X);
var h = Math.Abs(boxSelectEnd.Value.Y - boxSelectStart.Value.Y);
<rect class="box-select"
x="@x.ToString("F2")"
y="@y.ToString("F2")"
width="@w.ToString("F2")"
height="@h.ToString("F2")"
fill="rgba(25, 118, 210, 0.1)"
stroke="#1976d2"
stroke-width="0.02"
stroke-dasharray="0.1,0.05" />
}
@code {
[CascadingParameter]
public LayoutEditorState State { get; set; } = null!;
private bool isBoxSelecting;
private (double X, double Y)? boxSelectStart;
private (double X, double Y)? boxSelectEnd;
public void UpdateStart(double x, double y)
{
boxSelectStart = (x, y);
isBoxSelecting = true;
StateHasChanged();
}
public void UpdateEnd(double x, double y)
{
if (!isBoxSelecting) return;
boxSelectEnd = (x, y);
StateHasChanged();
}
public void FinishBox()
{
FinishBoxSelect();
}
public void CancelBox()
{
isBoxSelecting = false;
boxSelectStart = null;
boxSelectEnd = null;
StateHasChanged();
}
public void FinishBoxSelect()
{
if (!boxSelectStart.HasValue || !boxSelectEnd.HasValue)
{
isBoxSelecting = false;
return;
}
var minX = Math.Min(boxSelectStart.Value.X, boxSelectEnd.Value.X);
var maxX = Math.Max(boxSelectStart.Value.X, boxSelectEnd.Value.X);
var minY = Math.Min(boxSelectStart.Value.Y, boxSelectEnd.Value.Y);
var maxY = Math.Max(boxSelectStart.Value.Y, boxSelectEnd.Value.Y);
// Get filtered nodes and edges based on SelectedVehicleTypeId (same logic as rendering)
var filteredEdges = State.SelectedVehicleTypeId.HasValue
? State.Edges.Where(e => e.VehicleProperties?.Any(vp => vp.VehicleTypeId == State.SelectedVehicleTypeId.Value) == true).ToList()
: State.Edges.ToList();
var filteredNodes = new List<NodeDto>();
if (State.SelectedVehicleTypeId.HasValue)
{
var nodesWithVehicleType = State.Nodes
.Where(n => n.VehicleProperties?.Any(vp => vp.VehicleTypeId == State.SelectedVehicleTypeId.Value) == true)
.ToList();
var visibleEdgeNodeIds = filteredEdges
.SelectMany(e => new[] { e.StartNodeId, e.EndNodeId })
.Distinct()
.ToHashSet();
var nodesConnectedToVisibleEdges = State.Nodes
.Where(n => visibleEdgeNodeIds.Contains(n.Id))
.ToList();
filteredNodes = nodesWithVehicleType
.Union(nodesConnectedToVisibleEdges)
.DistinctBy(n => n.Id)
.ToList();
}
else
{
filteredNodes = State.Nodes.ToList();
}
// Select nodes completely within the rectangle (only from filtered nodes)
var selectedNodeIds = new List<Guid>();
foreach (var node in filteredNodes)
{
var svg = State.WorldToSvg(node.X, node.Y);
if (svg.X >= minX && svg.X <= maxX && svg.Y >= minY && svg.Y <= maxY)
{
selectedNodeIds.Add(node.Id);
}
}
// Select edges where both start and end nodes are within the rectangle (only from filtered edges)
var selectedEdgeIds = new List<Guid>();
foreach (var edge in filteredEdges)
{
var startNode = State.Nodes.FirstOrDefault(n => n.Id == edge.StartNodeId);
var endNode = State.Nodes.FirstOrDefault(n => n.Id == edge.EndNodeId);
if (startNode != null && endNode != null)
{
var startSvg = State.WorldToSvg(startNode.X, startNode.Y);
var endSvg = State.WorldToSvg(endNode.X, endNode.Y);
// Edge is selected if both nodes are within the rectangle
var startInBox = startSvg.X >= minX && startSvg.X <= maxX && startSvg.Y >= minY && startSvg.Y <= maxY;
var endInBox = endSvg.X >= minX && endSvg.X <= maxX && endSvg.Y >= minY && endSvg.Y <= maxY;
if (startInBox && endInBox)
{
selectedEdgeIds.Add(edge.Id);
}
}
}
// Clear previous selection and set new selection
State.SelectedNodeIds.Clear();
State.SelectedEdgeIds.Clear();
if (selectedNodeIds.Count > 0)
{
foreach (var id in selectedNodeIds)
{
State.SelectedNodeIds.Add(id);
}
}
if (selectedEdgeIds.Count > 0)
{
foreach (var id in selectedEdgeIds)
{
State.SelectedEdgeIds.Add(id);
}
}
State.NotifyStateChanged();
isBoxSelecting = false;
boxSelectStart = null;
boxSelectEnd = null;
StateHasChanged();
}
}

View File

@@ -0,0 +1,4 @@
/* Box select */
.box-select {
pointer-events: none;
}

View File

@@ -0,0 +1,433 @@
@using Microsoft.JSInterop
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Components.LayoutEditor.RightPanel
@inject LayoutEditorState State
@inject NavigationManager Navigation
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject IJSRuntime JSRuntime
@inject NavigationManager Nav
@implements IDisposable
<div class="layout-editor-container" Elevation="0">
<!-- Read-Only Banner -->
@if (State.IsReadOnly && !State.IsLoading)
{
<MudAlert Severity="Severity.Warning"
Variant="Variant.Filled"
Dense="true"
Class="readonly-banner mb-2">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Lock" />
<MudText Typo="Typo.body2">
<strong>Read-Only Mode:</strong> @State.ErrorMessage
</MudText>
</MudStack>
</MudAlert>
}
<!-- Top Toolbar -->
<EditorToolbar State="@State"
OnUndo="HandleUndo"
OnRedo="HandleRedo"
OnSave="HandleSave"
OnDelete="HandleDelete"
OnCheck="HandleCheckLayout"
OnExit="HandleExit"/>
<!-- Main Content Area -->
<div class="editor-main-content">
<!-- Left: SVG Canvas -->
<div class="editor-canvas-container" style="width: @(IsPanelOpen ? $"calc(100% - {PanelWidth}px - 8px)" : "100%")">
<!-- Toggle Button - Inside Canvas Container -->
<div class="panel-toggle-button-container">
<MudFab StartIcon="@(IsPanelOpen? Icons.Material.Filled.ChevronRight : Icons.Material.Filled.ChevronLeft)"
Color="Color.Primary"
Size="Size.Small"
OnClick="TogglePanel"/>
</div>
<!-- SVG Canvas -->
@if (State.IsLoading)
{
<div class="loading-overlay">
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
<MudText Typo="Typo.body1" Class="mt-4">Loading layout...</MudText>
</div>
}
else if (!string.IsNullOrEmpty(State.ErrorMessage) && !State.IsReadOnly)
{
<div class="error-overlay">
<MudIcon Icon="@Icons.Material.Filled.Error" Color="Color.Error" Size="Size.Large" />
<MudText Typo="Typo.h6" Color="Color.Error" Class="mt-4">@State.ErrorMessage</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="HandleExit" Class="mt-4">
Back to Layout Manager
</MudButton>
</div>
}
else
{
<SvgEditorCanvas State="@State"
OnUndo="HandleUndo"
OnRedo="HandleRedo"
OnSave="HandleSave"
OnDelete="HandleDelete"/>
}
</div>
@if (IsPanelOpen)
{
<!-- Resizable Divider -->
<div class="resizable-divider"
@onmousedown="StartResize"
@onmouseup:preventDefault="true">
<div class="resizable-divider-handle"></div>
</div>
<!-- Right: Properties Panel -->
<div class="editor-right-panel" style="width: @($"{PanelWidth}px")">
<EditorRightPanel State="@State" />
</div>
}
<!-- Resize Overlay (shown when resizing) -->
@if (IsResizing)
{
<div class="resize-overlay"
@onmousemove="OnResizeMouseMove"
@onmouseup="StopResize"
@onmouseleave="StopResize"></div>
}
</div>
</div>
@code {
[Parameter]
public Guid LevelId { get; set; }
// Panel state
private double PanelWidth { get; set; } = 350;
private bool IsPanelOpen { get; set; } = true;
private const double MinPanelWidth = 250;
private const double MaxPanelWidth = 600;
// Resize state
private bool IsResizing { get; set; } = false;
private double ResizeStartX { get; set; }
private double ResizeStartWidth { get; set; }
protected override async Task OnInitializedAsync()
{
State.OnStateChanged += HandleStateChanged;
await State.InitializeAsync(LevelId);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Setup keyboard shortcuts
await SetupKeyboardShortcuts();
}
}
public void Dispose()
{
State.OnStateChanged -= HandleStateChanged;
}
private void HandleStateChanged()
{
InvokeAsync(StateHasChanged);
}
private async Task SetupKeyboardShortcuts()
{
// Keyboard shortcuts are handled in svgEditor.js
// This method can be used for additional setup if needed
}
private void HandleUndo()
{
State.Undo();
Snackbar.Add("Undo", Severity.Info, config => config.VisibleStateDuration = 1000);
}
private void HandleRedo()
{
State.Redo();
Snackbar.Add("Redo", Severity.Info, config => config.VisibleStateDuration = 1000);
}
private async Task HandleSave()
{
await State.SaveAsync();
if (string.IsNullOrEmpty(State.ErrorMessage))
{
Snackbar.Add("Saved successfully", Severity.Success);
}
else
{
Snackbar.Add(State.ErrorMessage, Severity.Error);
}
}
private async Task HandleDelete()
{
var selectedNodes = State.GetSelectedNodes();
var selectedEdges = State.GetSelectedEdges();
if (selectedNodes.Count == 0 && selectedEdges.Count == 0)
{
Snackbar.Add("Nothing selected to delete", Severity.Warning);
return;
}
// Collect all edges to delete:
// 1. Selected edges
// 2. Edges connected to selected nodes
var edgesToDelete = new HashSet<Guid>(selectedEdges.Select(e => e.Id));
foreach (var node in selectedNodes)
{
var connectedEdges = State.Edges
.Where(e => e.StartNodeId == node.Id || e.EndNodeId == node.Id)
.Select(e => e.Id);
foreach (var edgeId in connectedEdges)
{
edgesToDelete.Add(edgeId);
}
}
if (edgesToDelete.Count == 0)
{
Snackbar.Add("Nothing to delete", Severity.Warning);
return;
}
// Show confirmation dialog
var message = $"Are you sure you want to delete the following items?\n\n" +
$"• {selectedNodes.Count} node(s)\n" +
$"• {edgesToDelete.Count} edge(s)\n\n" +
$"Orphaned nodes (nodes without edges) will be automatically removed.";
var result = await DialogService.ShowMessageBoxAsync(
"Confirm Deletion",
message,
yesText: "Delete",
cancelText: "Cancel");
if (result != true)
{
return;
}
// Delete edges (nodes will be auto-deleted if orphaned)
var success = await State.DeleteEdgesAsync(edgesToDelete.ToList());
if (success)
{
State.ClearSelection();
Snackbar.Add(
$"Deleted {edgesToDelete.Count} edge(s). Nodes were removed if orphaned.",
Severity.Success);
}
else
{
Snackbar.Add(
State.ErrorMessage ?? "Failed to delete",
Severity.Error);
}
}
private async Task HandleCheckLayout()
{
var issues = new List<string>();
var warnings = new List<string>();
// Get editor settings
var minEdgeLength = State.Level?.EditorSettings?.EdgeMinLengthCreate ?? 0.1;
// 1. Check for isolated nodes (nodes without any edges)
var nodesWithEdges = new HashSet<Guid>();
foreach (var edge in State.Edges)
{
nodesWithEdges.Add(edge.StartNodeId);
nodesWithEdges.Add(edge.EndNodeId);
}
var isolatedNodes = State.Nodes
.Where(n => !nodesWithEdges.Contains(n.Id))
.ToList();
if (isolatedNodes.Count > 0)
{
warnings.Add($"{isolatedNodes.Count} isolated node(s) found (nodes without edges): {string.Join(", ", isolatedNodes.Take(5).Select(n => n.NodeName ?? n.NodeId))}{(isolatedNodes.Count > 5 ? "..." : "")}");
}
// 2. Check edge minimum length
var shortEdges = new List<(EdgeDto Edge, double Length)>();
foreach (var edge in State.Edges)
{
var startNode = State.Nodes.FirstOrDefault(n => n.Id == edge.StartNodeId);
var endNode = State.Nodes.FirstOrDefault(n => n.Id == edge.EndNodeId);
if (startNode != null && endNode != null)
{
var dx = endNode.X - startNode.X;
var dy = endNode.Y - startNode.Y;
var length = Math.Sqrt(dx * dx + dy * dy);
if (length < minEdgeLength)
{
shortEdges.Add((edge, length));
}
}
}
if (shortEdges.Count > 0)
{
issues.Add($"{shortEdges.Count} edge(s) shorter than minimum length ({minEdgeLength:F2}m): {string.Join(", ", shortEdges.Take(5).Select(e => $"{e.Edge.EdgeName ?? e.Edge.EdgeId} ({e.Length:F2}m)"))}{(shortEdges.Count > 5 ? "..." : "")}");
}
// 3. Check for duplicate node positions (nodes too close)
var duplicatePositions = new List<(NodeDto Node1, NodeDto Node2, double Distance)>();
for (int i = 0; i < State.Nodes.Count; i++)
{
for (int j = i + 1; j < State.Nodes.Count; j++)
{
var node1 = State.Nodes[i];
var node2 = State.Nodes[j];
var dx = node2.X - node1.X;
var dy = node2.Y - node1.Y;
var distance = Math.Sqrt(dx * dx + dy * dy);
if (distance < 0.01) // Less than 1cm apart
{
duplicatePositions.Add((node1, node2, distance));
}
}
}
if (duplicatePositions.Count > 0)
{
warnings.Add($"{duplicatePositions.Count} pair(s) of nodes are very close (< 1cm): {string.Join(", ", duplicatePositions.Take(3).Select(p => $"{p.Node1.NodeName ?? p.Node1.NodeId} & {p.Node2.NodeName ?? p.Node2.NodeId}"))}{(duplicatePositions.Count > 3 ? "..." : "")}");
}
// 4. Check for edges with same start and end node
var selfLoops = State.Edges
.Where(e => e.StartNodeId == e.EndNodeId)
.ToList();
if (selfLoops.Count > 0)
{
warnings.Add($"{selfLoops.Count} self-loop edge(s) found (start = end): {string.Join(", ", selfLoops.Take(5).Select(e => e.EdgeName ?? e.EdgeId))}{(selfLoops.Count > 5 ? "..." : "")}");
}
// Display results
if (issues.Count == 0 && warnings.Count == 0)
{
Snackbar.Add("Layout check completed - No issues found", Severity.Success);
}
else
{
var message = new System.Text.StringBuilder();
if (issues.Count > 0)
{
message.AppendLine($"<strong>{issues.Count} Issue(s) Found:</strong>");
foreach (var issue in issues)
{
message.AppendLine($"• {issue}");
}
}
if (warnings.Count > 0)
{
if (issues.Count > 0) message.AppendLine();
message.AppendLine($"<strong>{warnings.Count} Warning(s):</strong>");
foreach (var warning in warnings)
{
message.AppendLine($"• {warning}");
}
}
await DialogService.ShowMessageBoxAsync(
issues.Count > 0 ? "Layout Check - Issues Found" : "Layout Check - Warnings",
message.ToString(),
yesText: "OK",
cancelText: null);
}
}
private async Task HandleExit()
{
if (State.HasUnsavedChanges)
{
var result = await DialogService.ShowMessageBoxAsync(
"Unsaved Changes",
"You have unsaved changes. Are you sure you want to leave?",
yesText: "Leave",
cancelText: "Cancel");
if (result != true)
{
return;
}
}
State.ErrorMessage = null;
Nav.NavigateTo("/layout-manager");
State.NotifyStateChanged();
}
// ==========================================
// PANEL TOGGLE & RESIZE
// ==========================================
private void TogglePanel()
{
IsPanelOpen = !IsPanelOpen;
StateHasChanged();
}
private void StartResize(Microsoft.AspNetCore.Components.Web.MouseEventArgs e)
{
IsResizing = true;
ResizeStartX = e.ClientX;
ResizeStartWidth = PanelWidth;
StateHasChanged();
}
private void StopResize()
{
if (IsResizing)
{
IsResizing = false;
StateHasChanged();
}
}
private void OnResizeMouseMove(Microsoft.AspNetCore.Components.Web.MouseEventArgs e)
{
if (!IsResizing) return;
var deltaX = ResizeStartX - e.ClientX; // Inverted because we're dragging left
var newWidth = ResizeStartWidth + deltaX;
// Clamp to min/max
if (newWidth < MinPanelWidth)
{
PanelWidth = MinPanelWidth;
}
else if (newWidth > MaxPanelWidth)
{
PanelWidth = MaxPanelWidth;
}
else
{
PanelWidth = newWidth;
}
StateHasChanged();
}
}

View File

@@ -0,0 +1,98 @@
.layout-editor-container {
display: flex;
flex-direction: column;
height: calc(100vh - 50px); /* Subtract app header height */
width: 100%;
overflow: hidden;
background-color: var(--mud-palette-background);
}
.editor-main-content {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0; /* Important for flex child overflow */
}
.editor-canvas-container {
flex: 1;
position: relative;
overflow: hidden;
background-color: #f0f0f0;
min-width: 0; /* Important for flex child overflow */
transition: width 0.2s ease;
}
.editor-right-panel {
overflow: hidden;
background-color: var(--mud-palette-surface);
border-left: 1px solid var(--mud-palette-lines-default);
flex-shrink: 0;
transition: width 0.2s ease;
}
.resizable-divider {
width: 8px;
cursor: col-resize;
background-color: var(--mud-palette-background);
border-left: 1px solid var(--mud-palette-lines-default);
border-right: 1px solid var(--mud-palette-lines-default);
position: relative;
flex-shrink: 0;
user-select: none;
display: flex;
align-items: center;
justify-content: center;
}
.resizable-divider:hover {
background-color: var(--mud-palette-action-hover);
}
.resizable-divider-handle {
width: 2px;
height: 40px;
background-color: var(--mud-palette-text-secondary);
border-radius: 1px;
opacity: 0.5;
}
.resizable-divider:hover .resizable-divider-handle {
opacity: 1;
background-color: var(--mud-palette-primary);
}
.panel-toggle-button-container {
position: absolute;
right: 8px;
top: 8px;
z-index: 10;
display: flex;
align-items: center;
}
.loading-overlay,
.error-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.9);
z-index: 100;
}
.resize-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
cursor: col-resize;
user-select: none;
}

View File

@@ -0,0 +1,19 @@
<div class="mouse-position-display">
<span class="coord-label">X:</span>
<span class="coord-value">@X.ToString("F2") m</span>
<span class="coord-label">Y:</span>
<span class="coord-value">@Y.ToString("F2") m</span>
</div>
@code {
private double X { get; set; }
private double Y { get; set; }
public void Update(double x, double y)
{
X = x;
Y = y;
StateHasChanged();
}
}

View File

@@ -0,0 +1,28 @@
.mouse-position-display {
position: absolute;
top: 10px;
left: 10px;
z-index: 50;
background-color: rgba(33, 33, 33, 0.85);
color: white;
padding: 6px 12px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
display: flex;
gap: 8px;
align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.coord-label {
color: #aaa;
font-weight: 500;
}
.coord-value {
color: #4fc3f7;
font-weight: bold;
min-width: 70px;
}

View File

@@ -0,0 +1,267 @@
@using System.Text.Json
@using RobotNet.VDA5050
@using RobotNet.VDA5050.Type
@using RobotNet10.MapEditor.Components.LayoutEditor.RightPanel.Dialogs
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<MudPaper Elevation="2" Class="pa-2">
<!-- Header with Add button -->
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2">Actions</MudText>
<MudStack Row="true" Spacing="1">
@if (DefaultActions != null && DefaultActions.Count > 0)
{
<MudTooltip Text="Add actions from vehicle type default">
<MudIconButton Icon="@Icons.Material.Filled.PlaylistAdd"
Size="Size.Small"
Color="Color.Primary"
Disabled="@IsReadOnly"
OnClick="OpenAddFromDefaultDialog" />
</MudTooltip>
}
<MudTooltip Text="Create new action">
<MudIconButton Icon="@Icons.Material.Filled.Add"
Size="Size.Small"
Color="Color.Success"
Disabled="@IsReadOnly"
OnClick="OpenCreateActionDialog" />
</MudTooltip>
</MudStack>
</MudStack>
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error" Dense="true" Class="my-2">@errorMessage</MudAlert>
}
<!-- Actions List -->
@if (actions.Count == 0)
{
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center">
No actions defined. Click + to add actions.
</MudText>
</MudPaper>
}
else
{
<MudList T="string" Dense="true">
@for (int i = 0; i < actions.Count; i++)
{
var index = i;
var action = actions[i];
<MudListItem T="string" Class="px-2">
<MudPaper Elevation="1" Class="pa-2">
<MudStack Spacing="1">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudChip T="string" Size="Size.Small" Color="Color.Primary" Variant="Variant.Text">
@action.ActionType
</MudChip>
<MudChip T="string" Size="Size.Small"
Color="@GetRequirementColor(action.RequirementType)"
Variant="Variant.Text">
@action.RequirementType
</MudChip>
</MudStack>
<MudStack Row="true" Spacing="0">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Disabled="@IsReadOnly"
OnClick="() => OpenEditActionDialog(index)" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
Disabled="@IsReadOnly"
OnClick="() => RemoveAction(index)" />
</MudStack>
</MudStack>
@if (!string.IsNullOrEmpty(action.ActionDescription))
{
<MudText Typo="Typo.body2" Color="Color.Secondary">
@action.ActionDescription
</MudText>
}
<MudText Typo="Typo.caption">
Blocking: <strong>@action.BlockingType</strong>
</MudText>
@if (action.ActionParameters != null && action.ActionParameters.Count > 0)
{
<MudText Typo="Typo.caption">
Parameters: @string.Join(", ", action.ActionParameters.Select(p => $"{p.Key}={p.Value}"))
</MudText>
}
</MudStack>
</MudPaper>
</MudListItem>
}
</MudList>
}
</MudPaper>
@code {
[Parameter]
public string? ActionsJson { get; set; }
[Parameter]
public EventCallback<string?> ActionsJsonChanged { get; set; }
[Parameter]
public List<ActionDto>? DefaultActions { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
private List<ActionDto> actions = new();
private string? errorMessage;
protected override void OnParametersSet()
{
ParseJson();
base.OnParametersSet();
}
private void ParseJson()
{
actions.Clear();
errorMessage = null;
if (string.IsNullOrWhiteSpace(ActionsJson))
{
return;
}
try
{
var parsed = JsonSerializer.Deserialize<List<ActionDto>>(ActionsJson, JsonOptionExtends.Read);
if (parsed != null)
{
actions.AddRange(parsed);
}
}
catch (Exception ex)
{
errorMessage = $"Invalid JSON: {ex.Message}";
Snackbar.Add(errorMessage, Severity.Error);
}
}
private async Task UpdateJson()
{
errorMessage = null;
if (actions.Count == 0)
{
await ActionsJsonChanged.InvokeAsync(null);
}
else
{
try
{
var json = JsonSerializer.Serialize(actions, JsonOptionExtends.Write);
await ActionsJsonChanged.InvokeAsync(json);
}
catch (Exception ex)
{
errorMessage = $"Failed to serialize: {ex.Message}";
Snackbar.Add(errorMessage, Severity.Error);
}
}
}
private Color GetRequirementColor(RequirementType requirementType)
{
return requirementType switch
{
RequirementType.REQUIRED => Color.Error,
RequirementType.CONDITIONAL => Color.Warning,
RequirementType.OPTIONAL => Color.Info,
_ => Color.Default
};
}
private async Task OpenAddFromDefaultDialog()
{
if (DefaultActions == null || DefaultActions.Count == 0) return;
var parameters = new DialogParameters
{
["DefaultActions"] = DefaultActions,
["ExistingActions"] = actions
};
var dialog = await DialogService.ShowAsync<AddActionsFromDefaultDialog>(
"Add Actions from Vehicle Type",
parameters,
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is List<ActionDto> selectedActions)
{
foreach (var action in selectedActions)
{
actions.Add(action);
}
await UpdateJson();
StateHasChanged();
Snackbar.Add($"Added {selectedActions.Count} action(s)", Severity.Success);
}
}
private async Task OpenCreateActionDialog()
{
var dialog = await DialogService.ShowAsync<EditActionDialog>(
"Create New Action",
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is ActionDto newAction)
{
actions.Add(newAction);
await UpdateJson();
StateHasChanged();
Snackbar.Add("Action created", Severity.Success);
}
}
private async Task OpenEditActionDialog(int index)
{
var actionToEdit = actions[index];
var parameters = new DialogParameters
{
["Action"] = actionToEdit
};
var dialog = await DialogService.ShowAsync<EditActionDialog>(
"Edit Action",
parameters,
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is ActionDto editedAction)
{
actions[index] = editedAction;
await UpdateJson();
StateHasChanged();
Snackbar.Add("Action updated", Severity.Success);
}
}
private async Task RemoveAction(int index)
{
actions.RemoveAt(index);
await UpdateJson();
StateHasChanged();
Snackbar.Add("Action removed", Severity.Info);
}
}

View File

@@ -0,0 +1,102 @@
@using RobotNet.VDA5050.Type
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">Add Actions from Vehicle Type</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Secondary">
Select actions to add from vehicle type default actions.
</MudText>
@if (DefaultActions == null || DefaultActions.Count == 0)
{
<MudAlert Severity="Severity.Info">
No default actions available for this vehicle type.
</MudAlert>
}
else
{
<MudList T="ActionDto" Dense="true" @bind-SelectedValues="selectedActions" SelectionMode="SelectionMode.MultiSelection" CheckBoxColor="Color.Tertiary">
@foreach (var action in DefaultActions)
{
var isAlreadyAdded = ExistingActions.Any(a => a.ActionType == action.ActionType);
<MudListItem T="ActionDto" Value="@action">
<MudStack Row="true" Spacing="2" Justify="Justify.FlexStart" AlignItems="AlignItems.Center">
<MudText Typo="Typo.body1">
<strong>@action.ActionType</strong>
</MudText>
<MudChip T="string" Size="Size.Small"
Color="@GetRequirementColor(action.RequirementType)"
Variant="Variant.Text">
@action.RequirementType
</MudChip>
@if (isAlreadyAdded)
{
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Text">
Already added
</MudChip>
}
</MudStack>
</MudListItem>
}
</MudList>
<MudText Typo="Typo.caption" Color="Color.Secondary">
Selected: @selectedActions.Count / @DefaultActions.Count
</MudText>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(selectedActions.Count == 0)">
Add Selected
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? MudDialog { get; set; }
[Parameter]
public List<ActionDto> DefaultActions { get; set; } = new();
[Parameter]
public List<ActionDto> ExistingActions { get; set; } = new();
private IReadOnlyCollection<ActionDto> selectedActions = [];
public IReadOnlyCollection<string> SelectedValues = ["Milk", "Cafe Latte"];
private Color GetRequirementColor(RequirementType requirementType)
{
return requirementType switch
{
RequirementType.REQUIRED => Color.Error,
RequirementType.CONDITIONAL => Color.Warning,
RequirementType.OPTIONAL => Color.Info,
_ => Color.Default
};
}
private void Cancel()
{
MudDialog?.Cancel();
}
private void Submit()
{
MudDialog?.Close(DialogResult.Ok(selectedActions));
}
}

View File

@@ -0,0 +1,92 @@
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">Add Vehicle Type</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Secondary">
Select a vehicle type to add properties for this node.
</MudText>
@if (AvailableVehicleTypes == null || AvailableVehicleTypes.Count == 0)
{
<MudAlert Severity="Severity.Info">
No available vehicle types.
</MudAlert>
}
else
{
<MudList T="VehicleTypeDto" Dense="true">
@foreach (var vehicleType in AvailableVehicleTypes)
{
<MudListItem T="VehicleTypeDto"
OnClick="() => SelectVehicleType(vehicleType)"
Class="@(selectedVehicleType?.Id == vehicleType.Id ? "mud-primary-text" : "")">
<MudStack Spacing="1">
<MudText Typo="Typo.body1">
<strong>@vehicleType.VehicleTypeName</strong>
</MudText>
@if (!string.IsNullOrWhiteSpace(vehicleType.VehicleTypeId))
{
<MudText Typo="Typo.body2" Color="Color.Secondary">
ID: @vehicleType.VehicleTypeId
</MudText>
}
@if (!string.IsNullOrWhiteSpace(vehicleType.Description))
{
<MudText Typo="Typo.caption" Color="Color.Secondary">
@vehicleType.Description
</MudText>
}
</MudStack>
</MudListItem>
}
</MudList>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(selectedVehicleType == null)">
Add
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? MudDialog { get; set; }
[Parameter]
public List<VehicleTypeDto> AvailableVehicleTypes { get; set; } = new();
private VehicleTypeDto? selectedVehicleType;
private void SelectVehicleType(VehicleTypeDto vehicleType)
{
selectedVehicleType = vehicleType;
StateHasChanged();
}
private void Cancel()
{
MudDialog?.Cancel();
}
private void Submit()
{
if (selectedVehicleType != null)
{
MudDialog?.Close(DialogResult.Ok(selectedVehicleType));
}
}
}

View File

@@ -0,0 +1,159 @@
@using RobotNet10.MapEditor.Shared.DTOs.Requests
@using RobotNet10.MapEditor.Services.API
@inject MapManagerApiService ApiService
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">Create New Station</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="2">
<MudTextField @bind-Value="request.StationId"
Label="Station ID *"
Required="true"
Variant="Variant.Outlined"
Margin="Margin.Dense"
HelperText="Unique identifier (must be unique within this level)" />
<MudTextField @bind-Value="request.StationName"
Label="Station Name"
Variant="Variant.Outlined"
Margin="Margin.Dense"
HelperText="Display name for this station" />
<MudTextField @bind-Value="request.StationDescription"
Label="Description"
Lines="2"
Variant="Variant.Outlined"
Margin="Margin.Dense"
HelperText="Optional description" />
<MudDivider Class="my-2" />
<MudText Typo="Typo.subtitle2">Position</MudText>
<MudStack Row="true" Spacing="2">
<MudNumericField @bind-Value="request.X"
Label="X (meters) *"
Required="true"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3" />
<MudNumericField @bind-Value="request.Y"
Label="Y (meters) *"
Required="true"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3" />
</MudStack>
<MudStack Row="true" Spacing="2">
<MudNumericField @bind-Value="request.Theta"
Label="Theta (radians)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
Min="-3.14159"
Max="3.14159"
HelperText="Range: [-π ... π]" />
<MudNumericField @bind-Value="request.StationHeight"
Label="Height (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F2"
Min="0"
HelperText="Optional" />
</MudStack>
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error" Dense="true">@errorMessage</MudAlert>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(!IsValid() || isSubmitting)">
@if (isSubmitting)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<MudText>Creating...</MudText>
}
else
{
<MudText>Create</MudText>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? MudDialog { get; set; }
[Parameter]
public Guid LayoutLevelId { get; set; }
[Parameter]
public double? DefaultX { get; set; }
[Parameter]
public double? DefaultY { get; set; }
[Parameter]
public List<Guid>? DefaultInteractionNodeIds { get; set; }
private CreateStationRequest request = new();
private bool isSubmitting = false;
private string? errorMessage;
protected override void OnParametersSet()
{
request.LayoutLevelId = LayoutLevelId;
request.X = DefaultX ?? 0.0;
request.Y = DefaultY ?? 0.0;
request.InteractionNodeIds = DefaultInteractionNodeIds ?? new List<Guid>();
}
private bool IsValid()
{
return !string.IsNullOrWhiteSpace(request.StationId);
}
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Submit()
{
if (!IsValid())
{
errorMessage = "Please fill in all required fields";
return;
}
isSubmitting = true;
errorMessage = null;
StateHasChanged();
try
{
var createdStation = await ApiService.CreateStationAsync(request);
MudDialog?.Close(DialogResult.Ok(createdStation));
}
catch (Exception ex)
{
errorMessage = ex.Message;
isSubmitting = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,181 @@
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@using RobotNet.VDA5050.Type
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">@(Action == null ? "Create" : "Edit") Action</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="2">
<MudTextField @bind-Value="actionType"
Label="Action Type"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Required="true"
Placeholder="e.g., pick, drop, charge"
Error="@(!string.IsNullOrEmpty(errorActionType))"
ErrorText="@errorActionType" />
<MudTextField @bind-Value="actionDescription"
Label="Action Description"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Lines="2"
Placeholder="Optional description of the action" />
<MudSelect T="RequirementType" @bind-Value="requirementType"
Label="Requirement Type"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Clearable="true">
<MudSelectItem Value="@RequirementType.REQUIRED">REQUIRED</MudSelectItem>
<MudSelectItem Value="@RequirementType.CONDITIONAL">CONDITIONAL</MudSelectItem>
<MudSelectItem Value="@RequirementType.OPTIONAL">OPTIONAL</MudSelectItem>
</MudSelect>
<MudSelect T="BlockingType" @bind-Value="blockingType"
Label="Blocking Type"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Clearable="true">
<MudSelectItem Value="@BlockingType.NONE">NONE</MudSelectItem>
<MudSelectItem Value="@BlockingType.SOFT">SOFT</MudSelectItem>
<MudSelectItem Value="@BlockingType.HARD">HARD</MudSelectItem>
</MudSelect>
<MudDivider />
<!-- Action Parameters -->
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2">Parameters</MudText>
<MudButton Size="Size.Small"
StartIcon="@Icons.Material.Filled.Add"
OnClick="AddParameter">
Add Parameter
</MudButton>
</MudStack>
@if (parameters.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">
No parameters. Click "Add Parameter" to add.
</MudText>
}
else
{
@for (int i = 0; i < parameters.Count; i++)
{
var index = i;
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudTextField @bind-Value="parameters[index].Key"
Label="Key"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Style="flex: 1;" />
<MudTextField @bind-Value="parameters[index].Value"
Label="Value"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Style="flex: 1;" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
OnClick="() => RemoveParameter(index)" />
</MudStack>
}
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit">
@(Action == null ? "Create" : "Save")
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? MudDialog { get; set; }
[Parameter]
public ActionDto? Action { get; set; }
private string actionType = string.Empty;
private string? actionDescription;
private RequirementType requirementType;
private BlockingType blockingType;
private List<ActionParameterDto> parameters = new();
private string? errorActionType;
protected override void OnParametersSet()
{
if (Action != null)
{
actionType = Action.ActionType;
actionDescription = Action.ActionDescription;
requirementType = Action.RequirementType;
blockingType = Action.BlockingType;
if (Action.ActionParameters != null)
{
parameters = new List<ActionParameterDto>(Action.ActionParameters);
}
}
}
private void AddParameter()
{
parameters.Add(new ActionParameterDto());
}
private void RemoveParameter(int index)
{
parameters.RemoveAt(index);
}
private void Cancel()
{
MudDialog?.Cancel();
}
private void Submit()
{
errorActionType = null;
// Validate
if (string.IsNullOrWhiteSpace(actionType))
{
errorActionType = "Action type is required";
Snackbar.Add(errorActionType, Severity.Error);
return;
}
var result = new ActionDto
{
ActionType = actionType.Trim(),
ActionDescription = string.IsNullOrWhiteSpace(actionDescription) ? null : actionDescription.Trim(),
RequirementType = requirementType,
BlockingType = blockingType,
ActionParameters = parameters
.Where(p => !string.IsNullOrWhiteSpace(p.Key))
.Select(p => new ActionParameterDto
{
Key = p.Key.Trim(),
Value = p.Value?.Trim() ?? string.Empty
})
.ToList()
};
MudDialog?.Close(DialogResult.Ok(result));
}
}

View File

@@ -0,0 +1,185 @@
@using RobotNet10.MapEditor.Shared.DTOs.Station
@using RobotNet10.MapEditor.Shared.DTOs.Requests
@using RobotNet10.MapEditor.Services.API
@inject MapManagerApiService ApiService
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">Edit Station</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="2">
<MudTextField Value="@Station.StationId"
Label="Station ID"
Variant="Variant.Outlined"
Margin="Margin.Dense"
ReadOnly="true"
HelperText="Station ID cannot be changed" />
<MudTextField @bind-Value="stationName"
Label="Station Name"
Variant="Variant.Outlined"
Margin="Margin.Dense"
HelperText="Display name for this station" />
<MudTextField @bind-Value="stationDescription"
Label="Description"
Lines="2"
Variant="Variant.Outlined"
Margin="Margin.Dense"
HelperText="Optional description" />
<MudDivider Class="my-2" />
<MudText Typo="Typo.subtitle2">Position</MudText>
<MudStack Row="true" Spacing="2">
<MudNumericField @bind-Value="x"
Label="X (meters) *"
Required="true"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3" />
<MudNumericField @bind-Value="y"
Label="Y (meters) *"
Required="true"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3" />
</MudStack>
<MudStack Row="true" Spacing="2">
<MudNumericField @bind-Value="theta"
Label="Theta (radians)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
Min="-3.14159"
Max="3.14159"
HelperText="Range: [-π ... π]" />
<MudNumericField @bind-Value="stationHeight"
Label="Height (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F2"
Min="0"
HelperText="Optional" />
</MudStack>
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error" Dense="true">@errorMessage</MudAlert>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(!IsValid() || isSubmitting)">
@if (isSubmitting)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<MudText>Saving...</MudText>
}
else
{
<MudText>Save</MudText>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? MudDialog { get; set; }
[Parameter]
public StationDto Station { get; set; } = null!;
[Parameter]
public Guid LayoutLevelId { get; set; }
private string? stationName;
private string? stationDescription;
private double x;
private double y;
private double? theta;
private double? stationHeight;
private bool isSubmitting = false;
private string? errorMessage;
protected override void OnParametersSet()
{
stationName = Station.StationName;
stationDescription = Station.StationDescription;
x = Station.X;
y = Station.Y;
theta = Station.Theta;
stationHeight = Station.StationHeight;
}
private bool IsValid()
{
return x != 0 || y != 0; // At least one coordinate must be set
}
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Submit()
{
if (!IsValid())
{
errorMessage = "Position coordinates are required";
return;
}
// Validate theta range
if (theta.HasValue && (theta.Value < -Math.PI || theta.Value > Math.PI))
{
errorMessage = "Theta must be between -π and π";
return;
}
isSubmitting = true;
errorMessage = null;
StateHasChanged();
try
{
// Preserve existing interaction nodes
var interactionNodeIds = Station.InteractionNodes?
.Select(i => i.NodeId)
.ToList() ?? new List<Guid>();
var updateRequest = new UpdateStationRequest
{
StationName = stationName,
StationDescription = stationDescription,
X = x,
Y = y,
Theta = theta,
StationHeight = stationHeight,
InteractionNodeIds = interactionNodeIds
};
var updatedStation = await ApiService.UpdateStationAsync(Station.Id, updateRequest);
MudDialog?.Close(DialogResult.Ok(updatedStation));
}
catch (Exception ex)
{
errorMessage = ex.Message;
isSubmitting = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,92 @@
@using RobotNet10.MapEditor.Shared.DTOs.Station
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">Link Existing Station</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Secondary">
Select a station to link to this node.
</MudText>
@if (AvailableStations == null || AvailableStations.Count == 0)
{
<MudAlert Severity="Severity.Info">
No available stations to link.
</MudAlert>
}
else
{
<MudList T="StationDto" Dense="true">
@foreach (var station in AvailableStations)
{
<MudListItem T="StationDto"
OnClick="() => SelectStation(station)"
Class="@(selectedStation?.Id == station.Id ? "mud-primary-text" : "")">
<MudStack Spacing="1">
<MudText Typo="Typo.body1">
<strong>@(station.StationName ?? station.StationId)</strong>
</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">
ID: @station.StationId
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
Position: (@station.X.ToString("F2"), @station.Y.ToString("F2"))
</MudText>
@if (station.InteractionNodes != null && station.InteractionNodes.Count > 0)
{
<MudText Typo="Typo.caption" Color="Color.Info">
Currently linked to @station.InteractionNodes.Count node(s)
</MudText>
}
</MudStack>
</MudListItem>
}
</MudList>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(selectedStation == null)">
Link
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? MudDialog { get; set; }
[Parameter]
public List<StationDto> AvailableStations { get; set; } = new();
private StationDto? selectedStation;
private void SelectStation(StationDto station)
{
selectedStation = station;
StateHasChanged();
}
private void Cancel()
{
MudDialog?.Cancel();
}
private void Submit()
{
if (selectedStation != null)
{
MudDialog?.Close(DialogResult.Ok(selectedStation));
}
}
}

View File

@@ -0,0 +1,668 @@
@using RobotNet.VDA5050
@using RobotNet.VDA5050.Order
@using RobotNet.VDA5050.Type
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Shared.DTOs.Edge
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@using RobotNet10.MapEditor.Components.LayoutEditor.RightPanel.Dialogs
@using System.Text.Json
@inject IDialogService DialogService
@inject ISnackbar Snackbar
<MudStack Spacing="2">
<MudStack Row="true" Spacing="1" Class="d-flex justify-content-between">
<MudText Typo="Typo.subtitle1" Color="Color.Primary">
<MudIcon Icon="@Icons.Material.Filled.RadioButtonChecked" Size="Size.Small" Class="mr-1" />
Edge Properties
</MudText>
<!-- Save Button -->
<div>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
FullWidth="true"
StartIcon="@Icons.Material.Filled.Save"
Size="Size.Small"
Disabled="@IsReadOnly"
OnClick="HandleSave">
Save
</MudButton>
</div>
</MudStack>
<MudDivider />
<MudPaper style="overflow-y: auto; height: calc(100vh - 237px)" Elevation="0">
<!-- Basic Info (always visible) -->
<MudStack Spacing="2">
<MudTextField @bind-Value="Edge.EdgeId"
Label="Edge ID"
Variant="Variant.Outlined"
Margin="Margin.Dense"
ReadOnly="true"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Tag" />
<MudTextField @bind-Value="Edge.EdgeName"
Label="Edge Name"
Variant="Variant.Outlined"
Margin="Margin.Dense"
ReadOnly="@IsReadOnly" />
<MudTextField @bind-Value="Edge.EdgeDescription"
Label="Description"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Lines="2"
ReadOnly="@IsReadOnly" />
<!-- Nodes (read-only) -->
<MudText Typo="Typo.caption" Color="Color.Secondary">Connected Nodes</MudText>
<MudStack Row="true" Spacing="2">
<MudTextField Value="@GetStartNodeName()"
Label="Start Node"
Variant="Variant.Outlined"
Margin="Margin.Dense"
ReadOnly="true" />
<MudTextField Value="@GetEndNodeName()"
Label="End Node"
Variant="Variant.Outlined"
Margin="Margin.Dense"
ReadOnly="true" />
</MudStack>
<!-- Length (auto-calculated) -->
<MudTextField Value="@GetEdgeLength()"
Label="Length (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
ReadOnly="true" />
</MudStack>
<!-- Expandable Sections -->
<MudExpansionPanels Dense="true">
<!-- Vehicle Properties -->
@if (State.VehicleTypes.Count > 0)
{
<MudExpansionPanel Text="Vehicle Properties" Expanded="false">
<MudStack Spacing="2" Style="overflow-y: hidden">
<!-- Vehicle Properties Table -->
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2">Vehicle Properties</MudText>
<MudButton Size="Size.Small"
Variant="Variant.Filled"
Color="Color.Success"
StartIcon="@Icons.Material.Filled.Add"
Disabled="@IsReadOnly"
OnClick="OpenAddVehicleTypeDialog">
Add
</MudButton>
</MudStack>
@if (Edge.VehicleProperties == null || Edge.VehicleProperties.Count == 0)
{
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center">
No vehicle properties defined. Click "Add Vehicle Type" to add.
</MudText>
</MudPaper>
}
else
{
<MudTable Items="@Edge.VehicleProperties" Dense="true" Hover="true" Striped="true" Class="mb-1">
<HeaderContent>
<MudTh>Vehicle Type</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
@{
var vehicleType = State.VehicleTypes.FirstOrDefault(vt => vt.Id == context.VehicleTypeId);
var isSelected = selectedVehicleProperty is not null && selectedVehicleProperty.Id == context.Id;
}
<MudTd DataLabel="Vehicle Type">
<MudText Typo="Typo.body2" Color="@(isSelected? Color.Primary: Color.Default)">
@(vehicleType?.VehicleTypeName ?? "Unknown")
</MudText>
</MudTd>
<MudTd DataLabel="Actions">
<MudStack Row="true" Spacing="1">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="@(isSelected ? Color.Primary : Color.Default)"
Disabled="@IsReadOnly"
OnClick="() => SelectVehicleType(context)" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
Disabled="@IsReadOnly"
OnClick="() => RemoveVehicleProperty(context)" />
</MudStack>
</MudTd>
</RowTemplate>
</MudTable>
}
<!-- Edit Form for Selected Vehicle Type -->
@if (selectedVehicleProperty is not null)
{
var VehicleType = State.VehicleTypes.FirstOrDefault(vt => vt.Id == selectedVehicleProperty.VehicleTypeId);
<MudDivider Class="my-3" />
<MudText Typo="Typo.subtitle2" Color="Color.Primary">
Editing: @(VehicleType?.VehicleTypeName ?? "Unknown")
</MudText>
<!-- Trajectory -->
<TrajectoryEditor State="State"
Degree="@selectedVehicleProperty?.TrajectoryDegree"
StartNode="@Edge.StartNode"
EndNode="@Edge.EndNode"
ControlPoint1X="@selectedVehicleProperty?.TrajectoryControlPoint1X"
ControlPoint1Y="@selectedVehicleProperty?.TrajectoryControlPoint1Y"
ControlPoint2X="@selectedVehicleProperty?.TrajectoryControlPoint2X"
ControlPoint2Y="@selectedVehicleProperty?.TrajectoryControlPoint2Y"
TrajectoryFieldsChanged="(fields) => UpdateTrajectoryFields(fields)"
IsReadOnly="@IsReadOnly" />
<!-- Actions -->
<ActionsEditor ActionsJson="@selectedVehicleProperty?.Actions"
ActionsJsonChanged="(json) => UpdateVehicleActions(json)"
DefaultActions="@GetDefaultActions()"
IsReadOnly="@IsReadOnly" />
<!-- Orientation -->
<MudSelect T="OrientationType ?"
Value="@selectedVehicleProperty?.OrientationType"
ValueChanged="(v) => UpdateVehicleOrientationType(v)"
Label="Orientation Type"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Dense="true"
Clearable="true"
Disabled="@IsReadOnly">
<MudSelectItem Value="@((OrientationType?)OrientationType.GLOBAL)">GLOBAL</MudSelectItem>
<MudSelectItem Value="@((OrientationType?)OrientationType.TANGENTIAL)">TANGENTIAL</MudSelectItem>
</MudSelect>
<MudNumericField T="double?"
Value="@selectedVehicleProperty?.VehicleOrientation"
ValueChanged="(v) => UpdateVehicleOrientation(v)"
Label="Vehicle Orientation (degrees)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F2"
Min="0"
Max="360"
ReadOnly="@IsReadOnly" />
<!-- Speed -->
<MudNumericField T="double?"
Value="@selectedVehicleProperty?.MaxSpeed"
ValueChanged="(v) => UpdateVehicleMaxSpeed(v)"
Label="Max Speed (m/s)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F2"
Min="0"
ReadOnly="@IsReadOnly" />
<MudNumericField T="double?"
Value="@selectedVehicleProperty?.MaxRotationSpeed"
ValueChanged="(v) => UpdateVehicleMaxRotationSpeed(v)"
Label="Max Rotation Speed (rad/s)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F2"
Min="0"
ReadOnly="@IsReadOnly" />
<!-- Height Restrictions -->
<MudNumericField T="double?"
Value="@selectedVehicleProperty?.MinHeight"
ValueChanged="(v) => UpdateVehicleMinHeight(v)"
Label="Min Height (m)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F2"
Min="0"
ReadOnly="@IsReadOnly" />
<MudNumericField T="double?"
Value="@selectedVehicleProperty?.MaxHeight"
ValueChanged="(v) => UpdateVehicleMaxHeight(v)"
Label="Max Height (m)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F2"
Min="0"
ReadOnly="@IsReadOnly" />
<!-- Rotation allowed -->
<MudCheckBox T="bool?"
Value="@selectedVehicleProperty?.RotationAllowed"
ValueChanged="(v) => UpdateVehicleRotationAllowed(v)"
Disabled="@IsReadOnly"
Label="Rotation Allowed">
</MudCheckBox>
<MudSelect T="RotationDirection ?"
Value="@selectedVehicleProperty?.RotationAtStartNodeAllowed"
ValueChanged="(v) => UpdateRotationAtStart(v)"
Label="Rotation at Start"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Dense="true"
Clearable="true"
Disabled="@IsReadOnly">
<MudSelectItem Value="@((RotationDirection?)RotationDirection.NONE)">NONE</MudSelectItem>
<MudSelectItem Value="@((RotationDirection?)RotationDirection.CCW)">CCW</MudSelectItem>
<MudSelectItem Value="@((RotationDirection?)RotationDirection.CW)">CW</MudSelectItem>
<MudSelectItem Value="@((RotationDirection?)RotationDirection.BOTH)">BOTH</MudSelectItem>
</MudSelect>
<MudSelect T="RotationDirection ?"
Value="@selectedVehicleProperty?.RotationAtEndNodeAllowed"
ValueChanged="(v) => UpdateRotationAtEnd(v)"
Label="Rotation at End"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Dense="true"
Clearable="true"
Disabled="@IsReadOnly">
<MudSelectItem Value="@((RotationDirection?)RotationDirection.NONE)">NONE</MudSelectItem>
<MudSelectItem Value="@((RotationDirection?)RotationDirection.CCW)">CCW</MudSelectItem>
<MudSelectItem Value="@((RotationDirection?)RotationDirection.CW)">CW</MudSelectItem>
<MudSelectItem Value="@((RotationDirection?)RotationDirection.BOTH)">BOTH</MudSelectItem>
</MudSelect>
<!-- Load Restriction -->
<MudText Typo="Typo.subtitle2">Load Restriction</MudText>
<MudStack Spacing="2" Class="pa-2">
<MudStack Row="true" Spacing="2">
<MudCheckBox T="bool?" Value="@(selectedVehicleProperty?.LoadRestriction?.Unloaded)"
ValueChanged="(v) => UpdateLoadRestrictionUnloaded(v)"
Disabled="@IsReadOnly"
Label="Unloaded">
</MudCheckBox>
<MudCheckBox T="bool?"
Value="@(selectedVehicleProperty?.LoadRestriction?.Loaded)"
ValueChanged="(v) => UpdateLoadRestrictionLoaded(v)"
Disabled="@IsReadOnly"
Label="Loaded">
</MudCheckBox>
</MudStack>
<LoadSetNamesEditor LoadSetNames="@selectedVehicleProperty?.LoadRestriction?.LoadSetNames"
LoadSetNamesChanged="(list) => UpdateLoadSetNames(list)"
IsReadOnly="@IsReadOnly" />
</MudStack>
<!-- Corridor (VDA5050) -->
<MudText Typo="Typo.subtitle2" Class="mt-3">Corridor (VDA5050)</MudText>
<MudStack Spacing="2" Class="pa-2">
<MudText Typo="Typo.body2" Color="Color.Secondary">
Definition of boundaries in which a vehicle can deviate from its trajectory
</MudText>
<MudStack Row="true" Spacing="2">
<MudNumericField T="double?"
Value="@(selectedVehicleProperty?.CorridorLeftWidth)"
ValueChanged="(v) => UpdateCorridorLeftWidth(v)"
Label="Left Width (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F2"
Min="0"
ReadOnly="@IsReadOnly"
HelperText="Width to the left of trajectory" />
<MudNumericField T="double?"
Value="@(selectedVehicleProperty?.CorridorRightWidth)"
ValueChanged="(v) => UpdateCorridorRightWidth(v)"
Label="Right Width (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F2"
Min="0"
ReadOnly="@IsReadOnly"
HelperText="Width to the right of trajectory" />
</MudStack>
<MudSelect T="CorridorRefPoint ?"
Value="@selectedVehicleProperty?.CorridorRefPoint"
ValueChanged="(v) => UpdateCorridorRefPoint(v)"
Label="Corridor Reference Point"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Dense="true"
Clearable="true"
Disabled="@IsReadOnly">
<MudSelectItem Value="@((CorridorRefPoint?)null)">None</MudSelectItem>
<MudSelectItem Value="@((CorridorRefPoint?)CorridorRefPoint.KINEMATICCENTER)">@CorridorRefPoint.KINEMATICCENTER</MudSelectItem>
<MudSelectItem Value="@((CorridorRefPoint?)CorridorRefPoint.CONTOUR)">@CorridorRefPoint.CONTOUR</MudSelectItem>
</MudSelect>
</MudStack>
}
</MudStack>
</MudExpansionPanel>
}
</MudExpansionPanels>
</MudPaper>
</MudStack>
@code {
[Parameter]
public EdgeDto Edge { get; set; } = null!;
[Parameter]
public LayoutEditorState State { get; set; } = null!;
[Parameter]
public EventCallback<EdgeDto> OnSave { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
private EdgeVehiclePropertyDto? selectedVehicleProperty;
private string GetStartNodeName()
{
var node = State.Nodes.FirstOrDefault(n => n.Id == Edge.StartNodeId);
return node?.NodeName ?? node?.NodeId ?? Edge.StartNodeId.ToString()[..8];
}
private string GetEndNodeName()
{
var node = State.Nodes.FirstOrDefault(n => n.Id == Edge.EndNodeId);
return node?.NodeName ?? node?.NodeId ?? Edge.EndNodeId.ToString()[..8];
}
private string GetEdgeLength()
{
var startNode = State.Nodes.FirstOrDefault(n => n.Id == Edge.StartNodeId);
var endNode = State.Nodes.FirstOrDefault(n => n.Id == Edge.EndNodeId);
if (startNode == null || endNode == null) return "N/A";
var dx = endNode.X - startNode.X;
var dy = endNode.Y - startNode.Y;
var length = Math.Sqrt(dx * dx + dy * dy);
return length.ToString("F3");
}
private void SelectVehicleType(EdgeVehiclePropertyDto vehicle)
{
var startNode = State.Nodes.FirstOrDefault(n => n.Id == Edge.StartNodeId);
var endNode = State.Nodes.FirstOrDefault(n => n.Id == Edge.EndNodeId);
if (startNode is not null && endNode is not null)
{
// Initialize default trajectory if not set
if (!vehicle.TrajectoryDegree.HasValue)
{
vehicle.TrajectoryDegree = 1;
}
selectedVehicleProperty = vehicle;
State.ChangeEdgeVehicleEditor(vehicle);
State.SetMode(EditorMode.TrajectoryEditor);
StateHasChanged();
}
}
private async Task OpenAddVehicleTypeDialog()
{
// Get vehicle types that are not already added
var existingVehicleTypeIds = Edge.VehicleProperties?.Select(vp => vp.VehicleTypeId).ToHashSet() ?? new HashSet<Guid>();
var availableVehicleTypes = State.VehicleTypes.Where(vt => !existingVehicleTypeIds.Contains(vt.Id)).ToList();
if (availableVehicleTypes.Count == 0)
{
Snackbar.Add("All vehicle types have been added", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AvailableVehicleTypes"] = availableVehicleTypes
};
var dialog = await DialogService.ShowAsync<AddVehicleTypeDialog>(
"Add Vehicle Type",
parameters,
new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is VehicleTypeDto selectedVehicleProperty)
{
// Add new vehicle property
Edge.VehicleProperties ??= new List<EdgeVehiclePropertyDto>();
var newProp = new EdgeVehiclePropertyDto
{
Id = Guid.NewGuid(),
EdgeId = Edge.Id,
VehicleTypeId = selectedVehicleProperty.Id
};
Edge.VehicleProperties.Add(newProp);
StateHasChanged();
Snackbar.Add($"Added {selectedVehicleProperty.VehicleTypeName}", Severity.Success);
}
}
private async Task RemoveVehicleProperty(EdgeVehiclePropertyDto vehicle)
{
var vehicleType = State.VehicleTypes.FirstOrDefault(vt => vt.Id == vehicle.VehicleTypeId);
var vehicleTypeName = vehicleType?.VehicleTypeName ?? "Unknown";
var result = await DialogService.ShowMessageBoxAsync(
"Remove Vehicle Properties",
$"Are you sure you want to remove vehicle properties for '{vehicleTypeName}'?",
yesText: "Remove",
cancelText: "Cancel");
if (result == true)
{
Edge.VehicleProperties?.RemoveAll(vp => vp.VehicleTypeId == vehicle.VehicleTypeId);
// Clear selection if it was the removed one
if (selectedVehicleProperty is not null && selectedVehicleProperty.Id == vehicle.Id)
{
selectedVehicleProperty = null;
}
StateHasChanged();
Snackbar.Add($"Removed vehicle properties for {vehicleTypeName}", Severity.Info);
}
}
private int GetActionsCount(string? actionsJson)
{
if (string.IsNullOrWhiteSpace(actionsJson)) return 0;
try
{
var actions = System.Text.Json.JsonSerializer.Deserialize<List<ActionDto>>(actionsJson, JsonOptionExtends.Read);
return actions?.Count ?? 0;
}
catch
{
return 0;
}
}
private void UpdateVehicleOrientationType(OrientationType? value)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.OrientationType = value;
}
private void UpdateVehicleOrientation(double? value)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.VehicleOrientation = value;
}
private void UpdateVehicleMaxSpeed(double? value)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.MaxSpeed = value;
}
private void UpdateVehicleMaxRotationSpeed(double? value)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.MaxRotationSpeed = value;
}
private void UpdateVehicleMinHeight(double? value)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.MinHeight = value;
}
private void UpdateVehicleMaxHeight(double? value)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.MaxHeight = value;
}
private void UpdateVehicleRotationAllowed(bool? value)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.RotationAllowed = value;
}
private void UpdateRotationAtStart(RotationDirection? value)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.RotationAtStartNodeAllowed = value;
}
private void UpdateRotationAtEnd(RotationDirection? value)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.RotationAtEndNodeAllowed = value;
}
private void UpdateLoadRestrictionUnloaded(bool? value)
{
if (selectedVehicleProperty != null)
{
selectedVehicleProperty.LoadRestriction ??= new LoadRestrictionDto();
selectedVehicleProperty.LoadRestriction.Unloaded = value;
}
}
private void UpdateLoadRestrictionLoaded(bool? value)
{
if (selectedVehicleProperty != null)
{
selectedVehicleProperty.LoadRestriction ??= new LoadRestrictionDto();
selectedVehicleProperty.LoadRestriction.Loaded = value;
}
}
private void UpdateLoadSetNames(List<string>? list)
{
if (selectedVehicleProperty != null)
{
if (list == null || list.Count == 0)
{
if (selectedVehicleProperty.LoadRestriction != null)
{
selectedVehicleProperty.LoadRestriction.LoadSetNames = null;
// If both Unloaded and Loaded are null, remove LoadRestriction entirely
if (!selectedVehicleProperty.LoadRestriction.Unloaded.HasValue && !selectedVehicleProperty.LoadRestriction.Loaded.HasValue)
{
selectedVehicleProperty.LoadRestriction = null;
}
}
}
else
{
selectedVehicleProperty.LoadRestriction ??= new LoadRestrictionDto();
selectedVehicleProperty.LoadRestriction.LoadSetNames = list;
}
}
}
private void UpdateTrajectoryFields((int? Degree, double? ControlPoint1X, double? ControlPoint1Y, double? ControlPoint2X, double? ControlPoint2Y) fields)
{
if (selectedVehicleProperty != null)
{
selectedVehicleProperty.TrajectoryDegree = fields.Degree;
selectedVehicleProperty.TrajectoryControlPoint1X = fields.ControlPoint1X;
selectedVehicleProperty.TrajectoryControlPoint1Y = fields.ControlPoint1Y;
selectedVehicleProperty.TrajectoryControlPoint2X = fields.ControlPoint2X;
selectedVehicleProperty.TrajectoryControlPoint2Y = fields.ControlPoint2Y;
State.NotifyTrajectoryChanged();
}
}
private void UpdateVehicleActions(string? json)
{
if (selectedVehicleProperty != null) selectedVehicleProperty.Actions = json;
}
private void UpdateCorridorLeftWidth(double? value)
{
if (selectedVehicleProperty != null)
{
selectedVehicleProperty.CorridorLeftWidth = value;
// If all corridor fields are empty, clear them
if (!selectedVehicleProperty.CorridorLeftWidth.HasValue &&
!selectedVehicleProperty.CorridorRightWidth.HasValue &&
selectedVehicleProperty.CorridorRefPoint == null)
{
// Already null, nothing to clear
}
}
}
private void UpdateCorridorRightWidth(double? value)
{
if (selectedVehicleProperty != null)
{
selectedVehicleProperty.CorridorRightWidth = value;
// If all corridor fields are empty, clear them
if (!selectedVehicleProperty.CorridorLeftWidth.HasValue &&
!selectedVehicleProperty.CorridorRightWidth.HasValue &&
selectedVehicleProperty.CorridorRefPoint == null)
{
// Already null, nothing to clear
}
}
}
private void UpdateCorridorRefPoint(CorridorRefPoint? value)
{
if (selectedVehicleProperty != null)
{
selectedVehicleProperty.CorridorRefPoint = value;
}
}
private List<ActionDto>? GetDefaultActions()
{
if (selectedVehicleProperty is null) return null;
var vehicleType = State.VehicleTypes.FirstOrDefault(vt => vt.Id == selectedVehicleProperty.VehicleTypeId);
if (vehicleType == null || string.IsNullOrWhiteSpace(vehicleType.Actions))
{
return null;
}
try
{
return System.Text.Json.JsonSerializer.Deserialize<List<ActionDto>>(vehicleType.Actions, JsonOptionExtends.Read);
}
catch
{
return null;
}
}
private async Task HandleSave()
{
await OnSave.InvokeAsync(Edge);
State.SetMode(EditorMode.Select);
}
}

View File

@@ -0,0 +1,16 @@
@using RobotNet10.MapEditor.Services.State
<MudTabs Elevation="0" Rounded="false" ApplyEffectsToContainer="true" TabPanelsClass="pa-3">
<MudTabPanel Text="Properties" Icon="@Icons.Material.Filled.Settings">
<PropertiesTab State="@State" />
</MudTabPanel>
<MudTabPanel Text="Settings" Icon="@Icons.Material.Filled.Tune">
<SettingsTab State="@State" />
</MudTabPanel>
</MudTabs>
@code {
[Parameter]
public LayoutEditorState State { get; set; } = null!;
}

View File

@@ -0,0 +1,116 @@
@using System.Text.Json
@inject ISnackbar Snackbar
<MudStack Spacing="2">
<MudText Typo="Typo.caption" Color="Color.Secondary">
Load Set Names (comma-separated)
</MudText>
<MudTextField @bind-Value="loadSetNamesText"
Label="Load Set Names"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Lines="2"
Placeholder='Enter load set names, e.g. "pallet, box, tray"'
HelperText="Press Enter or comma to separate items"
Error="@(!string.IsNullOrEmpty(errorMessage))"
ErrorText="@errorMessage"
Immediate="false"
ReadOnly="@IsReadOnly"
OnBlur="HandleBlur" />
@if (loadSetNames.Count > 0)
{
<MudStack Row="true" Spacing="1" Style="flex-wrap: wrap;">
@foreach (var name in loadSetNames)
{
<MudChip T="string" Size="Size.Small"
OnClick="@(async () => await RemoveItem(name))"
Color="Color.Primary"
Disabled="IsReadOnly"
Variant="Variant.Text">
@name
</MudChip>
}
</MudStack>
}
</MudStack>
@code {
[Parameter]
public List<string>? LoadSetNames { get; set; }
[Parameter]
public EventCallback<List<string>?> LoadSetNamesChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
private string loadSetNamesText = string.Empty;
private List<string> loadSetNames = new();
private string? errorMessage;
protected override void OnParametersSet()
{
LoadFromParameter();
loadSetNamesText = string.Join(", ", loadSetNames);
}
private void LoadFromParameter()
{
loadSetNames.Clear();
errorMessage = null;
if (LoadSetNames != null && LoadSetNames.Count > 0)
{
loadSetNames.AddRange(LoadSetNames.Where(s => !string.IsNullOrWhiteSpace(s)));
}
}
private async Task HandleBlur()
{
ParseText();
await UpdateList();
}
private void ParseText()
{
loadSetNames.Clear();
errorMessage = null;
if (string.IsNullOrWhiteSpace(loadSetNamesText))
{
return;
}
var items = loadSetNamesText.Split(new[] { ',', ';', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var item in items)
{
var trimmed = item.Trim();
if (!string.IsNullOrWhiteSpace(trimmed) && !loadSetNames.Contains(trimmed))
{
loadSetNames.Add(trimmed);
}
}
}
private async Task RemoveItem(string name)
{
loadSetNames.Remove(name);
loadSetNamesText = string.Join(", ", loadSetNames);
await UpdateList();
}
private async Task UpdateList()
{
if (loadSetNames.Count == 0)
{
await LoadSetNamesChanged.InvokeAsync(null);
}
else
{
await LoadSetNamesChanged.InvokeAsync(new List<string>(loadSetNames));
}
}
}

View File

@@ -0,0 +1,693 @@
@using RobotNet.VDA5050
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.Node
@using RobotNet10.MapEditor.Shared.DTOs.Station
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@using RobotNet10.MapEditor.Shared.DTOs.Requests
@using RobotNet10.MapEditor.Components.LayoutEditor.RightPanel.Dialogs
@implements IDisposable
<MudStack Spacing="2">
<MudStack Row="true" Spacing="1" Class="d-flex justify-content-between">
<MudText Typo="Typo.subtitle1" Color="Color.Primary">
<MudIcon Icon="@Icons.Material.Filled.RadioButtonChecked" Size="Size.Small" Class="mr-1" />
Node Properties
</MudText>
<!-- Save Button -->
<div>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
FullWidth="true"
StartIcon="@Icons.Material.Filled.Save"
Size="Size.Small"
Disabled="@IsReadOnly"
OnClick="HandleSave">
Save
</MudButton>
</div>
</MudStack>
<MudDivider />
<!-- Basic Info (always visible) -->
<MudPaper style="overflow-y: auto; height: calc(100vh - 237px)" Elevation="0">
<MudStack Spacing="2">
<MudTextField @bind-Value="Node.NodeId"
Label="Node ID"
Variant="Variant.Outlined"
Margin="Margin.Dense"
ReadOnly="true"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Tag" />
<MudTextField @bind-Value="Node.NodeName"
Label="Node Name"
Variant="Variant.Outlined"
Margin="Margin.Dense"
ReadOnly="@IsReadOnly" />
<MudTextField @bind-Value="Node.NodeDescription"
Label="Description"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Lines="2"
ReadOnly="@IsReadOnly" />
<!-- Position -->
<MudText Typo="Typo.caption" Color="Color.Secondary">Position (meters)</MudText>
<MudStack Row="true" Spacing="2">
<MudNumericField @bind-Value="Node.X"
Label="X"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
ReadOnly="@IsReadOnly" />
<MudNumericField @bind-Value="Node.Y"
Label="Y"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
ReadOnly="@IsReadOnly" />
</MudStack>
</MudStack>
<!-- Expandable Sections -->
<MudExpansionPanels Dense="true" Elevation="4">
<!-- Vehicle Properties -->
@if (State.VehicleTypes.Count > 0)
{
<MudExpansionPanel Text="Vehicle Properties" Expanded="false">
<MudStack Spacing="2" Class="pa-2">
<!-- Vehicle Properties Table -->
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2">Vehicle Properties</MudText>
<MudButton Size="Size.Small"
Variant="Variant.Filled"
Color="Color.Success"
StartIcon="@Icons.Material.Filled.Add"
Disabled="@IsReadOnly"
OnClick="OpenAddVehicleTypeDialog">
Add
</MudButton>
</MudStack>
@if (Node.VehicleProperties == null || Node.VehicleProperties.Count == 0)
{
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center">
No vehicle properties defined. Click "Add Vehicle Type" to add.
</MudText>
</MudPaper>
}
else
{
<MudTable Items="@Node.VehicleProperties" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>Vehicle Type</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
@{
var prop = context;
var vehicleType = State.VehicleTypes.FirstOrDefault(vt => vt.Id == prop.VehicleTypeId);
var isSelected = selectedVehicleTypeId == prop.VehicleTypeId;
}
<MudTd DataLabel="Vehicle Type">
<MudText Typo="Typo.body2" Style="@(isSelected ? "font-weight: bold; color: var(--mud-palette-primary);" : "")">
@(vehicleType?.VehicleTypeName ?? "Unknown")
</MudText>
</MudTd>
<MudTd DataLabel="Actions">
<MudStack Row="true" Spacing="1">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="@(isSelected ? Color.Primary : Color.Default)"
Disabled="@IsReadOnly"
OnClick="() => SelectVehicleType(prop.VehicleTypeId)" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
Disabled="@IsReadOnly"
OnClick="() => RemoveVehicleProperty(prop.VehicleTypeId)" />
</MudStack>
</MudTd>
</RowTemplate>
</MudTable>
}
<!-- Edit Form for Selected Vehicle Type -->
@if (selectedVehicleTypeId.HasValue)
{
var vehicleProps = GetOrCreateVehicleProps();
var selectedVehicleType = State.VehicleTypes.FirstOrDefault(vt => vt.Id == selectedVehicleTypeId.Value);
<MudDivider Class="my-3" />
<MudText Typo="Typo.subtitle2" Color="Color.Primary">
Editing: @(selectedVehicleType?.VehicleTypeName ?? "Unknown")
</MudText>
<MudNumericField T="double?"
Value="@vehicleProps?.Theta"
ValueChanged="(v) => UpdateVehicleTheta(v)"
Label="Theta (radians)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
Min="-3.14159"
Max="3.14159"
ReadOnly="@IsReadOnly"
HelperText="Orientation range: [-π ... π]" />
<!-- Deviation Settings (VDA5050) -->
<MudText Typo="Typo.subtitle2" Color="Color.Secondary" Class="mt-2">
Deviation Settings (VDA5050)
</MudText>
<MudNumericField T="double?"
Value="@vehicleProps?.AllowedDeviationXY"
ValueChanged="(v) => UpdateVehicleAllowedDeviationXY(v)"
Label="Allowed Deviation XY (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
Min="0"
ReadOnly="@IsReadOnly"
HelperText="Allowed deviation radius in meters. 0 = no deviation allowed." />
<MudNumericField T="double?"
Value="@vehicleProps?.AllowedDeviationTheta"
ValueChanged="(v) => UpdateVehicleAllowedDeviationTheta(v)"
Label="Allowed Deviation Theta (radians)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
Min="0"
Max="3.14159"
ReadOnly="@IsReadOnly"
HelperText="Allowed theta deviation range: [0 ... π]" />
}
</MudStack>
@if (selectedVehicleTypeId.HasValue)
{
var vehicleProps = GetOrCreateVehicleProps();
<MudStack Spacing="2" Class="pa-2">
<ActionsEditor ActionsJson="@vehicleProps?.Actions"
ActionsJsonChanged="(json) => UpdateVehicleActions(json)"
DefaultActions="@GetDefaultActions()"
IsReadOnly="@IsReadOnly" />
</MudStack>
}
</MudExpansionPanel>
}
<!-- Station Management -->
<MudExpansionPanel Text="Stations" Expanded="false">
<MudStack Spacing="2" Class="pa-2">
<!-- Action Buttons -->
<MudStack Row="true" Spacing="1" Justify="Justify.SpaceBetween">
<MudButton Size="Size.Small"
Variant="Variant.Filled"
Color="Color.Success"
StartIcon="@Icons.Material.Filled.Add"
Disabled="@IsReadOnly"
OnClick="OpenCreateStationDialog">
Create
</MudButton>
<MudButton Size="Size.Small"
Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Link"
Disabled="@IsReadOnly"
OnClick="OpenLinkStationDialog">
Link
</MudButton>
</MudStack>
<!-- Stations Table -->
@if (linkedStations.Count == 0)
{
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center">
No stations linked to this node. Click buttons above to add.
</MudText>
</MudPaper>
}
else
{
<MudTable Items="@linkedStations" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>Station ID</MudTh>
<MudTh>Name</MudTh>
<MudTh>Position</MudTh>
<MudTh>Actions</MudTh>
</HeaderContent>
<RowTemplate>
@{
var station = context;
}
<MudTd DataLabel="Station ID">
<MudText Typo="Typo.body2">@station.StationId</MudText>
</MudTd>
<MudTd DataLabel="Name">
<MudText Typo="Typo.body2">@(station.StationName ?? "-")</MudText>
</MudTd>
<MudTd DataLabel="Position">
<MudText Typo="Typo.body2">
(@station.X.ToString("F2"), @station.Y.ToString("F2"))
</MudText>
</MudTd>
<MudTd DataLabel="Actions">
<MudStack Row="true" Spacing="1">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Disabled="@IsReadOnly"
OnClick="() => OpenEditStationDialog(station)" />
<MudIconButton Icon="@Icons.Material.Filled.LinkOff"
Size="Size.Small"
Color="Color.Error"
Disabled="@IsReadOnly"
OnClick="() => UnlinkStation(station)" />
</MudStack>
</MudTd>
</RowTemplate>
</MudTable>
}
</MudStack>
</MudExpansionPanel>
</MudExpansionPanels>
</MudPaper>
</MudStack>
@code {
[Parameter]
public NodeDto Node { get; set; } = null!;
[Parameter]
public LayoutEditorState State { get; set; } = null!;
[Parameter]
public EventCallback<NodeDto> OnSave { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
[Inject]
private IDialogService DialogService { get; set; } = null!;
[Inject]
private ISnackbar Snackbar { get; set; } = null!;
[Inject]
private MapManagerApiService ApiService { get; set; } = null!;
private Guid? selectedVehicleTypeId;
private List<StationDto> linkedStations = new();
private List<ActionDto> Actions = new();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender) return;
State.OnDraggingNodesChanged += OnDraggingNodesChanged;
}
private void OnDraggingNodesChanged(Guid[] nodeIds)
{
if (nodeIds.Any(n => Node.Id == n)) StateHasChanged();
}
public void Dispose()
{
State.OnDraggingNodesChanged -= OnDraggingNodesChanged;
}
protected override void OnParametersSet()
{
// Find all linked stations
linkedStations = State.Stations
.Where(s => s.InteractionNodes?.Any(i => i.NodeId == Node.Id) == true)
.ToList();
// Set default vehicle type if none selected and we have properties
if (selectedVehicleTypeId == null && Node.VehicleProperties != null && Node.VehicleProperties.Count > 0)
{
selectedVehicleTypeId = Node.VehicleProperties.First().VehicleTypeId;
}
else if (selectedVehicleTypeId == null && State.VehicleTypes.Count > 0)
{
// If no properties exist, don't auto-select
selectedVehicleTypeId = null;
}
}
private NodeVehiclePropertyDto? GetOrCreateVehicleProps()
{
if (!selectedVehicleTypeId.HasValue) return null;
Node.VehicleProperties ??= new List<NodeVehiclePropertyDto>();
var props = Node.VehicleProperties.FirstOrDefault(vp => vp.VehicleTypeId == selectedVehicleTypeId);
return props;
}
private void SelectVehicleType(Guid vehicleTypeId)
{
selectedVehicleTypeId = vehicleTypeId;
StateHasChanged();
}
private async Task OpenAddVehicleTypeDialog()
{
// Get vehicle types that are not already added
var existingVehicleTypeIds = Node.VehicleProperties?.Select(vp => vp.VehicleTypeId).ToHashSet() ?? new HashSet<Guid>();
var availableVehicleTypes = State.VehicleTypes.Where(vt => !existingVehicleTypeIds.Contains(vt.Id)).ToList();
if (availableVehicleTypes.Count == 0)
{
Snackbar.Add("All vehicle types have been added", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AvailableVehicleTypes"] = availableVehicleTypes
};
var dialog = await DialogService.ShowAsync<AddVehicleTypeDialog>(
"Add Vehicle Type",
parameters,
new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is VehicleTypeDto selectedVehicleType)
{
// Add new vehicle property
Node.VehicleProperties ??= new List<NodeVehiclePropertyDto>();
var newProp = new NodeVehiclePropertyDto
{
Id = Guid.NewGuid(),
NodeId = Node.Id,
VehicleTypeId = selectedVehicleType.Id
};
Node.VehicleProperties.Add(newProp);
// Select the newly added vehicle type
selectedVehicleTypeId = selectedVehicleType.Id;
StateHasChanged();
Snackbar.Add($"Added {selectedVehicleType.VehicleTypeName}", Severity.Success);
}
}
private async Task RemoveVehicleProperty(Guid vehicleTypeId)
{
var vehicleType = State.VehicleTypes.FirstOrDefault(vt => vt.Id == vehicleTypeId);
var vehicleTypeName = vehicleType?.VehicleTypeName ?? "Unknown";
var result = await DialogService.ShowMessageBoxAsync(
"Remove Vehicle Properties",
$"Are you sure you want to remove vehicle properties for '{vehicleTypeName}'?",
yesText: "Remove",
cancelText: "Cancel");
if (result == true)
{
Node.VehicleProperties?.RemoveAll(vp => vp.VehicleTypeId == vehicleTypeId);
// Clear selection if it was the removed one
if (selectedVehicleTypeId == vehicleTypeId)
{
selectedVehicleTypeId = null;
}
StateHasChanged();
Snackbar.Add($"Removed vehicle properties for {vehicleTypeName}", Severity.Info);
}
}
private int GetActionsCount(string? actionsJson)
{
if (string.IsNullOrWhiteSpace(actionsJson)) return 0;
try
{
var actions = System.Text.Json.JsonSerializer.Deserialize<List<ActionDto>>(actionsJson, JsonOptionExtends.Read);
return actions?.Count ?? 0;
}
catch
{
return 0;
}
}
private void UpdateVehicleTheta(double? value)
{
var props = GetOrCreateVehicleProps();
if (props != null)
{
props.Theta = value;
}
}
private void UpdateVehicleActions(string? json)
{
var props = GetOrCreateVehicleProps();
if (props != null)
{
props.Actions = json;
}
}
private void UpdateVehicleAllowedDeviationXY(double? value)
{
var props = GetOrCreateVehicleProps();
if (props != null)
{
props.AllowedDeviationXY = value;
}
}
private void UpdateVehicleAllowedDeviationTheta(double? value)
{
var props = GetOrCreateVehicleProps();
if (props != null)
{
props.AllowedDeviationTheta = value;
}
}
private List<ActionDto>? GetDefaultActions()
{
if (!selectedVehicleTypeId.HasValue) return null;
var vehicleType = State.VehicleTypes.FirstOrDefault(vt => vt.Id == selectedVehicleTypeId.Value);
if (vehicleType == null || string.IsNullOrWhiteSpace(vehicleType.Actions))
{
return null;
}
try
{
return System.Text.Json.JsonSerializer.Deserialize<List<ActionDto>>(vehicleType.Actions, JsonOptionExtends.Read);
}
catch
{
return null;
}
}
private async Task HandleSave()
{
await OnSave.InvokeAsync(Node);
}
// ==========================================
// STATION MANAGEMENT
// ==========================================
private async Task OpenCreateStationDialog()
{
var parameters = new DialogParameters
{
["LayoutLevelId"] = State.LevelId,
["DefaultX"] = Node.X,
["DefaultY"] = Node.Y,
["DefaultInteractionNodeIds"] = new List<Guid> { Node.Id }
};
var dialog = await DialogService.ShowAsync<CreateStationDialog>(
"Create New Station",
parameters,
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is StationDto newStation)
{
// Reload stations from API
await State.ReloadStationsAsync();
// Update linked stations list
linkedStations = State.Stations
.Where(s => s.InteractionNodes?.Any(i => i.NodeId == Node.Id) == true)
.ToList();
StateHasChanged();
Snackbar.Add($"Created and linked station '{newStation.StationId}'", Severity.Success);
}
}
private async Task OpenLinkStationDialog()
{
// Get stations that are not already linked
var linkedStationIds = linkedStations.Select(s => s.Id).ToHashSet();
var availableStations = State.Stations.Where(s => !linkedStationIds.Contains(s.Id)).ToList();
if (availableStations.Count == 0)
{
Snackbar.Add("No available stations to link", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AvailableStations"] = availableStations
};
var dialog = await DialogService.ShowAsync<LinkStationDialog>(
"Link Existing Station",
parameters,
new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is StationDto selectedStation)
{
// Add this node to station's interaction nodes
var currentInteractionNodeIds = selectedStation.InteractionNodes?
.Select(i => i.NodeId)
.ToList() ?? new List<Guid>();
if (!currentInteractionNodeIds.Contains(Node.Id))
{
currentInteractionNodeIds.Add(Node.Id);
}
var updateRequest = new UpdateStationRequest
{
StationName = selectedStation.StationName,
StationDescription = selectedStation.StationDescription,
StationHeight = selectedStation.StationHeight,
X = selectedStation.X,
Y = selectedStation.Y,
Theta = selectedStation.Theta,
InteractionNodeIds = currentInteractionNodeIds
};
try
{
await ApiService.UpdateStationAsync(selectedStation.Id, updateRequest);
// Reload stations from API
await State.ReloadStationsAsync();
// Update linked stations list
linkedStations = State.Stations
.Where(s => s.InteractionNodes?.Any(i => i.NodeId == Node.Id) == true)
.ToList();
StateHasChanged();
Snackbar.Add($"Linked station '{selectedStation.StationId}' to node", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Failed to link station: {ex.Message}", Severity.Error);
}
}
}
private async Task OpenEditStationDialog(StationDto station)
{
var parameters = new DialogParameters
{
["Station"] = station,
["LayoutLevelId"] = State.LevelId
};
var dialog = await DialogService.ShowAsync<EditStationDialog>(
"Edit Station",
parameters,
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is StationDto updatedStation)
{
// Reload stations from API
await State.ReloadStationsAsync();
// Update linked stations list
linkedStations = State.Stations
.Where(s => s.InteractionNodes?.Any(i => i.NodeId == Node.Id) == true)
.ToList();
StateHasChanged();
Snackbar.Add($"Updated station '{updatedStation.StationId}'", Severity.Success);
}
}
private async Task UnlinkStation(StationDto station)
{
var result = await DialogService.ShowMessageBoxAsync(
"Unlink Station",
$"Are you sure you want to unlink station '{station.StationId}' from this node?\n\n" +
"Note: The station will not be deleted, only the link will be removed.",
yesText: "Unlink",
cancelText: "Cancel");
if (result == true)
{
// Remove this node from station's interaction nodes
var currentInteractionNodeIds = station.InteractionNodes?
.Where(i => i.NodeId != Node.Id)
.Select(i => i.NodeId)
.ToList() ?? new List<Guid>();
var updateRequest = new UpdateStationRequest
{
StationName = station.StationName,
StationDescription = station.StationDescription,
StationHeight = station.StationHeight,
X = station.X,
Y = station.Y,
Theta = station.Theta,
InteractionNodeIds = currentInteractionNodeIds
};
try
{
await ApiService.UpdateStationAsync(station.Id, updateRequest);
// Reload stations from API
await State.ReloadStationsAsync();
// Update linked stations list
linkedStations = State.Stations
.Where(s => s.InteractionNodes?.Any(i => i.NodeId == Node.Id) == true)
.ToList();
StateHasChanged();
Snackbar.Add($"Unlinked station '{station.StationId}' from node", Severity.Info);
}
catch (Exception ex)
{
Snackbar.Add($"Failed to unlink station: {ex.Message}", Severity.Error);
}
}
}
}

View File

@@ -0,0 +1,300 @@
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.Node
@using RobotNet10.MapEditor.Shared.DTOs.Edge
@using RobotNet10.MapEditor.Shared.DTOs.Requests
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@using RobotNet10.MapEditor.Components.LayoutEditor.RightPanel.Dialogs
@using MudBlazor
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<MudStack Spacing="3">
@if (State.SelectedNodeIds.Count == 0 && State.SelectedEdgeIds.Count == 0)
{
<!-- Nothing selected -->
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
Select a node or edge to view properties
</MudAlert>
}
else if (State.SelectedNodeIds.Count == 1 && State.SelectedEdgeIds.Count == 0)
{
<!-- Single node selected -->
var node = State.GetSelectedNodes().FirstOrDefault();
if (node != null)
{
<NodePropertiesEditor Node="@node" State="@State" OnSave="SaveNode" IsReadOnly="@State.IsReadOnly" />
}
}
else if (State.SelectedEdgeIds.Count == 1 && State.SelectedNodeIds.Count == 0)
{
<!-- Single edge selected -->
var edge = State.GetSelectedEdges().FirstOrDefault();
if (edge != null)
{
<EdgePropertiesEditor Edge="@edge" State="@State" OnSave="SaveEdge" IsReadOnly="@State.IsReadOnly" />
}
}
else
{
<!-- Multiple selection -->
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
<strong>Multiple Selection:</strong>
<br />
@State.SelectedNodeIds.Count nodes, @State.SelectedEdgeIds.Count edges
</MudAlert>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
Select a single item to edit properties.
Use toolbar buttons for bulk operations.
</MudText>
<!-- Add VehicleType for Multiple Selection -->
@if (State.VehicleTypes.Count > 0)
{
<MudPaper Elevation="1" Class="pa-3">
<MudStack Spacing="2">
<MudText Typo="Typo.subtitle2" Color="Color.Primary">
<MudIcon Icon="@Icons.Material.Filled.DirectionsCar" Size="Size.Small" Class="mr-1" />
Bulk Add Vehicle Type
</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">
Add a vehicle type to all selected nodes and edges. Items that already have this vehicle type will be skipped.
</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Success"
StartIcon="@Icons.Material.Filled.Add"
OnClick="OpenAddVehicleTypeForMultipleDialog"
FullWidth="true">
Add Vehicle Type
</MudButton>
</MudStack>
</MudPaper>
}
}
</MudStack>
@code {
[Parameter]
public LayoutEditorState State { get; set; } = null!;
private Task SaveNode(NodeDto node)
{
// Mark node as modified (will be saved via Save button on toolbar)
State.MarkNodeModified(node.Id);
// Update local state
var index = State.Nodes.FindIndex(n => n.Id == node.Id);
if (index >= 0)
{
State.Nodes[index] = node;
}
State.NotifyStateChanged();
Snackbar.Add("Node properties updated (will be saved with Save button)", Severity.Info);
return Task.CompletedTask;
}
private Task SaveEdge(EdgeDto edge)
{
// Mark edge as modified (will be saved via Save button on toolbar)
State.MarkEdgeModified(edge.Id);
// Update local state
var index = State.Edges.FindIndex(e => e.Id == edge.Id);
if (index >= 0)
{
State.Edges[index] = edge;
}
State.NotifyStateChanged();
Snackbar.Add("Edge properties updated (will be saved with Save button)", Severity.Info);
return Task.CompletedTask;
}
private async Task OpenAddVehicleTypeForMultipleDialog()
{
var selectedNodes = State.GetSelectedNodes();
var selectedEdges = State.GetSelectedEdges();
if (selectedNodes.Count == 0 && selectedEdges.Count == 0)
{
Snackbar.Add("No items selected", Severity.Warning);
return;
}
// Collect all VehicleTypeIds that are already present in ALL selected items
var allVehicleTypeIds = State.VehicleTypes.Select(vt => vt.Id).ToList();
var vehicleTypesInAllItems = new HashSet<Guid>();
foreach (var vehicleTypeId in allVehicleTypeIds)
{
bool inAllNodes = selectedNodes.Count == 0 || selectedNodes.All(node =>
node.VehicleProperties?.Any(vp => vp.VehicleTypeId == vehicleTypeId) == true);
bool inAllEdges = selectedEdges.Count == 0 || selectedEdges.All(edge =>
edge.VehicleProperties?.Any(vp => vp.VehicleTypeId == vehicleTypeId) == true);
if (inAllNodes && inAllEdges)
{
vehicleTypesInAllItems.Add(vehicleTypeId);
}
}
// Get available vehicle types (at least one item doesn't have it)
var availableVehicleTypes = State.VehicleTypes
.Where(vt => !vehicleTypesInAllItems.Contains(vt.Id))
.ToList();
if (availableVehicleTypes.Count == 0)
{
// Show which vehicle types all items already have
if (vehicleTypesInAllItems.Count > 0)
{
var vehicleTypeNames = State.VehicleTypes
.Where(vt => vehicleTypesInAllItems.Contains(vt.Id))
.Select(vt => vt.VehicleTypeName)
.ToList();
Snackbar.Add(
$"All selected items already have these vehicle types: {string.Join(", ", vehicleTypeNames)}",
Severity.Info);
}
else
{
Snackbar.Add("No available vehicle types to add", Severity.Info);
}
return;
}
var parameters = new DialogParameters
{
["AvailableVehicleTypes"] = availableVehicleTypes
};
var dialog = await DialogService.ShowAsync<AddVehicleTypeDialog>(
"Add Vehicle Type to Multiple Items",
parameters,
new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is VehicleTypeDto selectedVehicleType)
{
await AddVehicleTypeToMultipleItems(selectedVehicleType, selectedNodes, selectedEdges);
}
}
private Task AddVehicleTypeToMultipleItems(
VehicleTypeDto vehicleType,
List<NodeDto> selectedNodes,
List<EdgeDto> selectedEdges)
{
int nodesAdded = 0;
int nodesSkipped = 0;
int edgesAdded = 0;
int edgesSkipped = 0;
// Add to nodes
foreach (var node in selectedNodes)
{
// Check if node already has this vehicle type
var hasVehicleType = node.VehicleProperties?.Any(vp => vp.VehicleTypeId == vehicleType.Id) == true;
if (hasVehicleType)
{
nodesSkipped++;
}
else
{
// Add new vehicle property
node.VehicleProperties ??= new List<NodeVehiclePropertyDto>();
var newProp = new NodeVehiclePropertyDto
{
Id = Guid.NewGuid(),
NodeId = node.Id,
VehicleTypeId = vehicleType.Id
};
node.VehicleProperties.Add(newProp);
// Mark node as modified
State.MarkNodeModified(node.Id);
// Update local state
var index = State.Nodes.FindIndex(n => n.Id == node.Id);
if (index >= 0)
{
State.Nodes[index] = node;
}
nodesAdded++;
}
}
// Add to edges
foreach (var edge in selectedEdges)
{
// Check if edge already has this vehicle type
var hasVehicleType = edge.VehicleProperties?.Any(vp => vp.VehicleTypeId == vehicleType.Id) == true;
if (hasVehicleType)
{
edgesSkipped++;
}
else
{
// Add new vehicle property
edge.VehicleProperties ??= new List<EdgeVehiclePropertyDto>();
var newProp = new EdgeVehiclePropertyDto
{
Id = Guid.NewGuid(),
EdgeId = edge.Id,
VehicleTypeId = vehicleType.Id
};
edge.VehicleProperties.Add(newProp);
// Mark edge as modified
State.MarkEdgeModified(edge.Id);
// Update local state
var index = State.Edges.FindIndex(e => e.Id == edge.Id);
if (index >= 0)
{
State.Edges[index] = edge;
}
edgesAdded++;
}
}
// Notify state changed
State.NotifyStateChanged();
// Show detailed snackbar message
var messageParts = new List<string>();
if (nodesAdded > 0) messageParts.Add($"{nodesAdded} node(s)");
if (edgesAdded > 0) messageParts.Add($"{edgesAdded} edge(s)");
if (messageParts.Count > 0)
{
var addedMessage = $"Added '{vehicleType.VehicleTypeName}' to {string.Join(" and ", messageParts)}";
var skippedParts = new List<string>();
if (nodesSkipped > 0) skippedParts.Add($"{nodesSkipped} node(s)");
if (edgesSkipped > 0) skippedParts.Add($"{edgesSkipped} edge(s)");
if (skippedParts.Count > 0)
{
addedMessage += $". Skipped {string.Join(" and ", skippedParts)} (already have this vehicle type)";
}
Snackbar.Add(addedMessage, Severity.Success);
}
else
{
Snackbar.Add($"All selected items already have '{vehicleType.VehicleTypeName}'", Severity.Info);
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,215 @@
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.Requests
@using RobotNet10.MapEditor.Shared.Models
@inject MapManagerApiService ApiService
@inject ISnackbar Snackbar
<MudStack Spacing="3">
@if (State.Level?.EditorSettings != null)
{
var settings = State.Level.EditorSettings;
<!-- Grid Settings -->
<MudText Typo="Typo.caption" Color="Color.Secondary">Grid Settings</MudText>
<MudSelect T="double" @bind-Value="State.GridSpacing"
@bind-Value:after="State.NotifyStateChanged"
Label="Grid Spacing"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Dense="true">
<MudSelectItem Value="0.25">0.25 m</MudSelectItem>
<MudSelectItem Value="0.5">0.5 m</MudSelectItem>
<MudSelectItem Value="1.0">1.0 m</MudSelectItem>
<MudSelectItem Value="2.0">2.0 m</MudSelectItem>
<MudSelectItem Value="5.0">5.0 m</MudSelectItem>
<MudSelectItem Value="10.0">10.0 m</MudSelectItem>
</MudSelect>
<MudDivider Class="mt-2" />
<!-- Display Options -->
<MudText Typo="Typo.caption" Color="Color.Secondary">Display Options</MudText>
<MudStack Spacing="2">
<MudCheckBox T="bool" @bind-Value="State.ShowEdgeNames"
@bind-Value:after="State.NotifyStateChanged"
Label="Show Edge Names"
Size="Size.Small"
Dense="true" />
<MudCheckBox T="bool" @bind-Value="State.ShowNodeNames"
@bind-Value:after="State.NotifyStateChanged"
Label="Show Node Names"
Size="Size.Small"
Dense="true" />
<MudCheckBox T="bool" @bind-Value="State.ShowGrid"
@bind-Value:after="State.NotifyStateChanged"
Label="Show Grid"
Size="Size.Small"
Dense="true" />
<MudCheckBox T="bool" @bind-Value="State.ShowBackgroundImage"
@bind-Value:after="State.NotifyStateChanged"
Label="Show Background Map"
Size="Size.Small"
Dense="true" />
</MudStack>
<MudDivider Class="mt-2" />
<!-- Auto-generation Settings (Editable) - MOVED UP -->
<MudText Typo="Typo.caption" Color="Color.Secondary">Auto-generation Settings</MudText>
<MudStack Spacing="2">
<MudCheckBox T="bool" @bind-Value="nodeNameAutoGenerate"
Label="Auto-generate Node Names"
Size="Size.Small"
Dense="true" />
<MudCheckBox T="bool" @bind-Value="edgeNameAutoGenerate"
Label="Auto-generate Edge Names"
Size="Size.Small"
Dense="true" />
<MudNumericField T="double" @bind-Value="edgeMinLengthCreate"
Label="Edge Min Length (m)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Min="0.01"
Step="0.01"
Format="F2" />
<MudNumericField T="double" @bind-Value="nodeProximityRadius"
Label="Node Proximity Radius (m)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Min="0.01"
Step="0.01"
Format="F2" />
</MudStack>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Save"
OnClick="SaveEditorSettings"
Disabled="@isSaving"
FullWidth="true"
Class="mt-2">
@if (isSaving)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<MudText>Saving...</MudText>
}
else
{
<MudText>Save Settings</MudText>
}
</MudButton>
<MudDivider Class="mt-2" />
<!-- Layout Level Info (read-only) -->
<MudText Typo="Typo.caption" Color="Color.Secondary">Layout Level Info</MudText>
<MudPaper Elevation="0" Class="pa-2" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.body2"><strong>Level ID:</strong> @State.Level.LayoutLevelId</MudText>
<MudText Typo="Typo.body2"><strong>Resolution:</strong> @settings.Resolution.ToString("F4") m/px</MudText>
<MudText Typo="Typo.body2"><strong>Origin:</strong> (@settings.OriginX.ToString("F2"), @settings.OriginY.ToString("F2")) m</MudText>
@if (settings.ImageWidth.HasValue && settings.ImageHeight.HasValue)
{
<MudText Typo="Typo.body2"><strong>Image Size:</strong> @settings.ImageWidth × @settings.ImageHeight px</MudText>
var physicalW = settings.ImageWidth.Value * settings.Resolution;
var physicalH = settings.ImageHeight.Value * settings.Resolution;
<MudText Typo="Typo.body2"><strong>Physical Size:</strong> @physicalW.ToString("F2") × @physicalH.ToString("F2") m</MudText>
}
</MudPaper>
@if (settings.BoundsMinX.HasValue || settings.BoundsMaxX.HasValue)
{
<MudPaper Elevation="0" Class="pa-2 mt-2" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.body2"><strong>Bounds X:</strong> @(settings.BoundsMinX?.ToString("F2") ?? "∞") to @(settings.BoundsMaxX?.ToString("F2") ?? "∞") m</MudText>
<MudText Typo="Typo.body2"><strong>Bounds Y:</strong> @(settings.BoundsMinY?.ToString("F2") ?? "∞") to @(settings.BoundsMaxY?.ToString("F2") ?? "∞") m</MudText>
</MudPaper>
}
<MudDivider Class="mt-2" />
<!-- Statistics -->
<MudText Typo="Typo.caption" Color="Color.Secondary">Statistics</MudText>
<MudPaper Elevation="0" Class="pa-2" Style="background-color: var(--mud-palette-background-grey);">
<MudText Typo="Typo.body2"><strong>Nodes:</strong> @State.Nodes.Count</MudText>
<MudText Typo="Typo.body2"><strong>Edges:</strong> @State.Edges.Count</MudText>
<MudText Typo="Typo.body2"><strong>Stations:</strong> @State.Stations.Count</MudText>
</MudPaper>
}
else
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
Editor settings not available
</MudAlert>
}
</MudStack>
@code {
[Parameter]
public LayoutEditorState State { get; set; } = null!;
// Local editable state
private bool nodeNameAutoGenerate;
private bool edgeNameAutoGenerate;
private double edgeMinLengthCreate;
private double nodeProximityRadius;
private bool isSaving = false;
protected override void OnParametersSet()
{
// Initialize local state from settings
if (State.Level?.EditorSettings != null)
{
var settings = State.Level.EditorSettings;
nodeNameAutoGenerate = settings.NodeNameAutoGenerate;
edgeNameAutoGenerate = settings.EdgeNameAutoGenerate;
edgeMinLengthCreate = settings.EdgeMinLengthCreate;
nodeProximityRadius = settings.NodeProximityRadius;
}
}
private async Task SaveEditorSettings()
{
if (State.Level == null)
{
Snackbar.Add("Invalid level data", Severity.Error);
return;
}
isSaving = true;
try
{
var request = new UpdateLayoutLevelRequest
{
EditorSettings = new EditorSettingsInfo
{
NodeNameAutoGenerate = nodeNameAutoGenerate,
EdgeNameAutoGenerate = edgeNameAutoGenerate,
EdgeMinLengthCreate = edgeMinLengthCreate,
NodeProximityRadius = nodeProximityRadius
}
};
var updatedLevel = await ApiService.UpdateLevelAsync(State.Level.Id, request);
// Update State with new settings
State.Level = updatedLevel;
Snackbar.Add("Editor settings saved successfully", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Error saving settings: {ex.Message}", Severity.Error);
}
finally
{
isSaving = false;
}
}
}

View File

@@ -0,0 +1,234 @@
@using RobotNet10.MapEditor.Services.State
@inject ISnackbar Snackbar
@implements IDisposable
<MudPaper Elevation="2" Class="pa-2">
<!-- Header -->
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2">Trajectory</MudText>
</MudStack>
<!-- Degree Selector -->
<MudSelect T="int?"
Value="@Degree"
ValueChanged="(v) => OnDegreeChanged(v)"
Label="Degree"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Disabled="@IsReadOnly"
HelperText="Curve degree (1=linear, 2=quadratic, 3=cubic)">
<MudSelectItem Value="@((int?)1)">1 - Linear</MudSelectItem>
<MudSelectItem Value="@((int?)2)">2 - Quadratic</MudSelectItem>
<MudSelectItem Value="@((int?)3)">3 - Cubic</MudSelectItem>
</MudSelect>
<!-- Control Point 1 (for degree 2 and 3) -->
@if (Degree.HasValue && Degree.Value > 1)
{
<MudText Typo="Typo.body2" Class="mt-2 mb-1">
Control Point 1 (Quadratic/Cubic)
</MudText>
<MudStack Row="true" Spacing="2">
<MudNumericField T="double?"
Value="@ControlPoint1X"
ValueChanged="(v) => OnControlPoint1XChanged(v)"
Label="X (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
ReadOnly="@IsReadOnly"
Style="flex: 1;" />
<MudNumericField T="double?"
Value="@ControlPoint1Y"
ValueChanged="(v) => OnControlPoint1YChanged(v)"
Label="Y (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
ReadOnly="@IsReadOnly"
Style="flex: 1;" />
</MudStack>
}
<!-- Control Point 2 (for degree 3 only) -->
@if (Degree.HasValue && Degree.Value == 3)
{
<MudText Typo="Typo.body2" Class="mt-2 mb-1">
Control Point 2 (Cubic only)
</MudText>
<MudStack Row="true" Spacing="2">
<MudNumericField T="double?"
Value="@ControlPoint2X"
ValueChanged="(v) => OnControlPoint2XChanged(v)"
Label="X (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
ReadOnly="@IsReadOnly"
Style="flex: 1;" />
<MudNumericField T="double?"
Value="@ControlPoint2Y"
ValueChanged="(v) => OnControlPoint2YChanged(v)"
Label="Y (meters)"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Format="F3"
ReadOnly="@IsReadOnly"
Style="flex: 1;" />
</MudStack>
}
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
Note: Start and end nodes are determined by the edge's connected nodes and cannot be edited here.
</MudText>
</MudPaper>
@code {
[Parameter]
public LayoutEditorState State { get; set; } = null!;
[Parameter]
public NodeDto StartNode { get; set; } = null!;
[Parameter]
public NodeDto EndNode { get; set; } = null!;
[Parameter]
public int? Degree { get; set; }
[Parameter]
public double? ControlPoint1X { get; set; }
[Parameter]
public double? ControlPoint1Y { get; set; }
[Parameter]
public double? ControlPoint2X { get; set; }
[Parameter]
public double? ControlPoint2Y { get; set; }
[Parameter]
public EventCallback<(int? Degree, double? ControlPoint1X, double? ControlPoint1Y, double? ControlPoint2X, double? ControlPoint2Y)> TrajectoryFieldsChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender) return;
State.OnTrajectoryChanged += OnTrajectoryChanged;
}
public void OnTrajectoryChanged()
{
if (State.EdgeVehicleEditor is null) return;
ControlPoint1X = State.EdgeVehicleEditor.TrajectoryControlPoint1X;
ControlPoint1Y = State.EdgeVehicleEditor.TrajectoryControlPoint1Y;
ControlPoint2X = State.EdgeVehicleEditor.TrajectoryControlPoint2X;
ControlPoint2Y = State.EdgeVehicleEditor.TrajectoryControlPoint2Y;
StateHasChanged();
}
public void Dispose()
{
State.OnTrajectoryChanged -= OnTrajectoryChanged;
}
private void OnDegreeChanged(int? degree)
{
var oldDegree = Degree ?? 1;
var newDegree = degree ?? 1;
if (newDegree > oldDegree)
{
ElevateDegree(oldDegree, newDegree);
}
Degree = degree;
NotifyChanged();
}
private void OnControlPoint1XChanged(double? value)
{
ControlPoint1X = value;
NotifyChanged();
}
private void OnControlPoint1YChanged(double? value)
{
ControlPoint1Y = value;
NotifyChanged();
}
private void OnControlPoint2XChanged(double? value)
{
ControlPoint2X = value;
NotifyChanged();
}
private void OnControlPoint2YChanged(double? value)
{
ControlPoint2Y = value;
NotifyChanged();
}
private void NotifyChanged()
{
TrajectoryFieldsChanged.InvokeAsync((Degree, ControlPoint1X, ControlPoint1Y, ControlPoint2X, ControlPoint2Y));
}
private void ElevateDegree(int from, int to)
{
if (from == 1 && to == 2)
{
if (!ControlPoint1X.HasValue || !ControlPoint1Y.HasValue)
{
(ControlPoint1X, ControlPoint1Y) = CalculateNewCP_1to2();
}
}
else if (from == 2 && to == 3)
{
if (!ControlPoint2X.HasValue || !ControlPoint2Y.HasValue)
{
(ControlPoint2X, ControlPoint2Y) = CalculateNewCP_2to3();
}
}
else if (from == 1 && to == 3)
{
if (!ControlPoint1X.HasValue || !ControlPoint1Y.HasValue)
{
(ControlPoint1X, ControlPoint1Y) = CalculateNewCP_1to2();
}
if (!ControlPoint2X.HasValue || !ControlPoint2Y.HasValue)
{
(ControlPoint2X, ControlPoint2Y) = CalculateNewCP_2to3();
}
}
}
private (double X, double Y) CalculateNewCP_1to2()
{
double newX = (StartNode.X + EndNode.X) / 2;
double newY = (StartNode.Y + EndNode.Y) / 2;
return (newX, newY);
}
private (double X, double Y) CalculateNewCP_2to3()
{
if (!ControlPoint1X.HasValue || !ControlPoint1Y.HasValue)
{
(ControlPoint1X, ControlPoint1Y) = CalculateNewCP_1to2();
}
double newX = (ControlPoint1X.Value + EndNode.X) / 2;
double newY = (ControlPoint1Y.Value + EndNode.Y) / 2;
return (newX, newY);
}
}

View File

@@ -0,0 +1,42 @@
@using MudBlazor
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">Select Station Node</MudText>
</TitleContent>
<DialogContent>
<MudText>
The node "@NodeName" has a station. After splitting, which new node should receive the station?
</MudText>
<MudSelect T="int?" @bind-Value="SelectedNodeIndex"
Label="New Node Index"
Variant="Variant.Outlined"
Class="mt-4">
@for (int i = 0; i < EdgeCount; i++)
{
<MudSelectItem Value="@i">Node @(i + 1)</MudSelectItem>
}
</MudSelect>
<MudText Typo="Typo.caption" Class="mt-2">
Note: The node index corresponds to the order of connected edges (0-based).
</MudText>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Submit" Disabled="@(SelectedNodeIndex == null)">Split</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
[Parameter] public int EdgeCount { get; set; }
[Parameter] public string NodeName { get; set; } = string.Empty;
public int? SelectedNodeIndex { get; set; } = 0; // Default to first node
private void Cancel() => MudDialog?.Cancel();
private void Submit() => MudDialog?.Close(DialogResult.Ok(SelectedNodeIndex));
}

View File

@@ -0,0 +1,565 @@
@using Microsoft.JSInterop
@using RobotNet.VDA5050.Order
@using RobotNet10.MapEditor.Components.LayoutEditor.Element
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.Requests
@using RobotNet10.MapEditor.Shared.DTOs.LayoutData
@using RobotNet10.MapEditor.Shared.DTOs.Node
@using RobotNet10.MapEditor.Shared.DTOs.Edge
@using MudBlazor
@inject IJSRuntime JSRuntime
@inject ISnackbar Snackbar
@inject MapManagerApiService ApiService
@implements IAsyncDisposable
<div class="svg-editor-container" @ref="containerRef">
<!-- Mouse Position Display -->
<MousePositionDisplay @ref="MousePositionDisplayRef" />
<svg @ref="svgRef"
id="editor-svg"
class="editor-svg"
viewBox="@State.Viewport.ToViewBoxString()"
preserveAspectRatio="xMidYMid meet">
<!-- Layer 1: Background Image -->
@if (State.ShowBackgroundImage && State.BackgroundImage != null && State.Level?.EditorSettings != null)
{
var (physicalWidth, physicalHeight) = State.GetPhysicalDimensions();
<!-- No transform needed - image is rendered in SVG coordinate system (Y down) -->
<!-- Nodes/edges are transformed via WorldToSvg to match -->
<image href="@GetImageDataUrl()"
x="0"
y="0"
width="@physicalWidth.ToString("F2")"
height="@physicalHeight.ToString("F2")"
opacity="0.7"
preserveAspectRatio="none" />
}
<CascadingValue Value="State">
<!-- Layer 2: Grid -->
<Grid />
<!-- Origin Marker -->
<OriginMarker/>
<!-- Layer 3: Edges -->
<MapEdge />
<!-- Layer 4: Create Edge Preview -->
<EdgeCreatePreview @ref="EdgeCreatePreviewRef" />
<!-- Layer 5: Nodes -->
<MapNode />
<!-- Layer 5 +: Nodes ControlPoints -->
<EdgeEditing @ref="EdgeEditingRef"/>
<!-- Layer 6: Box Select Rectangle -->
<ScanView @ref="ScanViewRef"/>
<!-- Layer 7: Copy Preview -->
<CopyPreview @ref="CopyPreviewRef" />
</CascadingValue>
@* @if (State.Mode == EditorMode.Copy && State.CopySourceNodes != null && State.CopySourceNodes.Count > 0 &&
State.CopyOffsetX.HasValue && State.CopyOffsetY.HasValue)
{
var offsetX = State.CopyOffsetX.Value;
var offsetY = State.CopyOffsetY.Value;
<!-- Preview nodes -->
@foreach (var sourceNode in State.CopySourceNodes)
{
var newX = sourceNode.X + offsetX;
var newY = sourceNode.Y + offsetY;
var svg = State.WorldToSvg(newX, newY);
var nodeRadius = 0.25 / Math.Sqrt(State.Viewport.ZoomLevel / 1.5);
<circle class="copy-preview-node"
cx="@svg.X.ToString("F2")"
cy="@svg.Y.ToString("F2")"
r="@nodeRadius.ToString("F2")"
fill="rgba(255, 152, 0, 0.3)"
stroke="#ff9800"
stroke-width="0.02"
stroke-dasharray="0.05,0.05" />
}
<!-- Preview edges -->
@if (State.CopySourceEdges != null)
{
@foreach (var sourceEdge in State.CopySourceEdges)
{
var sourceStartNode = State.CopySourceNodes?.FirstOrDefault(n => n.Id == sourceEdge.StartNodeId);
var sourceEndNode = State.CopySourceNodes?.FirstOrDefault(n => n.Id == sourceEdge.EndNodeId);
if (sourceStartNode != null && sourceEndNode != null)
{
var startSvg = State.WorldToSvg(sourceStartNode.X + offsetX, sourceStartNode.Y + offsetY);
var endSvg = State.WorldToSvg(sourceEndNode.X + offsetX, sourceEndNode.Y + offsetY);
<line class="copy-preview-edge"
x1="@startSvg.X.ToString("F2")"
y1="@startSvg.Y.ToString("F2")"
x2="@endSvg.X.ToString("F2")"
y2="@endSvg.Y.ToString("F2")"
stroke="#ff9800"
stroke-width="0.03"
stroke-dasharray="0.1,0.05"
opacity="0.6" />
}
}
}
} *@
</svg>
</div>
@code {
[Parameter]
public LayoutEditorState State { get; set; } = null!;
[Parameter]
public EventCallback OnUndo { get; set; }
[Parameter]
public EventCallback OnRedo { get; set; }
[Parameter]
public EventCallback OnSave { get; set; }
[Parameter]
public EventCallback OnDelete { get; set; }
private ElementReference containerRef;
private ElementReference svgRef;
private IJSObjectReference? jsModule;
private DotNetObjectReference<SvgEditorCanvas>? dotNetRef;
private ScanView ScanViewRef = default!;
private EdgeEditing EdgeEditingRef = default!;
private EdgeCreatePreview EdgeCreatePreviewRef = default!;
private MousePositionDisplay MousePositionDisplayRef = default!;
private CopyPreview CopyPreviewRef = default!;
// Pan state
private bool isPanning;
private (double X, double Y)? panLastScreen; // Last screen coordinates (for incremental delta calculation)
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
dotNetRef = DotNetObjectReference.Create(this);
try
{
jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
"import", "./_content/RobotNet10.MapEditor/js/svgEditor.js");
await jsModule.InvokeVoidAsync("initEditor", svgRef, dotNetRef);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to initialize JS module: {ex.Message}");
}
}
}
public async ValueTask DisposeAsync()
{
if (jsModule != null)
{
try
{
await jsModule.InvokeVoidAsync("disposeEditor");
await jsModule.DisposeAsync();
}
catch { }
}
dotNetRef?.Dispose();
}
private string GetImageDataUrl()
{
if (State.BackgroundImage == null) return "";
return $"data:image/png;base64,{Convert.ToBase64String(State.BackgroundImage)}";
}
// Called from JavaScript
[JSInvokable]
public void OnMouseMove(double svgX, double svgY, double screenX = 0, double screenY = 0, bool ctrlKey = false)
{
// Update mouse position (always allowed)
var (worldX, worldY) = State.SvgToWorld(svgX, svgY);
MousePositionDisplayRef.Update(worldX, worldY);
// Pan is always allowed (even in ReadOnly mode) - handle it first
if (isPanning && panLastScreen.HasValue)
{
// Calculate incremental delta: from last position to current position
// This avoids accumulation because we're always calculating relative to the last move
if (jsModule != null)
{
_ = PanIncrementalAsync(panLastScreen.Value.X, panLastScreen.Value.Y, screenX, screenY);
}
// Update last position for next move
panLastScreen = (screenX, screenY);
return; // Pan handled, skip other operations
}
// Disable editing operations in ReadOnly mode (but allow box select for viewing)
if (State.IsReadOnly) return;
else
{
// Handle copy drag
if (State.Mode == EditorMode.Copy)
{
if (ctrlKey && State.CopyStartX.HasValue) CopyPreviewRef.UpdateCopyDrag(worldX, worldY);
return;
}
if (State.EdgeVehicleEditor != null && State.Mode == EditorMode.TrajectoryEditor)
{
if (State.Mode == EditorMode.Select && !ctrlKey) EdgeEditingRef.Cancel();
EdgeEditingRef.Update(svgX, svgY);
return;
}
// Handle node dragging
if (State.IsDraggingNodes && State.DraggedNodeId.HasValue && State.DragStartWorld.HasValue)
{
// Calculate delta from original position (in world coordinates)
var deltaX = worldX - State.DragStartWorld.Value.X;
var deltaY = worldY - State.DragStartWorld.Value.Y;
// In Select mode, finish drag if Ctrl key is released (keep nodes at current position)
// In Move mode, continue dragging regardless of Ctrl key
if (State.Mode == EditorMode.Select && !ctrlKey)
{
// Collect all nodes that were moved
var movedNodes = new List<NodeDto>();
var newPositions = new Dictionary<Guid, (double X, double Y)>();
foreach (var selectedId in State.SelectedNodeIds)
{
var node = State.Nodes.FirstOrDefault(n => n.Id == selectedId);
if (node != null && State.DragNodesOriginalPositions.ContainsKey(selectedId))
{
movedNodes.Add(node);
newPositions[selectedId] = (node.X, node.Y);
}
}
var movedEdges = new List<EdgeDto>();
var newCpPositions = new Dictionary<Guid, Dictionary<Guid, (double? CP1X, double? CP1Y, double? CP2X, double? CP2Y)>>();
foreach (var selectedId in State.SelectedEdgeIds)
{
var edge = State.Edges.FirstOrDefault(n => n.Id == selectedId);
if (edge != null)
{
if (edge.VehicleProperties is null) continue;
Dictionary<Guid, (double? CP1X, double? CP1Y, double? CP2X, double? CP2Y)> vehicleOriginCP = [];
foreach (var vehicle in edge.VehicleProperties)
{
vehicleOriginCP[vehicle.Id] = (vehicle.TrajectoryControlPoint1X, vehicle.TrajectoryControlPoint1Y, vehicle.TrajectoryControlPoint2X, vehicle.TrajectoryControlPoint2Y);
}
movedEdges.Add(edge);
newCpPositions[selectedId] = vehicleOriginCP;
}
}
// Create undo command if nodes were moved
if (movedNodes.Count > 0)
{
var command = new MoveNodesCommand(
movedNodes,
State.DragNodesOriginalPositions,
newPositions,
movedEdges,
State.DragEdgesOriginalPositions,
newCpPositions);
State.ExecuteCommand(command);
State.MarkDirty();
}
// Reset drag state
State.IsDraggingNodes = false;
State.DraggedNodeId = null;
State.DragStartWorld = null;
State.DragNodesOriginalPositions.Clear();
return;
}
var draggedNode = State.Nodes.FirstOrDefault(n => n.Id == State.DraggedNodeId.Value);
if (draggedNode != null)
{
// Update all selected nodes
foreach (var selectedId in State.SelectedNodeIds)
{
var node = State.Nodes.FirstOrDefault(n => n.Id == selectedId);
if (node != null && State.DragNodesOriginalPositions.ContainsKey(selectedId))
{
var originalPos = State.DragNodesOriginalPositions[selectedId];
node.X = originalPos.X + deltaX;
node.Y = originalPos.Y + deltaY;
}
}
// Update edge trajectories in real-time
foreach (var selectedId in State.SelectedEdgeIds)
{
var edge = State.Edges.FirstOrDefault(n => n.Id == selectedId);
if (edge != null && State.DragEdgesOriginalPositions.ContainsKey(selectedId))
{
if (edge.VehicleProperties is null) continue;
var originalEdge = State.DragEdgesOriginalPositions[selectedId];
foreach (var vehicle in edge.VehicleProperties)
{
if (!originalEdge.ContainsKey(vehicle.Id)) continue;
var originalPos = originalEdge[vehicle.Id];
if (vehicle.TrajectoryControlPoint1X.HasValue && originalPos.CP1X.HasValue) vehicle.TrajectoryControlPoint1X = originalPos.CP1X + deltaX;
if (vehicle.TrajectoryControlPoint1Y.HasValue && originalPos.CP1Y.HasValue) vehicle.TrajectoryControlPoint1Y = originalPos.CP1Y + deltaY;
if (vehicle.TrajectoryControlPoint2X.HasValue && originalPos.CP2X.HasValue) vehicle.TrajectoryControlPoint2X = originalPos.CP2X + deltaX;
if (vehicle.TrajectoryControlPoint2Y.HasValue && originalPos.CP2Y.HasValue) vehicle.TrajectoryControlPoint2Y = originalPos.CP2Y + deltaY;
}
}
}
State.NotifyDraggingNodesChanged();
State.NotifyDraggingEdgesChanged();
return;
}
}
// Update create edge preview
if ((State.Mode == EditorMode.CreateEdge1Way || State.Mode == EditorMode.CreateEdge2Way))
{
EdgeCreatePreviewRef.UpdateEdge(svgX, svgY);
}
}
// Update box select
ScanViewRef.UpdateEnd(svgX, svgY);
}
private async Task PanIncrementalAsync(double lastScreenX, double lastScreenY, double currentScreenX, double currentScreenY)
{
if (jsModule == null) return;
try
{
// Convert last and current screen positions to SVG coordinates
// using the CURRENT viewBox (before this pan)
var lastSvg = await jsModule.InvokeAsync<double[]>("screenToSvgArray", lastScreenX, lastScreenY);
var currentSvg = await jsModule.InvokeAsync<double[]>("screenToSvgArray", currentScreenX, currentScreenY);
if (lastSvg.Length >= 2 && currentSvg.Length >= 2)
{
// Calculate incremental delta: how much the mouse moved since last position
// Pan moves viewBox in opposite direction of mouse movement
var dx = lastSvg[0] - currentSvg[0];
var dy = lastSvg[1] - currentSvg[1];
State.Pan(dx, dy);
}
}
catch (Exception ex)
{
Console.WriteLine($"Error in PanIncrementalAsync: {ex.Message}");
}
}
[JSInvokable]
public async Task OnMouseDown(double svgX, double svgY, int button, bool ctrlKey, double screenX = 0, double screenY = 0)
{
// Middle mouse button - start pan (always allowed, even in ReadOnly)
if (button == 1)
{
isPanning = true;
panLastScreen = (screenX, screenY);
return;
}
if (State.Mode == EditorMode.Scanner && button == 0)
{
ScanViewRef.UpdateStart(svgX, svgY);
ScanViewRef.UpdateEnd(svgX, svgY);
}
// Disable editing operations in ReadOnly mode
if (State.IsReadOnly) return;
else
{
// Left mouse button
if (button == 0)
{
if (State.Mode == EditorMode.CreateEdge1Way || State.Mode == EditorMode.CreateEdge2Way)
{
// Handle create edge - click on canvas
await EdgeCreatePreviewRef.CreateEdge(svgX, svgY);
}
else if (State.Mode == EditorMode.Copy)
{
// Start copy drag
var (worldX, worldY) = State.SvgToWorld(svgX, svgY);
CopyPreviewRef.StartCopyDrag(worldX, worldY);
}
else if (State.Mode == EditorMode.Select && !ctrlKey)
{
// Click on empty space - clear selection
// State.ClearSelection();
}
}
}
}
[JSInvokable]
public async Task OnMouseUp(double svgX, double svgY, int button)
{
// End pan (always allowed)
if (button == 1)
{
isPanning = false;
panLastScreen = null;
return;
}
if (State.Mode == EditorMode.Scanner && button == 0)
{
ScanViewRef.UpdateEnd(svgX, svgY);
ScanViewRef.FinishBox();
}
// Disable editing operations in ReadOnly mode
if (State.IsReadOnly) return;
else
{
// Finish copy drag
if (button == 0 && State.Mode == EditorMode.Copy && State.CopyStartX.HasValue)
{
await State.CompleteCopyAsync();
}
// Finish node dragging
if (button == 0 && State.IsDraggingNodes && State.DraggedNodeId.HasValue && State.DragStartWorld.HasValue)
{
// Collect all nodes that were moved
var movedNodes = new List<NodeDto>();
var newPositions = new Dictionary<Guid, (double X, double Y)>();
foreach (var selectedId in State.SelectedNodeIds)
{
var node = State.Nodes.FirstOrDefault(n => n.Id == selectedId);
if (node != null && State.DragNodesOriginalPositions.ContainsKey(selectedId))
{
movedNodes.Add(node);
newPositions[selectedId] = (node.X, node.Y);
}
}
var movedEdges = new List<EdgeDto>();
var newCpPositions = new Dictionary<Guid, Dictionary<Guid, (double? CP1X, double? CP1Y, double? CP2X, double? CP2Y)>>();
foreach (var selectedId in State.SelectedEdgeIds)
{
var edge = State.Edges.FirstOrDefault(n => n.Id == selectedId);
if (edge != null)
{
if (edge.VehicleProperties is null) continue;
Dictionary<Guid, (double? CP1X, double? CP1Y, double? CP2X, double? CP2Y)> vehicleOriginCP = [];
foreach (var vehicle in edge.VehicleProperties)
{
vehicleOriginCP[vehicle.Id] = (vehicle.TrajectoryControlPoint1X, vehicle.TrajectoryControlPoint1Y, vehicle.TrajectoryControlPoint2X, vehicle.TrajectoryControlPoint2Y);
}
movedEdges.Add(edge);
newCpPositions[selectedId] = vehicleOriginCP;
}
}
// Create undo command (no API save yet, will save on button Save)
if (movedNodes.Count > 0)
{
var (worldX, worldY) = State.SvgToWorld(svgX, svgY);
var deltaX = worldX - State.DragStartWorld.Value.X;
var deltaY = worldY - State.DragStartWorld.Value.Y;
var command = new MoveNodesCommand(
movedNodes,
State.DragNodesOriginalPositions,
newPositions,
movedEdges,
State.DragEdgesOriginalPositions,
newCpPositions);
State.ExecuteCommand(command);
State.MarkDirty();
}
// Reset drag state
State.IsDraggingNodes = false;
State.DraggedNodeId = null;
State.DragStartWorld = null;
State.DragNodesOriginalPositions.Clear();
}
// Cancel Move Control Point
EdgeEditingRef.Cancel();
}
}
[JSInvokable]
public void OnWheel(double svgX, double svgY, double deltaY)
{
var factor = deltaY > 0 ? 0.9 : 1.1;
State.Zoom(factor, svgX, svgY);
}
[JSInvokable]
public void OnKeyDown(string key, bool ctrlKey)
{
switch (key)
{
case "z":
if (ctrlKey) OnUndo.InvokeAsync();
break;
case "y":
if (ctrlKey) OnRedo.InvokeAsync();
break;
case "s":
if (ctrlKey) OnSave.InvokeAsync();
break;
case "Delete":
OnDelete.InvokeAsync();
break;
case "Escape":
if (State.Mode == EditorMode.CreateEdge1Way || State.Mode == EditorMode.CreateEdge2Way)
{
EdgeCreatePreviewRef.CancelCreateEdge();
State.SetMode(EditorMode.Select);
}
else if (State.Mode == EditorMode.Copy)
{
State.CancelCopy();
}
else
{
State.ClearSelection();
ScanViewRef.CancelBox();
}
break;
case "c":
if (ctrlKey && (State.SelectedNodeIds.Count > 0 || State.SelectedEdgeIds.Count > 0))
{
State.StartCopy();
}
break;
case "m":
if (ctrlKey && State.SelectedNodeIds.Count > 0)
{
State.SetMode(EditorMode.Move);
}
break;
}
}
}

View File

@@ -0,0 +1,53 @@
.svg-editor-container {
width: 100%;
height: 100%;
overflow: hidden;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.editor-svg {
width: 100%;
height: 100%;
display: block;
cursor: default;
background-color: #e8e8e8;
image-rendering: pixelated;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Edges */
@keyframes dash {
to {
stroke-dashoffset: -20;
}
}
@keyframes pulse-preview {
0%, 100% {
opacity: 0.7;
}
50% {
opacity: 1;
}
}
/* Grid */
#grid-layer line {
pointer-events: none;
}

View File

@@ -0,0 +1,58 @@
@inject LayoutManagerState State
@inject ISnackbar Snackbar
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudTextField @bind-Value="request.LayoutId"
Label="Layout ID"
Required="true"
HelperText="Unique identifier for the layout" />
<MudTextField @bind-Value="request.LayoutName"
Label="Layout Name"
Required="true"
HelperText="Display name for the layout" />
<MudTextField @bind-Value="request.Description"
Label="Description"
Lines="3"
HelperText="Optional description" />
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Success" Variant="Variant.Filled" OnClick="Submit" Disabled="@(!IsValid())">
Create
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
private CreateLayoutRequest request = new();
private bool IsValid()
{
return !string.IsNullOrWhiteSpace(request.LayoutId) &&
!string.IsNullOrWhiteSpace(request.LayoutName);
}
private void Cancel() => MudDialog.Cancel();
private async Task Submit()
{
try
{
await State.CreateLayoutAsync(request);
MudDialog.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"Error creating layout: {ex.Message}", Severity.Error);
}
}
}

View File

@@ -0,0 +1,243 @@
@using Microsoft.AspNetCore.Components.Forms
@inject MapManagerApiService ApiService
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Add" Class="mr-2" />
Create New Level
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<!-- Basic Info -->
<MudTextField @bind-Value="layoutLevelId"
Label="Level ID *"
Required="true"
Variant="Variant.Outlined"
HelperText="Unique identifier (e.g., floor_1)" />
<MudNumericField @bind-Value="levelOrder"
Label="Level Order *"
Required="true"
Variant="Variant.Outlined"
HelperText="Display order (0-based)" />
<MudDivider Class="my-2" />
<!-- Image Upload (REQUIRED) -->
<MudText Typo="Typo.subtitle2">
<MudIcon Icon="@Icons.Material.Filled.Image" Class="mr-1" />
Background Image *
</MudText>
<MudFileUpload T="IBrowserFile"
@bind-Files="selectedFile"
Accept=".png"
OnFilesChanged="OnImageSelected"
MaximumFileCount="1">
<CustomContent>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.CloudUpload"
OnClick="@context.OpenFilePickerAsync">
Choose PNG File
</MudButton>
</CustomContent>
</MudFileUpload>
@if (selectedFile != null)
{
<MudPaper Class="pa-3" Elevation="1">
<MudStack Spacing="1">
<MudText Typo="Typo.body2">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" Class="mr-1" />
Selected: <strong>@selectedFile.Name</strong>
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
Size: @((selectedFile.Size / 1024.0).ToString("F2")) KB
</MudText>
@if (imageWidth > 0 && imageHeight > 0)
{
<MudText Typo="Typo.caption" Color="Color.Info">
<MudIcon Icon="@Icons.Material.Filled.AspectRatio" Size="Size.Small" />
Dimensions: <strong>@imageWidth × @imageHeight pixels</strong>
</MudText>
}
</MudStack>
</MudPaper>
}
<MudDivider Class="my-2" />
<!-- Coordinate System -->
<MudText Typo="Typo.subtitle2">Coordinate System *</MudText>
<MudNumericField @bind-Value="resolution"
Label="Resolution (m/pixel) *"
Variant="Variant.Outlined"
Step="0.001"
Min="0.001"
Required="true"
HelperText="Meters per pixel"
Format="F3" />
<MudStack Row="true" Spacing="2">
<MudNumericField @bind-Value="originX"
Label="Origin X (m) *"
Variant="Variant.Outlined"
Step="0.1"
Required="true"
Format="F2" />
<MudNumericField @bind-Value="originY"
Label="Origin Y (m) *"
Variant="Variant.Outlined"
Step="0.1"
Required="true"
Format="F2" />
</MudStack>
<!-- Calculated Map Size -->
@if (selectedFile != null && imageWidth > 0 && imageHeight > 0)
{
<MudPaper Class="pa-3" Elevation="0" Style="background-color: var(--mud-palette-success-lighten);">
<MudStack Spacing="1">
<MudText Typo="Typo.caption" Color="Color.Success">
<MudIcon Icon="@Icons.Material.Filled.Calculate" Size="Size.Small" /> Calculated Physical Map Size
</MudText>
<MudText Typo="Typo.body2">
<strong>@((imageWidth * resolution).ToString("F2")) × @((imageHeight * resolution).ToString("F2")) meters</strong>
</MudText>
</MudStack>
</MudPaper>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Success"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(!IsValid() || isCreating)">
@if (isCreating)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<MudText>Creating...</MudText>
}
else
{
<MudText>Create Level</MudText>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
[Parameter, EditorRequired] public Guid VersionId { get; set; }
private string layoutLevelId = string.Empty;
private int levelOrder = 0;
private double resolution = 0.05;
private double originX = 0.0;
private double originY = 0.0;
private IBrowserFile? selectedFile;
private int imageWidth = 0;
private int imageHeight = 0;
private bool isCreating = false;
private async Task OnImageSelected(InputFileChangeEventArgs e)
{
var file = e.File;
await ProcessSelectedImage(file);
}
private async Task ProcessSelectedImage(IBrowserFile? file)
{
selectedFile = file;
if (file == null)
{
imageWidth = 0;
imageHeight = 0;
return;
}
// Extract image dimensions using JavaScript
try
{
// Read file as base64 for dimension extraction
using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
var bytes = ms.ToArray();
// Simple PNG dimension extraction (bytes 16-23 contain width and height)
if (bytes.Length > 24 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
{
imageWidth = (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19];
imageHeight = (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23];
}
else
{
Snackbar.Add("Invalid PNG file format", Severity.Warning);
}
}
catch (Exception ex)
{
Snackbar.Add($"Error reading image: {ex.Message}", Severity.Error);
}
}
private bool IsValid()
{
return !string.IsNullOrWhiteSpace(layoutLevelId) && selectedFile != null && imageWidth > 0 && imageHeight > 0;
}
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Submit()
{
if (!IsValid() || selectedFile == null)
{
Snackbar.Add("Please fill all required fields and select an image", Severity.Warning);
return;
}
isCreating = true;
try
{
// Upload image and create level in one request
using var stream = selectedFile.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
var createdLevel = await ApiService.CreateLevelWithImageAsync(
VersionId,
layoutLevelId,
levelOrder,
resolution,
originX,
originY,
stream,
selectedFile.Name);
Snackbar.Add($"Level '{layoutLevelId}' created successfully with image", Severity.Success);
MudDialog?.Close(DialogResult.Ok(createdLevel));
}
catch (Exception ex)
{
Snackbar.Add($"Error creating level: {ex.Message}", Severity.Error);
}
finally
{
isCreating = false;
}
}
}

View File

@@ -0,0 +1,55 @@
@inject LayoutManagerState State
@inject ISnackbar Snackbar
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudTextField @bind-Value="request.Version"
Label="Version"
Required="true"
HelperText="Version number (e.g., 1.0, 2.1)" />
<MudTextField @bind-Value="request.LayoutDescription"
Label="Description"
Lines="3"
HelperText="Optional description for this version" />
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Success" Variant="Variant.Filled" OnClick="Submit" Disabled="@(!IsValid())">
Create
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter, EditorRequired]
public Guid LayoutId { get; set; }
private CreateLayoutVersionRequest request = new();
private bool IsValid()
{
return !string.IsNullOrWhiteSpace(request.Version);
}
private void Cancel() => MudDialog.Cancel();
private async Task Submit()
{
try
{
await State.CreateVersionAsync(LayoutId, request);
MudDialog.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"Error creating version: {ex.Message}", Severity.Error);
}
}
}

View File

@@ -0,0 +1,168 @@
@using RobotNet10.MapEditor.Shared.Models
@inject MapManagerApiService ApiService
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Edit" Class="mr-2" />
Edit Level Settings
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<!-- Level Info (Read-only) -->
<MudPaper Class="pa-3" Elevation="0" Style="background-color: var(--mud-palette-background-grey);">
<MudStack Spacing="1">
<MudText Typo="Typo.caption" Color="Color.Secondary">Level ID</MudText>
<MudText Typo="Typo.body2"><strong>@Level?.LayoutLevelId</strong></MudText>
</MudStack>
</MudPaper>
<!-- Coordinate System Settings -->
<MudText Typo="Typo.subtitle2" Class="mt-2">Coordinate System</MudText>
<MudNumericField @bind-Value="resolution"
Label="Resolution (m/pixel)"
Variant="Variant.Outlined"
Step="0.001"
Min="0.001"
Required="true"
HelperText="Meters per pixel"
Format="F3" />
<MudNumericField @bind-Value="originX"
Label="Origin X (meters)"
Variant="Variant.Outlined"
Step="0.1"
Required="true"
HelperText="X coordinate of origin point"
Format="F2" />
<MudNumericField @bind-Value="originY"
Label="Origin Y (meters)"
Variant="Variant.Outlined"
Step="0.1"
Required="true"
HelperText="Y coordinate of origin point"
Format="F2" />
<!-- Current Map Size (Read-only) -->
@if (Level?.EditorSettings != null)
{
<MudPaper Class="pa-3 mt-2" Elevation="0" Style="background-color: var(--mud-palette-info-lighten);">
<MudStack Spacing="1">
<MudText Typo="Typo.caption" Color="Color.Info">
<MudIcon Icon="@Icons.Material.Filled.Info" Size="Size.Small" /> Current Map Information
</MudText>
@if (Level.EditorSettings.ImageWidth.HasValue && Level.EditorSettings.ImageHeight.HasValue)
{
<MudText Typo="Typo.body2">
Image Size: <strong>@Level.EditorSettings.ImageWidth × @Level.EditorSettings.ImageHeight px</strong>
</MudText>
<MudText Typo="Typo.body2">
Physical Size: <strong>@((Level.EditorSettings.ImageWidth.Value * resolution).ToString("F2")) × @((Level.EditorSettings.ImageHeight.Value * resolution).ToString("F2")) m</strong>
</MudText>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Warning">No image uploaded yet</MudText>
}
</MudStack>
</MudPaper>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Save" Disabled="@isSaving">
@if (isSaving)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<MudText>Saving...</MudText>
}
else
{
<MudText>Save Changes</MudText>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
[Parameter] public LayoutLevelDto? Level { get; set; }
private double resolution;
private double originX;
private double originY;
private bool isSaving = false;
protected override void OnInitialized()
{
if (Level?.EditorSettings != null)
{
resolution = Level.EditorSettings.Resolution;
originX = Level.EditorSettings.OriginX;
originY = Level.EditorSettings.OriginY;
}
else
{
// Default values
resolution = 0.05;
originX = 0;
originY = 0;
}
}
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Save()
{
if (Level == null)
{
Snackbar.Add("Invalid level data", Severity.Error);
return;
}
isSaving = true;
try
{
var request = new UpdateLayoutLevelRequest
{
CoordinateSystem = new CoordinateSystemInfo
{
Resolution = resolution,
OriginX = originX,
OriginY = originY,
// Keep existing image dimensions and bounds
ImageWidth = Level.EditorSettings?.ImageWidth,
ImageHeight = Level.EditorSettings?.ImageHeight,
BoundsMinX = originX,
BoundsMaxX = Level.EditorSettings?.ImageWidth * resolution + originX,
BoundsMinY = originY,
BoundsMaxY = Level.EditorSettings?.ImageHeight * resolution + originY
}
};
var updatedLevel = await ApiService.UpdateLevelAsync(Level.Id, request);
Snackbar.Add("Level settings updated successfully", Severity.Success);
MudDialog?.Close(DialogResult.Ok(updatedLevel));
}
catch (Exception ex)
{
Snackbar.Add($"Error updating level: {ex.Message}", Severity.Error);
}
finally
{
isSaving = false;
}
}
}

View File

@@ -0,0 +1,87 @@
@inject ISnackbar Snackbar
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudText Typo="Typo.body1">
Export layout to VDMA LIF JSON file.
</MudText>
<MudDivider />
<MudTextField Value="@LayoutId.ToString()"
Label="Layout ID"
ReadOnly="true" />
<MudTextField Value="@Version"
Label="Version"
ReadOnly="true" />
<MudDivider />
<MudText Typo="Typo.caption" Color="Color.Default">
This will generate a VDMA LIF JSON file containing all levels and data for this layout version.
</MudText>
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Success"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@isExporting">
@if (isExporting)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>Exporting...</span>
}
else
{
<span>Export</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter, EditorRequired]
public Guid LayoutId { get; set; }
[Parameter, EditorRequired]
public string Version { get; set; } = "";
private bool isExporting;
private void Cancel() => MudDialog.Cancel();
private async Task Submit()
{
isExporting = true;
StateHasChanged();
try
{
// TODO: Implement export via API
// var jsonData = await ApiService.ExportLIFAsync(LayoutId, Version);
// Download file...
await Task.Delay(1000); // Simulate export
Snackbar.Add("Export functionality not yet implemented", Severity.Warning);
MudDialog.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"Error exporting layout: {ex.Message}", Severity.Error);
}
finally
{
isExporting = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,110 @@
@inject ISnackbar Snackbar
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudText Typo="Typo.body1">
Import VDMA LIF JSON file to create a new layout.
</MudText>
<MudFileUpload T="IBrowserFile" Accept=".json" FilesChanged="HandleFileSelected">
<CustomContent>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.CloudUpload"
OnClick="@context.OpenFilePickerAsync">
Select JSON File
</MudButton>
</CustomContent>
</MudFileUpload>
@if (selectedFile != null)
{
<MudChip T="string" Color="Color.Success" Icon="@Icons.Material.Filled.AttachFile">
@selectedFile.Name (@FormatFileSize(selectedFile.Size))
</MudChip>
}
<MudDivider />
<MudText Typo="Typo.caption" Color="Color.Default">
The file should be a valid VDMA LIF JSON file containing layout, version, and level data.
</MudText>
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Success"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(selectedFile == null || isUploading)">
@if (isUploading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>Importing...</span>
}
else
{
<span>Import</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
private IBrowserFile? selectedFile;
private bool isUploading;
private void HandleFileSelected(IBrowserFile? file)
{
selectedFile = file;
}
private void Cancel() => MudDialog.Cancel();
private async Task Submit()
{
if (selectedFile == null) return;
isUploading = true;
StateHasChanged();
try
{
// TODO: Implement import via API
// var stream = selectedFile.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
// await ApiService.ImportLIFAsync(stream);
await Task.Delay(1000); // Simulate upload
Snackbar.Add("Import functionality not yet implemented", Severity.Warning);
MudDialog.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"Error importing file: {ex.Message}", Severity.Error);
}
finally
{
isUploading = false;
StateHasChanged();
}
}
private string FormatFileSize(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB" };
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len = len / 1024;
}
return $"{len:0.##} {sizes[order]}";
}
}

View File

@@ -0,0 +1,179 @@
@inject LayoutManagerState State
@inject NavigationManager Navigation
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@implements IDisposable
<!-- Toolbar -->
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="mt-4">
<MudPaper Class="pa-4 mb-4" MinHeight="80px">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="3">
<MudText Typo="Typo.h5">Layout Manager</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<!-- Search Box -->
<MudTextField Value="searchText"
Placeholder="Search layouts..."
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Margin="Margin.Dense"
Style="min-width: 250px;"
Immediate="false"
T="string"
ValueChanged="TextSearchChanged"
Clearable="true" />
<!-- Import Button -->
<MudButton StartIcon="@Icons.Material.Filled.FileDownload"
Variant="Variant.Filled"
Color="Color.Primary"
OnClick="OpenImportDialog">
Import
</MudButton>
<!-- Add Layout Button -->
<MudButton StartIcon="@Icons.Material.Filled.Add"
Variant="Variant.Filled"
Color="Color.Success"
OnClick="OpenCreateLayoutDialog">
Add Layout
</MudButton>
</MudStack>
</MudStack>
</MudPaper>
<!-- Loading State -->
@if (State.IsLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-4" />
}
<!-- Main Content -->
@if (State.IsLoading)
{
<MudPaper Class="pa-16 text-center">
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
<MudText Typo="Typo.body1" Align="Align.Center" Class="mt-4">
Loading layouts...
</MudText>
</MudPaper>
}
else
{
<MudGrid Spacing="3">
<!-- Left Panel: Tree -->
<MudItem xs="12" md="4">
<MudPaper Elevation="2" Class="pa-4" Style="height: calc(100vh - 200px); overflow-y: auto;">
<LayoutTreePanel State="@State" />
</MudPaper>
</MudItem>
<!-- Right Panel: Preview -->
<MudItem xs="12" md="8">
<MudPaper Elevation="2" Class="pa-4" Style="height: calc(100vh - 200px); overflow-y: auto;">
@if (State.SelectedLevel != null)
{
<LayoutPreviewPanel State="@State"
OnEdit="NavigateToEditor"
OnStation="NavigateToStation"
OnExport="ExportLayout" />
}
else
{
<MudStack AlignItems="AlignItems.Center" Justify="Justify.Center" Style="height: 100%;">
<MudIcon Icon="@Icons.Material.Filled.Layers" Size="Size.Large" Color="Color.Secondary" Style="font-size: 100px;" />
<MudText Typo="Typo.h6" Color="Color.Secondary" Align="Align.Center" Class="mt-4">
Select a layout level to view preview
</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center">
Choose a level from the tree on the left
</MudText>
</MudStack>
}
</MudPaper>
</MudItem>
</MudGrid>
}
</MudContainer>
@code {
private string? searchText;
protected override async Task OnInitializedAsync()
{
State.OnStateChanged += StateHasChanged;
await State.LoadLayoutsAsync();
}
public void Dispose()
{
State.OnStateChanged -= StateHasChanged;
}
private async Task TextSearchChanged(string text)
{
searchText = text;
await State.LoadLayoutsAsync(searchText);
}
private async Task OpenCreateLayoutDialog()
{
var dialog = await DialogService.ShowAsync<CreateLayoutDialog>("Create New Layout");
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
Snackbar.Add("Layout created successfully", Severity.Success);
}
}
private async Task OpenImportDialog()
{
var dialog = await DialogService.ShowAsync<ImportLayoutDialog>("Import VDMA LIF");
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
Snackbar.Add("Layout imported successfully", Severity.Success);
}
}
private void NavigateToEditor()
{
if (State.SelectedLevel != null)
{
Navigation.NavigateTo($"/layout-editor/{State.SelectedLevel.Id}");
}
}
private void NavigateToStation()
{
if (State.SelectedLevel != null)
{
Navigation.NavigateTo($"/station-manager/{State.SelectedLevel.Id}");
}
}
private async Task ExportLayout()
{
if (State.SelectedLayout == null || State.SelectedVersion == null)
{
Snackbar.Add("No layout selected", Severity.Warning);
return;
}
var dialog = await DialogService.ShowAsync<ExportLayoutDialog>("Export Layout", new DialogParameters
{
["LayoutId"] = State.SelectedLayout.Id,
["Version"] = State.SelectedVersion.Version
});
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
Snackbar.Add("Layout exported successfully", Severity.Success);
}
}
}

View File

@@ -0,0 +1,267 @@
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.JSInterop
@inject MapManagerApiService ApiService
@inject IJSRuntime JS
@inject ISnackbar Snackbar
<MudStack Spacing="4">
<!-- Preview Canvas -->
<MudPaper Elevation="0" Style="height: 420px; border: 1px solid #ddd; position: relative;">
@if (State.IsLoadingPreview)
{
<MudStack AlignItems="AlignItems.Center" Justify="Justify.Center" Style="height: 100%;">
<MudProgressCircular Indeterminate="true" />
<MudText Typo="Typo.body2" Class="mt-2">Loading preview...</MudText>
</MudStack>
}
else if (State.PreviewData != null)
{
<SvgPreviewCanvas LayoutData="@State.PreviewData"
BackgroundImage="@State.PreviewImage"
EditorSettings="@State.SelectedLevel?.EditorSettings" />
}
else
{
<MudStack AlignItems="AlignItems.Center" Justify="Justify.Center" Style="height: 100%;">
<MudIcon Icon="@Icons.Material.Filled.ImageNotSupported" Size="Size.Large" Style="font-size: 60px; opacity: 0.3;" />
<MudText Typo="Typo.body2" Color="Color.Default" Style="opacity: 0.5;">
No preview data available
</MudText>
</MudStack>
}
</MudPaper>
<!-- Image Actions -->
<MudStack Row="true" Justify="Justify.Center" Spacing="2">
<MudButton StartIcon="@Icons.Material.Filled.Download"
Variant="Variant.Outlined"
Color="Color.Info"
Size="Size.Small"
OnClick="DownloadImage"
Disabled="@(State.PreviewImage == null)">
Download Image
</MudButton>
<MudFileUpload T="IBrowserFile"
Accept=".png"
OnFilesChanged="OnImageReplaceSelected"
MaximumFileCount="1">
<CustomContent>
<MudButton StartIcon="@Icons.Material.Filled.Upload"
Variant="Variant.Outlined"
Color="Color.Warning"
Size="Size.Small"
OnClick="@context.OpenFilePickerAsync">
Replace Image
</MudButton>
</CustomContent>
</MudFileUpload>
</MudStack>
<!-- Layout & Layout Information -->
<MudPaper Class="pa-3" Elevation="1">
<MudGrid>
<!-- Layout Info -->
<MudItem xs="12" md="4">
<MudStack Spacing="1">
<MudText Typo="Typo.caption" Color="Color.Secondary">LAYOUT INFO</MudText>
<MudText Typo="Typo.body2">
<strong>Layout:</strong> @State.SelectedLayout?.LayoutName
</MudText>
<MudText Typo="Typo.body2">
<strong>Version:</strong> @State.SelectedVersion?.Version
</MudText>
<MudText Typo="Typo.body2">
<strong>Level:</strong> @State.SelectedLevel?.LayoutLevelId
</MudText>
</MudStack>
</MudItem>
<!-- Element Counts -->
<MudItem xs="12" md="4">
<MudStack Spacing="1">
<MudText Typo="Typo.caption" Color="Color.Secondary">ELEMENTS</MudText>
<MudText Typo="Typo.body2">
<strong>Nodes:</strong> @(State.PreviewData?.Nodes.Count ?? 0)
</MudText>
<MudText Typo="Typo.body2">
<strong>Edges:</strong> @(State.PreviewData?.Edges.Count ?? 0)
</MudText>
<MudText Typo="Typo.body2">
<strong>Stations:</strong> @(State.PreviewData?.Stations.Count ?? 0)
</MudText>
</MudStack>
</MudItem>
<!-- Layout Settings -->
@if (State.SelectedLevel?.EditorSettings != null)
{
var settings = State.SelectedLevel.EditorSettings;
<MudItem xs="12" md="4">
<MudStack Spacing="1">
<MudText Typo="Typo.caption" Color="Color.Secondary">LAYOUT SETTINGS</MudText>
<MudText Typo="Typo.body2">
<strong>Resolution:</strong> @settings.Resolution.ToString("F3") m/px
</MudText>
<MudText Typo="Typo.body2">
<strong>Origin:</strong> (@settings.OriginX.ToString("F2"), @settings.OriginY.ToString("F2")) m
</MudText>
@if (settings.ImageWidth.HasValue && settings.ImageHeight.HasValue)
{
<MudText Typo="Typo.body2">
<strong>Image:</strong> @settings.ImageWidth × @settings.ImageHeight px
</MudText>
<MudText Typo="Typo.body2" Color="Color.Success">
<strong>Physical:</strong> @((settings.ImageWidth.Value * settings.Resolution).ToString("F2")) × @((settings.ImageHeight.Value * settings.Resolution).ToString("F2")) m
</MudText>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Warning">
<MudIcon Icon="@Icons.Material.Filled.Warning" Size="Size.Small" Class="mr-1" />
No image
</MudText>
}
</MudStack>
</MudItem>
}
</MudGrid>
</MudPaper>
<!-- Action Buttons -->
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2">
<MudButton StartIcon="@Icons.Material.Filled.LocationOn"
Variant="Variant.Filled"
Color="Color.Tertiary"
Size="Size.Large"
OnClick="OnStation">
Station
</MudButton>
<MudButton StartIcon="@Icons.Material.Filled.Edit"
Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Large"
OnClick="OnEdit">
Edit Layout
</MudButton>
<MudButton StartIcon="@Icons.Material.Filled.FileUpload"
Variant="Variant.Outlined"
Color="Color.Secondary"
Size="Size.Large"
OnClick="OnExport">
Export LIF
</MudButton>
<MudButton StartIcon="@Icons.Material.Filled.Refresh"
Variant="Variant.Text"
Color="Color.Default"
OnClick="RefreshPreview">
Refresh
</MudButton>
</MudStack>
</MudStack>
@code {
[Parameter, EditorRequired]
public LayoutManagerState State { get; set; } = null!;
[Parameter]
public EventCallback OnEdit { get; set; }
[Parameter]
public EventCallback OnStation { get; set; }
[Parameter]
public EventCallback OnExport { get; set; }
private async Task RefreshPreview()
{
if (State.SelectedLevel != null)
{
await State.SelectLevelAsync(State.SelectedLevel);
Snackbar.Add("Preview refreshed", Severity.Info);
}
}
private async Task DownloadImage()
{
if (State.SelectedLevel == null || State.PreviewImage == null)
{
Snackbar.Add("No image available to download", Severity.Warning);
return;
}
try
{
// Convert byte array to base64
var base64 = Convert.ToBase64String(State.PreviewImage);
var fileName = $"{State.SelectedLevel.LayoutLevelId}_background.png";
// Use JavaScript to trigger download
await JS.InvokeVoidAsync("eval",
$@"
const link = document.createElement('a');
link.href = 'data:image/png;base64,{base64}';
link.download = '{fileName}';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
");
Snackbar.Add($"Downloaded {fileName}", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Error downloading image: {ex.Message}", Severity.Error);
}
}
private async Task OnImageReplaceSelected(InputFileChangeEventArgs e)
{
var file = e.File;
await ProcessImageReplace(file);
}
private async Task ProcessImageReplace(IBrowserFile? file)
{
if (file == null || State.SelectedLevel == null)
return;
// Validate file type
if (!file.ContentType.Equals("image/png", StringComparison.OrdinalIgnoreCase))
{
Snackbar.Add("Only PNG images are supported", Severity.Warning);
return;
}
// Validate file size (max 10MB)
const long maxSize = 10 * 1024 * 1024;
if (file.Size > maxSize)
{
Snackbar.Add($"File size exceeds maximum of 10MB", Severity.Warning);
return;
}
try
{
Snackbar.Add("Uploading image...", Severity.Info);
// Upload new image
using var stream = file.OpenReadStream(maxSize);
await ApiService.UploadLayoutImageAsync(State.SelectedLevel.Id, stream, file.Name);
// Refresh preview to show new image
await RefreshPreview();
Snackbar.Add("Image replaced successfully", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Error uploading image: {ex.Message}", Severity.Error);
}
}
}

View File

@@ -0,0 +1,317 @@
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<MudStack>
<!-- Header -->
<MudText Typo="Typo.h6" Class="mb-2">Layouts</MudText>
@if (State.Layouts.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Default" Align="Align.Center" Class="mt-8" Style="opacity: 0.6;">
No layouts found. Click "Add Layout" to create one.
</MudText>
}
else
{
<!-- Custom Tree Implementation -->
<MudPaper Elevation="0">
@foreach (var layout in State.Layouts)
{
<MudStack Spacing="0">
<!-- Layout Item -->
<MudPaper Elevation="0" Class="pa-2 hover-highlight" Style="cursor: pointer;">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIconButton Icon="@(expandedLayouts.Contains(layout.Id) ? Icons.Material.Filled.ExpandMore : Icons.Material.Filled.ChevronRight)"
Size="Size.Small"
OnClick="() => ToggleLayout(layout.Id)" />
<MudIcon Icon="@Icons.Material.Filled.Map" Size="Size.Small" />
<MudText Typo="Typo.body2"><strong>@(layout.LayoutName)</strong></MudText>
@if (layout.IsActive)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Text">Active</MudChip>
}
</MudStack>
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Size="Size.Small" Dense="true">
<MudMenuItem Icon="@Icons.Material.Filled.Add" OnClick="() => OpenCreateVersionDialog(layout)">
Add Version
</MudMenuItem>
@if (layout.IsActive)
{
<MudMenuItem Icon="@Icons.Material.Filled.ToggleOff" OnClick="() => DeactivateLayout(layout)">
Deactivate
</MudMenuItem>
}
else
{
<MudMenuItem Icon="@Icons.Material.Filled.ToggleOn" OnClick="() => ActivateLayout(layout)">
Activate
</MudMenuItem>
}
<MudDivider />
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="() => DeleteLayout(layout)">
Delete
</MudMenuItem>
</MudMenu>
</MudStack>
</MudPaper>
<!-- Versions (Nested) -->
@if (expandedLayouts.Contains(layout.Id) && layout.Versions != null)
{
<MudStack Spacing="0" Class="ml-6">
@foreach (var version in layout.Versions)
{
<!-- Version Item -->
<MudPaper Elevation="0" Class="pa-2 hover-highlight" Style="cursor: pointer;">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIconButton Icon="@(expandedVersions.Contains(version.Id) ? Icons.Material.Filled.ExpandMore : Icons.Material.Filled.ChevronRight)"
Size="Size.Small"
OnClick="() => ToggleVersion(version.Id)" />
<MudIcon Icon="@Icons.Material.Filled.History" Size="Size.Small" />
<MudText Typo="Typo.body2">@version.Version</MudText>
@if (version.IsActive)
{
<MudChip T="string" Size="Size.Small" Color="Color.Primary" Variant="Variant.Text">Active</MudChip>
}
</MudStack>
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Size="Size.Small" Dense="true">
<MudMenuItem Icon="@Icons.Material.Filled.Add" OnClick="() => OpenCreateLevelDialog(version)">
Add Level
</MudMenuItem>
<MudDivider />
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="() => DeleteVersion(version)">
Delete
</MudMenuItem>
</MudMenu>
</MudStack>
</MudPaper>
<!-- Levels (Nested) -->
@if (expandedVersions.Contains(version.Id) && version.Levels != null)
{
<MudStack Spacing="0" Class="ml-6">
@foreach (var level in version.Levels.OrderBy(l => l.LevelOrder))
{
<!-- Level Item -->
<MudPaper Elevation="@(State.SelectedLevel?.Id == level.Id ? 1 : 0)"
Class="@GetLevelItemClass(level)"
Style="cursor: pointer;"
@onclick="() => SelectLevel(level)">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="ml-4">
<MudIcon Icon="@Icons.Material.Filled.Layers" Size="Size.Small" />
<MudText Typo="Typo.body2">@level.LayoutLevelId</MudText>
</MudStack>
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Size="Size.Small" Dense="true">
<MudMenuItem Icon="@Icons.Material.Filled.Edit" OnClick="() => OpenEditLevelDialog(level)">
Edit Settings
</MudMenuItem>
<MudDivider />
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error" OnClick="() => DeleteLevel(level)">
Delete
</MudMenuItem>
</MudMenu>
</MudStack>
</MudPaper>
}
</MudStack>
}
}
</MudStack>
}
</MudStack>
}
</MudPaper>
}
</MudStack>
@code {
[Parameter, EditorRequired]
public LayoutManagerState State { get; set; } = null!;
private HashSet<Guid> expandedLayouts = new();
private HashSet<Guid> expandedVersions = new();
private void ToggleLayout(Guid layoutId)
{
if (expandedLayouts.Contains(layoutId))
expandedLayouts.Remove(layoutId);
else
expandedLayouts.Add(layoutId);
}
private void ToggleVersion(Guid versionId)
{
if (expandedVersions.Contains(versionId))
expandedVersions.Remove(versionId);
else
expandedVersions.Add(versionId);
}
private async Task SelectLevel(LayoutLevelDto level)
{
await State.SelectLevelAsync(level);
}
private string GetLevelItemClass(LayoutLevelDto level)
{
var baseClass = "pa-2 hover-highlight";
return State.SelectedLevel?.Id == level.Id
? $"{baseClass} selected-item"
: baseClass;
}
// ===== LAYOUT ACTIONS =====
private async Task ActivateLayout(LayoutDto layout)
{
try
{
await State.ActivateLayoutAsync(layout.Id);
Snackbar.Add($"Layout '{layout.LayoutName}' activated", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Error activating layout: {ex.Message}", Severity.Error);
}
}
private async Task DeactivateLayout(LayoutDto layout)
{
try
{
await State.DeactivateLayoutAsync(layout.Id);
Snackbar.Add($"Layout '{layout.LayoutName}' deactivated", Severity.Info);
}
catch (Exception ex)
{
Snackbar.Add($"Error deactivating layout: {ex.Message}", Severity.Error);
}
}
private async Task DeleteLayout(LayoutDto layout)
{
bool? confirm = await DialogService.ShowMessageBoxAsync(
"Confirm Delete",
$"Are you sure you want to delete layout '{layout.LayoutName}'? This will delete all versions and levels.",
yesText: "Delete", cancelText: "Cancel");
if (confirm == true)
{
try
{
await State.DeleteLayoutAsync(layout.Id);
Snackbar.Add($"Layout '{layout.LayoutName}' deleted", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Error deleting layout: {ex.Message}", Severity.Error);
}
}
}
// ===== VERSION ACTIONS =====
private async Task OpenCreateVersionDialog(LayoutDto layout)
{
var dialog = await DialogService.ShowAsync<CreateVersionDialog>("Create New Version", new DialogParameters
{
["LayoutId"] = layout.Id
});
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
Snackbar.Add("Version created successfully", Severity.Success);
}
}
private async Task DeleteVersion(LayoutVersionDto version)
{
bool? confirm = await DialogService.ShowMessageBoxAsync(
"Confirm Delete",
$"Are you sure you want to delete version '{version.Version}'? This will delete all levels.",
yesText: "Delete", cancelText: "Cancel");
if (confirm == true)
{
try
{
await State.DeleteVersionAsync(version.Id);
Snackbar.Add($"Version '{version.Version}' deleted", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Error deleting version: {ex.Message}", Severity.Error);
}
}
}
// ===== LEVEL ACTIONS =====
private async Task OpenCreateLevelDialog(LayoutVersionDto version)
{
var dialog = await DialogService.ShowAsync<CreateLevelDialog>("Create New Level", new DialogParameters
{
["VersionId"] = version.Id
});
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
Snackbar.Add("Level created successfully", Severity.Success);
await State.LoadLayoutsAsync(); // Refresh tree
}
}
private async Task OpenEditLevelDialog(LayoutLevelDto level)
{
var dialog = await DialogService.ShowAsync<EditLevelDialog>("Edit Level Settings", new DialogParameters
{
["Level"] = level
});
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
Snackbar.Add("Level settings updated successfully", Severity.Success);
await State.LoadLayoutsAsync(); // Refresh tree
}
}
private async Task DeleteLevel(LayoutLevelDto level)
{
bool? confirm = await DialogService.ShowMessageBoxAsync(
"Confirm Delete",
$"Are you sure you want to delete level '{level.LayoutLevelId}'?",
yesText: "Delete", cancelText: "Cancel");
if (confirm == true)
{
try
{
await State.DeleteLevelAsync(level.Id);
Snackbar.Add($"Level '{level.LayoutLevelId}' deleted", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Error deleting level: {ex.Message}", Severity.Error);
}
}
}
}
<style>
.hover-highlight:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.selected-item {
background-color: rgba(33, 150, 243, 0.12) !important;
}
</style>

View File

@@ -0,0 +1,243 @@
@using RobotNet10.MapEditor.Shared.DTOs.LayoutData
<svg width="100%" height="100%" viewBox="@ViewBoxString" style="background: #f5f5f5;">
<!-- Marker Definitions -->
<defs>
<marker id="originvector" markerWidth="4" markerHeight="4" refX="0.8" refY="3.5">
<!-- X-axis (red, horizontal) -->
<line x1="0" y1="3.5" x2="3" y2="3.5" stroke="red" stroke-width="0.3" />
<path d="M 3 3.8 L 3.5 3.5 L 3 3.2 Z" fill="red" stroke-width="0" />
<!-- Y-axis (blue, vertical) -->
<line x1="0.8" y1="4" x2="0.8" y2="1" stroke="blue" stroke-width="0.3" />
<path d="M 1.1 1 L 0.8 0.5 L 0.5 1 Z" fill="blue" stroke-width="0" />
</marker>
</defs>
<!-- Background Image -->
@if (BackgroundImage != null && BackgroundImage.Length > 0 && EditorSettings != null)
{
var imageSvgX = GetImageSvgX();
var imageSvgY = GetImageSvgY();
<image href="@GetImageDataUrl()"
x="@imageSvgX"
y="@imageSvgY"
width="@GetImageWidth()"
height="@GetImageHeight()"
opacity="0.7"
preserveAspectRatio="none" />
}
<!-- Origin Marker -->
@if (EditorSettings != null)
{
// Origin in world coordinates is (0, 0), convert to SVG coordinates
var originSvg = WorldToSvg(0, 0);
var originXStr = originSvg.X.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
var originYStr = originSvg.Y.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
<line x1="@originXStr"
y1="@originYStr"
x2="@originXStr"
y2="@originYStr"
stroke="transparent"
marker-end="url(#originvector)"
stroke-width="0.6" />
}
<!-- Edges -->
<g id="edges">
@foreach (var edge in LayoutData?.Edges ?? new())
{
var startNode = LayoutData?.Nodes.FirstOrDefault(n => n.Id == edge.StartNodeId);
var endNode = LayoutData?.Nodes.FirstOrDefault(n => n.Id == edge.EndNodeId);
if (startNode != null && endNode != null)
{
var startSvg = WorldToSvg(startNode.X, startNode.Y);
var endSvg = WorldToSvg(endNode.X, endNode.Y);
<line x1="@startSvg.X.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)"
y1="@startSvg.Y.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)"
x2="@endSvg.X.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)"
y2="@endSvg.Y.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)"
stroke="#3498db"
stroke-width="0.2"
opacity="0.8" />
}
}
</g>
<!-- Nodes -->
<g id="nodes">
@foreach (var node in LayoutData?.Nodes ?? new())
{
var svg = WorldToSvg(node.X, node.Y);
<circle cx="@svg.X.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)"
cy="@svg.Y.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)"
r="0.3"
fill="#e74c3c"
stroke="#c0392b"
stroke-width="0.1" />
}
</g>
<!-- Stations -->
<g id="stations">
@foreach (var station in LayoutData?.Stations ?? new())
{
var svg = WorldToSvg(station.X, station.Y);
var rectX = (svg.X - 0.3).ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
var rectY = (svg.Y - 0.3).ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
<rect x="@rectX"
y="@rectY"
width="0.6" height="0.6"
fill="#27ae60"
stroke="#229954"
stroke-width="0.1" />
}
</g>
</svg>
@code {
[Parameter]
public LayoutDataDto? LayoutData { get; set; }
[Parameter]
public byte[]? BackgroundImage { get; set; }
[Parameter]
public LayoutLevelEditorSettingsDto? EditorSettings { get; set; }
/// <summary>
/// Get physical dimensions of the layout in meters
/// </summary>
private (double Width, double Height) GetPhysicalDimensions()
{
if (EditorSettings == null)
return (50, 30);
return (
(EditorSettings.ImageWidth ?? 1000) * EditorSettings.Resolution,
(EditorSettings.ImageHeight ?? 500) * EditorSettings.Resolution
);
}
/// <summary>
/// Transform world coordinates (layout) to SVG coordinates
/// World: Origin at bottom-left (relative to image), Y up
/// SVG: Origin at top-left, Y down
/// </summary>
private (double X, double Y) WorldToSvg(double worldX, double worldY)
{
if (EditorSettings == null)
return (worldX, worldY);
var (_, physicalHeight) = GetPhysicalDimensions();
var originX = EditorSettings.OriginX;
var originY = EditorSettings.OriginY;
// X: subtract origin to shift coordinate system
// Y: flip vertically (world Y-up -> SVG Y-down)
return (
worldX - originX,
physicalHeight - (worldY - originY)
);
}
private string ViewBoxString
{
get
{
// If we have image dimensions, use those for viewBox to show the full map
if (EditorSettings?.ImageWidth.HasValue == true && EditorSettings?.ImageHeight.HasValue == true)
{
var width = EditorSettings.ImageWidth.Value * EditorSettings.Resolution;
var height = EditorSettings.ImageHeight.Value * EditorSettings.Resolution;
// ViewBox starts at (0, 0) in SVG coordinates (top-left)
return $"0 0 {width.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)} {height.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}";
}
// Otherwise, calculate bounds from nodes in SVG coordinates
if (LayoutData != null && LayoutData.Nodes.Count > 0)
{
var svgCoords = LayoutData.Nodes.Select(n => WorldToSvg(n.X, n.Y)).ToList();
var minX = svgCoords.Min(c => c.X);
var maxX = svgCoords.Max(c => c.X);
var minY = svgCoords.Min(c => c.Y);
var maxY = svgCoords.Max(c => c.Y);
// Add padding
var padding = Math.Max((maxX - minX), (maxY - minY)) * 0.1;
minX -= padding;
minY -= padding;
var width = (maxX - minX) + 2 * padding;
var height = (maxY - minY) + 2 * padding;
return $"{minX.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)} {minY.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)} {width.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)} {height.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}";
}
// Default fallback
return "0 0 100 50";
}
}
private string GetImageDataUrl()
{
if (BackgroundImage == null || BackgroundImage.Length == 0)
return "";
return $"data:image/png;base64,{Convert.ToBase64String(BackgroundImage)}";
}
/// <summary>
/// Get image width in SVG coordinates (meters)
/// </summary>
private string GetImageWidth()
{
if (EditorSettings?.ImageWidth.HasValue == true)
{
var width = EditorSettings.ImageWidth.Value * EditorSettings.Resolution;
return width.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
}
return "100";
}
/// <summary>
/// Get image height in SVG coordinates (meters)
/// </summary>
private string GetImageHeight()
{
if (EditorSettings?.ImageHeight.HasValue == true)
{
var height = EditorSettings.ImageHeight.Value * EditorSettings.Resolution;
return height.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
}
return "50";
}
/// <summary>
/// Get image X position in SVG coordinates
/// Image should be positioned at (0, 0) in SVG coordinates (top-left)
/// </summary>
private string GetImageSvgX()
{
if (EditorSettings == null)
return "0";
// Image starts at origin in SVG coordinates (which is 0 after transformation)
return "0";
}
/// <summary>
/// Get image Y position in SVG coordinates
/// Image should be positioned at (0, 0) in SVG coordinates (top-left)
/// </summary>
private string GetImageSvgY()
{
if (EditorSettings == null)
return "0";
// Image starts at origin in SVG coordinates (which is 0 after transformation)
return "0";
}
}

View File

@@ -0,0 +1,177 @@
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Shared.DTOs.Requests
@inject StationManagerState State
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Add" Class="mr-2" />
Create New Station
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<!-- Station ID -->
<MudTextField @bind-Value="request.StationId"
Label="Station ID *"
Required="true"
Variant="Variant.Outlined"
HelperText="Unique identifier (must be unique within this level)"
Validation="@(new Func<string, string?>(ValidateStationId))" />
<!-- Station Name -->
<MudTextField @bind-Value="request.StationName"
Label="Station Name"
Variant="Variant.Outlined"
HelperText="Display name for this station" />
<!-- Description -->
<MudTextField @bind-Value="request.StationDescription"
Label="Description"
Lines="3"
Variant="Variant.Outlined"
HelperText="Optional description" />
<MudDivider Class="my-2" />
<!-- Position Section -->
<MudText Typo="Typo.subtitle2">Position</MudText>
<MudGrid>
<MudItem xs="6">
<MudNumericField @bind-Value="request.X"
Label="X (meters) *"
Required="true"
Variant="Variant.Outlined"
Format="F3"
Step="0.1" />
</MudItem>
<MudItem xs="6">
<MudNumericField @bind-Value="request.Y"
Label="Y (meters) *"
Required="true"
Variant="Variant.Outlined"
Format="F3"
Step="0.1" />
</MudItem>
<MudItem xs="6">
<MudNumericField @bind-Value="request.Theta"
Label="Theta (radians)"
Variant="Variant.Outlined"
Format="F3"
Step="0.1"
HelperText="Optional, range: -π to π" />
</MudItem>
<MudItem xs="6">
<MudNumericField @bind-Value="request.StationHeight"
Label="Height (meters)"
Variant="Variant.Outlined"
Format="F2"
Step="0.1"
HelperText="Optional" />
</MudItem>
</MudGrid>
<!-- Validation Messages -->
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error">@errorMessage</MudAlert>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(!IsValid() || isSubmitting)">
@if (isSubmitting)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<MudText>Creating...</MudText>
}
else
{
<MudText>Create</MudText>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter, EditorRequired]
public Guid LayoutLevelId { get; set; }
private CreateStationRequest request = new();
private bool isSubmitting = false;
private string? errorMessage;
protected override void OnInitialized()
{
request.LayoutLevelId = LayoutLevelId;
// Set default position
request.X = 0;
request.Y = 0;
}
private bool IsValid()
{
return !string.IsNullOrWhiteSpace(request.StationId);
}
private string? ValidateStationId(string value)
{
if (string.IsNullOrWhiteSpace(value))
return "Station ID is required";
if (value.Length > 128)
return "Station ID must be 128 characters or less";
// Check if already exists in current stations
if (State.Stations.Any(s => s.StationId.Equals(value, StringComparison.OrdinalIgnoreCase)))
return "Station ID already exists in this level";
return null;
}
private void Cancel() => MudDialog.Cancel();
private async Task Submit()
{
errorMessage = null;
// Validate theta range
if (request.Theta.HasValue && (request.Theta.Value < -Math.PI || request.Theta.Value > Math.PI))
{
errorMessage = "Theta must be between -π and π";
return;
}
// Validate station height
if (request.StationHeight.HasValue && request.StationHeight.Value < 0)
{
errorMessage = "Station height must be >= 0";
return;
}
isSubmitting = true;
try
{
var created = await State.CreateStationAsync(request);
MudDialog.Close(DialogResult.Ok(created));
}
catch (Exception ex)
{
errorMessage = ex.Message;
isSubmitting = false;
}
}
}

View File

@@ -0,0 +1,115 @@
@using RobotNet10.MapEditor.Services.State
@using RobotNet10.MapEditor.Shared.DTOs.Station
@inject StationManagerState State
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Class="mr-2" />
Delete Station
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<MudText>
Are you sure you want to delete this station?
</MudText>
<!-- Station Info -->
<MudPaper Class="pa-3" Elevation="0" Style="background-color: var(--mud-palette-background-grey);">
<MudStack Spacing="2">
<MudStack Row="true" Spacing="1">
<MudText Typo="Typo.body2" Color="Color.Secondary">Station ID:</MudText>
<MudText Typo="Typo.body2"><strong>@Station.StationId</strong></MudText>
</MudStack>
@if (!string.IsNullOrEmpty(Station.StationName))
{
<MudStack Row="true" Spacing="1">
<MudText Typo="Typo.body2" Color="Color.Secondary">Name:</MudText>
<MudText Typo="Typo.body2">@Station.StationName</MudText>
</MudStack>
}
<MudStack Row="true" Spacing="1">
<MudText Typo="Typo.body2" Color="Color.Secondary">Position:</MudText>
<MudText Typo="Typo.body2">(@Station.X.ToString("F2"), @Station.Y.ToString("F2"))</MudText>
</MudStack>
<MudStack Row="true" Spacing="1">
<MudText Typo="Typo.body2" Color="Color.Secondary">Interaction Nodes:</MudText>
<MudText Typo="Typo.body2">@(Station.InteractionNodes?.Count ?? 0)</MudText>
</MudStack>
</MudStack>
</MudPaper>
<!-- Warning about interaction nodes -->
@if (Station.InteractionNodes != null && Station.InteractionNodes.Count > 0)
{
<MudAlert Severity="Severity.Warning">
This station has @Station.InteractionNodes.Count interaction node(s).
The links to these nodes will be removed, but the nodes themselves will not be deleted.
</MudAlert>
}
<MudAlert Severity="Severity.Error">
<strong>This action cannot be undone.</strong>
</MudAlert>
<!-- Error Message -->
@if (!string.IsNullOrEmpty(errorMessage))
{
<MudAlert Severity="Severity.Error">
@errorMessage
</MudAlert>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Disabled="@isDeleting">Cancel</MudButton>
<MudButton Color="Color.Error"
Variant="Variant.Filled"
OnClick="Delete"
Disabled="@isDeleting">
@if (isDeleting)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<MudText>Deleting...</MudText>
}
else
{
<MudText>Delete Station</MudText>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter, EditorRequired]
public StationDto Station { get; set; } = null!;
private bool isDeleting = false;
private string? errorMessage;
private void Cancel() => MudDialog.Cancel();
private async Task Delete()
{
errorMessage = null;
isDeleting = true;
try
{
await State.DeleteStationAsync(Station.Id);
MudDialog.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
errorMessage = ex.Message;
isDeleting = false;
}
}
}

Some files were not shown because too many files have changed in this diff Show More