Add folder selector component (#1404)

This commit is contained in:
Leendert de Borst
2025-12-19 15:48:42 +01:00
parent 81316dab92
commit bcbc66e010
9 changed files with 277 additions and 16 deletions

View File

@@ -1,12 +1,12 @@
@using AliasVault.Client.Main.Models
@* FolderPill component - displays a folder as a compact clickable pill *@
<button @onclick="OnClick" class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-full bg-gray-100 dark:bg-gray-700/50 hover:bg-gray-200 dark:hover:bg-gray-600/50 transition-colors focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-1 dark:focus:ring-offset-gray-800">
<svg class="w-3.5 h-3.5 text-orange-500 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
<button @onclick="OnClick" class="inline-flex items-center gap-2 px-3.5 py-2 text-sm rounded-lg bg-gray-100 dark:bg-gray-700/50 hover:bg-gray-200 dark:hover:bg-gray-600/50 transition-colors focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-1 dark:focus:ring-offset-gray-800">
<svg class="w-4 h-4 text-orange-500 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z"/>
</svg>
<span class="text-gray-700 dark:text-gray-300 truncate max-w-[120px]" title="@Folder.Name">@Folder.Name</span>
<span class="text-gray-400 dark:text-gray-500 text-[10px]">@Folder.ItemCount</span>
<span class="text-gray-700 dark:text-gray-300 truncate max-w-[150px]" title="@Folder.Name">@Folder.Name</span>
<span class="text-gray-400 dark:text-gray-500 text-xs">@Folder.ItemCount</span>
</button>
@code {

View File

@@ -0,0 +1,170 @@
@using AliasVault.Client.Main.Components.Layout
@using AliasVault.Client.Main.Models
@using Microsoft.Extensions.Localization
@inject FolderService FolderService
@inject IStringLocalizerFactory LocalizerFactory
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700">
<svg class="w-5 h-5 text-gray-600 dark:text-gray-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z"/>
</svg>
</div>
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">@Localizer["FolderLabel"]</h4>
<p class="text-sm text-gray-500 dark:text-gray-400">
@if (SelectedFolderId.HasValue)
{
var folder = Folders.FirstOrDefault(f => f.Id == SelectedFolderId.Value);
<span class="text-primary-600 dark:text-primary-400">@folder?.Name</span>
}
else
{
<span>@Localizer["NoFolder"]</span>
}
</p>
</div>
</div>
<button type="button"
@onclick="OpenFolderModal"
@onclick:preventDefault="true"
class="px-3 py-1.5 text-sm font-medium text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-md transition-colors">
@Localizer["ChangeFolder"]
</button>
</div>
</div>
@* Folder Selection Modal *@
<FormModal
IsOpen="ShowFolderModal"
Title="@Localizer["SelectFolderTitle"]"
ShowDefaultFooter="false"
MaxWidth="sm"
OnClose="CloseFolderModal">
<Icon>
<svg class="h-6 w-6 text-orange-600 dark:text-orange-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z"/>
</svg>
</Icon>
<ChildContent>
<div class="space-y-1 max-h-64 overflow-y-auto -mx-2">
@* No Folder Option *@
<button type="button"
@onclick="() => SelectFolder(null)"
@onclick:preventDefault="true"
class="@GetFolderButtonClass(null)">
<svg class="@GetFolderIconClass(null)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
<span class="font-medium">&mdash;</span>
@if (!SelectedFolderId.HasValue)
{
<svg class="w-5 h-5 ml-auto text-primary-600 dark:text-primary-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
}
</button>
@* Folder Options *@
@foreach (var folder in Folders)
{
<button type="button"
@onclick="() => SelectFolder(folder.Id)"
@onclick:preventDefault="true"
class="@GetFolderButtonClass(folder.Id)">
<svg class="@GetFolderIconClass(folder.Id)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
<span class="font-medium">@folder.Name</span>
@if (folder.ItemCount > 0)
{
<span class="text-xs text-gray-400 dark:text-gray-500">(@folder.ItemCount)</span>
}
@if (SelectedFolderId == folder.Id)
{
<svg class="w-5 h-5 ml-auto text-primary-600 dark:text-primary-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
}
</button>
}
@if (Folders.Count == 0)
{
<p class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 italic">
@Localizer["NoFoldersAvailable"]
</p>
}
</div>
</ChildContent>
</FormModal>
@code {
private IStringLocalizer Localizer => LocalizerFactory.Create("Components.Main.Items.FolderSelector", "AliasVault.Client");
/// <summary>
/// Gets or sets the currently selected folder ID.
/// </summary>
[Parameter]
public Guid? SelectedFolderId { get; set; }
/// <summary>
/// Gets or sets the callback when folder selection changes.
/// </summary>
[Parameter]
public EventCallback<Guid?> SelectedFolderIdChanged { get; set; }
private List<FolderWithCount> Folders { get; set; } = [];
private bool ShowFolderModal { get; set; }
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await LoadFoldersAsync();
}
private async Task LoadFoldersAsync()
{
Folders = await FolderService.GetAllWithCountsAsync();
}
private void OpenFolderModal()
{
ShowFolderModal = true;
}
private Task CloseFolderModal()
{
ShowFolderModal = false;
return Task.CompletedTask;
}
private async Task SelectFolder(Guid? folderId)
{
SelectedFolderId = folderId;
await SelectedFolderIdChanged.InvokeAsync(folderId);
ShowFolderModal = false;
}
private string GetFolderButtonClass(Guid? folderId)
{
var isSelected = SelectedFolderId == folderId;
var baseClass = "w-full px-3 py-2 text-left rounded-md flex items-center gap-3 transition-colors";
if (isSelected)
{
return $"{baseClass} bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300";
}
return $"{baseClass} text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700";
}
private string GetFolderIconClass(Guid? folderId)
{
var isSelected = SelectedFolderId == folderId;
return isSelected ? "w-5 h-5 text-primary-500" : "w-5 h-5 text-gray-400";
}
}

View File

@@ -1,10 +1,3 @@
//-----------------------------------------------------------------------
// <copyright file="FormModal.razor" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
@using Microsoft.Extensions.Localization
@* FormModal component - generic modal for displaying forms and content *@

View File

@@ -55,6 +55,11 @@ public sealed class ItemEdit
/// </summary>
public byte[]? ServiceLogo { get; set; }
/// <summary>
/// Gets or sets the folder ID.
/// </summary>
public Guid? FolderId { get; set; }
/// <summary>
/// Gets or sets the username field.
/// </summary>
@@ -173,6 +178,7 @@ public sealed class ItemEdit
ServiceUrl = ItemService.GetFieldValue(item, FieldKey.LoginUrl),
LogoId = item.LogoId,
ServiceLogo = item.Logo?.FileData,
FolderId = item.FolderId,
Username = ItemService.GetFieldValue(item, FieldKey.LoginUsername) ?? string.Empty,
Password = ItemService.GetFieldValue(item, FieldKey.LoginPassword) ?? string.Empty,
Email = ItemService.GetFieldValue(item, FieldKey.LoginEmail) ?? string.Empty,
@@ -223,6 +229,7 @@ public sealed class ItemEdit
Name = ServiceName,
ItemType = ItemType,
LogoId = LogoId,
FolderId = FolderId,
Attachments = Attachments,
TotpCodes = TotpCodes,
};

