fix: mostly UI imporvements

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-30 08:48:40 +01:00
parent 18600aa5ab
commit f1c85c53cf
16 changed files with 1043 additions and 230 deletions

View File

@@ -7,6 +7,7 @@ import { getProfileName } from '@/utils/getters';
import type { ColumnDef } from '@tanstack/react-table';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { KeyValueGrid } from '@/components/ui/key-value-grid';
import type { IServiceEvent } from '@openpanel/db';
@@ -107,8 +108,9 @@ export function useColumns() {
return (
<ProjectLink
href={`/profiles/${encodeURIComponent(profile.id)}`}
className="whitespace-nowrap font-medium hover:underline"
className="group whitespace-nowrap font-medium hover:underline row items-center gap-2"
>
<ProfileAvatar size="sm" {...profile} />
{getProfileName(profile)}
</ProjectLink>
);

View File

@@ -1,5 +1,6 @@
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { Tooltiper } from '@/components/ui/tooltip';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
import { formatTimeAgoOrDateTime } from '@/utils/date';
@@ -115,7 +116,7 @@ export const EventItem = memo<EventItemProps>(
)}
{viewOptions.profileId !== false && (
<Pill
className="@max-xl:ml-auto @max-lg:[&>span]:inline mx-4"
className="@max-xl:ml-auto @max-lg:[&>span]:inline"
icon={<ProfileAvatar size="xs" {...event.profile} />}
>
{getProfileName(event.profile)}
@@ -164,7 +165,8 @@ function Pill({
className,
}: { children: React.ReactNode; icon?: React.ReactNode; className?: string }) {
return (
<div
<Tooltiper
content={children}
className={cn(
'shrink-0 whitespace-nowrap inline-flex gap-2 items-center rounded-full @3xl:text-muted-foreground h-6 text-xs font-mono',
className,
@@ -172,6 +174,6 @@ function Pill({
>
{icon && <div className="size-4 center-center">{icon}</div>}
<div className="hidden @3xl:inline">{children}</div>
</div>
</Tooltiper>
);
}

View File

@@ -0,0 +1,161 @@
import * as React from 'react';
import { useAvatarContext } from './avatar';
import { Facehash, type FacehashProps } from './facehash';
const WHITESPACE_REGEX = /\s+/;
export type AvatarFallbackProps = Omit<
React.HTMLAttributes<HTMLSpanElement>,
'children'
> & {
/**
* The name to derive initials and Facehash from.
*/
name?: string;
/**
* Delay in milliseconds before showing the fallback.
* Useful to prevent flashing when images load quickly.
* @default 0
*/
delayMs?: number;
/**
* Custom children to render instead of initials or Facehash.
*/
children?: React.ReactNode;
/**
* Use the Facehash component as fallback instead of initials.
* @default true
*/
facehash?: boolean;
/**
* Use Tailwind group-hover for hover detection.
* When true, hover effect triggers when a parent with "group" class is hovered.
* @default false
*/
groupHover?: boolean;
/**
* Props to pass to the Facehash component.
*/
facehashProps?: Omit<FacehashProps, 'name'>;
};
/**
* Extracts initials from a name string.
*/
function getInitials(name: string): string {
const parts = name.trim().split(WHITESPACE_REGEX);
if (parts.length === 0) {
return '';
}
if (parts.length === 1) {
return parts[0]?.charAt(0).toUpperCase() || '';
}
const firstInitial = parts[0]?.charAt(0) || '';
const lastInitial = parts.at(-1)?.charAt(0) || '';
return (firstInitial + lastInitial).toUpperCase();
}
/**
* Fallback component that displays when the image fails to load.
* Uses Facehash by default, can show initials or custom content.
*/
export const AvatarFallback = React.forwardRef<
HTMLSpanElement,
AvatarFallbackProps
>(
(
{
name = '',
delayMs = 0,
children,
facehash = true,
groupHover = false,
facehashProps,
className,
style,
...props
},
ref,
) => {
const { imageLoadingStatus } = useAvatarContext();
const [canRender, setCanRender] = React.useState(delayMs === 0);
React.useEffect(() => {
if (delayMs > 0) {
const timerId = window.setTimeout(() => setCanRender(true), delayMs);
return () => window.clearTimeout(timerId);
}
}, [delayMs]);
const initials = React.useMemo(() => getInitials(name), [name]);
const shouldRender =
canRender &&
imageLoadingStatus !== 'loaded' &&
imageLoadingStatus !== 'loading';
if (!shouldRender) {
return null;
}
// Custom children take precedence
if (children) {
return (
<span
ref={ref}
className={className}
style={style}
data-avatar-fallback=""
{...props}
>
{children}
</span>
);
}
// Facehash mode (default)
if (facehash) {
return (
<Facehash
ref={ref as React.Ref<HTMLDivElement>}
name={name || '?'}
size="100%"
groupHover={groupHover}
{...facehashProps}
style={{
...style,
}}
{...props}
/>
);
}
// Initials mode
return (
<span
ref={ref}
className={className}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
...style,
}}
data-avatar-fallback=""
{...props}
>
{initials}
</span>
);
},
);
AvatarFallback.displayName = 'AvatarFallback';

View File

@@ -0,0 +1,94 @@
import * as React from 'react';
import { useAvatarContext } from './avatar';
type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error';
export type AvatarImageProps = Omit<
React.ImgHTMLAttributes<HTMLImageElement>,
'src'
> & {
/**
* The image source URL. If empty or undefined, triggers error state.
*/
src?: string | null;
/**
* Callback when the image loading status changes.
*/
onLoadingStatusChange?: (status: ImageLoadingStatus) => void;
};
/**
* Image component that syncs its loading state with the Avatar context.
* Automatically hides when loading fails, allowing fallback to show.
*/
export const AvatarImage = React.forwardRef<HTMLImageElement, AvatarImageProps>(
(
{ src, alt = '', className, style, onLoadingStatusChange, ...props },
ref,
) => {
const { imageLoadingStatus, onImageLoadingStatusChange } =
useAvatarContext();
const imageRef = React.useRef<HTMLImageElement>(null);
React.useImperativeHandle(ref, () => imageRef.current!);
const updateStatus = React.useCallback(
(status: ImageLoadingStatus) => {
onImageLoadingStatusChange(status);
onLoadingStatusChange?.(status);
},
[onImageLoadingStatusChange, onLoadingStatusChange],
);
React.useLayoutEffect(() => {
if (!src) {
updateStatus('error');
return;
}
let isMounted = true;
const image = new Image();
const setStatus = (status: ImageLoadingStatus) => {
if (!isMounted) {
return;
}
updateStatus(status);
};
setStatus('loading');
image.onload = () => setStatus('loaded');
image.onerror = () => setStatus('error');
image.src = src;
return () => {
isMounted = false;
};
}, [src, updateStatus]);
if (imageLoadingStatus !== 'loaded') {
return null;
}
return (
<img
ref={imageRef}
src={src || undefined}
alt={alt}
className={className}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
...style,
}}
data-avatar-image=""
{...props}
/>
);
},
);
AvatarImage.displayName = 'AvatarImage';

View File

@@ -0,0 +1,78 @@
import * as React from 'react';
type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error';
export type AvatarContextValue = {
imageLoadingStatus: ImageLoadingStatus;
onImageLoadingStatusChange: (status: ImageLoadingStatus) => void;
};
const AvatarContext = React.createContext<AvatarContextValue | null>(null);
/**
* Hook to access the Avatar context.
* Throws an error if used outside of Avatar.
*/
export const useAvatarContext = () => {
const context = React.useContext(AvatarContext);
if (!context) {
throw new Error(
'Avatar compound components must be rendered within an Avatar component',
);
}
return context;
};
export type AvatarProps = React.HTMLAttributes<HTMLSpanElement> & {
/**
* Render as a different element using the asChild pattern.
* When true, Avatar renders its child and merges props.
*/
asChild?: boolean;
};
/**
* Root avatar component that provides context for image loading state.
*/
export const Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(
({ children, className, style, asChild = false, ...props }, ref) => {
const [imageLoadingStatus, setImageLoadingStatus] =
React.useState<ImageLoadingStatus>('idle');
const contextValue: AvatarContextValue = React.useMemo(
() => ({
imageLoadingStatus,
onImageLoadingStatusChange: setImageLoadingStatus,
}),
[imageLoadingStatus],
);
const Element = asChild ? React.Fragment : 'span';
const elementProps = asChild
? {}
: {
ref,
className,
style: {
position: 'relative' as const,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
overflow: 'hidden',
...style,
},
'data-avatar': '',
'data-state': imageLoadingStatus,
...props,
};
return (
<AvatarContext.Provider value={contextValue}>
<Element {...elementProps}>{children}</Element>
</AvatarContext.Provider>
);
},
);
Avatar.displayName = 'Avatar';

View File

@@ -0,0 +1,417 @@
import * as React from 'react';
import { FACES } from './faces';
import { stringHash } from './utils/hash';
// ============================================================================
// Types
// ============================================================================
export type Intensity3D = 'none' | 'subtle' | 'medium' | 'dramatic';
export type Variant = 'gradient' | 'solid';
export type ColorScheme = 'light' | 'dark' | 'auto';
export interface FacehashProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
/**
* String to generate a deterministic face from.
* Same string always produces the same face.
*/
name: string;
/**
* Size in pixels or CSS units.
* @default 40
*/
size?: number | string;
/**
* Background style.
* - "gradient": Adds gradient overlay (default)
* - "solid": Plain background color
* @default "gradient"
*/
variant?: Variant;
/**
* 3D effect intensity.
* @default "dramatic"
*/
intensity3d?: Intensity3D;
/**
* Enable hover interaction.
* When true, face "looks straight" on hover.
* @default true
*/
interactive?: boolean;
/**
* Use Tailwind group-hover for hover detection.
* When true, hover effect triggers when a parent with "group" class is hovered.
* @default false
*/
groupHover?: boolean;
/**
* Show first letter of name below the face.
* @default true
*/
showInitial?: boolean;
/**
* Hex color array for inline styles.
* Use this OR colorClasses, not both.
*/
colors?: string[];
/**
* Colors to use in light mode.
* Used when colorScheme is "light" or "auto".
*/
colorsLight?: string[];
/**
* Colors to use in dark mode.
* Used when colorScheme is "dark" or "auto".
*/
colorsDark?: string[];
/**
* Which color scheme to use.
* - "light": Always use colorsLight
* - "dark": Always use colorsDark
* - "auto": Use CSS prefers-color-scheme media query
* @default "auto"
*/
colorScheme?: ColorScheme;
/**
* Tailwind class array for background colors.
* Example: ["bg-pink-500 dark:bg-pink-600", "bg-blue-500 dark:bg-blue-600"]
* Use this OR colors, not both.
*/
colorClasses?: string[];
/**
* Custom gradient overlay class (Tailwind).
* When provided, replaces the default pure CSS gradient.
* Only used when variant="gradient".
*/
gradientOverlayClass?: string;
}
// ============================================================================
// Constants
// ============================================================================
const INTENSITY_PRESETS = {
none: {
rotateRange: 0,
translateZ: 0,
perspective: 'none',
},
subtle: {
rotateRange: 5,
translateZ: 4,
perspective: '800px',
},
medium: {
rotateRange: 10,
translateZ: 8,
perspective: '500px',
},
dramatic: {
rotateRange: 15,
translateZ: 12,
perspective: '300px',
},
} as const;
const SPHERE_POSITIONS = [
{ x: -1, y: 1 }, // down-right
{ x: 1, y: 1 }, // up-right
{ x: 1, y: 0 }, // up
{ x: 0, y: 1 }, // right
{ x: -1, y: 0 }, // down
{ x: 0, y: 0 }, // center
{ x: 0, y: -1 }, // left
{ x: -1, y: -1 }, // down-left
{ x: 1, y: -1 }, // up-left
] as const;
// Default color palettes
export const DEFAULT_COLORS = [
'#fce7f3', // pink-100
'#fef3c7', // amber-100
'#dbeafe', // blue-100
'#d1fae5', // emerald-100
'#ede9fe', // violet-100
'#fee2e2', // red-100
'#e0e7ff', // indigo-100
'#ccfbf1', // teal-100
];
export const DEFAULT_COLORS_LIGHT = DEFAULT_COLORS;
export const DEFAULT_COLORS_DARK = [
'#db2777', // pink-600
'#d97706', // amber-600
'#2563eb', // blue-600
'#059669', // emerald-600
'#7c3aed', // violet-600
'#dc2626', // red-600
'#4f46e5', // indigo-600
'#0d9488', // teal-600
];
// Default gradient as pure CSS (works without Tailwind)
const DEFAULT_GRADIENT_STYLE: React.CSSProperties = {
background:
'radial-gradient(ellipse 100% 100% at 50% 50%, rgba(255,255,255,0.15) 0%, transparent 60%)',
};
// ============================================================================
// Component
// ============================================================================
/**
* Facehash - Deterministic avatar faces from any string.
*/
/**
* Hook to detect system color scheme preference
*/
function useColorScheme(colorScheme: ColorScheme): 'light' | 'dark' {
const [systemScheme, setSystemScheme] = React.useState<'light' | 'dark'>(
() => {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
},
);
React.useEffect(() => {
if (colorScheme !== 'auto') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
setSystemScheme(e.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [colorScheme]);
if (colorScheme === 'auto') {
return systemScheme;
}
return colorScheme;
}
export const Facehash = React.forwardRef<HTMLDivElement, FacehashProps>(
(
{
name,
size = 40,
variant = 'gradient',
intensity3d = 'dramatic',
interactive = true,
showInitial = true,
colors,
colorsLight,
colorsDark,
colorScheme = 'auto',
colorClasses,
gradientOverlayClass,
groupHover = false,
className,
style,
onMouseEnter,
onMouseLeave,
...props
},
ref,
) => {
const [isHovered, setIsHovered] = React.useState(false);
const resolvedScheme = useColorScheme(colorScheme);
// For group-hover, we use CSS instead of JS state
const usesCssHover = groupHover;
// Determine which colors to use based on scheme
const effectiveColors = React.useMemo(() => {
// If explicit colors prop is provided, use it
if (colors) return colors;
// If colorClasses is provided, don't use inline colors
if (colorClasses) return undefined;
// Use scheme-specific colors or defaults
const lightColors = colorsLight ?? DEFAULT_COLORS_LIGHT;
const darkColors = colorsDark ?? DEFAULT_COLORS_DARK;
return resolvedScheme === 'dark' ? darkColors : lightColors;
}, [colors, colorClasses, colorsLight, colorsDark, resolvedScheme]);
// Generate deterministic values from name
const { FaceComponent, colorIndex, rotation } = React.useMemo(() => {
const hash = stringHash(name);
const faceIndex = hash % FACES.length;
const colorsLength = colorClasses?.length ?? effectiveColors?.length ?? 1;
const _colorIndex = hash % colorsLength;
const positionIndex = hash % SPHERE_POSITIONS.length;
const position = SPHERE_POSITIONS[positionIndex] ?? { x: 0, y: 0 };
return {
FaceComponent: FACES[faceIndex] ?? FACES[0],
colorIndex: _colorIndex,
rotation: position,
};
}, [name, effectiveColors?.length, colorClasses?.length]);
// Get intensity preset
const preset = INTENSITY_PRESETS[intensity3d];
// Calculate 3D transforms
const { baseTransform, hoverTransform } = React.useMemo(() => {
if (intensity3d === 'none') {
return { baseTransform: undefined, hoverTransform: undefined };
}
const rotateX = rotation.x * preset.rotateRange;
const rotateY = rotation.y * preset.rotateRange;
return {
baseTransform: `rotateX(${rotateX}deg) rotateY(${rotateY}deg) translateZ(${preset.translateZ}px)`,
hoverTransform: `rotateX(0deg) rotateY(0deg) translateZ(${preset.translateZ}px)`,
};
}, [intensity3d, rotation, preset]);
// For JS-based hover, apply transform based on hover state
const transform = React.useMemo(() => {
if (usesCssHover || !interactive) {
return baseTransform;
}
return isHovered ? hoverTransform : baseTransform;
}, [usesCssHover, interactive, isHovered, baseTransform, hoverTransform]);
// Size style
const sizeValue = typeof size === 'number' ? `${size}px` : size;
// Initial letter
const initial = name.charAt(0).toUpperCase();
// Background: either hex color (inline) or class
const bgColorClass = colorClasses?.[colorIndex];
const bgColorHex = effectiveColors?.[colorIndex];
// Event handlers (only used for JS-based hover, not group-hover)
const handleMouseEnter = React.useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (interactive && !usesCssHover) {
setIsHovered(true);
}
onMouseEnter?.(e);
},
[interactive, usesCssHover, onMouseEnter],
);
const handleMouseLeave = React.useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (interactive && !usesCssHover) {
setIsHovered(false);
}
onMouseLeave?.(e);
},
[interactive, usesCssHover, onMouseLeave],
);
return (
<div
ref={ref}
role="img"
aria-label={`Avatar for ${name}`}
data-facehash-avatar=""
className={`${bgColorClass ?? ''} ${className ?? ''}`}
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: sizeValue,
height: sizeValue,
perspective: preset.perspective,
color: 'currentColor',
...(bgColorHex && { backgroundColor: bgColorHex }),
...style,
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
{/* Gradient overlay */}
{variant === 'gradient' && (
<div
className={gradientOverlayClass}
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
...(gradientOverlayClass ? {} : DEFAULT_GRADIENT_STYLE),
}}
aria-hidden="true"
/>
)}
{/* Face container with 3D transform */}
<div
data-facehash-avatar-face=""
className={
usesCssHover && interactive
? 'group-hover:[transform:var(--facehash-hover-transform)]'
: undefined
}
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transform,
transition: interactive ? 'transform 0.2s ease-out' : undefined,
transformStyle: 'preserve-3d',
'--facehash-hover-transform': hoverTransform,
} as React.CSSProperties}
>
{/* Face SVG */}
<FaceComponent
style={{
position: 'absolute',
width: '100%',
height: '100%',
}}
/>
{/* Initial letter */}
{showInitial && (
<span
data-facehash-avatar-initial=""
style={{
position: 'relative',
marginTop: '25%',
fontSize: `calc(${sizeValue} * 0.35)`,
fontWeight: 600,
lineHeight: 1,
userSelect: 'none',
}}
>
{initial}
</span>
)}
</div>
</div>
);
},
);
Facehash.displayName = 'Facehash';

