Initial import

This commit is contained in:
how2ice
2026-04-21 03:33:23 +09:00
commit 9e4b70f1f1
495 changed files with 94680 additions and 0 deletions

View File

@@ -0,0 +1,158 @@
import {
CheckCircleOutlined,
ExclamationCircleOutlined,
InboxOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import { Card, Flex, Skeleton, Typography } from 'antd';
import type { CardProps } from 'antd';
import type { ReactNode } from 'react';
import './DataStatePanel.css';
const { Paragraph, Text, Title } = Typography;
export type DataStatePanelStatus = 'loading' | 'empty' | 'error' | 'ready';
export type DataStatePanelProps = {
state: DataStatePanelStatus;
title?: ReactNode;
description?: ReactNode;
actions?: ReactNode;
children?: ReactNode;
compact?: boolean;
icon?: ReactNode;
loadingRows?: number;
className?: string;
cardProps?: Omit<CardProps, 'children' | 'className'>;
};
function getDefaultIcon(state: DataStatePanelStatus) {
switch (state) {
case 'loading':
return <LoadingOutlined />;
case 'empty':
return <InboxOutlined />;
case 'error':
return <ExclamationCircleOutlined />;
case 'ready':
return <CheckCircleOutlined />;
default:
return null;
}
}
function getDefaultTitle(state: DataStatePanelStatus) {
switch (state) {
case 'loading':
return '데이터를 불러오는 중입니다.';
case 'empty':
return '표시할 데이터가 없습니다.';
case 'error':
return '데이터를 불러오지 못했습니다.';
case 'ready':
return '데이터를 확인할 수 있습니다.';
default:
return undefined;
}
}
function getDefaultDescription(state: DataStatePanelStatus) {
switch (state) {
case 'loading':
return '응답을 정리하는 동안 패널 레이아웃을 유지합니다.';
case 'empty':
return '조건을 조정하거나 새 항목을 추가하면 이 영역이 채워집니다.';
case 'error':
return '네트워크 또는 권한 상태를 확인한 뒤 다시 시도하세요.';
case 'ready':
return undefined;
default:
return undefined;
}
}
function getSkeletonWidths(compact: boolean, rows: number) {
if (compact) {
return Array.from({ length: rows }, (_, index) => (index === rows - 1 ? '84%' : '100%'));
}
return Array.from({ length: rows }, (_, index) => {
if (index === rows - 1) {
return '74%';
}
return '100%';
});
}
function renderLoadingBody(compact: boolean, rows: number) {
return (
<div className="data-state-panel__skeleton">
<Skeleton
active
title={{ width: compact ? '52%' : '38%' }}
paragraph={{ rows, width: getSkeletonWidths(compact, rows) }}
/>
</div>
);
}
export function DataStatePanel({
state,
title,
description,
actions,
children,
compact = false,
icon,
loadingRows = compact ? 2 : 3,
className,
cardProps,
}: DataStatePanelProps) {
const resolvedTitle = title ?? getDefaultTitle(state);
const resolvedDescription = description ?? getDefaultDescription(state);
const panelClassName = [
'data-state-panel',
`data-state-panel--${state}`,
compact ? 'data-state-panel--compact' : '',
className ?? '',
]
.filter(Boolean)
.join(' ');
return (
<Card {...cardProps} className={panelClassName}>
<Flex vertical gap={compact ? 14 : 18} className="data-state-panel__inner">
<div className="data-state-panel__hero">
<span className="data-state-panel__icon" aria-hidden="true">
{icon ?? getDefaultIcon(state)}
</span>
<Flex vertical gap={4} className="data-state-panel__copy">
{resolvedTitle ? (
compact ? (
<Text strong className="data-state-panel__title data-state-panel__title--compact">
{resolvedTitle}
</Text>
) : (
<Title level={5} className="data-state-panel__title">
{resolvedTitle}
</Title>
)
) : null}
{resolvedDescription ? (
<Paragraph className="data-state-panel__description" type="secondary">
{resolvedDescription}
</Paragraph>
) : null}
</Flex>
</div>
{state === 'loading' ? renderLoadingBody(compact, loadingRows) : children ? (
<div className="data-state-panel__content">{children}</div>
) : null}
{actions ? <div className="data-state-panel__actions">{actions}</div> : null}
</Flex>
</Card>
);
}