feat(ui/templates): native HTML5 drag-drop reorganization

This commit is contained in:
Joseph Doherty
2026-05-11 11:20:42 -04:00
parent fc105acd7c
commit c60aad9df4

View File

@@ -144,23 +144,26 @@
</div> </div>
<div style="max-height: calc(100vh - 160px); overflow-y: auto;"> <div style="max-height: calc(100vh - 160px); overflow-y: auto;">
<TreeView @ref="_tree" TItem="TmplNode" Items="_treeRoots" <div @ondragover:preventDefault="true" @ondrop="OnDropOnRoot"
ChildrenSelector="n => n.Children" style="min-height: 100%; padding: 4px;">
HasChildrenSelector="n => n.Kind != TmplNodeKind.Composition && n.Children.Count > 0" <TreeView @ref="_tree" TItem="TmplNode" Items="_treeRoots"
KeySelector="n => (object)n.Key" ChildrenSelector="n => n.Children"
StorageKey="templates-tree" HasChildrenSelector="n => n.Kind != TmplNodeKind.Composition && n.Children.Count > 0"
Selectable="true" KeySelector="n => (object)n.Key"
SelectedKeyChanged="OnTreeNodeSelected"> StorageKey="templates-tree"
<NodeContent Context="node"> Selectable="true"
@RenderNodeLabel(node) SelectedKeyChanged="OnTreeNodeSelected">
</NodeContent> <NodeContent Context="node">
<ContextMenu Context="node"> @RenderNodeLabel(node)
@RenderNodeContextMenu(node) </NodeContent>
</ContextMenu> <ContextMenu Context="node">
<EmptyContent> @RenderNodeContextMenu(node)
<span class="text-muted fst-italic">No templates yet. Use the buttons above to create a folder or template.</span> </ContextMenu>
</EmptyContent> <EmptyContent>
</TreeView> <span class="text-muted fst-italic">No templates yet. Use the buttons above to create a folder or template.</span>
</EmptyContent>
</TreeView>
</div>
</div> </div>
</div> </div>
@@ -389,6 +392,75 @@
private TreeView<TmplNode> _tree = default!; private TreeView<TmplNode> _tree = default!;
// ---- Drag-and-drop state ----
private (TmplNodeKind kind, int id)? _dragPayload;
private string? _dragOverKey;
private void OnDragStart(TmplNode node)
{
if (node.Kind == TmplNodeKind.Composition) return;
_dragPayload = (node.Kind, node.EntityId);
}
private void OnDragEnd()
{
_dragPayload = null;
_dragOverKey = null;
}
private void OnDragEnter(TmplNode targetFolder)
{
if (_dragPayload == null) return;
if (targetFolder.Kind != TmplNodeKind.Folder) return;
_dragOverKey = targetFolder.Key;
}
private void OnDragLeave(TmplNode targetFolder)
{
if (_dragOverKey == targetFolder.Key) _dragOverKey = null;
}
private async Task OnDrop(TmplNode targetFolder)
{
if (_dragPayload is not { } payload) return;
if (targetFolder.Kind != TmplNodeKind.Folder) return;
var user = await GetCurrentUserAsync();
if (payload.kind == TmplNodeKind.Folder)
{
var result = await TemplateFolderService.MoveFolderAsync(payload.id, targetFolder.EntityId, user);
if (result.IsFailure) _toast.ShowError(result.Error);
}
else if (payload.kind == TmplNodeKind.Template)
{
var result = await TemplateService.MoveTemplateAsync(payload.id, targetFolder.EntityId, user);
if (result.IsFailure) _toast.ShowError(result.Error);
}
OnDragEnd();
await LoadTemplatesAsync();
}
private async Task OnDropOnRoot()
{
if (_dragPayload is not { } payload) return;
var user = await GetCurrentUserAsync();
if (payload.kind == TmplNodeKind.Folder)
{
var result = await TemplateFolderService.MoveFolderAsync(payload.id, null, user);
if (result.IsFailure) _toast.ShowError(result.Error);
}
else if (payload.kind == TmplNodeKind.Template)
{
var result = await TemplateService.MoveTemplateAsync(payload.id, null, user);
if (result.IsFailure) _toast.ShowError(result.Error);
}
OnDragEnd();
await LoadTemplatesAsync();
}
private RenderFragment RenderTemplateDetail() => __builder => private RenderFragment RenderTemplateDetail() => __builder =>
{ {
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
@@ -519,34 +591,51 @@
private RenderFragment RenderNodeLabel(TmplNode node) => __builder => private RenderFragment RenderNodeLabel(TmplNode node) => __builder =>
{ {
switch (node.Kind) var draggable = node.Kind != TmplNodeKind.Composition;
{ var isDropTarget = node.Kind == TmplNodeKind.Folder;
case TmplNodeKind.Folder: var classes = "d-inline-block " + (_dragOverKey == node.Key ? "bg-info bg-opacity-25" : "");
<span class="me-1">📁</span> var style = node.Kind == TmplNodeKind.Composition && _dragPayload != null
<span>@node.Label</span> ? "opacity: 0.5;" : "";
<span class="badge bg-light text-dark ms-2">@node.Children.Count</span>
break; <div class="@classes" style="@style"
case TmplNodeKind.Template: draggable="@(draggable ? "true" : "false")"
<strong>@node.Label</strong> @ondragstart="() => OnDragStart(node)"
if (node.Template?.ParentTemplateId is int pid) @ondragend="OnDragEnd"
{ @ondragenter="() => OnDragEnter(node)"
<span class="text-muted small ms-1">inherits @(_templates.FirstOrDefault(t => t.Id == pid)?.Name)</span> @ondragleave="() => OnDragLeave(node)"
} @ondragover:preventDefault="@isDropTarget"
<span class="badge bg-light text-dark ms-2"> @ondrop="() => OnDrop(node)">
@node.Template!.Attributes.Count attr, @switch (node.Kind)
@node.Template.Alarms.Count alm, {
@node.Template.Scripts.Count scr case TmplNodeKind.Folder:
</span> <span class="me-1">📁</span>
if (node.Template.Compositions.Count > 0) <span>@node.Label</span>
{ <span class="badge bg-light text-dark ms-2">@node.Children.Count</span>
<span class="badge bg-info text-dark ms-1">@node.Template.Compositions.Count comp</span> break;
} case TmplNodeKind.Template:
break; <strong>@node.Label</strong>
case TmplNodeKind.Composition: if (node.Template?.ParentTemplateId is int pid)
<span>@node.Label</span> {
<span class="text-muted small ms-1"> @(_templates.FirstOrDefault(t => t.Id == node.Composition!.ComposedTemplateId)?.Name ?? $"#{node.Composition!.ComposedTemplateId}")</span> <span class="text-muted small ms-1">inherits @(_templates.FirstOrDefault(t => t.Id == pid)?.Name)</span>
break; }
} <span class="badge bg-light text-dark ms-2">
@node.Template!.Attributes.Count attr,
@node.Template.Alarms.Count alm,
@node.Template.Scripts.Count scr
</span>
if (node.Template.Compositions.Count > 0)
{
<span class="badge bg-info text-dark ms-1">@node.Template.Compositions.Count comp</span>
}
break;
case TmplNodeKind.Composition:
var composedName = _templates.FirstOrDefault(t => t.Id == node.Composition!.ComposedTemplateId)?.Name
?? $"#{node.Composition!.ComposedTemplateId}";
<span>@node.Label</span>
<span class="text-muted small ms-1">→ @composedName</span>
break;
}
</div>
}; };
private async Task OnTreeNodeSelected(object? key) private async Task OnTreeNodeSelected(object? key)