From ec7fc4d0c815459798ea13f377b8980346583a54 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 31 Mar 2026 09:16:40 +0200 Subject: [PATCH] Show subfolders in all item breadcrumb paths (#1695) --- .../Main/Pages/Items/AddEdit.razor | 18 ++- .../Main/Pages/Items/Delete.razor | 12 +- .../Main/Pages/Items/Home.razor | 94 +++++++++++---- .../Main/Pages/Items/View.razor | 7 +- .../AliasVault.Client/Main/Pages/MainBase.cs | 107 ++++++++++++++++++ .../Resources/Pages/Main/Items/Home.en.resx | 4 + .../wwwroot/css/tailwind.css | 14 +++ 7 files changed, 223 insertions(+), 33 deletions(-) diff --git a/apps/server/AliasVault.Client/Main/Pages/Items/AddEdit.razor b/apps/server/AliasVault.Client/Main/Pages/Items/AddEdit.razor index 59881c3a0..443627fc2 100644 --- a/apps/server/AliasVault.Client/Main/Pages/Items/AddEdit.razor +++ b/apps/server/AliasVault.Client/Main/Pages/Items/AddEdit.razor @@ -468,12 +468,9 @@ else { await base.OnInitializedAsync(); - if (EditMode) - { - BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["ViewItemBreadcrumb"], Url = $"/items/{Id}" }); - BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["EditItemBreadcrumb"] }); - } - else + // In edit mode, we'll build breadcrumbs after loading the item in LoadExistingCredential + // so we can include folder breadcrumbs + if (!EditMode) { BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["AddNewItemBreadcrumb"] }); } @@ -903,6 +900,15 @@ else Obj = ItemEdit.FromEntity(item); + // Build breadcrumbs with folder hierarchy + if (Obj.FolderId.HasValue) + { + await BuildFolderBreadcrumbsAsync(Obj.FolderId.Value); + } + + BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["ViewItemBreadcrumb"], Url = $"/items/{Id}" }); + BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["EditItemBreadcrumb"] }); + // Track initially visible fields foreach (var fieldDef in SystemFieldRegistry.GetFieldsForItemType(Obj.ItemType)) { diff --git a/apps/server/AliasVault.Client/Main/Pages/Items/Delete.razor b/apps/server/AliasVault.Client/Main/Pages/Items/Delete.razor index 412af1364..4ff4bfca6 100644 --- a/apps/server/AliasVault.Client/Main/Pages/Items/Delete.razor +++ b/apps/server/AliasVault.Client/Main/Pages/Items/Delete.razor @@ -54,8 +54,7 @@ else protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); - BreadcrumbItems.Add(new BreadcrumbItem { Url = "items/" + Id, DisplayName = Localizer["ViewItemBreadcrumb"] }); - BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["DeleteItemBreadcrumb"] }); + // Breadcrumb items will be added after loading the item in OnAfterRenderAsync } /// @@ -68,6 +67,15 @@ else // Load existing Obj, retrieve from service Obj = await ItemService.LoadEntryAsync(Id); + // Build breadcrumbs with folder hierarchy + if (Obj?.FolderId.HasValue == true) + { + await BuildFolderBreadcrumbsAsync(Obj.FolderId.Value); + } + + BreadcrumbItems.Add(new BreadcrumbItem { Url = "items/" + Id, DisplayName = Localizer["ViewItemBreadcrumb"] }); + BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["DeleteItemBreadcrumb"] }); + // Hide loading spinner IsLoading = false; diff --git a/apps/server/AliasVault.Client/Main/Pages/Items/Home.razor b/apps/server/AliasVault.Client/Main/Pages/Items/Home.razor index ada9931fb..92c9f0993 100644 --- a/apps/server/AliasVault.Client/Main/Pages/Items/Home.razor +++ b/apps/server/AliasVault.Client/Main/Pages/Items/Home.razor @@ -2,12 +2,14 @@ @page "/items/folder/{FolderId:guid}" @inherits MainBase @inject ItemService ItemService -@inject FolderService FolderService @using AliasVault.RazorComponents.Tables +@using AliasVault.RazorComponents.Models @using AliasVault.Client.Main.Models @using AliasVault.Client.Main.Components.Items @using AliasClientDb.Models @using AliasVault.Client.Main.Components.Folders +@using AliasVault.Client.Main.Utilities +@using AliasClientDb @using Microsoft.Extensions.Localization @implements IAsyncDisposable @@ -183,27 +185,30 @@ else } - @* Folders section - only show at root level when ShowFolders is enabled *@ - @if (!IsInFolder && !IsSearching && ShowFolders) + @* Folders section - show at root OR inside folders to display subfolders *@ + @if (!IsSearching && ShowFolders) {
- @foreach (var folder in Folders) + @foreach (var folder in CurrentLevelFolders) { } - + @if (CanCreateSubfolder) + { + + }
} @@ -339,10 +344,49 @@ else private List Items { get; set; } = new(); /// - /// Gets or sets the folders. + /// Gets or sets all folders in the system. /// private List Folders { get; set; } = new(); + /// + /// Gets all folders as Folder entities (for breadcrumb path computation). + /// + private List AllFolders => Folders.Select(f => new Folder + { + Id = f.Id, + Name = f.Name, + ParentFolderId = f.ParentFolderId, + Weight = f.Weight + }).ToList(); + + /// + /// Gets the folders to display at the current level (root folders if not in folder, subfolders if in folder). + /// + private List CurrentLevelFolders => + Folders.Where(f => f.ParentFolderId == FolderId).ToList(); + + /// + /// Gets whether we can create a subfolder at the current level. + /// Returns true if at root, or if current folder is not at max depth. + /// + private bool CanCreateSubfolder + { + get + { + if (!IsInFolder) + { + return true; // Can always create folders at root + } + + if (!FolderId.HasValue || AllFolders.Count == 0) + { + return false; + } + + return FolderTreeUtilities.CanHaveSubfolders(FolderId.Value, AllFolders); + } + } + /// /// Gets or sets the current folder name (when in folder view). /// @@ -765,11 +809,13 @@ else } /// - /// Create a new folder. + /// Create a new folder. If inside a folder, creates as subfolder. /// private async Task CreateFolderAsync(string folderName) { - var folderId = await FolderService.CreateAsync(folderName); + // Use current folder as parent if we're inside a folder, otherwise null for root + var parentFolderId = IsInFolder ? FolderId : null; + var folderId = await FolderService.CreateAsync(folderName, parentFolderId); if (folderId != Guid.Empty) { // Reload folders @@ -892,15 +938,21 @@ else var storedShowFolders = await LocalStorage.GetItemAsync(ShowFoldersStorageKey); ShowFolders = storedShowFolders ?? true; - // Load folder name if in folder view + // Load folder name and build breadcrumbs if in folder view if (FolderId.HasValue) { var folder = await FolderService.GetByIdAsync(FolderId.Value); CurrentFolderName = folder?.Name ?? string.Empty; + + // Build breadcrumbs for folder hierarchy using shared helper + BreadcrumbItems.RemoveAll(b => !b.ShowHomeIcon); + AddFolderBreadcrumbs(FolderId, AllFolders, Localizer["Items"], includeCurrentFolder: true); } else { CurrentFolderName = string.Empty; + // Clear breadcrumbs at root level (only show Home from base class) + BreadcrumbItems.RemoveAll(b => !b.ShowHomeIcon); } await LoadItemsAsync(); diff --git a/apps/server/AliasVault.Client/Main/Pages/Items/View.razor b/apps/server/AliasVault.Client/Main/Pages/Items/View.razor index 8ed636b6f..34e268003 100644 --- a/apps/server/AliasVault.Client/Main/Pages/Items/View.razor +++ b/apps/server/AliasVault.Client/Main/Pages/Items/View.razor @@ -1,7 +1,6 @@ @page "/items/{id:guid}" @inherits MainBase @inject ItemService ItemService -@inject FolderService FolderService @implements IAsyncDisposable @using Microsoft.Extensions.Localization @using AliasClientDb @@ -312,10 +311,10 @@ else Folder = await FolderService.GetByIdAsync(Item.FolderId.Value); } - // Add breadcrumb items - Home is already added by MainBase, add folder if present - if (Folder != null) + // Add breadcrumb items - Home is already added by MainBase, add folder hierarchy if present + if (Item.FolderId.HasValue) { - BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Folder.Name, Url = $"/items/folder/{Folder.Id}" }); + await BuildFolderBreadcrumbsAsync(Item.FolderId.Value); } BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["ViewItemBreadcrumb"] }); diff --git a/apps/server/AliasVault.Client/Main/Pages/MainBase.cs b/apps/server/AliasVault.Client/Main/Pages/MainBase.cs index 9957720a9..8f28c101d 100644 --- a/apps/server/AliasVault.Client/Main/Pages/MainBase.cs +++ b/apps/server/AliasVault.Client/Main/Pages/MainBase.cs @@ -7,6 +7,9 @@ namespace AliasVault.Client.Main.Pages; +using System.Collections.Generic; +using AliasClientDb; +using AliasVault.Client.Main.Utilities; using AliasVault.Client.Services; using AliasVault.Client.Services.Auth; using AliasVault.RazorComponents.Models; @@ -96,6 +99,12 @@ public abstract class MainBase : OwningComponentBase [Inject] public ILocalStorageService LocalStorage { get; set; } = null!; + /// + /// Gets or sets the FolderService. + /// + [Inject] + public FolderService FolderService { get; set; } = null!; + /// /// Gets the SharedLocalizer. This is used to access shared resource translations like buttons, etc. /// @@ -195,6 +204,104 @@ public abstract class MainBase : OwningComponentBase } } + /// + /// Builds breadcrumb navigation for folder hierarchy using async folder loading. + /// This helper method recursively builds folder breadcrumbs from the given folder up to the root. + /// + /// The folder ID to build breadcrumbs for. + /// A representing the asynchronous operation. + protected async Task BuildFolderBreadcrumbsAsync(Guid folderId) + { + // Load all folders to build the path + var foldersWithCounts = await FolderService.GetAllWithCountsAsync(); + + // Convert FolderWithCount to Folder for use with FolderTreeUtilities + var allFolders = foldersWithCounts.Select(f => new Folder + { + Id = f.Id, + Name = f.Name, + ParentFolderId = f.ParentFolderId, + Weight = f.Weight, + }).ToList(); + + // Get the folder ID path from root to current folder + var folderIdPath = FolderTreeUtilities.GetFolderIdPath(folderId, allFolders); + + if (folderIdPath.Count == 0) + { + return; + } + + // Get localized "Folder" text + var folderLabel = SharedLocalizer["Folder"]; + + // Add breadcrumb for each folder in the path (from root to current) + foreach (var currentFolderId in folderIdPath) + { + var folder = allFolders.FirstOrDefault(f => f.Id == currentFolderId); + if (folder != null) + { + BreadcrumbItems.Add(new BreadcrumbItem + { + DisplayName = $"{folderLabel}: {folder.Name}", + Url = $"/items/folder/{folder.Id}", + }); + } + } + } + + /// + /// Builds breadcrumb navigation for folder hierarchy and adds it to BreadcrumbItems. + /// This helper method can be called from any page to add folder breadcrumbs. + /// + /// The folder ID to build breadcrumbs for. + /// List of all folders (for path computation). + /// Label for the "Items" breadcrumb (default: "Items" from localization). + /// Whether to include the current folder as the last breadcrumb (default: true). + protected void AddFolderBreadcrumbs(Guid? folderId, List allFolders, string? itemsLabel = null, bool includeCurrentFolder = true) + { + if (!folderId.HasValue || allFolders.Count == 0) + { + return; + } + + // Get the folder path (list of folder names from root to current) + var folderPath = FolderTreeUtilities.GetFolderPath(folderId, allFolders); + var folderIdPath = FolderTreeUtilities.GetFolderIdPath(folderId, allFolders); + + if (folderPath.Count == 0) + { + return; + } + + // Add breadcrumb for "Items" (vault home) + var itemsLabelText = itemsLabel ?? SharedLocalizer["Items"]; + BreadcrumbItems.Add(new BreadcrumbItem + { + DisplayName = itemsLabelText, + Url = "/items", + }); + + // Determine how many folders to add as breadcrumbs + int endIndex = includeCurrentFolder ? folderPath.Count : folderPath.Count - 1; + + // Add breadcrumb for each folder in the path + for (int i = 0; i < endIndex; i++) + { + var currentFolderId = folderIdPath[i]; + var folderName = folderPath[i]; + + // Last item should not have a URL if includeCurrentFolder is true + bool isLastItem = includeCurrentFolder && i == folderPath.Count - 1; + + BreadcrumbItems.Add(new BreadcrumbItem + { + DisplayName = folderName, + Url = isLastItem ? null : $"/items/folder/{currentFolderId}", + }); + } + } + /// /// Checks if the encryption key is set. If not, redirect to the unlock screen /// where the user can re-enter the master password so the encryption key gets refreshed. diff --git a/apps/server/AliasVault.Client/Resources/Pages/Main/Items/Home.en.resx b/apps/server/AliasVault.Client/Resources/Pages/Main/Items/Home.en.resx index 886e65570..d4126444f 100644 --- a/apps/server/AliasVault.Client/Resources/Pages/Main/Items/Home.en.resx +++ b/apps/server/AliasVault.Client/Resources/Pages/Main/Items/Home.en.resx @@ -68,6 +68,10 @@ Find all of your items below. Page description text + + Items + Items label used in breadcrumb navigation + diff --git a/apps/server/AliasVault.Client/wwwroot/css/tailwind.css b/apps/server/AliasVault.Client/wwwroot/css/tailwind.css index ec623fe51..b25b4e5fd 100644 --- a/apps/server/AliasVault.Client/wwwroot/css/tailwind.css +++ b/apps/server/AliasVault.Client/wwwroot/css/tailwind.css @@ -1317,6 +1317,11 @@ video { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } +.rotate-90 { + --tw-rotate: 90deg; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + .transform { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } @@ -1439,6 +1444,10 @@ video { align-items: baseline; } +.items-stretch { + align-items: stretch; +} + .justify-start { justify-content: flex-start; } @@ -3676,6 +3685,11 @@ video { color: rgb(147 197 253 / var(--tw-text-opacity)); } +.dark\:text-gray-700:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(55 65 81 / var(--tw-text-opacity)); +} + .dark\:placeholder-gray-400:is(.dark *)::-moz-placeholder { --tw-placeholder-opacity: 1; color: rgb(156 163 175 / var(--tw-placeholder-opacity));