View File

@@ -40,6 +40,9 @@ else
ShowDropdownChanged="@((show) => ShowTypeDropdown = show)"
OnRegenerateAlias="@GenerateRandomAlias" />
@* Folder Selector *@
<FolderSelector @bind-SelectedFolderId="Obj.FolderId" />
@* Service/Name Section - Always shown *@
<div class="p-4 mb-4 bg-white border-2 border-primary-600 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">@GetNameSectionTitle()</h3>

View File

@@ -157,10 +157,10 @@ else
<FolderPill Folder="@folder" OnClick="() => NavigateToFolder(folder.Id)" />
}
<button @onclick="ShowCreateFolderModal" class="@GetAddFolderButtonClass()">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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 h-3 -ml-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<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>
@@ -490,10 +490,10 @@ else
{
if (Folders.Count > 0)
{
return "inline-flex items-center gap-1 px-2.5 py-1.5 text-xs rounded-full transition-colors focus:outline-none text-gray-500 dark:text-gray-400 hover:text-orange-600 dark:hover:text-orange-400 hover:bg-gray-100 dark:hover:bg-gray-700/50";
return "inline-flex items-center gap-1.5 px-3.5 py-2 text-sm rounded-lg transition-colors focus:outline-none text-gray-500 dark:text-gray-400 hover:text-orange-600 dark:hover:text-orange-400 hover:bg-gray-100 dark:hover:bg-gray-700/50";
}
return "inline-flex items-center gap-1 px-2.5 py-1.5 text-xs rounded-full transition-colors focus:outline-none text-gray-400 dark:text-gray-500 border border-dashed border-gray-300 dark:border-gray-600 hover:border-orange-400 dark:hover:border-orange-500 hover:text-orange-600 dark:hover:text-orange-400";
return "inline-flex items-center gap-1.5 px-3.5 py-2 text-sm rounded-lg transition-colors focus:outline-none text-gray-400 dark:text-gray-500 border border-dashed border-gray-300 dark:border-gray-600 hover:border-orange-400 dark:hover:border-orange-500 hover:text-orange-600 dark:hover:text-orange-400";
}
/// <summary>

