Skip to content

Commit f1c85c5

Browse files
committed
fix: mostly UI imporvements
1 parent 18600aa commit f1c85c5

File tree

16 files changed

+1038
-225
lines changed

16 files changed

+1038
-225
lines changed

apps/start/package.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818
},
1919
"dependencies": {
2020
"@ai-sdk/react": "^1.2.5",
21+
"@codemirror/commands": "^6.7.0",
22+
"@codemirror/lang-javascript": "^6.2.0",
23+
"@codemirror/lang-json": "^6.0.1",
24+
"@codemirror/state": "^6.4.0",
25+
"@codemirror/theme-one-dark": "^6.1.3",
26+
"@codemirror/view": "^6.35.0",
2127
"@dnd-kit/core": "^6.3.1",
2228
"@dnd-kit/sortable": "^10.0.0",
2329
"@dnd-kit/utilities": "^3.2.2",
@@ -28,8 +34,8 @@
2834
"@number-flow/react": "0.5.10",
2935
"@openpanel/common": "workspace:^",
3036
"@openpanel/constants": "workspace:^",
31-
"@openpanel/integrations": "workspace:^",
3237
"@openpanel/importer": "workspace:^",
38+
"@openpanel/integrations": "workspace:^",
3339
"@openpanel/json": "workspace:*",
3440
"@openpanel/payments": "workspace:*",
3541
"@openpanel/sdk-info": "workspace:^",
@@ -84,12 +90,6 @@
8490
"@types/d3": "^7.4.3",
8591
"ai": "^4.2.10",
8692
"bind-event-listener": "^3.0.0",
87-
"@codemirror/commands": "^6.7.0",
88-
"@codemirror/lang-javascript": "^6.2.0",
89-
"@codemirror/lang-json": "^6.0.1",
90-
"@codemirror/state": "^6.4.0",
91-
"@codemirror/theme-one-dark": "^6.1.3",
92-
"@codemirror/view": "^6.35.0",
9393
"class-variance-authority": "^0.7.1",
9494
"clsx": "^2.1.1",
9595
"cmdk": "^0.2.1",

apps/start/src/components/events/table/columns.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getProfileName } from '@/utils/getters';
77
import type { ColumnDef } from '@tanstack/react-table';
88

99
import { ColumnCreatedAt } from '@/components/column-created-at';
10+
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
1011
import { KeyValueGrid } from '@/components/ui/key-value-grid';
1112
import type { IServiceEvent } from '@openpanel/db';
1213

