Skip to content

Commit 3bc0313

Browse files
authored
Merge pull request #487 from clidey/claude/issue-413-20250523_174753
feat: Add information icons to profile dropdown with port and last accessed details
2 parents 451d223 + aac7949 commit 3bc0313

File tree

5 files changed

+225
-6
lines changed

5 files changed

+225
-6
lines changed

frontend/src/components/dropdown.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type IDropdownItem<T extends unknown = any> = {
3434
label: string;
3535
icon?: ReactElement;
3636
extra?: T;
37+
info?: ReactElement;
3738
};
3839

3940
export type IDropdownProps = {
@@ -102,9 +103,20 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
102103
"hover:gap-2": item.icon != null,
103104
})} onClick={() => handleClick(item)} data-value={item.id}>
104105
<div>{props.value?.id === item.id ? Icons.CheckCircle : item.icon}</div>
105-
<div className="whitespace-nowrap">{item.label}</div>
106+
<div className="whitespace-nowrap flex-1">{item.label}</div>
107+
{item.info && (
108+
<div
109+
className="ml-8"
110+
onClick={(e) => e.stopPropagation()}
111+
>
112+
{item.info}
113+
</div>
114+
)}
106115
{(props.enableAction?.(i) ?? true) && props.action != null && cloneElement(props.action, {
107-
className: "absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer transition-all opacity-0 group-hover/item:opacity-100",
116+
className: classNames("cursor-pointer transition-all opacity-0 group-hover/item:opacity-100", {
117+
"absolute right-4 top-1/2 -translate-y-1/2": !item.info,
118+
"absolute right-10 top-1/2 -translate-y-1/2": item.info,
119+
}),
108120
onClick: (e: MouseEvent) => {
109121
props.action?.props?.onClick?.(e, item);
110122
e.stopPropagation();

frontend/src/components/icons.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ export const Icons = {
9191
PlusCircle: <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
9292
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v6m3-3H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
9393
</svg>,
94+
Information: <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
95+
<path strokeLinecap="round" strokeLinejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
96+
</svg>,
9497
Adjustments: <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
9598
<path strokeLinecap="round" strokeLinejoin="round" d="M6 13.5V3.75m0 9.75a1.5 1.5 0 0 1 0 3m0-3a1.5 1.5 0 0 0 0 3m0 3.75V16.5m12-3V3.75m0 9.75a1.5 1.5 0 0 1 0 3m0-3a1.5 1.5 0 0 0 0 3m0 3.75V16.5m-6-9V3.75m0 3.75a1.5 1.5 0 0 1 0 3m0-3a1.5 1.5 0 0 0 0 3m0 9.75V10.5" />
9699
</svg>,
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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-zA-Z0-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:&nbsp;</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+
};

frontend/src/components/sidebar/sidebar.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { BRAND_COLOR_BG, ClassNames } from "../classes";
3535
import { createDropdownItem, Dropdown, IDropdownItem } from "../dropdown";
3636
import { Icons } from "../icons";
3737
import { Loading } from "../loading";
38+
import { ProfileInfoTooltip, updateProfileLastAccessed } from "../profile-info-tooltip";
3839

3940

4041
type IRoute = {
@@ -131,31 +132,37 @@ export const SideMenu: FC<IRouteProps> = (props) => {
131132

132133
function getDropdownLoginProfileItem(profile: LocalLoginProfile): IDropdownItem {
133134
const icon = (Icons.Logos as Record<string, ReactElement>)[profile.Type];
135+
const info = <ProfileInfoTooltip profile={profile} />;
136+
134137
if (profile.Saved) {
135138
return {
136139
id: profile.Id,
137140
label: profile.Id,
138141
icon,
142+
info,
139143
}
140144
}
141145
if (profile.Type === DatabaseType.MongoDb) {
142146
return {
143147
id: profile.Id,
144148
label: `${profile.Hostname} - ${profile.Username} [${profile.Type}]`,
145149
icon,
150+
info,
146151
}
147152
}
148153
if (profile.Type === DatabaseType.Sqlite3) {
149154
return {
150155
id: profile.Id,
151156
label: `${profile.Database} [${profile.Type}]`,
152157
icon,
158+
info,
153159
}
154160
}
155161
return {
156162
id: profile.Id,
157163
label: `${profile.Hostname} - ${profile.Database} [${profile.Type}]`,
158164
icon,
165+
info,
159166
};
160167
}
161168

@@ -211,6 +218,7 @@ export const Sidebar: FC = () => {
211218
},
212219
onCompleted(status) {
213220
if (status.LoginWithProfile.Status) {
221+
updateProfileLastAccessed(item.id);
214222
dispatch(DatabaseActions.setSchema(""));
215223
dispatch(AuthActions.switch({ id: item.id }));
216224
navigate(InternalRoutes.Dashboard.StorageUnit.path);
@@ -237,6 +245,7 @@ export const Sidebar: FC = () => {
237245
},
238246
onCompleted(status) {
239247
if (status.Login.Status) {
248+
updateProfileLastAccessed(selectedProfile.Id);
240249
dispatch(DatabaseActions.setSchema(""));
241250
dispatch(AuthActions.switch({ id: selectedProfile.Id }));
242251
navigate(InternalRoutes.Dashboard.StorageUnit.path);

frontend/src/pages/auth/login.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import classNames from "classnames";
1818
import { entries } from "lodash";
19-
import { FC, ReactElement, useCallback, useEffect, useMemo, useState } from "react";
19+
import { FC, ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
2020
import { useNavigate, useSearchParams } from "react-router-dom";
2121
import logoImage from "url:../../../public/images/logo.png";
2222
import { AnimatedButton } from "../../components/button";
@@ -31,9 +31,10 @@ import { DatabaseType, LoginCredentials, useGetDatabaseLazyQuery, useGetProfiles
3131
import { AuthActions } from "../../store/auth";
3232
import { DatabaseActions } from "../../store/database";
3333
import { notify } from "../../store/function";
34-
import { useAppDispatch } from "../../store/hooks";
34+
import { useAppDispatch, useAppSelector } from "../../store/hooks";
35+
import { updateProfileLastAccessed } from "../../components/profile-info-tooltip";
3536

36-
const databaseTypeDropdownItems: IDropdownItem<Record<string, string>>[] = [
37+
export const databaseTypeDropdownItems: IDropdownItem<Record<string, string>>[] = [
3738
{
3839
id: "Postgres",
3940
label: "Postgres",
@@ -92,6 +93,8 @@ const databaseTypeDropdownItems: IDropdownItem<Record<string, string>>[] = [
9293
export const LoginPage: FC = () => {
9394
const dispatch = useAppDispatch();
9495
const navigate = useNavigate();
96+
const currentProfile = useAppSelector(state => state.auth.current);
97+
const shouldUpdateLastAccessed = useRef(false);
9598

9699
const [login, { loading: loginLoading }] = useLoginMutation();
97100
const [loginWithProfile, { loading: loginWithProfileLoading }] = useLoginWithProfileMutation();
@@ -136,7 +139,9 @@ export const LoginPage: FC = () => {
136139
},
137140
onCompleted(data) {
138141
if (data.Login.Status) {
139-
dispatch(AuthActions.login(credentials));
142+
const profileData = { ...credentials };
143+
shouldUpdateLastAccessed.current = true;
144+
dispatch(AuthActions.login(profileData));
140145
navigate(InternalRoutes.Dashboard.StorageUnit.path);
141146
return notify("Login successfully", "success");
142147
}
@@ -165,6 +170,7 @@ export const LoginPage: FC = () => {
165170
},
166171
onCompleted(data) {
167172
if (data.LoginWithProfile.Status) {
173+
updateProfileLastAccessed(selectedAvailableProfile.id);
168174
dispatch(AuthActions.login({
169175
Type: profile?.Type as DatabaseType,
170176
Id: selectedAvailableProfile.id,
@@ -221,6 +227,14 @@ export const LoginPage: FC = () => {
221227
dispatch(DatabaseActions.setSchema(""));
222228
}, [dispatch]);
223229

230+
// Update last accessed time when a new profile is created during login
231+
useEffect(() => {
232+
if (shouldUpdateLastAccessed.current && currentProfile?.Id) {
233+
updateProfileLastAccessed(currentProfile.Id);
234+
shouldUpdateLastAccessed.current = false;
235+
}
236+
}, [currentProfile]);
237+
224238
useEffect(() => {
225239
if (searchParams.size > 0) {
226240
if (searchParams.has("type")) {

0 commit comments

Comments
 (0)