Show subfolders in all item breadcrumb paths (#1695)

This commit is contained in:
Leendert de Borst
2026-03-31 09:16:40 +02:00
parent ce4f3a3acd
commit ec7fc4d0c8
7 changed files with 223 additions and 33 deletions

View File

@@ -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))
{

View File

@@ -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
}
/// <inheritdoc />
@@ -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;

View File

@@ -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
</div>
}
@* 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)
{
<div class="flex flex-wrap items-center gap-2 mb-4">
@foreach (var folder in Folders)
@foreach (var folder in CurrentLevelFolders)
{
<FolderPill Folder="@folder" OnClick="() => NavigateToFolder(folder.Id)" />
}
<button @onclick="ShowCreateFolderModal" class="@GetAddFolderButtonClass()">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<svg class="w-3.5 h-3.5 -ml-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
@if (Folders.Count == 0)
{
<span>@Localizer["NewFolder"]</span>
}
</button>
@if (CanCreateSubfolder)
{
<button @onclick="ShowCreateFolderModal" class="@GetAddFolderButtonClass()">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<svg class="w-3.5 h-3.5 -ml-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
@if (CurrentLevelFolders.Count == 0)
{
<span>@Localizer["NewFolder"]</span>
}
</button>
}
</div>
}
@@ -339,10 +344,49 @@ else
private List<ItemListEntry> Items { get; set; } = new();
/// <summary>
/// Gets or sets the folders.
/// Gets or sets all folders in the system.
/// </summary>
private List<FolderWithCount> Folders { get; set; } = new();
/// <summary>
/// Gets all folders as Folder entities (for breadcrumb path computation).
/// </summary>
private List<Folder> AllFolders => Folders.Select(f => new Folder
{
Id = f.Id,
Name = f.Name,
ParentFolderId = f.ParentFolderId,
Weight = f.Weight
}).ToList();
/// <summary>
/// Gets the folders to display at the current level (root folders if not in folder, subfolders if in folder).
/// </summary>
private List<FolderWithCount> CurrentLevelFolders =>
Folders.Where(f => f.ParentFolderId == FolderId).ToList();
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>
/// Gets or sets the current folder name (when in folder view).
/// </summary>
@@ -765,11 +809,13 @@ else
}
/// <summary>
/// Create a new folder.
/// Create a new folder. If inside a folder, creates as subfolder.
/// </summary>
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<bool?>(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();

View File

@@ -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"] });

View File

@@ -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!;
/// <summary>
/// Gets or sets the FolderService.
/// </summary>
[Inject]
public FolderService FolderService { get; set; } = null!;
/// <summary>
/// Gets the SharedLocalizer. This is used to access shared resource translations like buttons, etc.
/// </summary>
@@ -195,6 +204,104 @@ public abstract class MainBase : OwningComponentBase
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="folderId">The folder ID to build breadcrumbs for.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
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}",
});
}
}
}
/// <summary>
/// Builds breadcrumb navigation for folder hierarchy and adds it to BreadcrumbItems.
/// This helper method can be called from any page to add folder breadcrumbs.
/// </summary>
/// <param name="folderId">The folder ID to build breadcrumbs for.</param>
/// <param name="allFolders">List of all folders (for path computation).</param>
/// <param name="itemsLabel">Label for the "Items" breadcrumb (default: "Items" from localization).</param>
/// <param name="includeCurrentFolder">Whether to include the current folder as the last breadcrumb (default: true).</param>
protected void AddFolderBreadcrumbs(Guid? folderId, List<Folder> 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}",
});
}
}
/// <summary>
/// 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.

View File

@@ -68,6 +68,10 @@
<value>Find all of your items below.</value>
<comment>Page description text</comment>
</data>
<data name="Items" xml:space="preserve">
<value>Items</value>
<comment>Items label used in breadcrumb navigation</comment>
</data>
<!-- Settings Dropdown -->
<data name="ViewModeLabel" xml:space="preserve">

View File

@@ -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));