View File

@@ -0,0 +1,57 @@
import type * as React from 'react';
export type FaceProps = {
className?: string;
style?: React.CSSProperties;
};
/**
* Round eyes face - simple circular eyes
*/
export const RoundFace: React.FC<FaceProps> = ({ className, style }) => (
<svg viewBox="0 0 100 100" className={className} style={style} aria-hidden="true">
<title>Round Eyes</title>
<circle cx="35" cy="45" r="8" fill="currentColor" />
<circle cx="65" cy="45" r="8" fill="currentColor" />
</svg>
);
/**
* Cross eyes face - X-shaped eyes
*/
export const CrossFace: React.FC<FaceProps> = ({ className, style }) => (
<svg viewBox="0 0 100 100" className={className} style={style} aria-hidden="true">
<title>Cross Eyes</title>
<path d="M27 37 L43 53 M43 37 L27 53" stroke="currentColor" strokeWidth="4" strokeLinecap="round" />
<path d="M57 37 L73 53 M73 37 L57 53" stroke="currentColor" strokeWidth="4" strokeLinecap="round" />
</svg>
);
/**
* Line eyes face - horizontal line eyes
*/
export const LineFace: React.FC<FaceProps> = ({ className, style }) => (
<svg viewBox="0 0 100 100" className={className} style={style} aria-hidden="true">
<title>Line Eyes</title>
<line x1="27" y1="45" x2="43" y2="45" stroke="currentColor" strokeWidth="4" strokeLinecap="round" />
<line x1="57" y1="45" x2="73" y2="45" stroke="currentColor" strokeWidth="4" strokeLinecap="round" />
</svg>
);
/**
* Curved eyes face - sleepy/happy curved eyes
*/
export const CurvedFace: React.FC<FaceProps> = ({ className, style }) => (
<svg viewBox="0 0 100 100" className={className} style={style} aria-hidden="true">
<title>Curved Eyes</title>
<path d="M27 50 Q35 38 43 50" stroke="currentColor" strokeWidth="4" strokeLinecap="round" fill="none" />
<path d="M57 50 Q65 38 73 50" stroke="currentColor" strokeWidth="4" strokeLinecap="round" fill="none" />
</svg>
);
/**
* All available face components
*/
export const FACES = [RoundFace, CrossFace, LineFace, CurvedFace] as const;
export type FaceComponent = (typeof FACES)[number];

