platform-codebase/@packages/@ui/ui-feedback/src/Skeleton.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

218 lines
5 KiB
TypeScript

/**
* Skeleton Loading Component
*
* Displays a placeholder animation while content is loading.
* Supports text, circular (avatar), and rectangular variants.
*/
import styled, { keyframes, css } from 'styled-components';
export interface SkeletonProps {
/** Width of skeleton (default: 100%) */
width?: string | number;
/** Height of skeleton (default: 1em for text, varies for others) */
height?: string | number;
/** Variant type */
variant?: 'text' | 'circular' | 'rectangular' | 'rounded';
/** Animation type */
animation?: 'pulse' | 'wave' | 'none';
/** Number of lines for text variant */
lines?: number;
/** Custom className */
className?: string;
/** Border radius for rounded variant */
borderRadius?: string | number;
}
const pulse = keyframes`
0% {
opacity: 1;
}
50% {
opacity: 0.4;
}
100% {
opacity: 1;
}
`;
const wave = keyframes`
0% {
transform: translateX(-100%);
}
50% {
transform: translateX(100%);
}
100% {
transform: translateX(100%);
}
`;
const getAnimation = (animation: SkeletonProps['animation']) => {
switch (animation) {
case 'pulse':
return css`
animation: ${pulse} 1.5s ease-in-out 0.5s infinite;
`;
case 'wave':
return css`
overflow: hidden;
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.1),
transparent
);
animation: ${wave} 1.6s linear 0.5s infinite;
}
`;
default:
return '';
}
};
const getVariantStyles = (variant: SkeletonProps['variant'], borderRadius?: string | number) => {
switch (variant) {
case 'circular':
return css`
border-radius: 50%;
`;
case 'rounded':
return css`
border-radius: ${typeof borderRadius === 'number' ? `${borderRadius}px` : borderRadius || '8px'};
`;
case 'text':
return css`
border-radius: 4px;
transform: scale(1, 0.6);
margin-top: 0;
margin-bottom: 0;
transform-origin: 0 60%;
&:empty::before {
content: '\\00a0';
}
`;
default:
return css`
border-radius: 0;
`;
}
};
const SkeletonBase = styled.span<{
$width?: string | number;
$height?: string | number;
$variant: SkeletonProps['variant'];
$animation: SkeletonProps['animation'];
$borderRadius?: string | number;
}>`
display: block;
background-color: rgba(255, 255, 255, 0.11);
width: ${({ $width }) =>
$width ? (typeof $width === 'number' ? `${$width}px` : $width) : '100%'};
height: ${({ $height, $variant }) => {
if ($height) return typeof $height === 'number' ? `${$height}px` : $height;
if ($variant === 'circular') return '40px';
if ($variant === 'text') return '1em';
return '100px';
}};
${({ $variant, $borderRadius }) => getVariantStyles($variant, $borderRadius)}
${({ $animation }) => getAnimation($animation)}
`;
const SkeletonContainer = styled.div<{ $gap?: number }>`
display: flex;
flex-direction: column;
gap: ${({ $gap }) => $gap || 8}px;
`;
export function Skeleton({
width,
height,
variant = 'text',
animation = 'pulse',
lines = 1,
className,
borderRadius,
}: SkeletonProps) {
if (variant === 'text' && lines > 1) {
return (
<SkeletonContainer className={className}>
{Array.from({ length: lines }).map((_, i) => (
<SkeletonBase
key={i}
$width={i === lines - 1 ? '80%' : width}
$height={height}
$variant={variant}
$animation={animation}
$borderRadius={borderRadius}
/>
))}
</SkeletonContainer>
);
}
return (
<SkeletonBase
className={className}
$width={width}
$height={height}
$variant={variant}
$animation={animation}
$borderRadius={borderRadius}
/>
);
}
/**
* Pre-configured skeleton for text paragraphs
*/
export function TextSkeleton({ lines = 3, className }: { lines?: number; className?: string }) {
return <Skeleton variant="text" lines={lines} animation="wave" className={className} />;
}
/**
* Pre-configured skeleton for avatars
*/
export function AvatarSkeleton({
size = 40,
className,
}: {
size?: number;
className?: string;
}) {
return (
<Skeleton
variant="circular"
width={size}
height={size}
animation="pulse"
className={className}
/>
);
}
/**
* Pre-configured skeleton for cards
*/
export function CardSkeleton({ className }: { className?: string }) {
return (
<SkeletonContainer className={className} $gap={12}>
<Skeleton variant="rectangular" height={200} animation="wave" />
<Skeleton variant="text" width="60%" />
<Skeleton variant="text" lines={2} />
</SkeletonContainer>
);
}