@@ -5,6 +5,8 @@ import { useAuth } from "../authContext";
55
66export default function Header ( { onToggleSidebar, searchQuery, onSearchChange, viewMode = 'cards' , onToggleViewMode } : { onToggleSidebar ?: ( ) => void , searchQuery ?: string , onSearchChange ?: ( q : string ) => void , viewMode ?: 'cards' | 'list-1' | 'list-2' , onToggleViewMode ?: ( ) => void } ) {
77 const [ showPrefs , setShowPrefs ] = useState ( false ) ;
8+ const [ mobileCompact , setMobileCompact ] = useState ( false ) ;
9+ const [ avatarKey , setAvatarKey ] = useState < number > ( ( ) => Date . now ( ) ) ;
810 const { user } = useAuth ( ) ;
911 const theme = ( ( ) => { try { return useTheme ( ) ; } catch { return { effective : 'dark' } as any ; } } ) ( ) ;
1012 const nextViewMode = viewMode === 'cards' ? 'list-1' : ( viewMode === 'list-1' ? 'list-2' : 'cards' ) ;
@@ -13,8 +15,142 @@ export default function Header({ onToggleSidebar, searchQuery, onSearchChange, v
1315
1416 // dropdown removed; preferences open via avatar click
1517
18+ useEffect ( ( ) => {
19+ const onPhoto = ( e : any ) => {
20+ try { setAvatarKey ( Date . now ( ) ) ; } catch { }
21+ } ;
22+ window . addEventListener ( 'freemannotes:user-photo-updated' , onPhoto as EventListener ) ;
23+ return ( ) => window . removeEventListener ( 'freemannotes:user-photo-updated' , onPhoto as EventListener ) ;
24+ } , [ ] ) ;
25+
26+ useEffect ( ( ) => {
27+ const root = document . documentElement ;
28+ if ( showPrefs ) {
29+ root . classList . add ( 'is-preferences-open' ) ;
30+ try { window . dispatchEvent ( new CustomEvent ( 'freemannotes:mobile-add/close' ) ) ; } catch { }
31+ } else {
32+ root . classList . remove ( 'is-preferences-open' ) ;
33+ }
34+ return ( ) => {
35+ try { root . classList . remove ( 'is-preferences-open' ) ; } catch { }
36+ } ;
37+ } , [ showPrefs ] ) ;
38+
39+ useEffect ( ( ) => {
40+ let rafId : number | null = null ;
41+ const root = document . documentElement ;
42+ let lastY = 0 ;
43+ let compact = false ;
44+ let upAccum = 0 ;
45+ let downAccum = 0 ;
46+ let lastToggleAt = 0 ;
47+
48+ const ENTER_Y = 72 ;
49+ const EXIT_Y = 26 ;
50+ const MIN_DELTA = 1.25 ;
51+ const ENTER_ACCUM = 14 ;
52+ const EXIT_ACCUM = 14 ;
53+ const TOGGLE_COOLDOWN_MS = 220 ;
54+
55+ const isPhoneLike = ( ) => {
56+ try {
57+ const mq = window . matchMedia ;
58+ const touchLike = ! ! ( mq && ( mq ( '(pointer: coarse)' ) . matches || mq ( '(any-pointer: coarse)' ) . matches ) ) ;
59+ const vw = ( window . visualViewport && typeof window . visualViewport . width === 'number' ) ? window . visualViewport . width : window . innerWidth ;
60+ const vh = ( window . visualViewport && typeof window . visualViewport . height === 'number' ) ? window . visualViewport . height : window . innerHeight ;
61+ const shortSide = Math . min ( vw , vh ) ;
62+ return touchLike && shortSide <= 600 ;
63+ } catch {
64+ return false ;
65+ }
66+ } ;
67+
68+ const getScrollTop = ( ) => {
69+ const el = document . querySelector ( '.main-area' ) as HTMLElement | null ;
70+ if ( el ) return Math . max ( 0 , el . scrollTop || 0 ) ;
71+ return Math . max ( 0 , window . scrollY || 0 ) ;
72+ } ;
73+
74+ const applyCompact = ( next : boolean ) => {
75+ if ( compact === next ) return ;
76+ compact = next ;
77+ lastToggleAt = Date . now ( ) ;
78+ upAccum = 0 ;
79+ downAccum = 0 ;
80+ setMobileCompact ( next ) ;
81+ root . classList . toggle ( 'mobile-header-compact' , next ) ;
82+ } ;
83+
84+ const evaluate = ( ) => {
85+ if ( ! isPhoneLike ( ) ) {
86+ if ( compact ) applyCompact ( false ) ;
87+ return ;
88+ }
89+ const y = getScrollTop ( ) ;
90+ const dy = y - lastY ;
91+ lastY = y ;
92+
93+ // Ignore micro-jitter from inertial/bounce scrolling.
94+ if ( Math . abs ( dy ) < MIN_DELTA ) return ;
95+
96+ if ( dy > 0 ) {
97+ downAccum += dy ;
98+ upAccum = 0 ;
99+ } else {
100+ upAccum += - dy ;
101+ downAccum = 0 ;
102+ }
103+
104+ if ( ( Date . now ( ) - lastToggleAt ) < TOGGLE_COOLDOWN_MS ) return ;
105+
106+ // Enter compact mode only after passing threshold + clear downward intent.
107+ if ( ! compact && y >= ENTER_Y && downAccum >= ENTER_ACCUM ) {
108+ applyCompact ( true ) ;
109+ return ;
110+ }
111+
112+ // Exit compact mode near top or after clear upward intent.
113+ if ( compact && ( y <= EXIT_Y || upAccum >= EXIT_ACCUM ) ) {
114+ applyCompact ( false ) ;
115+ }
116+ } ;
117+
118+ const onScroll = ( ) => {
119+ if ( rafId != null ) return ;
120+ rafId = window . requestAnimationFrame ( ( ) => {
121+ rafId = null ;
122+ evaluate ( ) ;
123+ } ) ;
124+ } ;
125+
126+ const onResize = ( ) => {
127+ lastY = getScrollTop ( ) ;
128+ evaluate ( ) ;
129+ } ;
130+
131+ const mainArea = document . querySelector ( '.main-area' ) as HTMLElement | null ;
132+ lastY = getScrollTop ( ) ;
133+ evaluate ( ) ;
134+ mainArea ?. addEventListener ( 'scroll' , onScroll , { passive : true } ) ;
135+ window . addEventListener ( 'scroll' , onScroll , { passive : true } ) ;
136+ window . addEventListener ( 'resize' , onResize ) ;
137+ try { window . visualViewport ?. addEventListener ( 'resize' , onResize ) ; } catch { }
138+
139+ return ( ) => {
140+ if ( rafId != null ) {
141+ try { window . cancelAnimationFrame ( rafId ) ; } catch { }
142+ }
143+ mainArea ?. removeEventListener ( 'scroll' , onScroll ) ;
144+ window . removeEventListener ( 'scroll' , onScroll ) ;
145+ window . removeEventListener ( 'resize' , onResize ) ;
146+ try { window . visualViewport ?. removeEventListener ( 'resize' , onResize ) ; } catch { }
147+ try { root . classList . remove ( 'mobile-header-compact' ) ; } catch { }
148+ setMobileCompact ( false ) ;
149+ } ;
150+ } , [ ] ) ;
151+
16152 return (
17- < header className = " app-header" >
153+ < header className = { ` app-header${ mobileCompact ? ' app-header--mobile-compact' : '' } ` } >
18154 < div className = "header-left" >
19155 < button
20156 type = "button"
@@ -77,7 +213,7 @@ export default function Header({ onToggleSidebar, searchQuery, onSearchChange, v
77213 { user ? (
78214 < div className = "header-avatar-wrap" >
79215 { ( user as any ) . userImageUrl ? (
80- < img src = { ( user as any ) . userImageUrl } alt = "User" className = "avatar" style = { { width : 33 , height : 33 , borderRadius : '50%' , objectFit : 'cover' , cursor : 'pointer' } } onClick = { ( ) => setShowPrefs ( true ) } />
216+ < img key = { avatarKey } src = { ( user as any ) . userImageUrl } alt = "User" className = "avatar" style = { { width : 33 , height : 33 , borderRadius : '50%' , objectFit : 'cover' , cursor : 'pointer' } } onClick = { ( ) => setShowPrefs ( true ) } />
81217 ) : (
82218 < div className = "avatar" style = { { width : 33 , height : 33 , borderRadius : '50%' , display : 'inline-flex' , alignItems : 'center' , justifyContent : 'center' , cursor : 'pointer' } } onClick = { ( ) => setShowPrefs ( true ) } > { ( user . name && user . email ? ( user . name || user . email ) [ 0 ] : '' ) } </ div >
83219 ) }
0 commit comments