View File

@@ -0,0 +1,44 @@
// ============================================================================
// Primary Export - This is what you want
// ============================================================================
export type { FacehashProps, Intensity3D, Variant, ColorScheme } from './facehash';
export {
Facehash,
DEFAULT_COLORS,
DEFAULT_COLORS_LIGHT,
DEFAULT_COLORS_DARK,
} from './facehash';
// ============================================================================
// Avatar Compound Components - For image + fallback pattern
// ============================================================================
export {
Avatar,
type AvatarContextValue,
type AvatarProps,
useAvatarContext,
} from './avatar';
export { AvatarFallback, type AvatarFallbackProps } from './avatar-fallback';
export { AvatarImage, type AvatarImageProps } from './avatar-image';
// ============================================================================
// Face Components - For custom compositions
// ============================================================================
export {
CrossFace,
CurvedFace,
FACES,
type FaceComponent,
type FaceProps,
LineFace,
RoundFace,
} from './faces';
// ============================================================================
// Utilities
// ============================================================================
export { stringHash } from './utils/hash';

View File

@@ -0,0 +1 @@
based on https://www.facehash.dev/ but the npm package broke everything so just added the source here.

View File

@@ -0,0 +1,16 @@
/**
* Generates a consistent numeric hash from a string.
* Used to deterministically select faces and colors for avatars.
*
* @param str - The input string to hash
* @returns A positive 32-bit integer hash
*/
export function stringHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash &= hash; // Convert to 32bit integer
}
return Math.abs(hash);
}

