refactor hover preview

This commit is contained in:
Dylan Knutson
2025-03-02 01:11:01 +00:00
parent f87c75186f
commit e1e2f1d472
5 changed files with 223 additions and 306 deletions

View File

@@ -0,0 +1,65 @@
import * as React from 'react';
import { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useHoverPreview } from '../utils/hoverPreviewUtils';
export interface HoverPreviewProps {
children: ReactNode;
previewContent: ReactNode;
previewClassName?: string;
maxWidth?: string;
maxHeight?: string;
}
export const HoverPreview: React.FC<HoverPreviewProps> = ({
children,
previewContent,
previewClassName = '',
maxWidth = '300px',
maxHeight = '500px',
}) => {
// Use shared hover preview hook
const {
containerRef,
previewRef,
portalContainer,
showPreview,
previewStyle,
handleMouseEnter,
handleMouseLeave,
} = useHoverPreview();
return (
<span
ref={containerRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
{showPreview &&
createPortal(
<div
ref={previewRef}
className={[
`max-w-[${maxWidth}] max-h-[${maxHeight}] rounded-lg border`,
'border-slate-100 bg-white dark:border-slate-600',
'divide-y divide-slate-100 dark:divide-slate-600',
'shadow-xl shadow-slate-700/50',
previewClassName,
].join(' ')}
style={{
...previewStyle,
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{previewContent}
</div>,
portalContainer,
)}
</span>
);
};
export default HoverPreview;

View File

@@ -1,7 +1,5 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useHoverPreview } from '../utils/hoverPreviewUtils';
import { HoverPreview } from './HoverPreview';
interface PostHoverPreviewProps {
children: React.ReactNode;
@@ -22,156 +20,81 @@ export const PostHoverPreview: React.FC<PostHoverPreviewProps> = ({
creatorName,
creatorAvatarPath,
}) => {
// State for image loading
const [imageLoaded, setImageLoaded] = useState(false);
// Use shared hover preview hook
const {
containerRef,
previewRef,
portalContainer,
showPreview,
previewStyle,
handleMouseEnter,
handleMouseLeave,
updatePosition,
} = useHoverPreview();
// Preload the thumbnail image when component mounts
useEffect(() => {
if (postThumbnailPath) {
const img = new Image();
img.onload = () => setImageLoaded(true);
img.src = postThumbnailPath;
}
}, [postThumbnailPath]);
// Preload creator avatar if available
useEffect(() => {
if (creatorAvatarPath) {
const img = new Image();
img.src = creatorAvatarPath;
}
}, [creatorAvatarPath]);
// Handle image load to reposition if needed
const handleImageLoad = () => {
if (showPreview) updatePosition();
};
const headerFooterClassName = [
'flex items-center justify-between overflow-hidden',
'border-slate-200 bg-gradient-to-r from-white to-slate-50 p-3',
'dark:border-slate-600 dark:from-slate-800 dark:to-slate-700',
].join(' ');
return (
<span
ref={containerRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
const previewContent = (
<>
{/* Header: Title */}
<div className={headerFooterClassName}>
<span
className="mr-2 min-w-0 truncate text-sm font-medium tracking-tight text-slate-800 dark:text-slate-300"
title={postTitle}
>
{postTitle}
</span>
</div>
{showPreview &&
createPortal(
<div
ref={previewRef}
className={[
'max-h-[500px] max-w-[300px] rounded-lg border',
'border-slate-100 bg-white dark:border-slate-600',
'divide-y divide-slate-100 dark:divide-slate-600',
'shadow-xl shadow-slate-700/50',
].join(' ')}
style={{
...previewStyle,
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Header: Title */}
<div className={headerFooterClassName}>
<span
className="mr-2 min-w-0 truncate text-sm font-medium tracking-tight text-slate-800 dark:text-slate-300"
title={postTitle}
>
{postTitle}
</span>
</div>
{/* Image Content */}
<div className="flex items-center justify-center bg-slate-50 p-2 dark:bg-slate-900">
{postThumbnailPath ? (
<div className="relative block overflow-hidden rounded-md transition-transform hover:scale-[1.02]">
{!imageLoaded && (
<div className="flex h-[200px] w-[200px] items-center justify-center rounded-md border border-slate-200 bg-slate-100 dark:border-slate-600 dark:bg-slate-800">
<span className="text-sm text-slate-500 dark:text-slate-400">
Loading...
</span>
</div>
)}
<img
src={postThumbnailPath}
alt={postTitle}
className={`max-h-[250px] max-w-full rounded-md border border-slate-200 object-contain shadow-md dark:border-slate-600 ${
!imageLoaded ? 'hidden' : ''
}`}
loading="eager"
onLoad={() => {
setImageLoaded(true);
handleImageLoad();
}}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
parent.innerHTML =
'<span class="text-sm italic text-slate-500 dark:text-slate-400">Image could not be loaded</span>';
}
}}
/>
</div>
) : (
<span className="block px-4 py-6 text-sm italic text-slate-500 dark:text-slate-400">
{postThumbnailAlt || 'No file available'}
</span>
)}
</div>
{/* Footer: Domain icon & Creator */}
{creatorName && (
<div className={headerFooterClassName}>
{(postDomainIcon && (
<img
src={postDomainIcon}
alt={postTitle}
className="h-6 w-6 rounded-md bg-slate-500 p-1 shadow-sm ring-sky-100 dark:ring-sky-900"
/>
)) || (
<span className="h-6 w-6 grow rounded-md shadow-sm ring-sky-100 dark:ring-sky-900"></span>
)}
<span className="flex items-center gap-2 justify-self-end">
<span
className="truncate text-xs font-medium text-slate-500 dark:text-slate-400"
title={creatorName}
>
{creatorName}
</span>
{creatorAvatarPath && (
<img
src={creatorAvatarPath}
alt={creatorName}
className="h-6 w-6 rounded-md shadow-sm ring-sky-100 dark:ring-sky-900"
/>
)}
</span>
</div>
)}
</div>,
portalContainer,
{/* Image Content */}
<div className="flex items-center justify-center bg-slate-50 p-2 dark:bg-slate-900">
{postThumbnailPath ? (
<div className="transition-transform hover:scale-[1.02]">
<img
src={postThumbnailPath}
alt={postTitle}
className="max-h-[250px] max-w-full rounded-md border border-slate-200 object-contain shadow-md dark:border-slate-600"
loading="eager"
/>
</div>
) : (
<span className="block px-4 py-6 text-sm italic text-slate-500 dark:text-slate-400">
{postThumbnailAlt || 'No file available'}
</span>
)}
</span>
</div>
{/* Footer: Domain icon & Creator */}
{creatorName && (
<div className={headerFooterClassName}>
{(postDomainIcon && (
<img
src={postDomainIcon}
alt={postTitle}
className="h-6 w-6 rounded-md bg-slate-500 p-1 shadow-sm ring-sky-100 dark:ring-sky-900"
/>
)) || (
<span className="h-6 w-6 grow rounded-md shadow-sm ring-sky-100 dark:ring-sky-900"></span>
)}
<span className="flex items-center gap-2 justify-self-end">
<span
className="truncate text-xs font-medium text-slate-500 dark:text-slate-400"
title={creatorName}
>
{creatorName}
</span>
{creatorAvatarPath && (
<img
src={creatorAvatarPath}
alt={creatorName}
className="h-6 w-6 rounded-md shadow-sm ring-sky-100 dark:ring-sky-900"
/>
)}
</span>
</div>
)}
</>
);
return (
<HoverPreview
children={children}
previewContent={previewContent}
maxWidth="300px"
maxHeight="500px"
/>
);
};

View File

@@ -38,7 +38,6 @@ export const PostHoverPreviewWrapper: React.FC<
>
<a
href={postPath}
// className="inline-flex items-center gap-1 rounded-md px-1"
className={anchorClassNamesForVisualStyle(visualStyle)}
>
{visualStyle === 'description-section-link' && (

View File

@@ -1,7 +1,5 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useHoverPreview } from '../utils/hoverPreviewUtils';
import { HoverPreview } from './HoverPreview';
interface UserHoverPreviewProps {
children: React.ReactNode;
@@ -26,175 +24,108 @@ export const UserHoverPreview: React.FC<UserHoverPreviewProps> = ({
userNumFollowedBy,
userNumFollowed,
}) => {
// State for image loading
const [imageLoaded, setImageLoaded] = useState(false);
// Use shared hover preview hook
const {
containerRef,
previewRef,
portalContainer,
showPreview,
previewStyle,
handleMouseEnter,
handleMouseLeave,
updatePosition,
} = useHoverPreview();
// Preload the avatar image when component mounts
useEffect(() => {
if (userAvatarPath) {
const img = new Image();
img.onload = () => setImageLoaded(true);
img.src = userAvatarPath;
}
}, [userAvatarPath]);
// Handle image load to reposition if needed
const handleImageLoad = () => {
if (showPreview) updatePosition();
};
const sectionClassName = [
'flex items-center overflow-hidden',
'border-slate-200 bg-gradient-to-r from-white to-slate-50 p-2',
'dark:border-slate-600 dark:from-slate-800 dark:to-slate-700',
].join(' ');
return (
<span
ref={containerRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
const previewContent = (
<>
{/* Header: User Name and Domain Icon */}
<div className={`${sectionClassName} justify-between`}>
<span
className="mr-2 min-w-0 truncate text-sm font-semibold tracking-tight text-slate-800 dark:text-slate-300"
title={userName}
>
{userName}
</span>
{userDomainIcon && (
<img
src={userDomainIcon}
alt="Domain"
className="h-5 w-5 rounded-md bg-slate-500 p-1 shadow-sm ring-sky-100 dark:ring-sky-900"
/>
)}
</div>
{showPreview &&
createPortal(
<div
ref={previewRef}
className={[
'max-h-[280px] max-w-[400px] rounded-lg border',
'border-slate-100 bg-white dark:border-slate-600',
'divide-y divide-slate-100 dark:divide-slate-600',
'shadow-xl shadow-slate-700/50',
].join(' ')}
style={{
...previewStyle,
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Header: User Name and Domain Icon */}
<div className={`${sectionClassName} justify-between`}>
<span
className="mr-2 min-w-0 truncate text-sm font-semibold tracking-tight text-slate-800 dark:text-slate-300"
title={userName}
>
{userName}
</span>
{userDomainIcon && (
{/* Profile Content */}
<div className="bg-slate-50 p-3 dark:bg-slate-900">
<div className="flex items-center gap-4">
{/* Avatar */}
<div className="flex-shrink-0">
{userAvatarPath ? (
<div className="overflow-hidden rounded-md">
<img
src={userDomainIcon}
alt="Domain"
className="h-5 w-5 rounded-md bg-slate-500 p-1 shadow-sm ring-sky-100 dark:ring-sky-900"
src={userAvatarPath}
alt={userAvatarAlt}
className="h-[80px] w-[80px] rounded-md border border-slate-200 object-cover shadow-sm dark:border-slate-600"
loading="eager"
/>
)}
</div>
</div>
) : (
<span className="block h-[80px] w-[80px] rounded-md border border-slate-200 bg-slate-100 p-2 text-xs italic text-slate-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-400">
No avatar
</span>
)}
</div>
{/* Profile Content */}
<div className="bg-slate-50 p-3 dark:bg-slate-900">
<div className="flex items-center gap-4">
{/* Avatar */}
<div className="flex-shrink-0">
{userAvatarPath ? (
<div className="relative block overflow-hidden rounded-md">
{!imageLoaded && (
<div className="flex h-[80px] w-[80px] items-center justify-center rounded-md border border-slate-200 bg-slate-100 dark:border-slate-600 dark:bg-slate-800">
<span className="text-xs text-slate-500 dark:text-slate-400">
Loading...
</span>
</div>
)}
<img
src={userAvatarPath}
alt={userAvatarAlt}
className={`h-[80px] w-[80px] rounded-md border border-slate-200 object-cover shadow-sm dark:border-slate-600 ${
!imageLoaded ? 'hidden' : ''
}`}
loading="eager"
onLoad={() => {
setImageLoaded(true);
handleImageLoad();
}}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
parent.innerHTML =
'<span class="text-xs italic text-slate-500 dark:text-slate-400">Avatar error</span>';
}
}}
/>
</div>
) : (
<span className="block h-[80px] w-[80px] rounded-md border border-slate-200 bg-slate-100 p-2 text-xs italic text-slate-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-400">
No avatar
{/* User Stats */}
<div className="flex-grow">
<div className="grid grid-cols-2 gap-3">
<div className="text-center">
<span className="block text-sm font-semibold text-slate-700 dark:text-slate-300">
{userNumPosts ?? '-'}
</span>
<span className="text-2xs text-slate-500 dark:text-slate-400">
Posts
</span>
</div>
<div className="text-center">
<span className="block text-sm font-semibold text-slate-700 dark:text-slate-300">
{userNumFollowedBy ?? '-'}
</span>
<span className="text-2xs text-slate-500 dark:text-slate-400">
Followers
</span>
</div>
<div className="text-center">
<span className="block text-sm font-semibold text-slate-700 dark:text-slate-300">
{userNumFollowed ?? '-'}
</span>
<span className="text-2xs text-slate-500 dark:text-slate-400">
Following
</span>
</div>
<div className="text-center">
{userRegisteredAt && (
<>
<span className="text-2xs block font-medium text-slate-700 dark:text-slate-300">
Member since
</span>
)}
</div>
{/* User Stats */}
<div className="flex-grow">
<div className="grid grid-cols-2 gap-3">
<div className="text-center">
<span className="block text-sm font-semibold text-slate-700 dark:text-slate-300">
{userNumPosts ?? '-'}
</span>
<span className="text-2xs text-slate-500 dark:text-slate-400">
Posts
</span>
</div>
<div className="text-center">
<span className="block text-sm font-semibold text-slate-700 dark:text-slate-300">
{userNumFollowedBy ?? '-'}
</span>
<span className="text-2xs text-slate-500 dark:text-slate-400">
Followers
</span>
</div>
<div className="text-center">
<span className="block text-sm font-semibold text-slate-700 dark:text-slate-300">
{userNumFollowed ?? '-'}
</span>
<span className="text-2xs text-slate-500 dark:text-slate-400">
Following
</span>
</div>
<div className="text-center">
{userRegisteredAt && (
<>
<span className="text-2xs block font-medium text-slate-700 dark:text-slate-300">
Member since
</span>
<span className="text-2xs leading-tight text-slate-500 dark:text-slate-400">
{userRegisteredAt}
</span>
</>
)}
</div>
</div>
</div>
<span className="text-2xs leading-tight text-slate-500 dark:text-slate-400">
{userRegisteredAt}
</span>
</>
)}
</div>
</div>
</div>,
portalContainer,
)}
</span>
</div>
</div>
</div>
</>
);
return (
<HoverPreview
children={children}
previewContent={previewContent}
maxWidth="400px"
maxHeight="280px"
/>
);
};

View File

@@ -134,7 +134,6 @@ export const useHoverPreview = () => {
previewStyle,
handleMouseEnter,
handleMouseLeave,
updatePosition,
};
};