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>
233 lines
5.2 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
}
|