diff --git a/apps/server/AliasVault.Client/Main/Components/Items/FolderSelector.razor b/apps/server/AliasVault.Client/Main/Components/Items/FolderSelector.razor index 32fa5a913..a0fd7a782 100644 --- a/apps/server/AliasVault.Client/Main/Components/Items/FolderSelector.razor +++ b/apps/server/AliasVault.Client/Main/Components/Items/FolderSelector.razor @@ -1,5 +1,7 @@ @using AliasVault.Client.Main.Components.Layout @using AliasVault.Client.Main.Models +@using AliasVault.Client.Main.Utilities +@using AliasClientDb @using Microsoft.Extensions.Localization @inject FolderService FolderService @@ -43,7 +45,7 @@ SelectFolder(null)" @onclick:preventDefault="true" - class="@GetFolderButtonClass(null)"> + class="@GetFolderButtonClass(null, false, false) w-full"> @@ -56,31 +58,64 @@ } - @* Folder Options *@ - @foreach (var folder in Folders) + @* Folder Options - Hierarchical Tree View *@ + @foreach (var node in FlatFolderTree) { - SelectFolder(folder.Id)" - @onclick:preventDefault="true" - class="@GetFolderButtonClass(folder.Id)"> - - - - @folder.Name - @if (folder.ItemCount > 0) + var canSelect = ExcludeFolderId == null || node.Folder.Id != ExcludeFolderId.Value; + var isDisabled = !canSelect; + var hasChildren = HasChildren(node); + var isExpanded = IsExpanded(node.Folder.Id); + + + @* Expand/Collapse Button *@ + @if (hasChildren) { - (@folder.ItemCount) + ToggleFolderExpansion(node.Folder.Id)" + @onclick:preventDefault="true" + @onclick:stopPropagation="true" + class="px-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-l-md transition-colors flex items-center" + style="margin-left: @(node.Depth * 1.5)rem"> + + + + } - @if (SelectedFolderId == folder.Id) + else { - - - + } - + + @* Folder Selection Button *@ + SelectFolder(node.Folder.Id)" + @onclick:preventDefault="true" + disabled="@isDisabled" + class="@GetFolderButtonClass(node.Folder.Id, isDisabled, hasChildren)"> + + + + + @node.Folder.Name + @if (ItemCounts.TryGetValue(node.Folder.Id, out var count) && count > 0) + { + (@count) + } + + @if (SelectedFolderId == node.Folder.Id) + { + + + + } + + } - @if (Folders.Count == 0) + @if (FlatFolderTree.Count == 0) { @Localizer["NoFoldersAvailable"] @@ -105,13 +140,42 @@ [Parameter] public EventCallback SelectedFolderIdChanged { get; set; } - private List Folders { get; set; } = []; + /// + /// Optional folder ID to exclude from the selector (useful when moving folders to prevent circular references). + /// + [Parameter] + public Guid? ExcludeFolderId { get; set; } + + /// + /// All folders with counts. + /// + [Parameter] + public List Folders { get; set; } = []; + + private List FlatFolderTree { get; set; } = []; + private List FullFolderTree { get; set; } = []; + private HashSet ExpandedFolderIds { get; set; } = new(); + private Dictionary ItemCounts { get; set; } = new(); private bool ShowFolderModal { get; set; } /// protected override async Task OnInitializedAsync() { - await LoadFoldersAsync(); + if (Folders.Count == 0) + { + await LoadFoldersAsync(); + } + + BuildFolderTree(); + } + + /// + protected override void OnParametersSet() + { + if (Folders.Count > 0) + { + BuildFolderTree(); + } } private async Task LoadFoldersAsync() @@ -119,6 +183,92 @@ Folders = await FolderService.GetAllWithCountsAsync(); } + private void BuildFolderTree() + { + // Convert FolderWithCount to Folder entities for tree building + var folderEntities = Folders.Select(f => new Folder + { + Id = f.Id, + Name = f.Name, + ParentFolderId = f.ParentFolderId, + Weight = f.Weight + }).ToList(); + + // Build the tree structure + FullFolderTree = FolderTreeUtilities.BuildFolderTree(folderEntities); + + // Auto-expand to the currently selected folder + if (SelectedFolderId.HasValue) + { + var pathToSelected = FolderTreeUtilities.GetFolderIdPath(SelectedFolderId.Value, folderEntities); + foreach (var folderId in pathToSelected) + { + // Expand all parent folders to show the selected folder + if (folderId != SelectedFolderId.Value) + { + ExpandedFolderIds.Add(folderId); + } + } + } + + // Update the flattened tree based on expanded state + UpdateFlattenedTree(); + + // Store item counts for quick lookup + ItemCounts = Folders.ToDictionary(f => f.Id, f => f.ItemCount); + } + + private void UpdateFlattenedTree() + { + var result = new List(); + + void Traverse(List nodes) + { + foreach (var node in nodes) + { + if (ExcludeFolderId.HasValue && node.Folder.Id == ExcludeFolderId.Value) + { + continue; // Skip excluded folder and its children + } + + result.Add(node); + + // Only show children if this folder is expanded + if (ExpandedFolderIds.Contains(node.Folder.Id) && node.Children.Count > 0) + { + Traverse(node.Children); + } + } + } + + Traverse(FullFolderTree); + FlatFolderTree = result; + } + + private void ToggleFolderExpansion(Guid folderId) + { + if (ExpandedFolderIds.Contains(folderId)) + { + ExpandedFolderIds.Remove(folderId); + } + else + { + ExpandedFolderIds.Add(folderId); + } + + UpdateFlattenedTree(); + } + + private bool HasChildren(FolderTreeNode node) + { + return node.Children.Count > 0; + } + + private bool IsExpanded(Guid folderId) + { + return ExpandedFolderIds.Contains(folderId); + } + private void OpenFolderModal() { ShowFolderModal = true; @@ -137,10 +287,16 @@ ShowFolderModal = false; } - private string GetFolderButtonClass(Guid? folderId) + private string GetFolderButtonClass(Guid? folderId, bool isDisabled = false, bool hasChevron = false) { var isSelected = SelectedFolderId == folderId; - var baseClass = "w-full px-3 py-2 text-left rounded-md flex items-center gap-3 transition-colors"; + var roundingClass = hasChevron ? "rounded-r-md" : "rounded-md"; + var baseClass = $"flex-1 px-3 py-2 text-left {roundingClass} flex items-center transition-colors"; + + if (isDisabled) + { + return $"{baseClass} bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 cursor-not-allowed opacity-50"; + } if (isSelected) { @@ -150,8 +306,13 @@ return $"{baseClass} text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"; } - private string GetFolderIconClass(Guid? folderId) + private string GetFolderIconClass(Guid? folderId, bool isDisabled = false) { + if (isDisabled) + { + return "w-5 h-5 text-gray-300 dark:text-gray-700"; + } + var isSelected = SelectedFolderId == folderId; return isSelected ? "w-5 h-5 text-primary-500" : "w-5 h-5 text-gray-400"; } diff --git a/apps/server/AliasVault.Client/Resources/Components/Main/Items/FolderSelector.en.resx b/apps/server/AliasVault.Client/Resources/Components/Main/Items/FolderSelector.en.resx index 4e9a01bc5..23283ad7a 100644 --- a/apps/server/AliasVault.Client/Resources/Components/Main/Items/FolderSelector.en.resx +++ b/apps/server/AliasVault.Client/Resources/Components/Main/Items/FolderSelector.en.resx @@ -32,4 +32,8 @@ No folders available. Create a folder from the vault home page. Message shown when no folders exist + + max depth + Label shown for folders at maximum nesting depth +
@Localizer["NoFoldersAvailable"] @@ -105,13 +140,42 @@ [Parameter] public EventCallback SelectedFolderIdChanged { get; set; } - private List Folders { get; set; } = []; + /// + /// Optional folder ID to exclude from the selector (useful when moving folders to prevent circular references). + /// + [Parameter] + public Guid? ExcludeFolderId { get; set; } + + /// + /// All folders with counts. + /// + [Parameter] + public List Folders { get; set; } = []; + + private List FlatFolderTree { get; set; } = []; + private List FullFolderTree { get; set; } = []; + private HashSet ExpandedFolderIds { get; set; } = new(); + private Dictionary ItemCounts { get; set; } = new(); private bool ShowFolderModal { get; set; } /// protected override async Task OnInitializedAsync() { - await LoadFoldersAsync(); + if (Folders.Count == 0) + { + await LoadFoldersAsync(); + } + + BuildFolderTree(); + } + + /// + protected override void OnParametersSet() + { + if (Folders.Count > 0) + { + BuildFolderTree(); + } } private async Task LoadFoldersAsync() @@ -119,6 +183,92 @@ Folders = await FolderService.GetAllWithCountsAsync(); } + private void BuildFolderTree() + { + // Convert FolderWithCount to Folder entities for tree building + var folderEntities = Folders.Select(f => new Folder + { + Id = f.Id, + Name = f.Name, + ParentFolderId = f.ParentFolderId, + Weight = f.Weight + }).ToList(); + + // Build the tree structure + FullFolderTree = FolderTreeUtilities.BuildFolderTree(folderEntities); + + // Auto-expand to the currently selected folder + if (SelectedFolderId.HasValue) + { + var pathToSelected = FolderTreeUtilities.GetFolderIdPath(SelectedFolderId.Value, folderEntities); + foreach (var folderId in pathToSelected) + { + // Expand all parent folders to show the selected folder + if (folderId != SelectedFolderId.Value) + { + ExpandedFolderIds.Add(folderId); + } + } + } + + // Update the flattened tree based on expanded state + UpdateFlattenedTree(); + + // Store item counts for quick lookup + ItemCounts = Folders.ToDictionary(f => f.Id, f => f.ItemCount); + } + + private void UpdateFlattenedTree() + { + var result = new List(); + + void Traverse(List nodes) + { + foreach (var node in nodes) + { + if (ExcludeFolderId.HasValue && node.Folder.Id == ExcludeFolderId.Value) + { + continue; // Skip excluded folder and its children + } + + result.Add(node); + + // Only show children if this folder is expanded + if (ExpandedFolderIds.Contains(node.Folder.Id) && node.Children.Count > 0) + { + Traverse(node.Children); + } + } + } + + Traverse(FullFolderTree); + FlatFolderTree = result; + } + + private void ToggleFolderExpansion(Guid folderId) + { + if (ExpandedFolderIds.Contains(folderId)) + { + ExpandedFolderIds.Remove(folderId); + } + else + { + ExpandedFolderIds.Add(folderId); + } + + UpdateFlattenedTree(); + } + + private bool HasChildren(FolderTreeNode node) + { + return node.Children.Count > 0; + } + + private bool IsExpanded(Guid folderId) + { + return ExpandedFolderIds.Contains(folderId); + } + private void OpenFolderModal() { ShowFolderModal = true; @@ -137,10 +287,16 @@ ShowFolderModal = false; } - private string GetFolderButtonClass(Guid? folderId) + private string GetFolderButtonClass(Guid? folderId, bool isDisabled = false, bool hasChevron = false) { var isSelected = SelectedFolderId == folderId; - var baseClass = "w-full px-3 py-2 text-left rounded-md flex items-center gap-3 transition-colors"; + var roundingClass = hasChevron ? "rounded-r-md" : "rounded-md"; + var baseClass = $"flex-1 px-3 py-2 text-left {roundingClass} flex items-center transition-colors"; + + if (isDisabled) + { + return $"{baseClass} bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 cursor-not-allowed opacity-50"; + } if (isSelected) { @@ -150,8 +306,13 @@ return $"{baseClass} text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"; } - private string GetFolderIconClass(Guid? folderId) + private string GetFolderIconClass(Guid? folderId, bool isDisabled = false) { + if (isDisabled) + { + return "w-5 h-5 text-gray-300 dark:text-gray-700"; + } + var isSelected = SelectedFolderId == folderId; return isSelected ? "w-5 h-5 text-primary-500" : "w-5 h-5 text-gray-400"; } diff --git a/apps/server/AliasVault.Client/Resources/Components/Main/Items/FolderSelector.en.resx b/apps/server/AliasVault.Client/Resources/Components/Main/Items/FolderSelector.en.resx index 4e9a01bc5..23283ad7a 100644 --- a/apps/server/AliasVault.Client/Resources/Components/Main/Items/FolderSelector.en.resx +++ b/apps/server/AliasVault.Client/Resources/Components/Main/Items/FolderSelector.en.resx @@ -32,4 +32,8 @@ No folders available. Create a folder from the vault home page. Message shown when no folders exist + + max depth + Label shown for folders at maximum nesting depth +