@@ -13,7 +13,7 @@ import { Button } from '@constructive-io/ui/button';
1313import { Input } from '@constructive-io/ui/input' ;
1414import { Progress } from '@constructive-io/ui/progress' ;
1515import { Separator } from '@constructive-io/ui/separator' ;
16- import { Copy , Eye , EyeOff , Plus , Star , Trash2 , X } from 'lucide-react' ;
16+ import { Check , Copy , Eye , EyeOff , Pencil , Plus , Star , Trash2 , X } from 'lucide-react' ;
1717import { useCallback , useEffect , useState } from 'react' ;
1818import { toast } from 'sonner' ;
1919
@@ -53,10 +53,16 @@ export const ItemDetail = ({
5353 const [ newFieldValue , setNewFieldValue ] = useState ( '' ) ;
5454 const [ newTag , setNewTag ] = useState ( '' ) ;
5555 const [ totp , setTotp ] = useState < TotpEntry | null > ( null ) ;
56+ /** The field being edited, and the value typed so far. */
57+ const [ editing , setEditing ] = useState < { name : string ; value : string } | null > ( null ) ;
58+ /** Non-null while the title is being renamed. */
59+ const [ title , setTitle ] = useState < string | null > ( null ) ;
60+ const [ removing , setRemoving ] = useState < VaultFieldMeta | null > ( null ) ;
5661 const icons = useBrandIcons ( [ item . title ] ) ;
5762
5863 const refresh = useCallback ( async ( ) => {
5964 setRevealed ( { } ) ;
65+ setEditing ( null ) ;
6066 setLoaded ( { itemId : item . id , fields : await dcrypt . fields . list ( item . id ) } ) ;
6167 setUrls ( await dcrypt . organize . urls ( item . id ) ) ;
6268 setTags ( await dcrypt . organize . tags ( item . id ) ) ;
@@ -113,6 +119,35 @@ export const ItemDetail = ({
113119 toast . success ( `Copied ${ name } — clipboard clears in 30s` ) ;
114120 } ;
115121
122+ /** Opens the editor on a field, revealing what is there to be edited. */
123+ const startEdit = async ( field : VaultFieldMeta ) => {
124+ const value = revealed [ field . name ] ?? ( await dcrypt . fields . reveal ( item . id , field . name ) ) ;
125+ setEditing ( { name : field . name , value } ) ;
126+ } ;
127+
128+ const saveEdit = async ( field : VaultFieldMeta ) => {
129+ if ( ! editing ) return ;
130+ // same name and purpose, so the vault replaces the value in place and the
131+ // database keeps the previous one in the item's password history
132+ await dcrypt . fields . set ( item . id , field . name , field . purpose , editing . value , field . concealed ) ;
133+ toast . success ( `Updated ${ field . name } ` ) ;
134+ await refresh ( ) ;
135+ } ;
136+
137+ const removeField = async ( field : VaultFieldMeta ) => {
138+ await dcrypt . fields . remove ( item . id , field . name ) ;
139+ setRemoving ( null ) ;
140+ toast . success ( `Removed ${ field . name } ` ) ;
141+ await refresh ( ) ;
142+ } ;
143+
144+ const saveTitle = async ( ) => {
145+ const next = title ?. trim ( ) ;
146+ if ( next && next !== item . title ) await dcrypt . items . rename ( item . id , next ) ;
147+ setTitle ( null ) ;
148+ onChanged ( ) ;
149+ } ;
150+
116151 const addField = async ( ) => {
117152 if ( ! newFieldName . trim ( ) || ! newFieldValue ) return ;
118153 await dcrypt . fields . set ( item . id , newFieldName . trim ( ) , 'text' , newFieldValue ) ;
@@ -146,7 +181,28 @@ export const ItemDetail = ({
146181 < div >
147182 < h2 className = "flex items-center gap-2 text-xl font-semibold" >
148183 < BrandGlyph name = { item . title } icon = { icons [ item . title ] } className = "size-6" />
149- { item . title }
184+ { title === null ? (
185+ < button
186+ className = "rounded px-1 text-left hover:bg-muted"
187+ onClick = { ( ) => setTitle ( item . title ) }
188+ aria-label = "Rename item"
189+ >
190+ { item . title }
191+ </ button >
192+ ) : (
193+ < Input
194+ autoFocus
195+ value = { title }
196+ onChange = { ( e ) => setTitle ( e . target . value ) }
197+ onBlur = { ( ) => void saveTitle ( ) }
198+ onKeyDown = { ( e ) => {
199+ if ( e . key === 'Enter' ) void saveTitle ( ) ;
200+ if ( e . key === 'Escape' ) setTitle ( null ) ;
201+ } }
202+ className = "h-9 text-xl font-semibold"
203+ aria-label = "Item title"
204+ />
205+ ) }
150206 </ h2 >
151207 < div className = "mt-1 flex items-center gap-2" >
152208 < Badge variant = "secondary" > { KIND_LABEL [ item . kind ] ?? item . kind } </ Badge >
@@ -208,24 +264,38 @@ export const ItemDetail = ({
208264 { fields . map ( ( field ) => (
209265 < div key = { field . id } className = "flex items-center gap-2" >
210266 < span className = "w-32 shrink-0 text-sm text-muted-foreground" > { field . name } </ span >
211- < Input
212- readOnly
213- type = { field . concealed && revealed [ field . name ] === undefined ? 'password' : 'text' }
214- value = {
215- revealed [ field . name ] !== undefined
216- ? revealed [ field . name ]
217- : field . concealed
218- ? '••••••••••••'
219- : ( revealed [ field . name ] ?? '' )
220- }
221- onFocus = { ( ) => {
222- if ( ! field . concealed && revealed [ field . name ] === undefined ) {
223- void toggleReveal ( field . name ) ;
267+ { editing ?. name === field . name ? (
268+ < Input
269+ autoFocus
270+ value = { editing . value }
271+ onChange = { ( e ) => setEditing ( { name : field . name , value : e . target . value } ) }
272+ onKeyDown = { ( e ) => {
273+ if ( e . key === 'Enter' ) void saveEdit ( field ) ;
274+ if ( e . key === 'Escape' ) setEditing ( null ) ;
275+ } }
276+ className = "font-mono"
277+ aria-label = { `${ field . name } value` }
278+ />
279+ ) : (
280+ < Input
281+ readOnly
282+ type = { field . concealed && revealed [ field . name ] === undefined ? 'password' : 'text' }
283+ value = {
284+ revealed [ field . name ] !== undefined
285+ ? revealed [ field . name ]
286+ : field . concealed
287+ ? '••••••••••••'
288+ : ( revealed [ field . name ] ?? '' )
224289 }
225- } }
226- className = "font-mono"
227- />
228- { field . concealed && (
290+ onFocus = { ( ) => {
291+ if ( ! field . concealed && revealed [ field . name ] === undefined ) {
292+ void toggleReveal ( field . name ) ;
293+ }
294+ } }
295+ className = "font-mono"
296+ />
297+ ) }
298+ { field . concealed && editing ?. name !== field . name && (
229299 < Button
230300 variant = "ghost"
231301 size = "icon"
@@ -239,14 +309,48 @@ export const ItemDetail = ({
239309 ) }
240310 </ Button >
241311 ) }
242- < Button
243- variant = "ghost"
244- size = "icon"
245- onClick = { ( ) => void copyField ( field . name ) }
246- aria-label = "Copy"
247- >
248- < Copy className = "size-4" />
249- </ Button >
312+ { editing ?. name === field . name ? (
313+ < >
314+ < Button
315+ variant = "ghost"
316+ size = "icon"
317+ onClick = { ( ) => void saveEdit ( field ) }
318+ aria-label = { `Save ${ field . name } ` }
319+ >
320+ < Check className = "size-4" />
321+ </ Button >
322+ < Button variant = "ghost" size = "icon" onClick = { ( ) => setEditing ( null ) } aria-label = "Cancel" >
323+ < X className = "size-4" />
324+ </ Button >
325+ </ >
326+ ) : (
327+ < >
328+ < Button
329+ variant = "ghost"
330+ size = "icon"
331+ onClick = { ( ) => void copyField ( field . name ) }
332+ aria-label = "Copy"
333+ >
334+ < Copy className = "size-4" />
335+ </ Button >
336+ < Button
337+ variant = "ghost"
338+ size = "icon"
339+ onClick = { ( ) => void startEdit ( field ) }
340+ aria-label = { `Edit ${ field . name } ` }
341+ >
342+ < Pencil className = "size-4" />
343+ </ Button >
344+ < Button
345+ variant = "ghost"
346+ size = "icon"
347+ onClick = { ( ) => setRemoving ( field ) }
348+ aria-label = { `Remove ${ field . name } ` }
349+ >
350+ < Trash2 className = "size-4 text-destructive" />
351+ </ Button >
352+ </ >
353+ ) }
250354 </ div >
251355 ) ) }
252356 { ! fields . length && (
@@ -299,6 +403,24 @@ export const ItemDetail = ({
299403 </ Button >
300404 </ div >
301405
406+ < AlertDialog open = { removing !== null } onOpenChange = { ( open ) => ! open && setRemoving ( null ) } >
407+ < AlertDialogContent >
408+ < AlertDialogHeader >
409+ < AlertDialogTitle > Remove { removing ?. name } ?</ AlertDialogTitle >
410+ < AlertDialogDescription >
411+ The value is deleted from the vault. A field has no trash to go to,
412+ so this cannot be undone.
413+ </ AlertDialogDescription >
414+ </ AlertDialogHeader >
415+ < AlertDialogFooter >
416+ < AlertDialogCancel > Cancel</ AlertDialogCancel >
417+ < AlertDialogAction onClick = { ( ) => removing && void removeField ( removing ) } >
418+ Remove field
419+ </ AlertDialogAction >
420+ </ AlertDialogFooter >
421+ </ AlertDialogContent >
422+ </ AlertDialog >
423+
302424 < AlertDialog open = { confirmDelete } onOpenChange = { setConfirmDelete } >
303425 < AlertDialogContent >
304426 < AlertDialogHeader >
0 commit comments