| | | 1 | | @using Chronicis.Client.Models |
| | | 2 | | @using Chronicis.Client.Components.Dialogs |
| | | 3 | | @using Chronicis.Shared.DTOs |
| | | 4 | | @inject ITreeStateService TreeState |
| | | 5 | | @inject ISnackbar Snackbar |
| | | 6 | | @inject NavigationManager Navigation |
| | | 7 | | @inject IArticleApiService ArticleApi |
| | | 8 | | @inject IAppContextService AppContext |
| | | 9 | | @inject IDialogService DialogService |
| | | 10 | | @inject IJSRuntime JSRuntime |
| | | 11 | | @implements IDisposable |
| | | 12 | | |
| | | 13 | | <div class="article-tree @(_draggedNodeId.HasValue ? "article-tree--dragging" : "")"> |
| | 0 | 14 | | @if (TreeState.IsLoading) |
| | | 15 | | { |
| | | 16 | | <div class="article-tree__loading"> |
| | | 17 | | <MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Small" /> |
| | | 18 | | <MudText Typo="Typo.body2" Class="ml-2">Loading...</MudText> |
| | | 19 | | </div> |
| | | 20 | | } |
| | 0 | 21 | | else if (!TreeState.RootNodes.Any()) |
| | | 22 | | { |
| | | 23 | | <div class="article-tree__empty"> |
| | | 24 | | <div class="article-tree__empty-icon">🌍</div> |
| | | 25 | | <MudText Typo="Typo.body2" Class="mt-2 mb-3">No worlds yet</MudText> |
| | | 26 | | <MudButton |
| | | 27 | | Variant="Variant.Text" |
| | | 28 | | Color="Color.Primary" |
| | | 29 | | Size="Size.Small" |
| | | 30 | | StartIcon="@Icons.Material.Filled.Add" |
| | | 31 | | OnClick="CreateWorld"> |
| | | 32 | | Create Your First World |
| | | 33 | | </MudButton> |
| | | 34 | | </div> |
| | | 35 | | } |
| | 0 | 36 | | else if (TreeState.IsSearchActive && !HasVisibleNodes()) |
| | | 37 | | { |
| | | 38 | | <div class="article-tree__empty"> |
| | | 39 | | <div class="article-tree__empty-icon">🔍</div> |
| | | 40 | | <MudText Typo="Typo.body2" Class="mt-2"> |
| | 0 | 41 | | No articles match "@TreeState.SearchQuery" |
| | | 42 | | </MudText> |
| | | 43 | | </div> |
| | | 44 | | } |
| | | 45 | | else |
| | | 46 | | { |
| | | 47 | | <div class="article-tree__nodes"> |
| | | 48 | | @* Dashboard link *@ |
| | | 49 | | <MudNavLink Href="/dashboard" |
| | | 50 | | Match="NavLinkMatch.All" |
| | | 51 | | Class="article-tree__dashboard-link"> |
| | | 52 | | <div style="width: 30px;"></div> |
| | | 53 | | <MudIcon Icon="@Icons.Material.Filled.Home" |
| | | 54 | | Color="Color.Primary" |
| | | 55 | | Size="Size.Small" |
| | | 56 | | Style="margin-right: 8px;" /> |
| | | 57 | | <span style="vertical-align: top;">Dashboard</span> |
| | | 58 | | </MudNavLink> |
| | | 59 | | |
| | | 60 | | <MudDivider Class="my-2" Style="opacity: 0.3;" /> |
| | | 61 | | |
| | | 62 | | @* Worlds and their contents *@ |
| | 0 | 63 | | @foreach (var node in TreeState.RootNodes.Where(n => n.IsVisible)) |
| | | 64 | | { |
| | | 65 | | <ArticleTreeNode |
| | | 66 | | Node="node" |
| | | 67 | | OnSelect="HandleSelect" |
| | | 68 | | OnToggle="HandleToggle" |
| | | 69 | | OnAddChild="HandleAddChild" |
| | | 70 | | OnDelete="HandleDelete" |
| | | 71 | | OnMove="HandleMove" |
| | | 72 | | DraggedNodeId="_draggedNodeId" |
| | | 73 | | DraggedNodeIdChanged="OnDraggedNodeIdChanged" /> |
| | | 74 | | } |
| | | 75 | | |
| | | 76 | | <MudDivider Class="my-2" Style="opacity: 0.3;" /> |
| | | 77 | | |
| | | 78 | | @* Drop zone to make items root-level *@ |
| | 0 | 79 | | @if (_draggedNodeId.HasValue) |
| | | 80 | | { |
| | | 81 | | <div class="article-tree__root-drop-zone @(_isOverRootZone ? "article-tree__root-drop-zone--active" : "" |
| | | 82 | | @ondragover="HandleRootDragOver" |
| | | 83 | | @ondragover:preventDefault="true" |
| | | 84 | | @ondragleave="HandleRootDragLeave" |
| | | 85 | | @ondrop="HandleRootDrop" |
| | | 86 | | @ondrop:preventDefault="true"> |
| | | 87 | | <MudIcon Icon="@Icons.Material.Filled.ArrowUpward" Size="Size.Small" Class="mr-2" /> |
| | | 88 | | <span>Move to root level</span> |
| | | 89 | | </div> |
| | | 90 | | } |
| | | 91 | | </div> |
| | | 92 | | } |
| | | 93 | | </div> |
| | | 94 | | |
| | | 95 | | @code { |
| | | 96 | | private Guid? _draggedNodeId; |
| | | 97 | | private bool _isOverRootZone; |
| | | 98 | | |
| | | 99 | | protected override async Task OnInitializedAsync() |
| | | 100 | | { |
| | 0 | 101 | | TreeState.OnStateChanged += OnTreeStateChanged; |
| | | 102 | | |
| | | 103 | | // Initialize tree if not already loaded |
| | 0 | 104 | | if (!TreeState.RootNodes.Any() && !TreeState.IsLoading) |
| | | 105 | | { |
| | 0 | 106 | | await TreeState.InitializeAsync(); |
| | | 107 | | } |
| | 0 | 108 | | } |
| | | 109 | | |
| | | 110 | | private void OnTreeStateChanged() |
| | | 111 | | { |
| | 0 | 112 | | InvokeAsync(StateHasChanged); |
| | 0 | 113 | | } |
| | | 114 | | |
| | | 115 | | private bool HasVisibleNodes() |
| | | 116 | | { |
| | 0 | 117 | | return TreeState.RootNodes.Any(n => n.IsVisible); |
| | | 118 | | } |
| | | 119 | | |
| | | 120 | | private async Task HandleSelect(Guid nodeId) |
| | | 121 | | { |
| | | 122 | | // Get the node to determine its type |
| | 0 | 123 | | var node = TreeState.RootNodes |
| | 0 | 124 | | .SelectMany(GetAllNodes) |
| | 0 | 125 | | .FirstOrDefault(n => n.Id == nodeId); |
| | | 126 | | |
| | 0 | 127 | | if (node == null) return; |
| | | 128 | | |
| | | 129 | | // Route based on node type |
| | 0 | 130 | | switch (node.NodeType) |
| | | 131 | | { |
| | | 132 | | case TreeNodeType.World: |
| | 0 | 133 | | Navigation.NavigateTo($"/world/{nodeId}"); |
| | 0 | 134 | | break; |
| | | 135 | | |
| | | 136 | | case TreeNodeType.Campaign: |
| | 0 | 137 | | Navigation.NavigateTo($"/campaign/{nodeId}"); |
| | 0 | 138 | | break; |
| | | 139 | | |
| | | 140 | | case TreeNodeType.Arc: |
| | 0 | 141 | | Navigation.NavigateTo($"/arc/{nodeId}"); |
| | 0 | 142 | | break; |
| | | 143 | | |
| | | 144 | | case TreeNodeType.VirtualGroup: |
| | | 145 | | // Virtual groups just expand/collapse, don't navigate |
| | 0 | 146 | | TreeState.ToggleNode(nodeId); |
| | 0 | 147 | | break; |
| | | 148 | | |
| | | 149 | | case TreeNodeType.Article: |
| | 0 | 150 | | TreeState.ExpandPathToAndSelect(nodeId); |
| | 0 | 151 | | var article = await ArticleApi.GetArticleDetailAsync(nodeId); |
| | 0 | 152 | | if (article != null && article.Breadcrumbs.Any()) |
| | | 153 | | { |
| | 0 | 154 | | var path = string.Join("/", article.Breadcrumbs.Select(b => b.Slug)); |
| | 0 | 155 | | Navigation.NavigateTo($"/article/{path}"); |
| | | 156 | | } |
| | | 157 | | break; |
| | | 158 | | } |
| | 0 | 159 | | } |
| | | 160 | | |
| | | 161 | | // Helper to flatten tree for searching |
| | | 162 | | private IEnumerable<TreeNode> GetAllNodes(TreeNode node) |
| | | 163 | | { |
| | 0 | 164 | | yield return node; |
| | 0 | 165 | | foreach (var child in node.Children) |
| | | 166 | | { |
| | 0 | 167 | | foreach (var descendant in GetAllNodes(child)) |
| | | 168 | | { |
| | 0 | 169 | | yield return descendant; |
| | | 170 | | } |
| | | 171 | | } |
| | 0 | 172 | | } |
| | | 173 | | |
| | | 174 | | private void HandleToggle(Guid nodeId) |
| | | 175 | | { |
| | 0 | 176 | | TreeState.ToggleNode(nodeId); |
| | 0 | 177 | | } |
| | | 178 | | |
| | | 179 | | private async Task HandleAddChild(Guid parentId) |
| | | 180 | | { |
| | 0 | 181 | | var newId = await TreeState.CreateChildArticleAsync(parentId); |
| | | 182 | | |
| | 0 | 183 | | if (newId.HasValue) |
| | | 184 | | { |
| | 0 | 185 | | Snackbar.Add("Article created", Severity.Success); |
| | | 186 | | |
| | | 187 | | // Navigate to the new article |
| | 0 | 188 | | var article = await ArticleApi.GetArticleDetailAsync(newId.Value); |
| | 0 | 189 | | if (article != null && article.Breadcrumbs.Any()) |
| | | 190 | | { |
| | 0 | 191 | | var path = string.Join("/", article.Breadcrumbs.Select(b => b.Slug)); |
| | 0 | 192 | | Navigation.NavigateTo($"/article/{path}"); |
| | | 193 | | } |
| | | 194 | | } |
| | | 195 | | else |
| | | 196 | | { |
| | 0 | 197 | | Snackbar.Add("Failed to create article", Severity.Error); |
| | | 198 | | } |
| | 0 | 199 | | } |
| | | 200 | | |
| | | 201 | | private async Task HandleDelete(Guid nodeId) |
| | | 202 | | { |
| | | 203 | | // Get node info for confirmation message |
| | 0 | 204 | | var article = await ArticleApi.GetArticleDetailAsync(nodeId); |
| | 0 | 205 | | if (article == null) return; |
| | | 206 | | |
| | 0 | 207 | | var title = string.IsNullOrWhiteSpace(article.Title) ? "(Untitled)" : article.Title; |
| | 0 | 208 | | var message = $"Are you sure you want to delete '{title}'?"; |
| | | 209 | | |
| | 0 | 210 | | if (article.ChildCount > 0) |
| | | 211 | | { |
| | 0 | 212 | | var childText = article.ChildCount == 1 ? "1 child article" : $"{article.ChildCount} child articles"; |
| | 0 | 213 | | message += $"\n\n⚠️ WARNING: This will also delete {childText} and all their descendants."; |
| | | 214 | | } |
| | | 215 | | |
| | 0 | 216 | | message += "\n\nThis action cannot be undone."; |
| | | 217 | | |
| | 0 | 218 | | var confirmed = await JSRuntime.InvokeAsync<bool>("confirm", message); |
| | 0 | 219 | | if (!confirmed) return; |
| | | 220 | | |
| | 0 | 221 | | var success = await TreeState.DeleteArticleAsync(nodeId); |
| | | 222 | | |
| | 0 | 223 | | if (success) |
| | | 224 | | { |
| | 0 | 225 | | Snackbar.Add("Article deleted", Severity.Success); |
| | | 226 | | |
| | | 227 | | // Navigate to parent or dashboard |
| | 0 | 228 | | if (article.ParentId.HasValue) |
| | | 229 | | { |
| | 0 | 230 | | var parent = await ArticleApi.GetArticleDetailAsync(article.ParentId.Value); |
| | 0 | 231 | | if (parent != null && parent.Breadcrumbs.Any()) |
| | | 232 | | { |
| | 0 | 233 | | var path = string.Join("/", parent.Breadcrumbs.Select(b => b.Slug)); |
| | 0 | 234 | | Navigation.NavigateTo($"/article/{path}"); |
| | | 235 | | } |
| | | 236 | | } |
| | | 237 | | else |
| | | 238 | | { |
| | 0 | 239 | | Navigation.NavigateTo("/dashboard"); |
| | | 240 | | } |
| | | 241 | | } |
| | | 242 | | else |
| | | 243 | | { |
| | 0 | 244 | | Snackbar.Add("Failed to delete article", Severity.Error); |
| | | 245 | | } |
| | 0 | 246 | | } |
| | | 247 | | |
| | | 248 | | private async Task CreateWorld() |
| | | 249 | | { |
| | 0 | 250 | | var dialog = await DialogService.ShowAsync<CreateWorldDialog>("New World"); |
| | 0 | 251 | | var result = await dialog.Result; |
| | | 252 | | |
| | 0 | 253 | | if (result != null && !result.Canceled && result.Data is WorldDto world) |
| | | 254 | | { |
| | 0 | 255 | | await TreeState.RefreshAsync(); |
| | 0 | 256 | | Navigation.NavigateTo($"/world/{world.Id}"); |
| | 0 | 257 | | Snackbar.Add("World created", Severity.Success); |
| | | 258 | | } |
| | 0 | 259 | | } |
| | | 260 | | |
| | | 261 | | // ============================================ |
| | | 262 | | // Drag and Drop |
| | | 263 | | // ============================================ |
| | | 264 | | |
| | | 265 | | private void OnDraggedNodeIdChanged(Guid? nodeId) |
| | | 266 | | { |
| | 0 | 267 | | _draggedNodeId = nodeId; |
| | 0 | 268 | | _isOverRootZone = false; |
| | 0 | 269 | | StateHasChanged(); |
| | 0 | 270 | | } |
| | | 271 | | |
| | | 272 | | private async Task HandleMove((Guid ArticleId, Guid? NewParentId) moveInfo) |
| | | 273 | | { |
| | 0 | 274 | | var success = await TreeState.MoveArticleAsync(moveInfo.ArticleId, moveInfo.NewParentId); |
| | | 275 | | |
| | 0 | 276 | | if (success) |
| | | 277 | | { |
| | 0 | 278 | | Snackbar.Add("Article moved", Severity.Success); |
| | | 279 | | } |
| | | 280 | | else |
| | | 281 | | { |
| | 0 | 282 | | Snackbar.Add("Cannot move article here", Severity.Warning); |
| | | 283 | | } |
| | | 284 | | |
| | 0 | 285 | | _draggedNodeId = null; |
| | 0 | 286 | | } |
| | | 287 | | |
| | | 288 | | private void HandleRootDragOver(DragEventArgs e) |
| | | 289 | | { |
| | 0 | 290 | | _isOverRootZone = true; |
| | 0 | 291 | | } |
| | | 292 | | |
| | | 293 | | private void HandleRootDragLeave(DragEventArgs e) |
| | | 294 | | { |
| | 0 | 295 | | _isOverRootZone = false; |
| | 0 | 296 | | } |
| | | 297 | | |
| | | 298 | | private async Task HandleRootDrop(DragEventArgs e) |
| | | 299 | | { |
| | 0 | 300 | | _isOverRootZone = false; |
| | | 301 | | |
| | 0 | 302 | | if (_draggedNodeId.HasValue) |
| | | 303 | | { |
| | 0 | 304 | | await HandleMove((_draggedNodeId.Value, null)); |
| | | 305 | | } |
| | 0 | 306 | | } |
| | | 307 | | |
| | | 308 | | public void Dispose() |
| | | 309 | | { |
| | 0 | 310 | | TreeState.OnStateChanged -= OnTreeStateChanged; |
| | 0 | 311 | | } |
| | | 312 | | } |