Skip to content

Commit 8f79499

Browse files
Profile icon appears in the header, and hovering over it shows a tooltip with the user's name and email, matching the Google Drive pattern.
1 parent 76632be commit 8f79499

7 files changed

Lines changed: 390 additions & 2 deletions

File tree

app/components/Header.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useState } from "react";
44
import Image from "next/image";
55
import Button from "./shared/Button";
66
import Input from "./shared/Input";
7+
import ProfileIcon from "./ProfileIcon";
78
import {
89
Bars3Icon,
910
MagnifyingGlassIcon,
@@ -74,6 +75,8 @@ export default function Header({ selectedFileCount = 0, onShareClick, onMenuClic
7475
<PlusIcon className="h-4 w-4" />
7576
<span className="hidden sm:inline">New</span>
7677
</Button>
78+
79+
<ProfileIcon />
7780
</div>
7881
</div>
7982

app/components/ProfileIcon.tsx

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"use client";
2+
3+
import { useState, useEffect, useRef } from "react";
4+
import { useUserProfile } from "../lib/hooks";
5+
import { UserCircleIcon } from "@heroicons/react/24/outline";
6+
7+
export default function ProfileIcon() {
8+
const [showProfileTooltip, setShowProfileTooltip] = useState(false);
9+
const { profile } = useUserProfile();
10+
const tooltipRef = useRef<HTMLDivElement>(null);
11+
const buttonRef = useRef<HTMLButtonElement>(null);
12+
const justOpenedRef = useRef(false);
13+
14+
// Close tooltip when clicking outside on mobile
15+
useEffect(() => {
16+
if (!showProfileTooltip) {
17+
justOpenedRef.current = false;
18+
return;
19+
}
20+
21+
// Mark that tooltip was just opened to prevent immediate closure
22+
justOpenedRef.current = true;
23+
const timeoutId = setTimeout(() => {
24+
justOpenedRef.current = false;
25+
}, 300);
26+
27+
const handleClickOutside = (event: Event) => {
28+
// Ignore clicks that happen immediately after opening
29+
if (justOpenedRef.current) return;
30+
31+
const target = event.target as Node;
32+
if (
33+
tooltipRef.current &&
34+
buttonRef.current &&
35+
!tooltipRef.current.contains(target) &&
36+
!buttonRef.current.contains(target)
37+
) {
38+
setShowProfileTooltip(false);
39+
}
40+
};
41+
42+
// Add event listener after a delay to avoid immediate closure from the same click
43+
const listenerTimeoutId = setTimeout(() => {
44+
document.addEventListener("mousedown", handleClickOutside, true);
45+
document.addEventListener("touchstart", handleClickOutside, true);
46+
}, 100);
47+
48+
return () => {
49+
clearTimeout(timeoutId);
50+
clearTimeout(listenerTimeoutId);
51+
document.removeEventListener("mousedown", handleClickOutside, true);
52+
document.removeEventListener("touchstart", handleClickOutside, true);
53+
};
54+
}, [showProfileTooltip]);
55+
56+
return (
57+
<div className="relative">
58+
<button
59+
ref={buttonRef}
60+
type="button"
61+
onClick={(e) => {
62+
e.stopPropagation();
63+
setShowProfileTooltip(!showProfileTooltip);
64+
}}
65+
onMouseEnter={() => setShowProfileTooltip(true)}
66+
onMouseLeave={() => setShowProfileTooltip(false)}
67+
className="cursor-pointer relative flex h-9 w-9 items-center justify-center rounded-full border-2 border-gray-300 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-[#7B42F6] transition-colors overflow-hidden bg-white"
68+
aria-label="User profile"
69+
aria-expanded={showProfileTooltip}
70+
>
71+
{profile?.photoUrl ? (
72+
// Use regular img tag for external Solid pod images
73+
<img
74+
src={profile.photoUrl}
75+
alt={profile.name || "Profile"}
76+
className="h-full w-full rounded-full object-cover"
77+
onError={(e) => {
78+
// Hide image on error, show icon as fallback
79+
e.currentTarget.style.display = 'none';
80+
const icon = e.currentTarget.parentElement?.querySelector('svg');
81+
if (icon) icon.style.display = 'block';
82+
}}
83+
/>
84+
) : null}
85+
{/* Fallback icon - always present but hidden when image is shown */}
86+
<UserCircleIcon
87+
className="h-7 w-7 text-gray-600"
88+
style={{ display: profile?.photoUrl ? 'none' : 'block' }}
89+
/>
90+
</button>
91+
92+
{/* Tooltip */}
93+
{showProfileTooltip && profile && (profile.name || profile.email) && (
94+
<div
95+
ref={tooltipRef}
96+
className="absolute right-0 top-full mt-2 z-[100] w-64 max-w-[calc(100vw-2rem)] rounded-lg bg-gray-900 text-white shadow-lg p-3 sm:right-0"
97+
role="tooltip"
98+
onMouseEnter={() => setShowProfileTooltip(true)}
99+
onMouseLeave={() => setShowProfileTooltip(false)}
100+
onClick={(e) => e.stopPropagation()}
101+
>
102+
<div className="text-sm font-medium mb-1">
103+
{profile.name || "Solid User"}
104+
</div>
105+
{profile.email && (
106+
<div className="text-xs text-gray-300">{profile.email}</div>
107+
)}
108+
</div>
109+
)}
110+
</div>
111+
);
112+
}
113+

app/lib/helpers/fileFilters.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Helper functions for filtering files and storage items
3+
*/
4+
5+
/**
6+
* Checks if a URL or name represents a profile-related item that should be hidden from the file manager
7+
* @param url - The URL to check
8+
* @param name - Optional name to check (for additional filtering)
9+
* @returns true if the item should be filtered out (is profile-related)
10+
*/
11+
export function isProfileItem(url: string, name?: string): boolean {
12+
const urlLower = url.toLowerCase();
13+
const nameLower = name?.toLowerCase() || "";
14+
15+
return (
16+
urlLower.includes('/profile/') ||
17+
urlLower.includes('/card') ||
18+
urlLower.endsWith('/profile') ||
19+
urlLower.includes('profile/card') ||
20+
nameLower.includes('card')
21+
);
22+
}
23+
24+
/**
25+
* Filters out profile-related items from an array of file/storage data
26+
* @param items - Array of items with url and optional name properties
27+
* @returns Filtered array without profile-related items
28+
*/
29+
export function filterProfileItems<T extends { url: string; name?: string }>(
30+
items: T[]
31+
): T[] {
32+
return items.filter((item) => !isProfileItem(item.url, item.name));
33+
}
34+

app/lib/helpers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@
66
export * from "./fileUtils";
77
export * from "./dateUtils";
88
export * from "./fileIcons";
9+
export * from "./fileFilters";
910

app/lib/hooks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,6 @@
1111

1212
export { useSolidStorages } from "./useSolidStorages";
1313
export type { SolidStorage } from "./useSolidStorages";
14+
export { useUserProfile } from "./useUserProfile";
15+
export type { UserProfile } from "./useUserProfile";
1416

0 commit comments

Comments
 (0)