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