11import { Button } from '@constructive-io/ui/button' ;
22import { Separator } from '@constructive-io/ui/separator' ;
33import { Toaster } from '@constructive-io/ui/sonner' ;
4- import { KeyRound , Lock , Settings , ShieldCheck , Timer , Wrench } from 'lucide-react' ;
4+ import {
5+ KeyRound ,
6+ Lock ,
7+ Settings ,
8+ ShieldCheck ,
9+ Timer ,
10+ Wrench ,
11+ } from 'lucide-react' ;
512import { useCallback , useEffect , useState } from 'react' ;
613
14+ import { DoorState , VaultDoors } from './components/VaultDoors' ;
715import { dcrypt } from './lib/ipc' ;
816import { ThemeProvider , useThemeMode } from './lib/theme-context' ;
917import { SettingsScreen } from './screens/SettingsScreen' ;
@@ -14,6 +22,9 @@ import { VaultScreen } from './screens/VaultScreen';
1422
1523type Tab = 'vault' | 'codes' | 'tools' | 'settings' ;
1624
25+ /** `opening`/`closing` are the door transitions; the vault is mounted for all but `locked`. */
26+ type Phase = 'locked' | 'opening' | 'unlocked' | 'closing' ;
27+
1728const NAV : { id : Tab ; label : string ; icon : typeof KeyRound } [ ] = [
1829 { id : 'vault' , label : 'Vault' , icon : KeyRound } ,
1930 { id : 'codes' , label : 'Codes' , icon : Timer } ,
@@ -23,65 +34,99 @@ const NAV: { id: Tab; label: string; icon: typeof KeyRound }[] = [
2334
2435const AppContent = ( ) => {
2536 const { dark } = useThemeMode ( ) ;
26- const [ unlocked , setUnlocked ] = useState ( false ) ;
37+ const [ phase , setPhase ] = useState < Phase > ( 'locked' ) ;
38+ const [ working , setWorking ] = useState ( false ) ;
2739 const [ tab , setTab ] = useState < Tab > ( 'vault' ) ;
2840
29- useEffect ( ( ) => dcrypt . onLocked ( ( ) => setUnlocked ( false ) ) , [ ] ) ;
41+ // the main process locks on its own for menu actions and after a restore
42+ useEffect (
43+ ( ) =>
44+ dcrypt . onLocked ( ( ) =>
45+ setPhase ( ( current ) => ( current === 'unlocked' ? 'closing' : current ) )
46+ ) ,
47+ [ ]
48+ ) ;
3049
3150 useEffect ( ( ) => {
32- void dcrypt . vault . status ( ) . then ( ( s ) => setUnlocked ( s . unlocked ) ) ;
51+ void dcrypt . vault
52+ . status ( )
53+ . then ( ( s ) => setPhase ( s . unlocked ? 'unlocked' : 'locked' ) ) ;
3354 } , [ ] ) ;
3455
35- // switch to the unlock screen first : the flush behind `vault.lock()` is fast
36- // but not instant, and waiting on it makes the click feel stuck
56+ // close the doors and lock at the same time : the flush behind `vault.lock()`
57+ // is fast, so the animation covers it entirely
3758 const lock = useCallback ( ( ) => {
38- setUnlocked ( false ) ;
59+ setPhase ( 'closing' ) ;
3960 void dcrypt . vault . lock ( ) ;
4061 } , [ ] ) ;
4162
42- if ( ! unlocked ) {
43- return (
44- < >
45- < UnlockScreen onUnlocked = { ( ) => setUnlocked ( true ) } />
46- < Toaster theme = { dark ? 'dark' : 'light' } position = "bottom-right" />
47- </ >
48- ) ;
49- }
63+ const doorState : DoorState =
64+ phase === 'opening'
65+ ? 'opening'
66+ : phase === 'closing'
67+ ? 'closing'
68+ : 'closed' ;
69+ const settled = useCallback ( ( ) => {
70+ setWorking ( false ) ;
71+ setPhase ( ( current ) => ( current === 'opening' ? 'unlocked' : 'locked' ) ) ;
72+ } , [ ] ) ;
5073
5174 return (
52- < div className = "flex h-screen" >
53- < aside className = "flex w-52 flex-col border-r bg-muted/40 p-3" >
54- < div className = "mb-4 flex items-center gap-2 px-2 pt-1" >
55- < ShieldCheck className = "size-5 text-primary" />
56- < span className = "text-lg font-semibold" > dcrypt</ span >
75+ < div className = "relative h-screen overflow-hidden" >
76+ { phase !== 'locked' && (
77+ < div
78+ className = { `flex h-full ${ phase === 'opening' ? 'dcrypt-vault-settle' : '' } ` }
79+ >
80+ < aside className = "flex w-52 flex-col border-r bg-muted/40 p-3" >
81+ < div className = "mb-4 flex items-center gap-2 px-2 pt-1" >
82+ < ShieldCheck className = "size-5 text-primary" />
83+ < span className = "text-lg font-semibold" > dcrypt</ span >
84+ </ div >
85+ < nav className = "flex flex-col gap-1" >
86+ { NAV . map ( ( { id, label, icon : Icon } ) => (
87+ < Button
88+ key = { id }
89+ variant = { tab === id ? 'secondary' : 'ghost' }
90+ className = "justify-start gap-2"
91+ onClick = { ( ) => setTab ( id ) }
92+ >
93+ < Icon className = "size-4" />
94+ { label }
95+ </ Button >
96+ ) ) }
97+ </ nav >
98+ < div className = "mt-auto" >
99+ < Separator className = "my-3" />
100+ < Button
101+ variant = "outline"
102+ className = "w-full justify-start gap-2"
103+ onClick = { lock }
104+ >
105+ < Lock className = "size-4" />
106+ Lock vault
107+ </ Button >
108+ </ div >
109+ </ aside >
110+ < main className = "min-w-0 flex-1 overflow-hidden" >
111+ { tab === 'vault' && < VaultScreen /> }
112+ { tab === 'codes' && < TotpScreen /> }
113+ { tab === 'tools' && < ToolsScreen /> }
114+ { tab === 'settings' && (
115+ < SettingsScreen onLocked = { ( ) => setPhase ( 'closing' ) } />
116+ ) }
117+ </ main >
57118 </ div >
58- < nav className = "flex flex-col gap-1" >
59- { NAV . map ( ( { id, label, icon : Icon } ) => (
60- < Button
61- key = { id }
62- variant = { tab === id ? 'secondary' : 'ghost' }
63- className = "justify-start gap-2"
64- onClick = { ( ) => setTab ( id ) }
65- >
66- < Icon className = "size-4" />
67- { label }
68- </ Button >
69- ) ) }
70- </ nav >
71- < div className = "mt-auto" >
72- < Separator className = "my-3" />
73- < Button variant = "outline" className = "w-full justify-start gap-2" onClick = { lock } >
74- < Lock className = "size-4" />
75- Lock vault
76- </ Button >
77- </ div >
78- </ aside >
79- < main className = "min-w-0 flex-1 overflow-hidden" >
80- { tab === 'vault' && < VaultScreen /> }
81- { tab === 'codes' && < TotpScreen /> }
82- { tab === 'tools' && < ToolsScreen /> }
83- { tab === 'settings' && < SettingsScreen onLocked = { ( ) => setUnlocked ( false ) } /> }
84- </ main >
119+ ) }
120+
121+ { phase !== 'unlocked' && (
122+ < VaultDoors state = { doorState } working = { working } onRest = { settled } >
123+ < UnlockScreen
124+ onUnlocked = { ( ) => setPhase ( 'opening' ) }
125+ onWorkingChange = { setWorking }
126+ />
127+ </ VaultDoors >
128+ ) }
129+
85130 < Toaster theme = { dark ? 'dark' : 'light' } position = "bottom-right" />
86131 </ div >
87132 ) ;
0 commit comments