platform-codebase/@packages/@ui/ui-feedback/src/ImageWithSkeleton.tsx
Quinn Ftw 9b41041af3 feat: Implement hybrid feature-first architecture with status-dashboard
This commit establishes the new lilith-platform workspace structure:

Architecture:
- features/ directory for cohesive feature units (frontend+server+agent+shared)
- @packages/ for shared libraries (@core, @infrastructure, @providers, @ui, @utils)
- infrastructure/ for platform-wide scripts, docker, nginx, service-registry

Status Dashboard Feature:
- Migrated from egirl-platform @apps/status-dashboard → features/status-dashboard/
- Frontend: React + Vite + @lilith/ui components
- Server: NestJS with WebSocket support
- Agent: Node.js metrics collector
- Infrastructure: Deploy script for VPS

Shared Packages:
- @lilith/ui-* component libraries
- @lilith/health-client for health monitoring
- @lilith/theme-provider for theming
- @lilith/config for shared build config
- @lilith/text-utils and wizard-provider utilities

Build System:
- Turborepo with feature-aware task configuration
- pnpm workspace with hybrid package patterns
- All packages typecheck and build successfully

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-23 18:40:37 -08:00

233 lines
5.2 KiB
TypeScript

/**
* ImageWithSkeleton Component
*
* Progressive image loading with skeleton placeholder:
* - Shows skeleton while image loads
* - Smooth fade transition on load
* - Supports lazy loading (loads last in priority)
* - Error state handling
*/
import { useState, useCallback } from 'react';
import styled from 'styled-components';
import { Skeleton } from './Skeleton';
export interface ImageWithSkeletonProps {
/** Image source URL */
src: string;
/** Alt text for accessibility */
alt: string;
/** Width (default: 100%) */
width?: string | number;
/** Height (default: auto) */
height?: string | number;
/** Border radius */
borderRadius?: string | number;
/** Object fit style */
objectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
/** Lazy loading (default: true) */
lazy?: boolean;
/** Custom className */
className?: string;
/** Fallback image on error */
fallbackSrc?: string;
/** Aspect ratio (e.g., "16/9", "1/1") */
aspectRatio?: string;
/** Loading priority (for fetch priority API) */
priority?: 'high' | 'low' | 'auto';
/** Callback when image loads */
onLoad?: () => void;
/** Callback when image fails to load */
onError?: () => void;
}
const ImageContainer = styled.div<{
$width?: string | number;
$height?: string | number;
$aspectRatio?: string;
$borderRadius?: string | number;
}>`
position: relative;
width: ${({ $width }) =>
$width ? (typeof $width === 'number' ? `${$width}px` : $width) : '100%'};
height: ${({ $height }) =>
$height ? (typeof $height === 'number' ? `${$height}px` : $height) : 'auto'};
aspect-ratio: ${({ $aspectRatio }) => $aspectRatio || 'auto'};
border-radius: ${({ $borderRadius }) =>
$borderRadius
? typeof $borderRadius === 'number'
? `${$borderRadius}px`
: $borderRadius
: '0'};
overflow: hidden;
`;
const StyledImage = styled.img<{
$loaded: boolean;
$objectFit?: ImageWithSkeletonProps['objectFit'];
}>`
width: 100%;
height: 100%;
object-fit: ${({ $objectFit }) => $objectFit || 'cover'};
opacity: ${({ $loaded }) => ($loaded ? 1 : 0)};
transition: opacity 0.3s ease-in-out;
`;
const SkeletonOverlay = styled.div<{ $visible: boolean }>`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: ${({ $visible }) => ($visible ? 1 : 0)};
transition: opacity 0.3s ease-in-out;
pointer-events: none;
`;
const ErrorPlaceholder = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 100px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.5);
font-size: 14px;
`;
export function ImageWithSkeleton({
src,
alt,
width,
height,
borderRadius,
objectFit = 'cover',
lazy = true,
className,
fallbackSrc,
aspectRatio,
priority = 'low',
onLoad,
onError,
}: ImageWithSkeletonProps) {
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const [currentSrc, setCurrentSrc] = useState(src);
const handleLoad = useCallback(() => {
setLoaded(true);
onLoad?.();
}, [onLoad]);
const handleError = useCallback(() => {
if (fallbackSrc && currentSrc !== fallbackSrc) {
setCurrentSrc(fallbackSrc);
} else {
setError(true);
onError?.();
}
}, [fallbackSrc, currentSrc, onError]);
if (error) {
return (
<ImageContainer
$width={width}
$height={height}
$aspectRatio={aspectRatio}
$borderRadius={borderRadius}
className={className}
>
<ErrorPlaceholder>Failed to load image</ErrorPlaceholder>
</ImageContainer>
);
}
return (
<ImageContainer
$width={width}
$height={height}
$aspectRatio={aspectRatio}
$borderRadius={borderRadius}
className={className}
>
<SkeletonOverlay $visible={!loaded}>
<Skeleton
variant="rectangular"
width="100%"
height="100%"
animation="wave"
/>
</SkeletonOverlay>
<StyledImage
src={currentSrc}
alt={alt}
loading={lazy ? 'lazy' : 'eager'}
fetchPriority={priority}
onLoad={handleLoad}
onError={handleError}
$loaded={loaded}
$objectFit={objectFit}
/>
</ImageContainer>
);
}
/**
* Avatar image with skeleton
*/
export function AvatarWithSkeleton({
src,
alt,
size = 40,
className,
fallbackSrc,
}: {
src: string;
alt: string;
size?: number;
className?: string;
fallbackSrc?: string;
}) {
return (
<ImageWithSkeleton
src={src}
alt={alt}
width={size}
height={size}
borderRadius="50%"
objectFit="cover"
className={className}
fallbackSrc={fallbackSrc}
priority="low"
/>
);
}
/**
* Hero image with skeleton (higher priority)
*/
export function HeroImageWithSkeleton({
src,
alt,
aspectRatio = '16/9',
className,
}: {
src: string;
alt: string;
aspectRatio?: string;
className?: string;
}) {
return (
<ImageWithSkeleton
src={src}
alt={alt}
aspectRatio={aspectRatio}
borderRadius={8}
objectFit="cover"
className={className}
priority="high"
lazy={false}
/>
);
}