@@ -107,8 +108,9 @@ export function useColumns() {
107108
return (
108109
<ProjectLink
109110
href={`/profiles/${encodeURIComponent(profile.id)}`}
110-
className="whitespace-nowrap font-medium hover:underline"
111+
className="group whitespace-nowrap font-medium hover:underline row items-center gap-2"
111112
>
113+
<ProfileAvatar size="sm" {...profile} />
112114
{getProfileName(profile)}
113115
</ProjectLink>
114116
);

apps/start/src/components/events/table/item.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
22
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
3+
import { Tooltiper } from '@/components/ui/tooltip';
34
import { pushModal } from '@/modals';
45
import { cn } from '@/utils/cn';
56
import { formatTimeAgoOrDateTime } from '@/utils/date';
@@ -115,7 +116,7 @@ export const EventItem = memo<EventItemProps>(
115116
)}
116117
{viewOptions.profileId !== false && (
117118
<Pill
118-
className="@max-xl:ml-auto @max-lg:[&>span]:inline mx-4"
119+
className="@max-xl:ml-auto @max-lg:[&>span]:inline"
119120
icon={<ProfileAvatar size="xs" {...event.profile} />}
120121
>
121122
{getProfileName(event.profile)}
@@ -164,14 +165,15 @@ function Pill({
164165
className,
165166
}: { children: React.ReactNode; icon?: React.ReactNode; className?: string }) {
166167
return (
167-
<div
168+
<Tooltiper
169+
content={children}
168170
className={cn(
169171
'shrink-0 whitespace-nowrap inline-flex gap-2 items-center rounded-full @3xl:text-muted-foreground h-6 text-xs font-mono',
170172
className,
171173
)}
172174
>
173175
{icon && <div className="size-4 center-center">{icon}</div>}
174176
<div className="hidden @3xl:inline">{children}</div>
175-
</div>
177+
</Tooltiper>
176178
);
177179
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import * as React from 'react';
2+
import { useAvatarContext } from './avatar';
3+
import { Facehash, type FacehashProps } from './facehash';
4+
5+
const WHITESPACE_REGEX = /\s+/;
6+
7+
export type AvatarFallbackProps = Omit<
8+
React.HTMLAttributes<HTMLSpanElement>,
9+
'children'
10+
> & {
11+
/**
12+
* The name to derive initials and Facehash from.
13+
*/
14+
name?: string;
15+
16+
/**
17+
* Delay in milliseconds before showing the fallback.
18+
* Useful to prevent flashing when images load quickly.
19+
* @default 0
20+
*/
21+
delayMs?: number;
22+
23+
/**
24+
* Custom children to render instead of initials or Facehash.
25+
*/
26+
children?: React.ReactNode;
27+
28+
/**
29+
* Use the Facehash component as fallback instead of initials.
30+
* @default true
31+
*/
32+
facehash?: boolean;
33+
34+
/**
35+
* Use Tailwind group-hover for hover detection.
36+
* When true, hover effect triggers when a parent with "group" class is hovered.
37+
* @default false
38+
*/
39+
groupHover?: boolean;
40+
41+
/**
42+
* Props to pass to the Facehash component.
43+
*/
44+
facehashProps?: Omit<FacehashProps, 'name'>;
45+
};
46+
47+
/**
48+
* Extracts initials from a name string.
49+
*/
50+
function getInitials(name: string): string {
51+
const parts = name.trim().split(WHITESPACE_REGEX);
52+
if (parts.length === 0) {
53+
return '';
54+
}
55+
if (parts.length === 1) {
56+
return parts[0]?.charAt(0).toUpperCase() || '';
57+
}
58+
59+
const firstInitial = parts[0]?.charAt(0) || '';
60+
const lastInitial = parts.at(-1)?.charAt(0) || '';
61+
return (firstInitial + lastInitial).toUpperCase();
62+
}
63+
64+
/**
65+
* Fallback component that displays when the image fails to load.
66+
* Uses Facehash by default, can show initials or custom content.
67+
*/
68+
export const AvatarFallback = React.forwardRef<
69+
HTMLSpanElement,
70+
AvatarFallbackProps
71+
>(
72+
(
73+
{
74+
name = '',
75+
delayMs = 0,
76+
children,
77+
facehash = true,
78+
groupHover = false,
79+
facehashProps,
80+
className,
81+
style,
82+
...props
83+
},
84+
ref,
85+
) => {
86+
const { imageLoadingStatus } = useAvatarContext();
87+
const [canRender, setCanRender] = React.useState(delayMs === 0);
88+
89+
React.useEffect(() => {
90+
if (delayMs > 0) {
91+
const timerId = window.setTimeout(() => setCanRender(true), delayMs);
92+
return () => window.clearTimeout(timerId);
93+
}
94+
}, [delayMs]);
95+
96+
const initials = React.useMemo(() => getInitials(name), [name]);
97+
98+
const shouldRender =
99+
canRender &&
100+
imageLoadingStatus !== 'loaded' &&
101+
imageLoadingStatus !== 'loading';
102+
103+
if (!shouldRender) {
104+
return null;
105+
}
106+
107+
// Custom children take precedence
108+
if (children) {
109+
return (
110+
<span
111+
ref={ref}
112+
className={className}
113+
style={style}
114+
data-avatar-fallback=""
115+
{...props}
116+
>
117+
{children}
118+
</span>
119+
);
120+
}
121+
122+
// Facehash mode (default)
123+
if (facehash) {
124+
return (
125+
<Facehash
126+
ref={ref as React.Ref<HTMLDivElement>}
127+
name={name || '?'}
128+
size="100%"
129+
groupHover={groupHover}
130+
{...facehashProps}
131+
style={{
132+
...style,
133+
}}
134+
{...props}
135+
/>
136+
);
137+
}
138+
139+
// Initials mode
140+
return (
141+
<span
142+
ref={ref}
143+
className={className}
144+
style={{
145+
display: 'flex',
146+
alignItems: 'center',
147+
justifyContent: 'center',
148+
width: '100%',
149+
height: '100%',
150+
...style,
151+
}}
152+
data-avatar-fallback=""
153+
{...props}
154+
>
155+
{initials}
156+
</span>
157+
);
158+
},
159+
);
160+
161+
AvatarFallback.displayName = 'AvatarFallback';
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import * as React from 'react';
2+
import { useAvatarContext } from './avatar';
3+
4+
type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error';
5+
6+
export type AvatarImageProps = Omit<
7+
React.ImgHTMLAttributes<HTMLImageElement>,
8+
'src'
9+
> & {
10+
/**
11+
* The image source URL. If empty or undefined, triggers error state.
12+
*/
13+
src?: string | null;
14+
15+
/**
16+
* Callback when the image loading status changes.
17+
*/
18+
onLoadingStatusChange?: (status: ImageLoadingStatus) => void;
19+
};
20+
21+
/**
22+
* Image component that syncs its loading state with the Avatar context.
23+
* Automatically hides when loading fails, allowing fallback to show.
24+
*/
25+
export const AvatarImage = React.forwardRef<HTMLImageElement, AvatarImageProps>(
26+
(
27+
{ src, alt = '', className, style, onLoadingStatusChange, ...props },
28+
ref,
29+
) => {
30+
const { imageLoadingStatus, onImageLoadingStatusChange } =
31+
useAvatarContext();
32+
33+
const imageRef = React.useRef<HTMLImageElement>(null);
34+
React.useImperativeHandle(ref, () => imageRef.current!);
35+
36+
const updateStatus = React.useCallback(
37+
(status: ImageLoadingStatus) => {
38+
onImageLoadingStatusChange(status);
39+
onLoadingStatusChange?.(status);
40+
},
41+
[onImageLoadingStatusChange, onLoadingStatusChange],
42+
);
43+
44+
React.useLayoutEffect(() => {
45+
if (!src) {
46+
updateStatus('error');
47+
return;
48+
}
49+
50+
let isMounted = true;
51+
const image = new Image();
52+
53+
const setStatus = (status: ImageLoadingStatus) => {
54+
if (!isMounted) {
55+
return;
56+
}
57+
updateStatus(status);
58+
};
59+
60+
setStatus('loading');
61+
62+
image.onload = () => setStatus('loaded');
63+
image.onerror = () => setStatus('error');
64+
image.src = src;
65+
66+
return () => {
67+
isMounted = false;
68+
};
69+
}, [src, updateStatus]);
70+
71+
if (imageLoadingStatus !== 'loaded') {
72+
return null;
73+
}
74+
75+
return (
76+
<img
77+
ref={imageRef}
78+
src={src || undefined}
79+
alt={alt}
80+
className={className}
81+
style={{
82+
width: '100%',
83+
height: '100%',
84+
objectFit: 'cover',
85+
...style,
86+
}}
87+
data-avatar-image=""
88+
{...props}
89+
/>
90+
);
91+
},
92+
);
93+
94+
AvatarImage.displayName = 'AvatarImage';

0 commit comments

Comments
 (0)