fix: mostly UI imporvements
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
161
apps/start/src/components/facehash/avatar-fallback.tsx
Normal file
161
apps/start/src/components/facehash/avatar-fallback.tsx
Normal 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';
|
||||
94
apps/start/src/components/facehash/avatar-image.tsx
Normal file
94
apps/start/src/components/facehash/avatar-image.tsx
Normal 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';
|
||||
78
apps/start/src/components/facehash/avatar.tsx
Normal file
78
apps/start/src/components/facehash/avatar.tsx
Normal 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';
|
||||
417
apps/start/src/components/facehash/facehash.tsx
Normal file
417
apps/start/src/components/facehash/facehash.tsx
Normal 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';
|
||||
57
apps/start/src/components/facehash/faces.tsx
Normal file
57
apps/start/src/components/facehash/faces.tsx
Normal 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];
|
||||
44
apps/start/src/components/facehash/index.ts
Normal file
44
apps/start/src/components/facehash/index.ts
Normal 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';
|
||||
1
apps/start/src/components/facehash/readme.md
Normal file
1
apps/start/src/components/facehash/readme.md
Normal file
@@ -0,0 +1 @@
|
||||
based on https://www.facehash.dev/ but the npm package broke everything so just added the source here.
|
||||
16
apps/start/src/components/facehash/utils/hash.ts
Normal file
16
apps/start/src/components/facehash/utils/hash.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user