Initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
|
||||
namespace RobotNet10.Components;
|
||||
|
||||
public static class RenderMode
|
||||
{
|
||||
public readonly static InteractiveWebAssemblyRenderMode InteractiveWebAssemblyNoPrerender = new(prerender: false);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
|
||||
@using Microsoft.JSInterop
|
||||
@using MudBlazor
|
||||
@using RobotNet10.Components
|
||||
15
srcs/RobotNet10/Components/RobotNet10.Components/libman.json
Normal file
15
srcs/RobotNet10/Components/RobotNet10.Components/libman.json
Normal 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/"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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");
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/)
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.edge-preview {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.preview-node {
|
||||
pointer-events: none;
|
||||
animation: pulse-preview 1s infinite;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
|
||||
.preview-control-node {
|
||||
cursor: pointer;
|
||||
animation: pulse-preview 1s infinite;
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Box select */
|
||||
.box-select {
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user