View File

@@ -1,6 +1,7 @@
@page "/items/{id:guid}"
@inherits MainBase
@inject ItemService ItemService
@inject FolderService FolderService
@implements IAsyncDisposable
@using Microsoft.Extensions.Localization
@using AliasClientDb
@@ -63,6 +64,15 @@ else
}
</div>
}
@if (Folder != null)
{
<a href="/items/folder/@Folder.Id" class="inline-flex items-center gap-2 mt-2 px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
<svg class="w-4 h-4 text-orange-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z"/>
</svg>
<span>@Folder.Name</span>
</a>
}
</div>
</div>
</div>
@@ -245,6 +255,7 @@ else
private bool IsLoading { get; set; } = true;
private Item? Item { get; set; } = new();
private Folder? Folder { get; set; }
private Dictionary<FieldCategory, List<DisplayField>> GroupedFields { get; set; } = new();
private List<string> UrlValues { get; set; } = new();
@@ -252,7 +263,7 @@ else
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["ViewItemBreadcrumb"] });
// Breadcrumb items are added dynamically in LoadEntryAsync after folder is loaded
}
/// <inheritdoc />
@@ -281,6 +292,21 @@ else
return;
}
// Load the folder if the item is in one
Folder = null;
if (Item.FolderId.HasValue)
{
Folder = await FolderService.GetByIdAsync(Item.FolderId.Value);
}
// Add breadcrumb items - Home is already added by MainBase, add folder if present
if (Folder != null)
{
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Folder.Name, Url = $"/items/folder/{Folder.Id}" });
}
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["ViewItemBreadcrumb"] });
// Group fields by category for display
GroupedFields = FieldGrouper.GroupByCategory(Item);

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<data name="FolderLabel">
<value>Folder</value>
<comment>Label for the folder section</comment>
</data>
<data name="NoFolder">
<value>No folder</value>
<comment>Text shown when no folder is selected</comment>
</data>
<data name="ChangeFolder">
<value>Change</value>
<comment>Button text to change folder</comment>
</data>
<data name="SelectFolderTitle">
<value>Select Folder</value>
<comment>Title of the folder selection modal</comment>
</data>
<data name="NoFoldersAvailable">
<value>No folders available. Create a folder from the vault home page.</value>
<comment>Message shown when no folders exist</comment>
</data>
</root>

View File

@@ -779,6 +779,11 @@ video {
margin-bottom: 1.5rem;
}
.-mx-2 {
margin-left: -0.5rem;
margin-right: -0.5rem;
}
.-ml-0 {
margin-left: -0px;
}
@@ -2086,6 +2091,11 @@ video {
padding-bottom: 2rem;
}
.px-3\.5 {
padding-left: 0.875rem;
padding-right: 0.875rem;
}
.pb-28 {
padding-bottom: 7rem;
}
@@ -2791,6 +2801,11 @@ video {
background-color: rgb(244 149 65 / var(--tw-bg-opacity));
}
.hover\:bg-primary-50:hover {
--tw-bg-opacity: 1;
background-color: rgb(255 224 150 / var(--tw-bg-opacity));
}
.hover\:from-primary-600:hover {
--tw-gradient-from: #d68338 var(--tw-gradient-from-position);
--tw-gradient-to: rgb(214 131 56 / 0) var(--tw-gradient-to-position);
@@ -3609,6 +3624,10 @@ video {
background-color: rgb(123 74 30 / 0.4);
}
.dark\:hover\:bg-primary-900\/20:hover:is(.dark *) {
background-color: rgb(123 74 30 / 0.2);
}
.dark\:hover\:from-primary-500:hover:is(.dark *) {
--tw-gradient-from: #f49541 var(--tw-gradient-from-position);
--tw-gradient-to: rgb(244 149 65 / 0) var(--tw-gradient-to-position);