Update AddEdit UI (#1404)

This commit is contained in:
Leendert de Borst
2025-12-09 22:27:21 +01:00
parent c8fa9255cf
commit a5d993f9ef
2 changed files with 571 additions and 85 deletions

View File

@@ -15,12 +15,68 @@ import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsConte
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate';
import { IdentityHelperUtils, CreateIdentityGenerator, convertAgeRangeToBirthdateOptions } from '@/utils/dist/shared/identity-generator';
import type { Item, ItemField, ItemType, FieldType } from '@/utils/dist/shared/models/vault';
import { getSystemFieldsForItemType } from '@/utils/dist/shared/models/vault';
import { CreatePasswordGenerator } from '@/utils/dist/shared/password-generator';
// Valid item types from the shared model
const VALID_ITEM_TYPES: ItemType[] = ['Login', 'CreditCard', 'Identity', 'Note'];
// Default item type for new items
const DEFAULT_ITEM_TYPE: ItemType = 'Login';
/**
* Item type option configuration.
*/
type ItemTypeOption = {
type: ItemType;
titleKey: string;
iconSvg: React.ReactNode;
};
/**
* Available item type options with icons.
*/
const ITEM_TYPE_OPTIONS: ItemTypeOption[] = [
{
type: 'Login',
titleKey: 'itemTypes.login.title',
iconSvg: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
)
},
{
type: 'CreditCard',
titleKey: 'itemTypes.creditCard.title',
iconSvg: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
)
},
{
type: 'Identity',
titleKey: 'itemTypes.identity.title',
iconSvg: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
)
},
{
type: 'Note',
titleKey: 'itemTypes.note.title',
iconSvg: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
)
}
];
/**
* Temporary custom field definition (before persisting to database)
*/
@@ -69,6 +125,18 @@ const ItemAddEdit: React.FC = () => {
// Folder selection state
const [folders, setFolders] = useState<Array<{ Id: string; Name: string }>>([]);
// Type selector dropdown state (for create mode)
const [showTypeDropdown, setShowTypeDropdown] = useState(false);
// Alias fields visibility state (for Login type - hidden by default, shown when user adds it)
const [showAliasFields, setShowAliasFields] = useState(false);
// Notes field visibility state (hidden by default, shown when user adds it)
const [showNotes, setShowNotes] = useState(false);
// Add menu dropdown state (unified + button)
const [showAddMenu, setShowAddMenu] = useState(false);
/**
* Get all applicable system fields for the current item type.
* These are sorted by DefaultDisplayOrder.
@@ -80,13 +148,36 @@ const ItemAddEdit: React.FC = () => {
return getSystemFieldsForItemType(item.ItemType);
}, [item]);
/**
* Fields that should be shown inline with service name (like login.url).
* These are Login category fields that start with "login." but aren't username/password.
*/
const serviceInlineFields = useMemo(() => {
return applicableSystemFields.filter(field =>
field.FieldKey === 'login.url'
);
}, [applicableSystemFields]);
/**
* The notes field (login.notes) - handled separately for collapsible UI.
*/
const notesField = useMemo(() => {
return applicableSystemFields.find(field => field.FieldKey === 'login.notes');
}, [applicableSystemFields]);
/**
* Group system fields by category for organized rendering.
* Excludes service inline fields (login.url) and notes field.
*/
const groupedSystemFields = useMemo(() => {
const groups: Record<string, typeof applicableSystemFields> = {};
applicableSystemFields.forEach(field => {
// Skip fields handled separately
if (field.FieldKey === 'login.url' || field.FieldKey === 'login.notes') {
return;
}
const category = field.Category || 'Other';
if (!groups[category]) {
groups[category] = [];
@@ -104,18 +195,16 @@ const ItemAddEdit: React.FC = () => {
if (!dbContext?.sqliteClient || !id || !isEditMode) {
/*
* Create mode - initialize with defaults
* Validate item type parameter
* Use provided type parameter or default to 'Login'
*/
if (!itemTypeParam || !VALID_ITEM_TYPES.includes(itemTypeParam)) {
/* Redirect to type selector if no valid type specified */
navigate('/items/select-type');
return;
}
const effectiveType: ItemType = (itemTypeParam && VALID_ITEM_TYPES.includes(itemTypeParam))
? itemTypeParam
: DEFAULT_ITEM_TYPE;
setItem({
Id: crypto.randomUUID().toUpperCase(),
Name: itemNameParam || '',
ItemType: itemTypeParam,
ItemType: effectiveType,
FolderId: null,
Fields: [],
CreatedAt: new Date().toISOString(),
@@ -337,6 +426,198 @@ const ItemAddEdit: React.FC = () => {
));
}, []);
/**
* Handle item type change from dropdown.
*/
const handleTypeChange = useCallback((newType: ItemType) => {
if (!item || isEditMode) {
return;
}
// Clear field values when changing type (except name)
setFieldValues({});
setCustomFields([]);
setShowAliasFields(false);
setShowNotes(false);
setItem({
...item,
ItemType: newType,
Fields: []
});
setShowTypeDropdown(false);
}, [item, isEditMode]);
/**
* Initialize generators for random alias generation.
*/
const initializeGenerators = useCallback(async () => {
// Get effective identity language (smart default based on UI language if no explicit override)
const identityLanguage = await dbContext.sqliteClient!.getEffectiveIdentityLanguage();
// Initialize identity generator based on language
const identityGenerator = CreateIdentityGenerator(identityLanguage);
// Initialize password generator with settings from vault
const passwordSettings = dbContext.sqliteClient!.getPasswordSettings();
const passwordGenerator = CreatePasswordGenerator(passwordSettings);
return { identityGenerator, passwordGenerator };
}, [dbContext.sqliteClient]);
/**
* Generate random alias and populate alias fields.
* This shows the alias fields and fills them with random values.
*/
const handleGenerateAlias = useCallback(async () => {
if (!dbContext?.sqliteClient) {
return;
}
try {
const { identityGenerator, passwordGenerator } = await initializeGenerators();
// Get gender preference from database
const genderPreference = dbContext.sqliteClient.getDefaultIdentityGender();
// Get age range preference and convert to birthdate options
const ageRange = dbContext.sqliteClient.getDefaultIdentityAgeRange();
const birthdateOptions = convertAgeRangeToBirthdateOptions(ageRange);
// Generate identity with gender preference and birthdate options
const identity = identityGenerator.generateRandomIdentity(genderPreference, birthdateOptions);
const password = passwordGenerator.generateRandomPassword();
const defaultEmailDomain = await dbContext.sqliteClient.getDefaultEmailDomain();
const email = defaultEmailDomain ? `${identity.emailPrefix}@${defaultEmailDomain}` : identity.emailPrefix;
// Set field values for alias fields
setFieldValues(prev => ({
...prev,
'alias.email': email,
'alias.first_name': identity.firstName,
'alias.last_name': identity.lastName,
'alias.nickname': identity.nickName,
'alias.gender': identity.gender,
'alias.birthdate': IdentityHelperUtils.normalizeBirthDateForDisplay(identity.birthDate.toISOString()),
// Also set username and password if they're empty
'login.username': prev['login.username'] || identity.nickName,
'login.password': prev['login.password'] || password
}));
// Show alias fields section
setShowAliasFields(true);
} catch (error) {
console.error('Error generating random alias:', error);
}
}, [dbContext.sqliteClient, initializeGenerators]);
/**
* Clear all alias field values but keep them visible.
*/
const handleClearAliasFields = useCallback(() => {
setFieldValues(prev => ({
...prev,
'alias.email': '',
'alias.first_name': '',
'alias.last_name': '',
'alias.nickname': '',
'alias.gender': '',
'alias.birthdate': ''
}));
}, []);
/**
* Remove alias section - clears values and hides the section.
*/
const handleRemoveAliasSection = useCallback(() => {
handleClearAliasFields();
setShowAliasFields(false);
}, [handleClearAliasFields]);
/**
* Remove notes section - clears value and hides the section.
*/
const handleRemoveNotesSection = useCallback(() => {
setFieldValues(prev => ({
...prev,
'login.notes': ''
}));
setShowNotes(false);
}, []);
/**
* Get the selected item type option for display.
*/
const selectedTypeOption = useMemo(() => {
return ITEM_TYPE_OPTIONS.find(opt => opt.type === item?.ItemType);
}, [item?.ItemType]);
/**
* Handle adding notes section from menu.
*/
const handleAddNotesFromMenu = useCallback((): void => {
setShowNotes(true);
setShowAddMenu(false);
}, []);
/**
* Handle adding custom field from menu.
*/
const handleAddCustomFieldFromMenu = useCallback((): void => {
setShowAddCustomFieldModal(true);
setShowAddMenu(false);
}, []);
/**
* Add menu options - shows available optional sections (Notes and Custom Fields only).
* Alias has its own dedicated button since it's a core feature.
*/
const addMenuOptions = useMemo(() => {
const options: Array<{
key: string;
label: string;
icon: React.ReactNode;
action: () => void;
}> = [];
// Notes option (when not shown and no value)
if (notesField && !showNotes && !fieldValues['login.notes'] && !isEditMode) {
options.push({
key: 'notes',
label: t('credentials.notes'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
action: handleAddNotesFromMenu
});
}
// Custom field option (always available)
options.push({
key: 'custom',
label: t('itemTypes.addCustomField'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
),
action: handleAddCustomFieldFromMenu
});
return options;
}, [showNotes, notesField, fieldValues, isEditMode, t, handleAddNotesFromMenu, handleAddCustomFieldFromMenu]);
/**
* Whether to show the dedicated "Add alias" button (for Login type in create mode when alias not shown).
*/
const showAddAliasButton = useMemo(() => {
return item?.ItemType === 'Login' && !showAliasFields && !isEditMode;
}, [item?.ItemType, showAliasFields, isEditMode]);
// Set header buttons
useEffect(() => {
const headerButtonsJSX = isEditMode ? (
@@ -462,55 +743,86 @@ const ItemAddEdit: React.FC = () => {
}
return (
<div className="space-y-6">
{/* Item Name */}
<div>
<FormInput
id="itemName"
label={t('credentials.serviceName')}
value={item.Name || ''}
onChange={(value) => setItem({ ...item, Name: value })}
type="text"
placeholder={t('credentials.serviceName')}
required
/>
</div>
<div className="space-y-4">
{/* Item Type Selector (create mode only) */}
{!isEditMode && (
<div className="relative">
<button
type="button"
onClick={() => setShowTypeDropdown(!showTypeDropdown)}
className="w-full px-4 py-2 bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg text-left flex items-center justify-between hover:bg-primary-100 dark:hover:bg-primary-900/30 transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-primary-600 dark:text-primary-400">
{selectedTypeOption?.iconSvg}
</span>
<span className="text-primary-700 dark:text-primary-300 font-medium text-sm">
{t('itemTypes.creating')} {selectedTypeOption ? t(selectedTypeOption.titleKey) : ''}
</span>
</div>
<svg
className={`w-4 h-4 text-primary-500 transition-transform ${showTypeDropdown ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Folder Selection */}
<div>
<label
htmlFor="folderSelect"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('items.folder')}
</label>
<select
id="folderSelect"
value={item.FolderId || ''}
onChange={(e) => setItem({ ...item, FolderId: e.target.value || null })}
className="w-full p-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-blue-500 focus:border-blue-500"
>
<option value="">{t('items.noFolder')}</option>
{folders.map(folder => (
<option key={folder.Id} value={folder.Id}>
{folder.Name}
</option>
))}
</select>
</div>
{/* Type Dropdown Menu */}
{showTypeDropdown && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setShowTypeDropdown(false)}
/>
<div className="absolute z-20 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg">
{ITEM_TYPE_OPTIONS.map((option) => (
<button
key={option.type}
type="button"
onClick={() => handleTypeChange(option.type)}
className={`w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 border-b border-gray-100 dark:border-gray-700 last:border-b-0 ${
item.ItemType === option.type
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300'
: 'text-gray-900 dark:text-white'
}`}
>
<span className={item.ItemType === option.type ? 'text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400'}>
{option.iconSvg}
</span>
<span className="font-medium">
{t(option.titleKey)}
</span>
{item.ItemType === option.type && (
<svg className="w-5 h-5 ml-auto text-primary-600 dark:text-primary-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="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" clipRule="evenodd" />
</svg>
)}
</button>
))}
</div>
</>
)}
</div>
)}
{/* Render fields grouped by category */}
{Object.keys(groupedSystemFields).map(category => (
<div key={category} className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
{category === 'Login' && t('credentials.loginCredentials')}
{category === 'Alias' && t('credentials.alias')}
{category === 'Card' && t('credentials.cardInformation')}
{category === 'Identity' && t('credentials.identityInformation')}
{category !== 'Login' && category !== 'Alias' && category !== 'Card' && category !== 'Identity' && category}
</h2>
{groupedSystemFields[category].map(field => (
{/* Service Section - Name and URL */}
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">{t('credentials.service')}</h2>
<div className="space-y-4">
<FormInput
id="itemName"
label={t('credentials.serviceName')}
value={item.Name || ''}
onChange={(value) => setItem({ ...item, Name: value })}
type="text"
placeholder={t('credentials.serviceName')}
required
/>
{/* Service inline fields (login.url) - shown without header */}
{serviceInlineFields.map(field => (
<div key={field.FieldKey}>
{renderFieldInput(
field.FieldKey,
@@ -522,48 +834,219 @@ const ItemAddEdit: React.FC = () => {
</div>
))}
</div>
))}
</div>
{/* Render fields grouped by category */}
{Object.keys(groupedSystemFields).map(category => {
// Special handling for Alias category in Login type (create mode only)
const isAliasInLoginCreate = category === 'Alias' && item.ItemType === 'Login' && !isEditMode;
// If alias in login create mode and not shown, skip rendering (will be available via + menu)
if (isAliasInLoginCreate && !showAliasFields) {
return null;
}
return (
<div key={category} className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center justify-between">
<span>
{category === 'Login' && t('credentials.loginCredentials')}
{category === 'Alias' && t('credentials.alias')}
{category === 'Card' && t('credentials.cardInformation')}
{category === 'Identity' && t('credentials.identityInformation')}
{category !== 'Login' && category !== 'Alias' && category !== 'Card' && category !== 'Identity' && category}
</span>
{/* Show action buttons for Alias section in Login create mode */}
{isAliasInLoginCreate && showAliasFields && (
<div className="flex items-center gap-2">
{/* Regenerate button */}
<button
type="button"
onClick={handleGenerateAlias}
className="p-1.5 text-gray-400 hover:text-primary-500 focus:outline-none"
title={t('credentials.generateRandomAlias')}
>
<svg className='w-4 h-4' viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 4v6h-6"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
</button>
{/* Remove button */}
<button
type="button"
onClick={handleRemoveAliasSection}
className="p-1.5 text-gray-400 hover:text-red-500 focus:outline-none"
title={t('common.delete')}
>
<svg className='w-4 h-4' viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
)}
</h2>
<div className="space-y-4">
{groupedSystemFields[category].map(field => (
<div key={field.FieldKey}>
{renderFieldInput(
field.FieldKey,
field.Label,
field.FieldType,
field.IsHidden,
field.IsMultiValue
)}
</div>
))}
</div>
</div>
);
})}
{/* Custom Fields Section */}
{customFields.length > 0 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
{t('common.customFields')}
</h2>
<div className="space-y-4">
{customFields.map(field => (
<div key={field.tempId}>
<EditableFieldLabel
htmlFor={field.tempId}
label={field.label}
onLabelChange={(newLabel) => handleUpdateCustomFieldLabel(field.tempId, newLabel)}
onDelete={() => handleDeleteCustomField(field.tempId)}
/>
{customFields.map(field => (
<div key={field.tempId}>
<EditableFieldLabel
htmlFor={field.tempId}
label={field.label}
onLabelChange={(newLabel) => handleUpdateCustomFieldLabel(field.tempId, newLabel)}
onDelete={() => handleDeleteCustomField(field.tempId)}
/>
{/* Field input */}
{renderFieldInput(
field.tempId,
'',
field.fieldType,
field.isHidden,
false
)}
</div>
))}
{/* Field input */}
{renderFieldInput(
field.tempId,
'',
field.fieldType,
field.isHidden,
false
)}
</div>
))}
</div>
</div>
)}
{/* Add Custom Field Button */}
<button
type="button"
onClick={() => setShowAddCustomFieldModal(true)}
className="w-full px-4 py-3 border-2 border-dashed border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded-md hover:border-primary-500 hover:text-primary-600 dark:hover:text-primary-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors"
>
+ Add Custom Field
</button>
{/* Notes Section - Hidden by default in create mode, with remove button */}
{notesField && (showNotes || isEditMode || fieldValues['login.notes']) && (
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center justify-between">
<span>{t('credentials.notes')}</span>
{/* Remove button for notes in create mode */}
{!isEditMode && (
<button
type="button"
onClick={handleRemoveNotesSection}
className="p-1.5 text-gray-400 hover:text-red-500 focus:outline-none"
title={t('common.delete')}
>
<svg className='w-4 h-4' viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
)}
</h2>
<div className="space-y-4">
{renderFieldInput(
notesField.FieldKey,
notesField.Label,
notesField.FieldType,
notesField.IsHidden,
notesField.IsMultiValue
)}
</div>
</div>
)}
{/* Dedicated "Add Alias" button - highlighted as core feature */}
{showAddAliasButton && (
<button
type="button"
onClick={handleGenerateAlias}
className="w-full px-4 py-3 bg-primary-50 dark:bg-primary-900/20 border-2 border-primary-300 dark:border-primary-700 text-primary-700 dark:text-primary-300 rounded-lg hover:bg-primary-100 dark:hover:bg-primary-900/30 hover:border-primary-400 dark:hover:border-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors flex items-center justify-center gap-2 font-medium"
>
<svg className='w-5 h-5' viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8" cy="8" r="1"/>
<circle cx="16" cy="8" r="1"/>
<circle cx="12" cy="12" r="1"/>
<circle cx="8" cy="16" r="1"/>
<circle cx="16" cy="16" r="1"/>
</svg>
{t('itemTypes.addAlias')}
</button>
)}
{/* Generic + button with dropdown menu for Notes and Custom Fields */}
<div className="relative">
<button
type="button"
onClick={() => setShowAddMenu(!showAddMenu)}
className="w-full px-4 py-3 border-2 border-dashed border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded-md hover:border-primary-500 hover:text-primary-600 dark:hover:text-primary-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</button>
{/* Add Menu Dropdown */}
{showAddMenu && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setShowAddMenu(false)}
/>
<div className="absolute bottom-full left-0 right-0 mb-1 z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg overflow-hidden">
{addMenuOptions.map((option) => (
<button
key={option.key}
type="button"
onClick={option.action}
className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 border-b border-gray-100 dark:border-gray-700 last:border-b-0 text-gray-700 dark:text-gray-300"
>
<span className="text-gray-500 dark:text-gray-400">
{option.icon}
</span>
<span>{option.label}</span>
</button>
))}
</div>
</>
)}
</div>
{/* Folder Selection - Compact at bottom */}
{folders.length > 0 && (
<div className="flex items-center gap-2 text-sm">
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={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>
<select
id="folderSelect"
value={item.FolderId || ''}
onChange={(e) => setItem({ ...item, FolderId: e.target.value || null })}
className="flex-1 py-1 px-2 text-sm border dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-primary-500 focus:border-primary-500"
>
<option value="">{t('items.noFolder')}</option>
{folders.map(folder => (
<option key={folder.Id} value={folder.Id}>
{folder.Name}
</option>
))}
</select>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-3 pt-6 border-t border-gray-200 dark:border-gray-700">
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={handleSave}

View File

@@ -292,6 +292,9 @@
"typeLabel": "Item Type",
"creating": "Creating",
"generateAlias": "Generate random alias",
"addAlias": "Add alias",
"addNotes": "Add notes",
"addCustomField": "Add custom field",
"login": {
"title": "Login",
"description": "Username, password, and website credentials"