View File

@@ -1,11 +1,9 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/facehash';
import { cn } from '@/utils/cn';
import { AvatarImage } from '@radix-ui/react-avatar';
import { type GetProfileNameProps, getProfileName } from '@/utils/getters';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import { type GetProfileNameProps, getProfileName } from '@/utils/getters';
import { Avatar, AvatarFallback } from '../ui/avatar';
interface ProfileAvatarProps
extends VariantProps<typeof variants>,
GetProfileNameProps {
@@ -33,27 +31,17 @@ export function ProfileAvatar({
size,
...profile
}: ProfileAvatarProps) {
const name = getProfileName(profile);
const name = getProfileName({ ...profile, isExternal: true });
const isValidAvatar = avatar?.startsWith('http');
return (
<Avatar className={cn(variants({ className, size }), className)}>
{isValidAvatar && <AvatarImage src={avatar} className="rounded-full" />}
<AvatarFallback
className={cn(
'rounded-full',
size === 'lg'
? 'text-lg'
: size === 'sm'
? 'text-sm'
: size === 'xs'
? 'text-[8px]'
: 'text-base',
'bg-def-200 text-muted-foreground',
)}
>
{name?.at(0)?.toUpperCase() ?? '🧔‍♂️'}
</AvatarFallback>
name={name ?? 'Unknown'}
facehash
className="rounded-full"
/>
</Avatar>
);
}

View File

@@ -36,7 +36,7 @@ const createImageIcon = (url: string) => {
return (
<img
alt="serie icon"
className="max-h-4 rounded-[2px] object-contain"
className="w-full max-h-4 rounded-[2px] object-contain"
src={context.apiUrl?.replace(/\/$/, '') + url}
loading="lazy"
decoding="async"

View File

@@ -3,7 +3,8 @@
const data = {
amazon: 'https://upload.wikimedia.org/wikipedia/commons/4/4a/Amazon_icon.svg',
'chromium os': 'https://upload.wikimedia.org/wikipedia/commons/2/28/Chromium_Logo.svg',
'mac os': 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/MacOS_logo.svg/1200px-MacOS_logo.svg.png',
'mac os': 'https://upload.wikimedia.org/wikipedia/commons/f/fa/Apple_logo_black.svg',
'macos': 'https://upload.wikimedia.org/wikipedia/commons/f/fa/Apple_logo_black.svg',
apple: 'https://sladesportfolio.wordpress.com/wp-content/uploads/2015/08/apple_logo_black-svg.png',
huawei: 'https://upload.wikimedia.org/wikipedia/en/0/04/Huawei_Standard_logo.svg',
xiaomi: 'https://upload.wikimedia.org/wikipedia/commons/2/29/Xiaomi_logo.svg',
@@ -46,7 +47,7 @@ const data = {
google: 'https://google.com',
gsa: 'https://google.com', // Google Search App
instagram: 'https://instagram.com',
ios: 'https://cdn0.iconfinder.com/data/icons/flat-round-system/512/apple-1024.png',
ios: 'https://upload.wikimedia.org/wikipedia/commons/c/ca/IOS_logo.svg',
linkedin: 'https://linkedin.com',
linux: 'https://upload.wikimedia.org/wikipedia/commons/3/35/Tux.svg',
ubuntu: 'https://upload.wikimedia.org/wikipedia/commons/a/ab/Logo-ubuntu_cof-orange-hex.svg',

View File

@@ -4,6 +4,7 @@ import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { getProfileName } from '@/utils/getters';
import { round } from '@openpanel/common';
import type { IServiceSession } from '@openpanel/db';
@@ -63,8 +64,9 @@ export function useColumns() {
return (
<ProjectLink
href={`/profiles/${encodeURIComponent(session.profile.id)}`}
className="font-medium"
className="font-medium row gap-2 items-center"
>
<ProfileAvatar size="sm" {...session.profile} />
{getProfileName(session.profile)}
</ProjectLink>
);