1
+ import classNames from "classnames" ;
2
+ import { FC , useState , useRef , useEffect , useCallback , useMemo } from "react" ;
3
+ import { createPortal } from "react-dom" ;
4
+ import { Icons } from "./icons" ;
5
+ import { ClassNames } from "./classes" ;
6
+ import { LocalLoginProfile } from "../store/auth" ;
7
+ import { databaseTypeDropdownItems } from "../pages/auth/login" ;
8
+
9
+ interface ProfileInfoTooltipProps {
10
+ profile : LocalLoginProfile ;
11
+ className ?: string ;
12
+ }
13
+
14
+ const PROFILE_ID_REGEX = / ^ [ a - z A - Z 0 - 9 _ \- ] + $ / ;
15
+ const PROFILE_ID_MAX_LENGTH = 64 ;
16
+ const TOOLTIP_OFFSET = 12 ;
17
+
18
+ const isValidProfileId = ( profileId : string ) : boolean =>
19
+ typeof profileId === 'string' &&
20
+ profileId . length > 0 &&
21
+ profileId . length <= PROFILE_ID_MAX_LENGTH &&
22
+ PROFILE_ID_REGEX . test ( profileId ) ;
23
+
24
+ const getPortFromAdvanced = ( profile : LocalLoginProfile ) : string | null => {
25
+ const defaultPortItem = databaseTypeDropdownItems . find ( item => item . id === profile . Type ) ;
26
+ const defaultPort = defaultPortItem ?. extra ?. Port ;
27
+
28
+ if ( ! defaultPort ) return null ;
29
+
30
+ if ( profile . Advanced ) {
31
+ const portObj = profile . Advanced . find ( item => item . Key === 'Port' ) ;
32
+ return portObj ?. Value || defaultPort ;
33
+ }
34
+
35
+ return defaultPort ;
36
+ } ;
37
+
38
+ const getLastAccessedTime = ( profileId : string ) : string | null => {
39
+ if ( ! isValidProfileId ( profileId ) ) return null ;
40
+
41
+ try {
42
+ const lastAccessed = localStorage . getItem ( `whodb_profile_last_accessed_${ profileId } ` ) ;
43
+ if ( ! lastAccessed ) return null ;
44
+
45
+ const date = new Date ( lastAccessed ) ;
46
+ if ( isNaN ( date . getTime ( ) ) ) return null ;
47
+
48
+ const timeZone = Intl . DateTimeFormat ( ) . resolvedOptions ( ) . timeZone ;
49
+ const formattedTimeZone = timeZone . replace ( / _ / g, ' ' ) . split ( '/' ) . join ( ' / ' ) ;
50
+ return `${ date . toLocaleDateString ( ) } ${ date . toLocaleTimeString ( [ ] , { hour : '2-digit' , minute : '2-digit' } ) } (${ formattedTimeZone } )` ;
51
+ } catch {
52
+ return null ;
53
+ }
54
+ } ;
55
+
56
+ let tooltipPortalContainer : HTMLDivElement | null = null ;
57
+
58
+ const getTooltipPortalContainer = ( ) : HTMLDivElement => {
59
+ if ( ! tooltipPortalContainer ) {
60
+ tooltipPortalContainer = document . createElement ( 'div' ) ;
61
+ tooltipPortalContainer . id = 'whodb-tooltip-portal' ;
62
+ document . body . appendChild ( tooltipPortalContainer ) ;
63
+ }
64
+ return tooltipPortalContainer ;
65
+ } ;
66
+
67
+ const TOOLTIP_CLASSES = {
68
+ container : classNames (
69
+ "fixed z-[9999] px-3 py-2 text-xs font-medium bg-white border border-gray-200 rounded-lg shadow-lg" ,
70
+ "dark:bg-[#2C2F33] dark:border-white/20 dark:text-gray-200" ,
71
+ "min-w-[180px]" ,
72
+ "animate-fade"
73
+ ) ,
74
+ button : "flex items-center justify-center w-4 h-4 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 focus:outline-none focus:ring-1 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900 rounded-full transition-colors"
75
+ } ;
76
+
77
+ export const ProfileInfoTooltip : FC < ProfileInfoTooltipProps > = ( { profile, className } ) => {
78
+ const [ isVisible , setIsVisible ] = useState ( false ) ;
79
+ const [ tooltipPos , setTooltipPos ] = useState < { top : number ; left : number } | null > ( null ) ;
80
+ const btnRef = useRef < HTMLButtonElement | null > ( null ) ;
81
+
82
+ const port = getPortFromAdvanced ( profile ) ;
83
+ const lastAccessed = getLastAccessedTime ( profile . Id ) ;
84
+
85
+ if ( ! port && ! lastAccessed ) return null ;
86
+
87
+ const showTooltip = useCallback ( ( ) => {
88
+ if ( ! btnRef . current ) return ;
89
+
90
+ const rect = btnRef . current . getBoundingClientRect ( ) ;
91
+ setTooltipPos ( {
92
+ top : rect . top + rect . height / 2 ,
93
+ left : rect . right + TOOLTIP_OFFSET ,
94
+ } ) ;
95
+ setIsVisible ( true ) ;
96
+ } , [ ] ) ;
97
+
98
+ const hideTooltip = useCallback ( ( ) => setIsVisible ( false ) , [ ] ) ;
99
+
100
+ const handleClickAway = useCallback ( ( event : MouseEvent ) => {
101
+ if ( btnRef . current ?. contains ( event . target as Node ) ) return ;
102
+ setIsVisible ( false ) ;
103
+ } , [ ] ) ;
104
+
105
+ const handleKeyDown = useCallback ( ( event : KeyboardEvent ) => {
106
+ if ( event . key === "Escape" ) setIsVisible ( false ) ;
107
+ } , [ ] ) ;
108
+
109
+ useEffect ( ( ) => {
110
+ if ( ! isVisible ) return ;
111
+
112
+ document . addEventListener ( "mousedown" , handleClickAway ) ;
113
+ document . addEventListener ( "keydown" , handleKeyDown ) ;
114
+
115
+ return ( ) => {
116
+ document . removeEventListener ( "mousedown" , handleClickAway ) ;
117
+ document . removeEventListener ( "keydown" , handleKeyDown ) ;
118
+ } ;
119
+ } , [ isVisible , handleClickAway , handleKeyDown ] ) ;
120
+
121
+ const portalContainer = useMemo ( getTooltipPortalContainer , [ ] ) ;
122
+
123
+ const tooltip = isVisible && tooltipPos && createPortal (
124
+ < div
125
+ id = { `tooltip-${ profile . Id } ` }
126
+ role = "tooltip"
127
+ className = { TOOLTIP_CLASSES . container }
128
+ style = { {
129
+ top : tooltipPos . top ,
130
+ left : tooltipPos . left ,
131
+ transform : "translateY(-50%)" ,
132
+ } }
133
+ >
134
+ < div className = "space-y-1" >
135
+ { port && (
136
+ < div className = "flex justify-between" >
137
+ < span className = "text-gray-600 dark:text-gray-400" > Port:</ span >
138
+ < span className = { ClassNames . Text } > { port } </ span >
139
+ </ div >
140
+ ) }
141
+ { lastAccessed && (
142
+ < div className = "flex justify-between" >
143
+ < span className = "text-gray-600 dark:text-gray-400" > Last Logged In: </ span >
144
+ < span className = { ClassNames . Text } > { lastAccessed } </ span >
145
+ </ div >
146
+ ) }
147
+ </ div >
148
+ < div className = "absolute top-1/2 left-0 -translate-x-full -translate-y-1/2" >
149
+ < div className = "w-0 h-0 border-t-4 border-b-4 border-r-4 border-t-transparent border-b-transparent border-r-gray-200 dark:border-r-white/20" />
150
+ </ div >
151
+ </ div > ,
152
+ portalContainer
153
+ ) ;
154
+
155
+ return (
156
+ < div className = { classNames ( "relative" , className ) } >
157
+ < button
158
+ ref = { btnRef }
159
+ className = { TOOLTIP_CLASSES . button }
160
+ onClick = { isVisible ? hideTooltip : showTooltip }
161
+ aria-label = { `Profile information for ${ profile . Id } ` }
162
+ aria-describedby = { `tooltip-${ profile . Id } ` }
163
+ tabIndex = { 0 }
164
+ type = "button"
165
+ >
166
+ < div className = "w-4 h-4" > { Icons . Information } </ div >
167
+ </ button >
168
+ { tooltip }
169
+ </ div >
170
+ ) ;
171
+ } ;
172
+
173
+ export const updateProfileLastAccessed = ( profileId : string ) : void => {
174
+ if ( ! isValidProfileId ( profileId ) ) return ;
175
+
176
+ try {
177
+ localStorage . setItem ( `whodb_profile_last_accessed_${ profileId } ` , new Date ( ) . toISOString ( ) ) ;
178
+ } catch {
179
+ // Silently fail if localStorage is not available
180
+ }
181
+ } ;
0 commit comments