Compare commits
13 Commits
ec281bf354
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 861fac88f7 | |||
| 8d6bff88d6 | |||
| 52f8c30b2e | |||
| 01c3590682 | |||
| 01bfc751f2 | |||
| b390777af0 | |||
| 8b33161284 | |||
| 35f6023f5a | |||
| a836333512 | |||
| 57a6e4b51e | |||
| 5c4df5a19a | |||
| 839901d663 | |||
| 76dab1c6a9 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
||||
/api-app/app/src/main/resources/application-local.yml
|
||||
api-app/.gradle
|
||||
api-app/build
|
||||
19
web-app/.storybook/main.ts
Normal file
19
web-app/.storybook/main.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { StorybookConfig } from '@storybook/react-vite'
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../app/**/*.stories.@(ts|tsx)'],
|
||||
|
||||
framework: '@storybook/react-vite',
|
||||
|
||||
// Storybook 전용 Vite 설정 파일 사용 (React Router 플러그인 제외)
|
||||
core: {
|
||||
builder: {
|
||||
name: '@storybook/builder-vite',
|
||||
options: {
|
||||
viteConfigPath: '.storybook/vite.config.ts',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
16
web-app/.storybook/preview.ts
Normal file
16
web-app/.storybook/preview.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Preview } from '@storybook/react'
|
||||
|
||||
import '../app/app.css'
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default preview
|
||||
13
web-app/.storybook/vite.config.ts
Normal file
13
web-app/.storybook/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
// Storybook 전용 Vite 설정 (React Router 플러그인 제외)
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': new URL('../app', import.meta.url).pathname,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -36,6 +36,10 @@
|
||||
--font-sans: "Pretendard", ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
|
||||
--color-dabeeo-blue: #2768ff;
|
||||
--color-dabeeo-red: #ff0000;
|
||||
--color-dabeeo-yellow: #fffce8;
|
||||
|
||||
/* Primary (Navy) */
|
||||
--color-primary: var(--color-dabeeo-navy-main);
|
||||
--color-primary-secondary: var(--color-dabeeo-navy-secondary);
|
||||
@@ -50,6 +54,23 @@
|
||||
--color-dabeeo-navy-tertiary01: #d4dde9;
|
||||
--color-dabeeo-navy-tertiary02: #f0f3f7;
|
||||
|
||||
/* Green */
|
||||
--color-dabeeo-green-main: #1b8466;
|
||||
--color-dabeeo-green-secondary: #125a46;
|
||||
--color-dabeeo-green-tertiary: #89bea7;
|
||||
--color-dabeeo-green-tertiary01: #d1e6e1;
|
||||
--color-dabeeo-green-tertiary02: #ebf2f0;
|
||||
|
||||
/* Orange */
|
||||
--color-dabeeo-orange-main: #ff7937;
|
||||
--color-dabeeo-orange-secondary: #c14b11;
|
||||
--color-dabeeo-orange-tertiary01: #fff2eb;
|
||||
--color-dabeeo-orange-tertiary02: #ffe4d7;
|
||||
|
||||
/* Yellow */
|
||||
--color-dabeeo-yellow-main: #ffb724;
|
||||
--color-dabeeo-yellow-secondary: #fffce8;
|
||||
|
||||
/* Gray */
|
||||
--color-dabeeo-gray-44: #444444;
|
||||
--color-dabeeo-gray-99: #999999;
|
||||
@@ -57,6 +78,12 @@
|
||||
--color-dabeeo-gray-da: #dadada;
|
||||
--color-dabeeo-gray-eb: #ebebeb;
|
||||
--color-dabeeo-gray-f9: #f9f9f9;
|
||||
|
||||
--color-dabeeo-black-22: #222222;
|
||||
--color-dabeeo-black-34: #343434;
|
||||
--color-dabeeo-black-47: #475954;
|
||||
--color-dabeeo-black-2a: #2a2e35;
|
||||
--color-dabeeo-black-4d: #4d5562;
|
||||
}
|
||||
|
||||
html,
|
||||
|
||||
87
web-app/app/features/imagery/api/aerial.ts
Normal file
87
web-app/app/features/imagery/api/aerial.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import axios from 'axios';
|
||||
import type { ApiResponse, PagedResponse } from '~/shared/types/api';
|
||||
import type {
|
||||
AerialData,
|
||||
AerialDetail,
|
||||
AerialItem,
|
||||
AerialListParams,
|
||||
} from '../types/aerial';
|
||||
|
||||
/**
|
||||
* 항공영상 목록 조회
|
||||
*/
|
||||
export const fetchAerialList = async (params: AerialListParams) => {
|
||||
try {
|
||||
const response = await axios.get<ApiResponse<PagedResponse<AerialItem>>>('/api/imagery/aerial/list', {
|
||||
params: {
|
||||
dateRangeType: params.dateRangeType,
|
||||
strtDttm: params.strtDttm,
|
||||
endDttm: params.endDttm,
|
||||
page: params.page ?? 0,
|
||||
size: params.size ?? 20,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
list: response.data.data.content,
|
||||
pagination: {
|
||||
currentPage: response.data.data.number,
|
||||
pageSize: response.data.data.size,
|
||||
totalPages: response.data.data.totalPages,
|
||||
totalItems: response.data.data.totalElements,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('fetchAerialList error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 항공영상 상세 요약정보 조회
|
||||
*/
|
||||
export const fetchAerialDetail = async (uuid: string) => {
|
||||
try {
|
||||
const response = await axios.get<ApiResponse<AerialDetail>>(`/api/imagery/aerial/detail/${uuid}`);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error('fetchAerialDetail error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 항공영상 상세 영상 조회 (이미지 다운로드 URL 반환)
|
||||
*/
|
||||
export const getAerialImageUrl = (uuid: string, imageType: 'before' | 'after' = 'before') => {
|
||||
return `/api/imagery/detail/image?uuid=${uuid}&imageType=${imageType}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 항공영상 상세 영상 조회 (Blob으로 다운로드)
|
||||
*/
|
||||
export const fetchAerialImage = async (uuid: string, imageType: 'before' | 'after' = 'before') => {
|
||||
try {
|
||||
const response = await axios.get('/api/imagery/detail/image', {
|
||||
params: { uuid, imageType },
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('fetchAerialImage error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 항공영상 데이터 등록
|
||||
*/
|
||||
export const registerAerialData = async (data: AerialData) => {
|
||||
try {
|
||||
const response = await axios.post('/api/imagery/aerial', data);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error('registerAerialData error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
74
web-app/app/features/imagery/api/imagery.ts
Normal file
74
web-app/app/features/imagery/api/imagery.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import axios from 'axios';
|
||||
import type { ApiResponse } from '~/shared/types/api';
|
||||
import type { ChunkUploadParams, ChunkUploadResponse, FolderListResponse, Region } from '../types/imageryRegister';
|
||||
|
||||
/**
|
||||
* 업로드 지역 조회 (시/도)
|
||||
*/
|
||||
export const fetchRegions = async () => {
|
||||
try {
|
||||
const response = await axios.get<ApiResponse<Region[]>>('/api/imagery/regions/provinces');
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error('fetchRegions error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 업로드 폴더 조회
|
||||
*/
|
||||
export const fetchFolderList = async (dirPath: string) => {
|
||||
try {
|
||||
const response = await axios.post<ApiResponse<FolderListResponse>>('/api/imagery/folder-list', {
|
||||
dirPath,
|
||||
});
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error('fetchFolderList error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 대용량 파일 분할 전송 (청크 업로드)
|
||||
*/
|
||||
export const uploadFileChunk = async (params: ChunkUploadParams) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('fileName', params.fileName);
|
||||
formData.append('fileSize', String(params.fileSize));
|
||||
formData.append('chunkIndex', String(params.chunkIndex));
|
||||
formData.append('chunkTotalIndex', String(params.chunkTotalIndex));
|
||||
formData.append('chunkFile', params.chunkFile);
|
||||
|
||||
const response = await axios.post<ApiResponse<ChunkUploadResponse>>(
|
||||
'/api/imagery/upload/file-chunk-upload',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error('uploadFileChunk error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 업로드 완료된 파일 병합
|
||||
*/
|
||||
export const completeChunkUpload = async (uuid: string) => {
|
||||
try {
|
||||
const response = await axios.put<ApiResponse<ChunkUploadResponse>>(
|
||||
`/api/imagery/upload/chunk-upload-complete/${uuid}`,
|
||||
);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error('completeChunkUpload error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
114
web-app/app/features/imagery/components/AerialList.tsx
Normal file
114
web-app/app/features/imagery/components/AerialList.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '~/shared/components/button/Button';
|
||||
import { Section } from '~/shared/components/section/Section';
|
||||
import { Table } from '~/shared/components/table';
|
||||
import type { AerialItem } from '../types/aerial';
|
||||
import { AerialRegisterModal } from './AerialRegisterModal';
|
||||
|
||||
export function AerialList() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [data, setData] = useState<AerialItem[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [isRegisterModalOpen, setIsRegisterModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// const result = await fetchAerialList('YEAR', strtDttm, endDttm);
|
||||
const result = {
|
||||
list: [],
|
||||
pagination: {
|
||||
currentPage: 0,
|
||||
pagiSize: 0,
|
||||
totalPages: 0,
|
||||
totalItems: 0,
|
||||
},
|
||||
};
|
||||
if (result) {
|
||||
setData(result.list);
|
||||
setTotalCount(result.pagination.totalItems);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section className="w-full pb-4" variant="list">
|
||||
<div className="flex flex-col h-full p-4 gap-4">
|
||||
<h1 className="text-xl font-bold">항공영상관리</h1>
|
||||
|
||||
<Table isLayoutFixed isFullHeight>
|
||||
<Table.Caption>
|
||||
<Table.CaptionLeft>
|
||||
<Table.Total count={totalCount} />
|
||||
</Table.CaptionLeft>
|
||||
<Table.CaptionRight>
|
||||
<div className="flex gap-2">
|
||||
<Button color="primary" onClick={() => setIsRegisterModalOpen(true)}>항공영상 등록</Button>
|
||||
</div>
|
||||
</Table.CaptionRight>
|
||||
</Table.Caption>
|
||||
|
||||
<Table.Container>
|
||||
<Table.Colgroup>
|
||||
<Table.Col width={60} />
|
||||
<Table.Col width={180} />
|
||||
<Table.Col width={200} />
|
||||
<Table.Col width={120} />
|
||||
<Table.Col width={100} />
|
||||
<Table.Col width={120} />
|
||||
<Table.Col width={80} />
|
||||
</Table.Colgroup>
|
||||
|
||||
<Table.Header>
|
||||
<Table.HeaderRow>
|
||||
<Table.HeaderCell align="center">No</Table.HeaderCell>
|
||||
<Table.HeaderCell>파일명</Table.HeaderCell>
|
||||
<Table.HeaderCell>지역</Table.HeaderCell>
|
||||
<Table.HeaderCell align="center">촬영일시</Table.HeaderCell>
|
||||
<Table.HeaderCell align="center">축적</Table.HeaderCell>
|
||||
<Table.HeaderCell align="right">용량</Table.HeaderCell>
|
||||
<Table.HeaderCell align="center">전처리</Table.HeaderCell>
|
||||
</Table.HeaderRow>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{isLoading ? (
|
||||
<Table.Loading colSpan={7} />
|
||||
) : data.length === 0 ? (
|
||||
<Table.Empty colSpan={7}>등록된 항공영상이 없습니다.</Table.Empty>
|
||||
) : (
|
||||
data.map((item, index) => (
|
||||
<Table.Row
|
||||
key={item.mapId}
|
||||
isSelected={selectedId === item.mapId}
|
||||
onClick={() => setSelectedId(item.mapId)}
|
||||
>
|
||||
<Table.Cell align="center">{index + 1}</Table.Cell>
|
||||
<Table.Cell>{item.fileName}</Table.Cell>
|
||||
<Table.Cell>{item.region}</Table.Cell>
|
||||
<Table.Cell align="center">{item.capturedDttm}</Table.Cell>
|
||||
<Table.Cell align="center">{item.scale}</Table.Cell>
|
||||
<Table.Cell align="right">{item.fileSize}</Table.Cell>
|
||||
<Table.Cell align="center">
|
||||
{item.status}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))
|
||||
)}
|
||||
</Table.Body>
|
||||
</Table.Container>
|
||||
</Table>
|
||||
</div>
|
||||
</Section>
|
||||
{isRegisterModalOpen && <AerialRegisterModal isOpen={isRegisterModalOpen} onClose={() => setIsRegisterModalOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
423
web-app/app/features/imagery/components/AerialRegisterModal.tsx
Normal file
423
web-app/app/features/imagery/components/AerialRegisterModal.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import { Button } from '~/shared/components/button/Button';
|
||||
import { Modal, ModalRegion } from '~/shared/components/modal';
|
||||
import { Select, type SelectOption } from '~/shared/components/select/Select';
|
||||
import { Tree, type TreeItemType, type TreeRef } from '~/shared/components/tree/Tree';
|
||||
|
||||
import { fetchFolderList, fetchRegions } from '../api/imagery';
|
||||
import { useAerialChunkUpload, type UploadingFile } from '../hooks/useAerialChunkUpload';
|
||||
|
||||
type AerialRegisterModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onUploadSuccess?: () => void;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
type: string;
|
||||
region: string;
|
||||
folderPath: string;
|
||||
};
|
||||
|
||||
const TYPE_OPTIONS: SelectOption[] = [
|
||||
{ value: 'mapFrame', label: '도곽' },
|
||||
];
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))}${sizes[i]}`;
|
||||
};
|
||||
|
||||
const ProgressBar = ({ progress }: { progress: number }) => {
|
||||
return (
|
||||
<div className="flex-1 h-2 bg-dabeeo-gray-eb overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FileItem = ({
|
||||
file,
|
||||
folderPath,
|
||||
onRemove,
|
||||
}: {
|
||||
file: UploadingFile;
|
||||
folderPath: string;
|
||||
onRemove: (id: string) => void;
|
||||
}) => {
|
||||
const fullPath = folderPath ? `${folderPath}/${file.fileName}` : file.fileName;
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<span className="text-sm text-dabeeo-black-34 truncate flex-1">{fullPath}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(file.id)}
|
||||
className="text-dabeeo-gray-99 hover:text-dabeeo-black-34 cursor-pointer"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M12.5 4L4 12.5M4 4L12.5 12.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AerialRegisterModal = ({ isOpen, onClose, onUploadSuccess }: AerialRegisterModalProps) => {
|
||||
const treeRef = useRef<TreeRef | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { control, setValue, watch, reset: resetForm } = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
type: 'mapFrame',
|
||||
region: '',
|
||||
folderPath: '',
|
||||
},
|
||||
});
|
||||
|
||||
const selectedRegion = watch('region');
|
||||
const selectedFolder = watch('folderPath');
|
||||
|
||||
const [regions, setRegions] = useState<SelectOption[]>([]);
|
||||
const [treeItems, setTreeItems] = useState<TreeItemType[]>([]);
|
||||
const [isLoadingFolders, setIsLoadingFolders] = useState(false);
|
||||
|
||||
const {
|
||||
files,
|
||||
addFiles,
|
||||
startUpload,
|
||||
removeFile,
|
||||
reset: resetUpload,
|
||||
isUploading,
|
||||
isAllCompleted,
|
||||
hasFiles,
|
||||
totalSize,
|
||||
uploadedSize,
|
||||
overallProgress,
|
||||
} = useAerialChunkUpload();
|
||||
|
||||
const memorizedSelectedKeys = useMemo(
|
||||
() => new Set(selectedFolder ? [selectedFolder] : []),
|
||||
[selectedFolder]
|
||||
);
|
||||
|
||||
// 지역 목록 조회
|
||||
useEffect(() => {
|
||||
const loadRegions = async () => {
|
||||
try {
|
||||
const data = await fetchRegions();
|
||||
setRegions(data.map((r) => ({ value: r.code, label: r.name })));
|
||||
} catch (error) {
|
||||
console.error('Failed to load regions:', error);
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
loadRegions();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 지역 선택 시 폴더 목록 조회
|
||||
useEffect(() => {
|
||||
if (!selectedRegion) {
|
||||
setTreeItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadFolders = async () => {
|
||||
setIsLoadingFolders(true);
|
||||
try {
|
||||
const data = await fetchFolderList(selectedRegion);
|
||||
const items: TreeItemType[] = data.folders.map((folder) => ({
|
||||
id: folder.fullPath,
|
||||
title: folder.folderNm,
|
||||
type: 'directory',
|
||||
}));
|
||||
setTreeItems(items);
|
||||
} catch (error) {
|
||||
console.error('Failed to load folders:', error);
|
||||
} finally {
|
||||
setIsLoadingFolders(false);
|
||||
}
|
||||
};
|
||||
loadFolders();
|
||||
}, [selectedRegion]);
|
||||
|
||||
const handleExpandFolder = async (key: string | number) => {
|
||||
const folderPath = String(key);
|
||||
try {
|
||||
const data = await fetchFolderList(folderPath);
|
||||
data.folders.forEach((folder) => {
|
||||
const newItem: TreeItemType = {
|
||||
id: folder.fullPath,
|
||||
title: folder.folderNm,
|
||||
type: 'directory',
|
||||
};
|
||||
treeRef.current?.addItem(newItem, folderPath);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load subfolders:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCollapseFolder = (key: string | number) => {
|
||||
treeRef.current?.clearItemsByParentKey(key);
|
||||
};
|
||||
|
||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = e.target.files;
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
const fileArray = Array.from(selectedFiles);
|
||||
const addedFiles = addFiles(fileArray);
|
||||
|
||||
// 파일 선택 즉시 업로드 시작
|
||||
startUpload(addedFiles);
|
||||
|
||||
// input 초기화 (같은 파일 재선택 가능하도록)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (!selectedFolder) {
|
||||
ModalRegion.alert({ content: '업로드할 폴더를 선택해주세요.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasFiles) {
|
||||
ModalRegion.alert({ content: '업로드할 파일을 선택해주세요.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUploading) {
|
||||
ModalRegion.alert({ content: '파일 업로드가 진행 중입니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAllCompleted) {
|
||||
ModalRegion.alert({
|
||||
content: '파일 업로드가 완료되었습니다.',
|
||||
onCancel: () => {
|
||||
onUploadSuccess?.();
|
||||
handleClose();
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetUpload();
|
||||
resetForm();
|
||||
setTreeItems([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (isUploading || hasFiles) {
|
||||
ModalRegion.confirm({
|
||||
content: '업로드를 취소하시겠습니까?\n진행 중인 업로드가 중단됩니다.',
|
||||
confirmText: '예',
|
||||
cancelText: '아니오',
|
||||
onConfirm: (close) => {
|
||||
close();
|
||||
handleClose();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const currentUploadingFile = files.find((f) => f.status === 'uploading');
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={handleCancel} isKeyboardDismissDisabled>
|
||||
{() => (
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleUpload(); }}>
|
||||
<Modal.Header hasCloseButton={false}>업로드 폴더 선택</Modal.Header>
|
||||
<Modal.Body>
|
||||
<div className="flex flex-col gap-4 py-2 w-140">
|
||||
{/* 타입 선택 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0">
|
||||
타입 선택
|
||||
</label>
|
||||
<Controller
|
||||
name="type"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Select
|
||||
items={TYPE_OPTIONS}
|
||||
selectedKey={value}
|
||||
onChange={onChange}
|
||||
className="w-40"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 지역 선택 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0">
|
||||
지역 선택
|
||||
</label>
|
||||
<Controller
|
||||
name="region"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Select
|
||||
items={regions}
|
||||
selectedKey={value}
|
||||
onChange={(v) => {
|
||||
onChange(v);
|
||||
setValue('folderPath', '');
|
||||
}}
|
||||
placeholder="지역선택"
|
||||
className="w-40"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 폴더 선택 */}
|
||||
<div className="flex items-start gap-4">
|
||||
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0 pt-2">
|
||||
폴더 선택
|
||||
</label>
|
||||
<div className="flex-1 border border-dabeeo-gray-be max-h-60 min-h-40 overflow-y-auto">
|
||||
{isLoadingFolders ? (
|
||||
<div className="flex items-center justify-center h-40 text-sm text-dabeeo-gray-99">
|
||||
로딩 중...
|
||||
</div>
|
||||
) : treeItems.length > 0 ? (
|
||||
<Controller
|
||||
name="folderPath"
|
||||
control={control}
|
||||
render={({ field: { onChange } }) => (
|
||||
<Tree
|
||||
ref={treeRef}
|
||||
items={treeItems}
|
||||
selectedKeys={memorizedSelectedKeys}
|
||||
onExpand={(key, item) => {
|
||||
handleExpandFolder(key);
|
||||
onChange(String(key));
|
||||
}}
|
||||
onCollapse={(key) => {
|
||||
handleCollapseFolder(key);
|
||||
}}
|
||||
onSelect={(key) => {
|
||||
if (key) onChange(String(key));
|
||||
}}
|
||||
enableDragAndDrop={false}
|
||||
persistSelectionOnCollapse
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : selectedRegion ? (
|
||||
<div className="flex items-center justify-center h-40 text-sm text-dabeeo-gray-99">
|
||||
폴더가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-40 text-sm text-dabeeo-gray-99">
|
||||
지역을 선택해주세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-dabeeo-orange-main ml-24">
|
||||
※ 업로드 대상 폴더를 정확하게 지정하시기 바랍니다.
|
||||
</p>
|
||||
|
||||
{/* 파일명 */}
|
||||
<div className="flex items-start gap-4">
|
||||
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0 pt-2">
|
||||
파일명 <span className="text-dabeeo-red">*</span>
|
||||
</label>
|
||||
<div className="flex-1 border border-dabeeo-gray-be p-2 min-h-10 max-h-24 overflow-y-auto">
|
||||
{files.length > 0 ? (
|
||||
files.map((file) => (
|
||||
<FileItem key={file.id} file={file} folderPath={selectedFolder} onRemove={removeFile} />
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-dabeeo-gray-99">파일이 업로드 되면 자동 입력됩니다.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 파일 선택 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-20 text-sm font-semibold text-dabeeo-black-34 shrink-0">
|
||||
파일 <span className="text-dabeeo-red">*</span>
|
||||
</label>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div className="flex-1 h-9 border border-dabeeo-gray-be px-3 flex items-center">
|
||||
<span className="text-sm text-dabeeo-gray-99 truncate">
|
||||
{currentUploadingFile?.fileName || ''}
|
||||
</span>
|
||||
</div>
|
||||
<label
|
||||
htmlFor="aerial-file-input"
|
||||
className="h-9 px-4 bg-dabeeo-black-34 text-white text-sm font-medium flex items-center justify-center cursor-pointer hover:bg-dabeeo-black-47"
|
||||
>
|
||||
파일선택
|
||||
</label>
|
||||
<input
|
||||
id="aerial-file-input"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".tif,.tiff"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 진행률 표시 */}
|
||||
{hasFiles && (
|
||||
<div className="flex items-center gap-4 ml-24">
|
||||
<ProgressBar progress={overallProgress} />
|
||||
<span className="text-xs text-dabeeo-black-34 w-10 text-right">{overallProgress}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFiles && (
|
||||
<div className="text-xs text-dabeeo-gray-99 ml-24">
|
||||
Loading {formatFileSize(uploadedSize)} / {formatFileSize(totalSize)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button type="button" color="gray" size="large" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
size="large"
|
||||
isDisabled={!hasFiles || isUploading}
|
||||
isPending={isUploading}
|
||||
>
|
||||
업로드
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
164
web-app/app/features/imagery/hooks/useAerialChunkUpload.ts
Normal file
164
web-app/app/features/imagery/hooks/useAerialChunkUpload.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { completeChunkUpload, uploadFileChunk } from '../api/imagery';
|
||||
|
||||
export type FileUploadStatus = 'idle' | 'uploading' | 'completed' | 'error'
|
||||
|
||||
export type UploadingFile = {
|
||||
id: string;
|
||||
file: File;
|
||||
fileName: string;
|
||||
progress: number;
|
||||
status: FileUploadStatus;
|
||||
totalSize: number;
|
||||
uploadedSize: number;
|
||||
uuid?: string;
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
const MAX_CONCURRENT_UPLOADS = 3
|
||||
|
||||
export const useAerialChunkUpload = () => {
|
||||
const [files, setFiles] = useState<UploadingFile[]>([])
|
||||
const abortControllersRef = useRef<Map<string, AbortController>>(new Map())
|
||||
|
||||
const updateFileState = useCallback((id: string, updates: Partial<UploadingFile>) => {
|
||||
setFiles((prev) => prev.map((f) => f.id === id ? { ...f, ...updates } : f))
|
||||
}, [])
|
||||
|
||||
const uploadSingleFile = useCallback(async (uploadFile: UploadingFile) => {
|
||||
const { id, file } = uploadFile
|
||||
const abortController = new AbortController()
|
||||
abortControllersRef.current.set(id, abortController)
|
||||
|
||||
const { signal } = abortController
|
||||
const fileSize = file.size
|
||||
const fileName = file.name
|
||||
const totalChunks = Math.ceil(fileSize / CHUNK_SIZE)
|
||||
|
||||
updateFileState(id, { status: 'uploading', progress: 0 })
|
||||
|
||||
let uuid = ''
|
||||
|
||||
try {
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
const start = chunkIndex * CHUNK_SIZE
|
||||
const end = Math.min(start + CHUNK_SIZE, fileSize)
|
||||
const chunkFile = file.slice(start, end)
|
||||
|
||||
const response = await uploadFileChunk({
|
||||
fileName,
|
||||
fileSize,
|
||||
chunkIndex,
|
||||
chunkTotalIndex: totalChunks - 1,
|
||||
chunkFile,
|
||||
})
|
||||
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
// 첫 번째 청크 응답에서 uuid 저장
|
||||
if (chunkIndex === 0 && response.uuid) {
|
||||
uuid = response.uuid
|
||||
updateFileState(id, { uuid })
|
||||
}
|
||||
|
||||
const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100)
|
||||
const uploadedSize = end
|
||||
|
||||
updateFileState(id, {
|
||||
progress,
|
||||
uploadedSize,
|
||||
})
|
||||
}
|
||||
|
||||
// 청크 업로드 완료 후 completeChunkUpload 호출
|
||||
if (uuid) {
|
||||
await completeChunkUpload(uuid)
|
||||
}
|
||||
|
||||
updateFileState(id, { status: 'completed', progress: 100 })
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
console.error('Upload error:', error)
|
||||
updateFileState(id, { status: 'error' })
|
||||
}
|
||||
} finally {
|
||||
abortControllersRef.current.delete(id)
|
||||
}
|
||||
}, [updateFileState])
|
||||
|
||||
const addFiles = useCallback((newFiles: File[]) => {
|
||||
const uploadFiles: UploadingFile[] = newFiles.map((file) => ({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
fileName: file.name,
|
||||
progress: 0,
|
||||
status: 'idle' as FileUploadStatus,
|
||||
totalSize: file.size,
|
||||
uploadedSize: 0,
|
||||
}))
|
||||
|
||||
setFiles((prev) => [...prev, ...uploadFiles])
|
||||
return uploadFiles
|
||||
}, [])
|
||||
|
||||
const startUpload = useCallback(async (filesToUpload?: UploadingFile[]) => {
|
||||
const targetFiles = filesToUpload || files.filter((f) => f.status === 'idle')
|
||||
|
||||
// 병렬 업로드 (최대 MAX_CONCURRENT_UPLOADS개씩)
|
||||
for (let i = 0; i < targetFiles.length; i += MAX_CONCURRENT_UPLOADS) {
|
||||
const batch = targetFiles.slice(i, i + MAX_CONCURRENT_UPLOADS)
|
||||
await Promise.all(batch.map((file) => uploadSingleFile(file)))
|
||||
}
|
||||
}, [files, uploadSingleFile])
|
||||
|
||||
const removeFile = useCallback((id: string) => {
|
||||
// 업로드 중이면 취소
|
||||
const controller = abortControllersRef.current.get(id)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
abortControllersRef.current.delete(id)
|
||||
}
|
||||
|
||||
setFiles((prev) => prev.filter((f) => f.id !== id))
|
||||
}, [])
|
||||
|
||||
const cancelAllUploads = useCallback(() => {
|
||||
abortControllersRef.current.forEach((controller) => controller.abort())
|
||||
abortControllersRef.current.clear()
|
||||
setFiles([])
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
cancelAllUploads()
|
||||
}, [cancelAllUploads])
|
||||
|
||||
const isUploading = files.some((f) => f.status === 'uploading')
|
||||
const isAllCompleted = files.length > 0 && files.every((f) => f.status === 'completed')
|
||||
const hasFiles = files.length > 0
|
||||
|
||||
const totalSize = files.reduce((acc, f) => acc + f.totalSize, 0)
|
||||
const uploadedSize = files.reduce((acc, f) => acc + f.uploadedSize, 0)
|
||||
const overallProgress = totalSize > 0 ? Math.round((uploadedSize / totalSize) * 100) : 0
|
||||
|
||||
return {
|
||||
files,
|
||||
addFiles,
|
||||
startUpload,
|
||||
removeFile,
|
||||
cancelAllUploads,
|
||||
reset,
|
||||
isUploading,
|
||||
isAllCompleted,
|
||||
hasFiles,
|
||||
totalSize,
|
||||
uploadedSize,
|
||||
overallProgress,
|
||||
}
|
||||
}
|
||||
46
web-app/app/features/imagery/types/aerial.ts
Normal file
46
web-app/app/features/imagery/types/aerial.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// 목록 아이템
|
||||
export type AerialItem = {
|
||||
mapId: string;
|
||||
fileName: string;
|
||||
region: string;
|
||||
scale: string;
|
||||
capturedDttm: string;
|
||||
fileSize: string;
|
||||
createdDttm: string;
|
||||
createdBy: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
// 상세 정보
|
||||
export type AerialDetail = {
|
||||
uuid: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
region: string;
|
||||
category: string;
|
||||
createdName: string;
|
||||
createdDttm: string;
|
||||
capturedDttm: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
// 목록 조회 파라미터
|
||||
export type AerialListParams = {
|
||||
dateRangeType: 'capturedDttm' | 'createdDttm';
|
||||
strtDttm: string;
|
||||
endDttm: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
// 항공영상 데이터 등록
|
||||
export type AerialData = {
|
||||
fileName: string;
|
||||
region: string;
|
||||
scale: string;
|
||||
capturedDttm: string;
|
||||
fileSize: string;
|
||||
status: string;
|
||||
type: '도곽';
|
||||
};
|
||||
40
web-app/app/features/imagery/types/imageryRegister.ts
Normal file
40
web-app/app/features/imagery/types/imageryRegister.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// 지역 (시/도)
|
||||
export type Region = {
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
// 폴더
|
||||
export type Folder = {
|
||||
folderNm: string;
|
||||
parentFolderNm: string;
|
||||
parentPath: string;
|
||||
fullPath: string;
|
||||
depth: number;
|
||||
childCnt: number;
|
||||
lastModified: string;
|
||||
isValid: boolean;
|
||||
};
|
||||
|
||||
export type FolderListResponse = {
|
||||
dirPath: string;
|
||||
folders: Folder[];
|
||||
};
|
||||
|
||||
// 청크 업로드
|
||||
export type ChunkUploadParams = {
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
chunkIndex: number;
|
||||
chunkTotalIndex: number;
|
||||
chunkFile: Blob;
|
||||
};
|
||||
|
||||
export type ChunkUploadResponse = {
|
||||
uuid: string;
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
chunkIndex: number;
|
||||
chunkTotalIndex: number;
|
||||
uploadId?: string;
|
||||
};
|
||||
3
web-app/app/routes/change-model/classification/page.tsx
Normal file
3
web-app/app/routes/change-model/classification/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>변화모델 분류모델관리</div>;
|
||||
}
|
||||
3
web-app/app/routes/change-model/g1/page.tsx
Normal file
3
web-app/app/routes/change-model/g1/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>변화모델 G1 모델관리</div>;
|
||||
}
|
||||
3
web-app/app/routes/change-model/g2/page.tsx
Normal file
3
web-app/app/routes/change-model/g2/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>변화모델 G2 모델관리</div>;
|
||||
}
|
||||
3
web-app/app/routes/change-model/g3/page.tsx
Normal file
3
web-app/app/routes/change-model/g3/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>변화모델 G3 모델관리</div>;
|
||||
}
|
||||
3
web-app/app/routes/change-model/parameter/page.tsx
Normal file
3
web-app/app/routes/change-model/parameter/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>변화모델 파라미터관리</div>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>탐지모델 분류모델관리</div>;
|
||||
}
|
||||
3
web-app/app/routes/detection-model/g1/page.tsx
Normal file
3
web-app/app/routes/detection-model/g1/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>탐지모델 G1 모델관리</div>;
|
||||
}
|
||||
3
web-app/app/routes/detection-model/g2/page.tsx
Normal file
3
web-app/app/routes/detection-model/g2/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>탐지모델 G2 모델관리</div>;
|
||||
}
|
||||
3
web-app/app/routes/detection-model/g3/page.tsx
Normal file
3
web-app/app/routes/detection-model/g3/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>탐지모델 G3 모델관리</div>;
|
||||
}
|
||||
3
web-app/app/routes/detection-model/parameter/page.tsx
Normal file
3
web-app/app/routes/detection-model/parameter/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>탐지모델 파라미터관리</div>;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { AerialList } from '~/features/imagery/components/AerialList';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div>항공영상관리 목록</div>
|
||||
<AerialList></AerialList>
|
||||
);
|
||||
}
|
||||
|
||||
3
web-app/app/routes/labeling-management/change/page.tsx
Normal file
3
web-app/app/routes/labeling-management/change/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>변화탐지 라벨링관리</div>;
|
||||
}
|
||||
3
web-app/app/routes/labeling-management/object/page.tsx
Normal file
3
web-app/app/routes/labeling-management/object/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>객체탐지 라벨링관리</div>;
|
||||
}
|
||||
3
web-app/app/routes/log/error/page.tsx
Normal file
3
web-app/app/routes/log/error/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>에러 로그</div>;
|
||||
}
|
||||
3
web-app/app/routes/object/page.tsx
Normal file
3
web-app/app/routes/object/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>객체탐지</div>;
|
||||
}
|
||||
3
web-app/app/routes/terrain/page.tsx
Normal file
3
web-app/app/routes/terrain/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <div>지형변화탐지</div>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import Breadcrumbs from './Breadcrumbs'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Breadcrumbs',
|
||||
component: Breadcrumbs,
|
||||
} satisfies Meta<typeof Breadcrumbs>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
items: ['항공영상 관리', '항공영상 목록'],
|
||||
},
|
||||
}
|
||||
|
||||
export const SingleItem: Story = {
|
||||
args: {
|
||||
items: ['대시보드'],
|
||||
},
|
||||
}
|
||||
|
||||
export const ThreeItems: Story = {
|
||||
args: {
|
||||
items: ['설정', '시스템 설정', '일반'],
|
||||
},
|
||||
}
|
||||
|
||||
export const FourItems: Story = {
|
||||
args: {
|
||||
items: ['프로젝트', '프로젝트 A', '탐지 관리', '탐지 결과'],
|
||||
},
|
||||
}
|
||||
|
||||
export const LongNames: Story = {
|
||||
args: {
|
||||
items: ['항공영상 관리 시스템', '상세 항공영상 목록 페이지', '결과 분석'],
|
||||
},
|
||||
}
|
||||
58
web-app/app/shared/components/breadcrumbs/Breadcrumbs.tsx
Normal file
58
web-app/app/shared/components/breadcrumbs/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { FC } from 'react';
|
||||
import { Breadcrumb as AriaBreadcrumb, Breadcrumbs as AriaBreadcrumbs } from 'react-aria-components';
|
||||
|
||||
export interface BreadcrumbsProps {
|
||||
items: string[];
|
||||
}
|
||||
const Breadcrumbs: FC<BreadcrumbsProps> = (props) => {
|
||||
const { items } = props;
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb">
|
||||
<AriaBreadcrumbs className="flex items-center gap-2.5">
|
||||
<AriaBreadcrumb aria-label="홈">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="19"
|
||||
height="18"
|
||||
viewBox="0 0 19 18"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M6.75 14.4545H4.25V7.83333L9.25 4L14.25 7.83333V14.4545H11.75"
|
||||
stroke="#6C7789"
|
||||
strokeLinecap="square"
|
||||
/>
|
||||
<path d="M10.8381 12.9615V10.3633H7.65625V12.9615" stroke="#6C7789" />
|
||||
</svg>
|
||||
</AriaBreadcrumb>
|
||||
{items.map((item, index) => (
|
||||
<AriaBreadcrumb
|
||||
className="flex items-center gap-2.5 text-xs text-dabeeo-black-47 data-current:text-dabeeo-black-22"
|
||||
key={`breadcrumb-${index}-${item}`}
|
||||
>
|
||||
<>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="6"
|
||||
height="8"
|
||||
viewBox="0 0 6 8"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M2.70722 4.00064L0.25 1.54349L1.4556 0.337891L5.12572 4.00064L1.45572 7.66347L0.250117 6.45786L2.70722 4.00064Z"
|
||||
fill="#6C7789"
|
||||
/>
|
||||
</svg>
|
||||
{item}
|
||||
</>
|
||||
</AriaBreadcrumb>
|
||||
))}
|
||||
</AriaBreadcrumbs>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Breadcrumbs;
|
||||
5
web-app/app/shared/components/breadcrumbs/index.ts
Normal file
5
web-app/app/shared/components/breadcrumbs/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { BreadcrumbsProps } from './Breadcrumbs';
|
||||
import InternalBreadcrumbs from './Breadcrumbs';
|
||||
|
||||
export type { BreadcrumbsProps };
|
||||
export const Breadcrumbs = InternalBreadcrumbs;
|
||||
113
web-app/app/shared/components/button/Button.stories.tsx
Normal file
113
web-app/app/shared/components/button/Button.stories.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { Button } from './Button'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Button',
|
||||
component: Button,
|
||||
argTypes: {
|
||||
color: {
|
||||
control: 'select',
|
||||
options: ['primary', 'light', 'green', 'lightGreen', 'black', 'gray', 'orange', 'navy', 'lightNavy'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['small', 'medium', 'large'],
|
||||
},
|
||||
isDisabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
isPending: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Button>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: '버튼',
|
||||
color: 'primary',
|
||||
size: 'medium',
|
||||
},
|
||||
}
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
children: '작은 버튼',
|
||||
size: 'small',
|
||||
},
|
||||
}
|
||||
|
||||
export const Medium: Story = {
|
||||
args: {
|
||||
children: '중간 버튼',
|
||||
size: 'medium',
|
||||
},
|
||||
}
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
children: '큰 버튼',
|
||||
size: 'large',
|
||||
},
|
||||
}
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
children: 'Primary',
|
||||
color: 'primary',
|
||||
},
|
||||
}
|
||||
|
||||
export const Light: Story = {
|
||||
args: {
|
||||
children: 'Light',
|
||||
color: 'light',
|
||||
},
|
||||
}
|
||||
|
||||
export const Green: Story = {
|
||||
args: {
|
||||
children: 'Green',
|
||||
color: 'green',
|
||||
},
|
||||
}
|
||||
|
||||
export const Navy: Story = {
|
||||
args: {
|
||||
children: 'Navy',
|
||||
color: 'navy',
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
children: '비활성화',
|
||||
isDisabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
children: '로딩 중',
|
||||
isPending: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const AllColors: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button color="primary">Primary</Button>
|
||||
<Button color="light">Light</Button>
|
||||
<Button color="green">Green</Button>
|
||||
<Button color="lightGreen">Light Green</Button>
|
||||
<Button color="black">Black</Button>
|
||||
<Button color="gray">Gray</Button>
|
||||
<Button color="orange">Orange</Button>
|
||||
<Button color="navy">Navy</Button>
|
||||
<Button color="lightNavy">Light Navy</Button>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -19,21 +19,21 @@ const button = tv({
|
||||
large: 'min-w-30 h-13 px-5 text-sm font-semibold',
|
||||
},
|
||||
color: {
|
||||
primary: 'text-white bg-primary border-primary disabled:bg-kc-gray-be disabled:border-kc-gray-be ring-primary',
|
||||
primary: 'text-white bg-primary border-primary disabled:bg-dabeeo-gray-be disabled:border-dabeeo-gray-be ring-primary',
|
||||
light:
|
||||
'text-primary border-primary bg-primary-tertiary02 disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-primary',
|
||||
'text-primary border-primary bg-primary-tertiary02 disabled:text-dabeeo-gray-99 disabled:bg-dabeeo-gray-eb disabled:border-dabeeo-gray-be ring-primary',
|
||||
green:
|
||||
'text-white bg-kc-green-main border-kc-green-main disabled:bg-kc-gray-be disabled:border-kc-gray-be ring-kc-green-main',
|
||||
'text-white bg-dabeeo-green-main border-dabeeo-green-main disabled:bg-dabeeo-gray-be disabled:border-dabeeo-gray-be ring-dabeeo-green-main',
|
||||
lightGreen:
|
||||
'text-kc-green-main border-kc-green-main bg-kc-green-tertiary02 disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-kc-green-main',
|
||||
'text-dabeeo-green-main border-dabeeo-green-main bg-dabeeo-green-tertiary02 disabled:text-dabeeo-gray-99 disabled:bg-dabeeo-gray-eb disabled:border-dabeeo-gray-be ring-dabeeo-green-main',
|
||||
black:
|
||||
'text-white bg-kc-black-34 border-kc-black-34 disabled:bg-kc-gray-be disabled:border-kc-gray-be ring-kc-black-34',
|
||||
gray: 'text-kc-black-34 bg-kc-gray-eb border-kc-black-34 disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-kc-black-34',
|
||||
'text-white bg-dabeeo-black-34 border-dabeeo-black-34 disabled:bg-dabeeo-gray-be disabled:border-dabeeo-gray-be ring-dabeeo-black-34',
|
||||
gray: 'text-dabeeo-black-34 bg-dabeeo-gray-eb border-dabeeo-black-34 disabled:text-dabeeo-gray-99 disabled:bg-dabeeo-gray-eb disabled:border-dabeeo-gray-be ring-dabeeo-black-34',
|
||||
orange:
|
||||
'text-kc-orange-main bg-kc-orange-tertiary01 border-kc-orange-main disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-kc-orange-main',
|
||||
navy: 'text-white bg-kc-navy-main border-kc-navy-main disabled:bg-kc-gray-be disabled:border-kc-gray-be ring-kc-navy-main',
|
||||
'text-dabeeo-orange-main bg-dabeeo-orange-tertiary01 border-dabeeo-orange-main disabled:text-dabeeo-gray-99 disabled:bg-dabeeo-gray-eb disabled:border-dabeeo-gray-be ring-dabeeo-orange-main',
|
||||
navy: 'text-white bg-dabeeo-navy-main border-dabeeo-navy-main disabled:bg-dabeeo-gray-be disabled:border-dabeeo-gray-be ring-dabeeo-navy-main',
|
||||
lightNavy:
|
||||
'text-kc-navy-main border-kc-navy-main bg-kc-navy-tertiary02 disabled:text-kc-gray-99 disabled:bg-kc-gray-eb disabled:border-kc-gray-be ring-kc-navy-main',
|
||||
'text-dabeeo-navy-main border-dabeeo-navy-main bg-dabeeo-navy-tertiary02 disabled:text-dabeeo-gray-99 disabled:bg-dabeeo-gray-eb disabled:border-dabeeo-gray-be ring-dabeeo-navy-main',
|
||||
},
|
||||
isDisabled: {
|
||||
true: 'cursor-not-allowed',
|
||||
@@ -62,48 +62,48 @@ const button = tv({
|
||||
isDisabled: false,
|
||||
isPending: false,
|
||||
className:
|
||||
'hover:bg-kc-green-tertiary hover:border-kc-green-tertiary active:bg-kc-green-secondary active:border-kc-green-secondary',
|
||||
'hover:bg-dabeeo-green-tertiary hover:border-dabeeo-green-tertiary active:bg-dabeeo-green-secondary active:border-dabeeo-green-secondary',
|
||||
},
|
||||
{
|
||||
color: 'lightGreen',
|
||||
isDisabled: false,
|
||||
isPending: false,
|
||||
className:
|
||||
'hover:bg-kc-green-tertiary01 active:text-kc-green-secondary active:bg-kc-green-tertiary01 active:border-kc-green-secondary',
|
||||
'hover:bg-dabeeo-green-tertiary01 active:text-dabeeo-green-secondary active:bg-dabeeo-green-tertiary01 active:border-dabeeo-green-secondary',
|
||||
},
|
||||
{
|
||||
color: 'black',
|
||||
isDisabled: false,
|
||||
isPending: false,
|
||||
className: 'hover:bg-kc-black-47 hover:border-kc-black-47 active:bg-kc-black-22 active:border-kc-black-22',
|
||||
className: 'hover:bg-dabeeo-black-47 hover:border-dabeeo-black-47 active:bg-dabeeo-black-22 active:border-dabeeo-black-22',
|
||||
},
|
||||
{
|
||||
color: 'gray',
|
||||
isDisabled: false,
|
||||
isPending: false,
|
||||
className:
|
||||
'hover:bg-kc-gray-da hover:border-kc-black-47 active:text-kc-black-22 active:bg-kc-gray-da active:border-kc-black-22',
|
||||
'hover:bg-dabeeo-gray-da hover:border-dabeeo-black-47 active:text-dabeeo-black-22 active:bg-dabeeo-gray-da active:border-dabeeo-black-22',
|
||||
},
|
||||
{
|
||||
color: 'orange',
|
||||
isDisabled: false,
|
||||
isPending: false,
|
||||
className:
|
||||
'hover:bg-kc-orange-tertiary02 active:text-kc-orange-secondary active:bg-kc-orange-tertiary02 active:border-kc-orange-secondary',
|
||||
'hover:bg-dabeeo-orange-tertiary02 active:text-dabeeo-orange-secondary active:bg-dabeeo-orange-tertiary02 active:border-dabeeo-orange-secondary',
|
||||
},
|
||||
{
|
||||
color: 'navy',
|
||||
isDisabled: false,
|
||||
isPending: false,
|
||||
className:
|
||||
'hover:border-kc-navy-tertiary hover:bg-kc-navy-tertiary active:bg-kc-navy-secondary active:border-kc-navy-secondary',
|
||||
'hover:border-dabeeo-navy-tertiary hover:bg-dabeeo-navy-tertiary active:bg-dabeeo-navy-secondary active:border-dabeeo-navy-secondary',
|
||||
},
|
||||
{
|
||||
color: 'lightNavy',
|
||||
isDisabled: false,
|
||||
isPending: false,
|
||||
className:
|
||||
'hover:bg-kc-navy-tertiary01 active:text-kc-navy-secondary active:bg-kc-navy-tertiary01 active:border-kc-navy-secondary',
|
||||
'hover:bg-dabeeo-navy-tertiary01 active:text-dabeeo-navy-secondary active:bg-dabeeo-navy-tertiary01 active:border-dabeeo-navy-secondary',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
62
web-app/app/shared/components/calendar/Calendar.stories.tsx
Normal file
62
web-app/app/shared/components/calendar/Calendar.stories.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { Calendar } from './Calendar'
|
||||
import { RangeCalendar } from './RangeCalendar'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Calendar',
|
||||
component: Calendar,
|
||||
argTypes: {
|
||||
isDisabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Calendar>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
}
|
||||
|
||||
export const WithSelectedDate: Story = {
|
||||
render: function Render() {
|
||||
const [date, setDate] = useState<Date | null>(new Date())
|
||||
return <Calendar value={date} onChange={setDate} />
|
||||
},
|
||||
}
|
||||
|
||||
export const WithMinMaxDate: Story = {
|
||||
render: function Render() {
|
||||
const [date, setDate] = useState<Date | null>(new Date())
|
||||
const minDate = new Date()
|
||||
minDate.setDate(minDate.getDate() - 7)
|
||||
const maxDate = new Date()
|
||||
maxDate.setDate(maxDate.getDate() + 7)
|
||||
return <Calendar value={date} onChange={setDate} minValue={minDate} maxValue={maxDate} />
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
isDisabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const Range: StoryObj<typeof RangeCalendar> = {
|
||||
render: function Render() {
|
||||
const [range, setRange] = useState<{ start: Date; end: Date } | null>(null)
|
||||
return <RangeCalendar value={range} onChange={setRange} />
|
||||
},
|
||||
}
|
||||
|
||||
export const RangeWithSelectedDates: StoryObj<typeof RangeCalendar> = {
|
||||
render: function Render() {
|
||||
const start = new Date()
|
||||
const end = new Date()
|
||||
end.setDate(end.getDate() + 7)
|
||||
const [range, setRange] = useState<{ start: Date; end: Date } | null>({ start, end })
|
||||
return <RangeCalendar value={range} onChange={setRange} />
|
||||
},
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { getLocalTimeZone, today } from '@internationalized/date';
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@/components/icons';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '../icons';
|
||||
|
||||
import { calendarDateToDate, dateToCalendarDate } from './utils';
|
||||
|
||||
@@ -24,13 +24,13 @@ const cellStyles = tv({
|
||||
false: '',
|
||||
},
|
||||
isDisabled: {
|
||||
true: 'cursor-default text-kc-gray-be',
|
||||
true: 'cursor-default text-dabeeo-gray-be',
|
||||
},
|
||||
isUnavailable: {
|
||||
true: 'cursor-default text-kc-gray-be line-through',
|
||||
true: 'cursor-default text-dabeeo-gray-be line-through',
|
||||
},
|
||||
isOutsideMonth: {
|
||||
true: 'text-kc-gray-be',
|
||||
true: 'text-dabeeo-gray-be',
|
||||
},
|
||||
isSunday: {
|
||||
true: 'text-[#e48686]',
|
||||
@@ -128,8 +128,7 @@ export function Calendar({ value, defaultValue, onChange, maxValue, minValue, is
|
||||
isToday: !isSelected ? isToday : false,
|
||||
isHovered: !isSelected ? isHovered : false,
|
||||
isFocusVisible,
|
||||
})
|
||||
}
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
@@ -143,8 +142,8 @@ const navButton = tv({
|
||||
base: 'flex h-7 w-7 items-center justify-center rounded-sm border-none bg-transparent outline-none',
|
||||
variants: {
|
||||
isDisabled: {
|
||||
true: 'cursor-default text-kc-gray-be',
|
||||
false: 'cursor-pointer text-kc-black-34 hover:bg-kc-gray-eb',
|
||||
true: 'cursor-default text-dabeeo-gray-be',
|
||||
false: 'cursor-pointer text-dabeeo-black-34 hover:bg-dabeeo-gray-eb',
|
||||
},
|
||||
isFocusVisible: {
|
||||
true: 'ring-2 ring-offset-2 ring-primary',
|
||||
@@ -161,7 +160,7 @@ export function CalendarHeader() {
|
||||
<AriaButton slot="previous" className={(renderProps) => navButton(renderProps)}>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</AriaButton>
|
||||
<Heading className="mx-2 flex-1 text-center text-sm font-semibold text-kc-black-34" />
|
||||
<Heading className="mx-2 flex-1 text-center text-sm font-semibold text-dabeeo-black-34" />
|
||||
<AriaButton slot="next" className={(renderProps) => navButton(renderProps)}>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</AriaButton>
|
||||
@@ -173,7 +172,7 @@ export function CalendarGridHeader() {
|
||||
return (
|
||||
<AriaCalendarGridHeader>
|
||||
{(day) => (
|
||||
<CalendarHeaderCell className="h-[30px] w-[30px] text-xs font-semibold text-kc-gray-99 first:text-[#e48686] last:text-[#7b8cc8]">
|
||||
<CalendarHeaderCell className="h-[30px] w-[30px] text-xs font-semibold text-dabeeo-gray-99 first:text-[#e48686] last:text-[#7b8cc8]">
|
||||
{day}
|
||||
</CalendarHeaderCell>
|
||||
)}
|
||||
|
||||
@@ -21,10 +21,10 @@ const rangeCell = tv({
|
||||
cap: 'bg-primary font-bold text-white',
|
||||
},
|
||||
isDisabled: {
|
||||
true: 'text-kc-gray-be',
|
||||
true: 'text-dabeeo-gray-be',
|
||||
},
|
||||
isOutsideMonth: {
|
||||
true: 'text-kc-gray-be',
|
||||
true: 'text-dabeeo-gray-be',
|
||||
},
|
||||
isSunday: {
|
||||
true: 'text-[#e48686]',
|
||||
|
||||
115
web-app/app/shared/components/checkbox/Checkbox.stories.tsx
Normal file
115
web-app/app/shared/components/checkbox/Checkbox.stories.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { Checkbox } from './Checkbox'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Checkbox',
|
||||
component: Checkbox,
|
||||
argTypes: {
|
||||
isDisabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
isIndeterminate: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Checkbox>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: '동의합니다',
|
||||
},
|
||||
}
|
||||
|
||||
export const Checked: Story = {
|
||||
render: function Render() {
|
||||
const [isSelected, setIsSelected] = useState(true)
|
||||
return (
|
||||
<Checkbox isSelected={isSelected} onChange={setIsSelected}>
|
||||
선택됨
|
||||
</Checkbox>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Unchecked: Story = {
|
||||
render: function Render() {
|
||||
const [isSelected, setIsSelected] = useState(false)
|
||||
return (
|
||||
<Checkbox isSelected={isSelected} onChange={setIsSelected}>
|
||||
선택 안됨
|
||||
</Checkbox>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Indeterminate: Story = {
|
||||
args: {
|
||||
isIndeterminate: true,
|
||||
children: '일부 선택됨',
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
isDisabled: true,
|
||||
children: '비활성화',
|
||||
},
|
||||
}
|
||||
|
||||
export const DisabledChecked: Story = {
|
||||
args: {
|
||||
isDisabled: true,
|
||||
isSelected: true,
|
||||
children: '비활성화 (선택됨)',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithoutLabel: Story = {
|
||||
args: {},
|
||||
}
|
||||
|
||||
export const MultipleCheckboxes: Story = {
|
||||
render: function Render() {
|
||||
const [checkedItems, setCheckedItems] = useState<Record<string, boolean>>({
|
||||
option1: false,
|
||||
option2: true,
|
||||
option3: false,
|
||||
})
|
||||
|
||||
const handleChange = (key: string) => (isSelected: boolean) => {
|
||||
setCheckedItems((prev) => ({ ...prev, [key]: isSelected }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Checkbox isSelected={checkedItems.option1} onChange={handleChange('option1')}>
|
||||
옵션 1
|
||||
</Checkbox>
|
||||
<Checkbox isSelected={checkedItems.option2} onChange={handleChange('option2')}>
|
||||
옵션 2
|
||||
</Checkbox>
|
||||
<Checkbox isSelected={checkedItems.option3} onChange={handleChange('option3')}>
|
||||
옵션 3
|
||||
</Checkbox>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Checkbox>기본</Checkbox>
|
||||
<Checkbox isSelected>선택됨</Checkbox>
|
||||
<Checkbox isIndeterminate>일부 선택</Checkbox>
|
||||
<Checkbox isDisabled>비활성화</Checkbox>
|
||||
<Checkbox isDisabled isSelected>
|
||||
비활성화 (선택됨)
|
||||
</Checkbox>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
66
web-app/app/shared/components/checkbox/Checkbox.tsx
Normal file
66
web-app/app/shared/components/checkbox/Checkbox.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { CheckboxProps as AriaCheckboxProps } from 'react-aria-components';
|
||||
import { Checkbox as AriaCheckbox, composeRenderProps } from 'react-aria-components';
|
||||
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
const checkbox = tv({
|
||||
base: 'inline-flex items-center gap-2 text-sm font-medium leading-7',
|
||||
variants: {
|
||||
isDisabled: {
|
||||
true: 'text-dabeeo-gray-be',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const box = tv({
|
||||
base: 'w-4 h-4 box-border shrink-0 flex items-center justify-center text-white border border-dabeeo-gray-be transition',
|
||||
variants: {
|
||||
isSelected: {
|
||||
true: 'bg-primary border-primary',
|
||||
},
|
||||
isDisabled: {
|
||||
true: 'text-dabeeo-gray-99 bg-dabeeo-gray-eb border-dabeeo-gray-be cursor-not-allowed',
|
||||
},
|
||||
isFocusVisible: {
|
||||
true: 'ring-2 ring-offset-2 ring-primary',
|
||||
},
|
||||
},
|
||||
compoundVariants: [],
|
||||
});
|
||||
|
||||
export const Checkbox = (props: AriaCheckboxProps) => {
|
||||
const { className, children, ...restProps } = props;
|
||||
return (
|
||||
<AriaCheckbox
|
||||
className={composeRenderProps(className, (className, renderProps) => checkbox({ ...renderProps, className }))}
|
||||
{...restProps}
|
||||
>
|
||||
{composeRenderProps(children, (children, { isSelected, isIndeterminate, ...renderProps }) => (
|
||||
<>
|
||||
<div className={box({ isSelected: isSelected || isIndeterminate, ...renderProps })}>
|
||||
{isIndeterminate ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
) : isSelected ? (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 7.00893L7.85714 10.625L13 5" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
))}
|
||||
</AriaCheckbox>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { DatePicker } from './DatePicker'
|
||||
import { DateRangePicker } from './DateRangePicker'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/DatePicker',
|
||||
component: DatePicker,
|
||||
args: {
|
||||
value: null,
|
||||
onChange: () => {},
|
||||
},
|
||||
argTypes: {
|
||||
isDisabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof DatePicker>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: function Render() {
|
||||
const [date, setDate] = useState<Date | null>(null)
|
||||
return <DatePicker value={date} onChange={setDate} />
|
||||
},
|
||||
}
|
||||
|
||||
export const WithSelectedDate: Story = {
|
||||
render: function Render() {
|
||||
const [date, setDate] = useState<Date | null>(new Date())
|
||||
return <DatePicker value={date} onChange={setDate} />
|
||||
},
|
||||
}
|
||||
|
||||
export const WithMinMaxDate: Story = {
|
||||
render: function Render() {
|
||||
const [date, setDate] = useState<Date | null>(new Date())
|
||||
const minDate = new Date()
|
||||
minDate.setDate(minDate.getDate() - 7)
|
||||
const maxDate = new Date()
|
||||
maxDate.setDate(maxDate.getDate() + 7)
|
||||
return <DatePicker value={date} onChange={setDate} minValue={minDate} maxValue={maxDate} />
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: function Render() {
|
||||
const [date, setDate] = useState<Date | null>(new Date())
|
||||
return <DatePicker value={date} onChange={setDate} isDisabled />
|
||||
},
|
||||
}
|
||||
|
||||
export const Range: StoryObj<typeof DateRangePicker> = {
|
||||
render: function Render() {
|
||||
const [range, setRange] = useState<{ start: Date; end: Date } | null>(null)
|
||||
return <DateRangePicker value={range} onChange={setRange} />
|
||||
},
|
||||
}
|
||||
|
||||
export const RangeWithSelectedDates: StoryObj<typeof DateRangePicker> = {
|
||||
render: function Render() {
|
||||
const start = new Date()
|
||||
const end = new Date()
|
||||
end.setDate(end.getDate() + 7)
|
||||
const [range, setRange] = useState<{ start: Date; end: Date } | null>({ start, end })
|
||||
return <DateRangePicker value={range} onChange={setRange} />
|
||||
},
|
||||
}
|
||||
|
||||
export const RangeDisabled: StoryObj<typeof DateRangePicker> = {
|
||||
render: function Render() {
|
||||
const start = new Date()
|
||||
const end = new Date()
|
||||
end.setDate(end.getDate() + 7)
|
||||
const [range, setRange] = useState<{ start: Date; end: Date } | null>({ start, end })
|
||||
return <DateRangePicker value={range} onChange={setRange} isDisabled />
|
||||
},
|
||||
}
|
||||
@@ -8,16 +8,16 @@ import { calendarDateToDate, dateToCalendarDate } from '../calendar/utils';
|
||||
import { CalendarIcon } from '../icons';
|
||||
|
||||
const trigger = tv({
|
||||
base: 'flex h-9 w-[156px] cursor-pointer items-center border border-kc-gray-be bg-white text-left outline-none transition',
|
||||
base: 'flex h-9 w-[156px] cursor-pointer items-center border border-dabeeo-gray-be bg-white text-left outline-none transition',
|
||||
variants: {
|
||||
isHovered: {
|
||||
true: 'border-kc-black-34',
|
||||
true: 'border-dabeeo-black-34',
|
||||
},
|
||||
isFocused: {
|
||||
true: 'border-kc-black-34',
|
||||
true: 'border-dabeeo-black-34',
|
||||
},
|
||||
isDisabled: {
|
||||
true: 'cursor-default border-kc-gray-99 bg-kc-gray-eb',
|
||||
true: 'cursor-default border-dabeeo-gray-99 bg-dabeeo-gray-eb',
|
||||
},
|
||||
isFocusVisible: {
|
||||
true: 'ring-2 ring-offset-2 ring-primary',
|
||||
@@ -51,21 +51,21 @@ export function DatePicker({ value, onChange, maxValue, minValue, isDisabled, cl
|
||||
>
|
||||
<Group>
|
||||
<Button className={(renderProps) => trigger(renderProps)}>
|
||||
<span className={`flex-1 px-3 text-sm ${value ? 'text-kc-black-34' : 'text-kc-gray-99'}`}>
|
||||
<span className={`flex-1 px-3 text-sm ${value ? 'text-dabeeo-black-34' : 'text-dabeeo-gray-99'}`}>
|
||||
{value ? dayjs(value).format('YYYY.MM.DD') : 'YYYY.MM.DD'}
|
||||
</span>
|
||||
<span className="flex h-full w-8 shrink-0 items-center justify-center text-kc-gray-99">
|
||||
<span className="flex h-full w-8 shrink-0 items-center justify-center text-dabeeo-gray-99">
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
</span>
|
||||
</Button>
|
||||
</Group>
|
||||
<Popover className="bg-white border border-kc-gray-be p-3 px-6 pb-6 drop-shadow-modal outline-none" offset={12}>
|
||||
<Popover className="bg-white border border-dabeeo-gray-be p-3 px-6 pb-6 drop-shadow-modal outline-none" offset={12}>
|
||||
<OverlayArrow className="group/arrow">
|
||||
<svg
|
||||
width="12"
|
||||
height="8"
|
||||
viewBox="0 0 12 8"
|
||||
className="block fill-white stroke-kc-gray-be group-data-[placement=bottom]/arrow:rotate-180"
|
||||
className="block fill-white stroke-dabeeo-gray-be group-data-[placement=bottom]/arrow:rotate-180"
|
||||
>
|
||||
<path d="M0 0 L6 8 L12 0" />
|
||||
</svg>
|
||||
|
||||
@@ -10,22 +10,22 @@ import {
|
||||
import dayjs from 'dayjs';
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
import { CalendarIcon } from '@/components/icons';
|
||||
import { CalendarIcon } from '../icons';
|
||||
|
||||
import { RangeCalendar } from '../calendar/RangeCalendar';
|
||||
import { calendarDateToDate, dateToCalendarDate } from '../calendar/utils';
|
||||
|
||||
const trigger = tv({
|
||||
base: 'flex h-9 w-[260px] cursor-pointer items-center border border-kc-gray-be bg-white text-left outline-none transition',
|
||||
base: 'flex h-9 w-[260px] cursor-pointer items-center border border-dabeeo-gray-be bg-white text-left outline-none transition',
|
||||
variants: {
|
||||
isHovered: {
|
||||
true: 'border-kc-black-34',
|
||||
true: 'border-dabeeo-black-34',
|
||||
},
|
||||
isFocused: {
|
||||
true: 'border-kc-black-34',
|
||||
true: 'border-dabeeo-black-34',
|
||||
},
|
||||
isDisabled: {
|
||||
true: 'cursor-default border-kc-gray-99 bg-kc-gray-eb',
|
||||
true: 'cursor-default border-dabeeo-gray-99 bg-dabeeo-gray-eb',
|
||||
},
|
||||
isFocusVisible: {
|
||||
true: 'ring-2 ring-offset-2 ring-primary',
|
||||
@@ -81,25 +81,25 @@ export function DateRangePicker({
|
||||
>
|
||||
<Group>
|
||||
<Button className={(renderProps) => trigger(renderProps)}>
|
||||
<span className={`tabular-nums px-3 text-sm ${value?.start ? 'text-kc-black-34' : 'text-kc-gray-99'}`}>
|
||||
<span className={`tabular-nums px-3 text-sm ${value?.start ? 'text-dabeeo-black-34' : 'text-dabeeo-gray-99'}`}>
|
||||
{value?.start ? dayjs(value.start).format('YYYY. MM. DD') : 'YYYY.MM.DD'}
|
||||
</span>
|
||||
<span className="text-sm text-kc-gray-99">~</span>
|
||||
<span className={`tabular-nums flex-1 px-3 text-sm ${value?.end ? 'text-kc-black-34' : 'text-kc-gray-99'}`}>
|
||||
<span className="text-sm text-dabeeo-gray-99">~</span>
|
||||
<span className={`tabular-nums flex-1 px-3 text-sm ${value?.end ? 'text-dabeeo-black-34' : 'text-dabeeo-gray-99'}`}>
|
||||
{value?.end ? dayjs(value.end).format('YYYY. MM. DD') : 'YYYY.MM.DD'}
|
||||
</span>
|
||||
<span className="flex h-full w-8 shrink-0 items-center justify-center text-kc-gray-99">
|
||||
<span className="flex h-full w-8 shrink-0 items-center justify-center text-dabeeo-gray-99">
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
</span>
|
||||
</Button>
|
||||
</Group>
|
||||
<Popover className="bg-white border border-kc-gray-be p-3 px-6 pb-6 drop-shadow-modal outline-none" offset={12}>
|
||||
<Popover className="bg-white border border-dabeeo-gray-be p-3 px-6 pb-6 drop-shadow-modal outline-none" offset={12}>
|
||||
<OverlayArrow className="group/arrow">
|
||||
<svg
|
||||
width="12"
|
||||
height="8"
|
||||
viewBox="0 0 12 8"
|
||||
className="block fill-white stroke-kc-gray-be group-data-[placement=bottom]/arrow:rotate-180"
|
||||
className="block fill-white stroke-dabeeo-gray-be group-data-[placement=bottom]/arrow:rotate-180"
|
||||
>
|
||||
<path d="M0 0 L6 8 L12 0" />
|
||||
</svg>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { Descriptions } from './Descriptions'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Descriptions',
|
||||
component: Descriptions,
|
||||
argTypes: {
|
||||
column: {
|
||||
control: 'select',
|
||||
options: [1, 2, 3, 4],
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Descriptions>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ title: '이름', content: '홍길동' },
|
||||
{ title: '이메일', content: 'hong@example.com' },
|
||||
{ title: '전화번호', content: '010-1234-5678' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const TwoColumns: Story = {
|
||||
args: {
|
||||
column: 2,
|
||||
items: [
|
||||
{ title: '이름', content: '홍길동' },
|
||||
{ title: '이메일', content: 'hong@example.com' },
|
||||
{ title: '전화번호', content: '010-1234-5678' },
|
||||
{ title: '주소', content: '서울특별시 강남구' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const ThreeColumns: Story = {
|
||||
args: {
|
||||
column: 3,
|
||||
items: [
|
||||
{ title: '이름', content: '홍길동' },
|
||||
{ title: '이메일', content: 'hong@example.com' },
|
||||
{ title: '전화번호', content: '010-1234-5678' },
|
||||
{ title: '주소', content: '서울특별시 강남구' },
|
||||
{ title: '부서', content: '개발팀' },
|
||||
{ title: '직급', content: '선임연구원' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const FourColumns: Story = {
|
||||
args: {
|
||||
column: 4,
|
||||
items: [
|
||||
{ title: '이름', content: '홍길동' },
|
||||
{ title: '이메일', content: 'hong@example.com' },
|
||||
{ title: '전화번호', content: '010-1234-5678' },
|
||||
{ title: '주소', content: '서울특별시 강남구' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const WithSpan: Story = {
|
||||
args: {
|
||||
column: 2,
|
||||
items: [
|
||||
{ title: '이름', content: '홍길동' },
|
||||
{ title: '이메일', content: 'hong@example.com' },
|
||||
{ title: '주소', content: '서울특별시 강남구 역삼동 123-456', span: 'filled' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const WithCustomTitleWidth: Story = {
|
||||
args: {
|
||||
column: 2,
|
||||
titleWidth: 150,
|
||||
items: [
|
||||
{ title: '사용자 이름', content: '홍길동' },
|
||||
{ title: '이메일 주소', content: 'hong@example.com' },
|
||||
{ title: '휴대전화 번호', content: '010-1234-5678' },
|
||||
{ title: '거주지 주소', content: '서울특별시 강남구' },
|
||||
],
|
||||
},
|
||||
}
|
||||
70
web-app/app/shared/components/descriptions/Descriptions.tsx
Normal file
70
web-app/app/shared/components/descriptions/Descriptions.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { DescriptionsItem, DescriptionsItemProps } from './items/DescriptionsItem';
|
||||
|
||||
export type DescriotionItemType = {
|
||||
items: (DescriptionsItemProps & { key?: string })[];
|
||||
column?: 1 | 2 | 3 | 4;
|
||||
titleWidth?: number;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
export const Descriptions = (props: DescriotionItemType) => {
|
||||
const { items, column = 1, titleWidth, className, titleClassName, contentClassName } = props;
|
||||
|
||||
const columnClassName =
|
||||
{
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-2',
|
||||
3: 'grid-cols-3',
|
||||
4: 'grid-cols-4',
|
||||
}[column] || 'grid-cols-1';
|
||||
|
||||
const getFirstRowItemIndices = () => {
|
||||
const firstRowItemIndices: number[] = [];
|
||||
let availableColumns = column;
|
||||
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
if (availableColumns <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const item = items[index];
|
||||
const itemSpan = item.span === 'filled' ? column : (item.span ?? 1);
|
||||
const effectiveSpan = Math.min(typeof itemSpan === 'number' ? itemSpan : column, column);
|
||||
|
||||
if (effectiveSpan > availableColumns) {
|
||||
break;
|
||||
}
|
||||
|
||||
firstRowItemIndices.push(index);
|
||||
availableColumns -= effectiveSpan;
|
||||
|
||||
if (availableColumns === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return firstRowItemIndices;
|
||||
};
|
||||
|
||||
const firstRowItemIndices = getFirstRowItemIndices();
|
||||
|
||||
return (
|
||||
<div className={`grid w-full gap-0 ${columnClassName || ''} ${className || ''}`}>
|
||||
{items.map((item, index) => {
|
||||
const { key, ...itemProps } = item;
|
||||
return (
|
||||
<DescriptionsItem
|
||||
key={key || index}
|
||||
{...itemProps}
|
||||
isFirstRow={firstRowItemIndices.includes(index)}
|
||||
titleWidth={titleWidth}
|
||||
titleClassName={titleClassName}
|
||||
contentClassName={contentClassName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
export type DescriptionsItemProps = {
|
||||
title: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
span?: number | 'filled';
|
||||
isFirstRow?: boolean;
|
||||
titleWidth?: number;
|
||||
titleClassName?: string;
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
export const DescriptionsItem = (props: DescriptionsItemProps) => {
|
||||
const { title, content, span, isFirstRow = false, titleWidth, titleClassName, contentClassName } = props;
|
||||
|
||||
const gridColumnStyle =
|
||||
span === 'filled' ? { gridColumn: '1 / -1' } : span && span > 0 ? { gridColumn: `span ${span}` } : {};
|
||||
|
||||
return (
|
||||
<div className="flex" style={gridColumnStyle}>
|
||||
<div
|
||||
className={`flex w-25 min-h-10 items-center px-4 bg-primary-tertiary01 text-primary-secondary text-sm font-semibold border-b border-white ${titleClassName || ''}`}
|
||||
style={{ width: titleWidth }}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'relative flex flex-1 items-center pl-4 pr-3 py-2 text-sm font-medium text-dabeeo-black-22 overflow-x-auto after:content-[\'\'] after:absolute after:left-0 after:right-0 after:bottom-0 after:h-px after:bg-primary-tertiary01',
|
||||
contentClassName,
|
||||
isFirstRow &&
|
||||
'before:content-[\'\'] before:absolute before:left-0 before:right-0 before:top-0 before:h-px before:bg-primary-tertiary01'
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
9
web-app/app/shared/components/icons/OverlayArrow.tsx
Normal file
9
web-app/app/shared/components/icons/OverlayArrow.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function OverlayArrowIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M4.5 7L0 0H9L4.5 7Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -2,4 +2,5 @@ export * from './Calendar';
|
||||
export * from './ChevronLeft';
|
||||
export * from './ChevronRight';
|
||||
export * from './LoadingSpinner';
|
||||
export * from './OverlayArrow';
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { InputProps as AriaInputProps } from 'react-aria-components';
|
||||
import { Input as AriaInput } from 'react-aria-components';
|
||||
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import style from './style';
|
||||
|
||||
export interface InputProps extends AriaInputProps {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import Input from './Input'
|
||||
import { InputGroup } from './InputGroup'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/InputGroup',
|
||||
component: InputGroup,
|
||||
argTypes: {
|
||||
isReadOnly: { control: 'boolean' },
|
||||
},
|
||||
} satisfies Meta<typeof InputGroup>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => (
|
||||
<InputGroup {...args}>
|
||||
<Input placeholder="입력해주세요" />
|
||||
</InputGroup>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithValue: Story = {
|
||||
render: (args) => (
|
||||
<InputGroup {...args}>
|
||||
<Input defaultValue="입력된 값" />
|
||||
</InputGroup>
|
||||
),
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<InputGroup>
|
||||
<Input placeholder="비활성화" disabled />
|
||||
</InputGroup>
|
||||
),
|
||||
}
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
isReadOnly: true,
|
||||
},
|
||||
render: (args) => (
|
||||
<InputGroup {...args}>
|
||||
<Input defaultValue="읽기 전용" readOnly />
|
||||
</InputGroup>
|
||||
),
|
||||
}
|
||||
|
||||
export const NumberInput: Story = {
|
||||
render: () => (
|
||||
<InputGroup>
|
||||
<Input type="number" placeholder="숫자 입력" />
|
||||
</InputGroup>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithPrefix: Story = {
|
||||
render: () => (
|
||||
<InputGroup>
|
||||
<span className="pl-3 text-dabeeo-gray-99">@</span>
|
||||
<Input placeholder="사용자명" />
|
||||
</InputGroup>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithSuffix: Story = {
|
||||
render: () => (
|
||||
<InputGroup>
|
||||
<Input placeholder="금액" type="number" />
|
||||
<span className="pr-3 text-dabeeo-gray-99">원</span>
|
||||
</InputGroup>
|
||||
),
|
||||
}
|
||||
|
||||
export const CustomWidth: Story = {
|
||||
render: () => (
|
||||
<InputGroup className="w-60">
|
||||
<Input placeholder="너비 240px" />
|
||||
</InputGroup>
|
||||
),
|
||||
}
|
||||
@@ -16,18 +16,18 @@ const style = tv({
|
||||
'flex',
|
||||
'items-center',
|
||||
'text-sm',
|
||||
'text-kc-black-34',
|
||||
'text-dabeeo-black-34',
|
||||
'leading-[18px]',
|
||||
'border',
|
||||
'border-kc-gray-be',
|
||||
'has-data-focused:border-kc-black-34',
|
||||
'has-data-hovered:border-kc-black-34',
|
||||
'has-data-disabled:bg-kc-gray-eb',
|
||||
'has-data-disabled:text-kc-gray-99',
|
||||
'has-data-disabled:border-kc-gray-be',
|
||||
'has-data-invalid:border-kc-red',
|
||||
'has-data-invalid:has-data-focused:border-kc-red',
|
||||
'has-data-invalid:has-data-hovered:border-kc-red',
|
||||
'border-dabeeo-gray-be',
|
||||
'has-data-focused:border-dabeeo-black-34',
|
||||
'has-data-hovered:border-dabeeo-black-34',
|
||||
'has-data-disabled:bg-dabeeo-gray-eb',
|
||||
'has-data-disabled:text-dabeeo-gray-99',
|
||||
'has-data-disabled:border-dabeeo-gray-be',
|
||||
'has-data-invalid:border-dabeeo-red',
|
||||
'has-data-invalid:has-data-focused:border-dabeeo-red',
|
||||
'has-data-invalid:has-data-hovered:border-dabeeo-red',
|
||||
'w-full',
|
||||
'has-[[type=number]]:leading-6',
|
||||
],
|
||||
@@ -36,7 +36,7 @@ const style = tv({
|
||||
variants: {
|
||||
isReadOnly: {
|
||||
true: {
|
||||
base: 'bg-kc-yellow-secondary has-data-hovered:border-kc-gray-be has-data-focused:border-kc-gray-be',
|
||||
base: 'bg-dabeeo-yellow-secondary has-data-hovered:border-dabeeo-gray-be has-data-focused:border-dabeeo-gray-be',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
35
web-app/app/shared/components/inputGroup/style.ts
Normal file
35
web-app/app/shared/components/inputGroup/style.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
export default tv({
|
||||
slots: {
|
||||
container: 'relative flex flex-col justify-center items-start bg-white',
|
||||
base: [
|
||||
'h-9',
|
||||
'flex',
|
||||
'items-center',
|
||||
'text-sm',
|
||||
'text-dabeeo-black-34',
|
||||
'leading-[18px]',
|
||||
'border',
|
||||
'border-dabeeo-gray-be',
|
||||
'has-data-focused:border-dabeeo-black-34',
|
||||
'has-data-hovered:border-dabeeo-black-34',
|
||||
'has-data-disabled:bg-dabeeo-gray-eb',
|
||||
'has-data-disabled:text-dabeeo-gray-99',
|
||||
'has-data-disabled:border-dabeeo-gray-be',
|
||||
'has-data-invalid:border-dabeeo-red',
|
||||
'has-data-invalid:has-data-focused:border-dabeeo-red',
|
||||
'has-data-invalid:has-data-hovered:border-dabeeo-red',
|
||||
'w-full',
|
||||
'has-[[type=number]]:leading-6',
|
||||
],
|
||||
input: ['w-full', 'px-3', 'outline-0', '[&[type=number]]:pr-1.5'],
|
||||
},
|
||||
variants: {
|
||||
isReadOnly: {
|
||||
true: {
|
||||
base: 'bg-dabeeo-yellow-secondary has-data-hovered:border-dabeeo-gray-be has-data-focused:border-dabeeo-gray-be',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
101
web-app/app/shared/components/menu/Menu.stories.tsx
Normal file
101
web-app/app/shared/components/menu/Menu.stories.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { Menu, type MenuItemChildrenType } from './Menu'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Menu',
|
||||
component: Menu,
|
||||
args: {
|
||||
items: [],
|
||||
currentPath: '',
|
||||
onSelectionChange: () => {},
|
||||
},
|
||||
} satisfies Meta<typeof Menu>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const sampleItems = [
|
||||
{
|
||||
id: '1',
|
||||
name: '항공영상 관리',
|
||||
menuUrl: null,
|
||||
children: [
|
||||
{ id: '1-1', name: '항공영상 목록', menuUrl: '/imagery/list' },
|
||||
{ id: '1-2', name: '항공영상 등록', menuUrl: '/imagery/register' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '탐지 관리',
|
||||
menuUrl: null,
|
||||
children: [
|
||||
{ id: '2-1', name: '탐지 실행', menuUrl: '/detection/run' },
|
||||
{ id: '2-2', name: '탐지 결과', menuUrl: '/detection/result' },
|
||||
{ id: '2-3', name: '탐지 이력', menuUrl: '/detection/history' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '설정',
|
||||
menuUrl: null,
|
||||
children: [
|
||||
{ id: '3-1', name: '시스템 설정', menuUrl: '/settings/system' },
|
||||
{ id: '3-2', name: '사용자 설정', menuUrl: '/settings/user' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const Default: Story = {
|
||||
render: function Render() {
|
||||
const [currentPath, setCurrentPath] = useState('/imagery/list')
|
||||
const handleSelectionChange = (menu: MenuItemChildrenType) => {
|
||||
setCurrentPath(menu.menuUrl)
|
||||
}
|
||||
return (
|
||||
<div className="w-60 bg-dabeeo-gray-f5">
|
||||
<Menu items={sampleItems} currentPath={currentPath} onSelectionChange={handleSelectionChange} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const WithDifferentPath: Story = {
|
||||
render: function Render() {
|
||||
const [currentPath, setCurrentPath] = useState('/detection/result')
|
||||
const handleSelectionChange = (menu: MenuItemChildrenType) => {
|
||||
setCurrentPath(menu.menuUrl)
|
||||
}
|
||||
return (
|
||||
<div className="w-60 bg-dabeeo-gray-f5">
|
||||
<Menu items={sampleItems} currentPath={currentPath} onSelectionChange={handleSelectionChange} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const SingleCategory: Story = {
|
||||
render: function Render() {
|
||||
const singleCategoryItems = [
|
||||
{
|
||||
id: '1',
|
||||
name: '항공영상 관리',
|
||||
menuUrl: null,
|
||||
children: [
|
||||
{ id: '1-1', name: '항공영상 목록', menuUrl: '/imagery/list' },
|
||||
{ id: '1-2', name: '항공영상 등록', menuUrl: '/imagery/register' },
|
||||
{ id: '1-3', name: '항공영상 상세', menuUrl: '/imagery/detail' },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [currentPath, setCurrentPath] = useState('/imagery/list')
|
||||
const handleSelectionChange = (menu: MenuItemChildrenType) => {
|
||||
setCurrentPath(menu.menuUrl)
|
||||
}
|
||||
return (
|
||||
<div className="w-60 bg-dabeeo-gray-f5">
|
||||
<Menu items={singleCategoryItems} currentPath={currentPath} onSelectionChange={handleSelectionChange} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
154
web-app/app/shared/components/modal/AlertModal.stories.tsx
Normal file
154
web-app/app/shared/components/modal/AlertModal.stories.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '../button/Button'
|
||||
import { AlertModal } from './AlertModal'
|
||||
import { ConfirmModal } from './ConfirmModal'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Modal/AlertModal',
|
||||
component: AlertModal,
|
||||
} satisfies Meta<typeof AlertModal>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: function Render() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsOpen(true)}>알림 모달 열기</Button>
|
||||
<AlertModal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
title="알림"
|
||||
content="작업이 완료되었습니다."
|
||||
onConfirm={() => console.log('확인 클릭')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const WithoutTitle: Story = {
|
||||
render: function Render() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsOpen(true)}>알림 모달 열기</Button>
|
||||
<AlertModal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
content="제목 없는 알림 메시지입니다."
|
||||
onConfirm={() => console.log('확인 클릭')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomConfirmText: Story = {
|
||||
render: function Render() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsOpen(true)}>알림 모달 열기</Button>
|
||||
<AlertModal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
title="알림"
|
||||
content="파일이 성공적으로 업로드되었습니다."
|
||||
confirmText="닫기"
|
||||
onConfirm={() => console.log('닫기 클릭')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const MultilineContent: Story = {
|
||||
render: function Render() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsOpen(true)}>알림 모달 열기</Button>
|
||||
<AlertModal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
title="안내"
|
||||
content={`처리가 완료되었습니다.\n\n총 3개의 파일이 업로드되었습니다.\n업로드된 파일은 목록에서 확인하실 수 있습니다.`}
|
||||
onConfirm={() => console.log('확인 클릭')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
const confirmMeta = {
|
||||
title: 'Components/Modal/ConfirmModal',
|
||||
component: ConfirmModal,
|
||||
} satisfies Meta<typeof ConfirmModal>
|
||||
|
||||
export const Confirm: StoryObj<typeof ConfirmModal> = {
|
||||
render: function Render() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsOpen(true)}>확인 모달 열기</Button>
|
||||
<ConfirmModal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
title="확인"
|
||||
content="정말 삭제하시겠습니까?"
|
||||
onConfirm={() => console.log('삭제 확인')}
|
||||
onCancel={() => console.log('취소 클릭')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const ConfirmWithCustomText: StoryObj<typeof ConfirmModal> = {
|
||||
render: function Render() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsOpen(true)}>확인 모달 열기</Button>
|
||||
<ConfirmModal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
title="저장 확인"
|
||||
content="변경사항을 저장하시겠습니까?"
|
||||
confirmText="저장"
|
||||
cancelText="저장 안 함"
|
||||
onConfirm={() => console.log('저장 확인')}
|
||||
onCancel={() => console.log('저장 안 함 클릭')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const ConfirmWithAsyncAction: StoryObj<typeof ConfirmModal> = {
|
||||
render: function Render() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const handleConfirm = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
console.log('비동기 작업 완료')
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsOpen(true)}>확인 모달 열기</Button>
|
||||
<ConfirmModal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
title="삭제 확인"
|
||||
content="삭제하면 복구할 수 없습니다. 계속하시겠습니까?"
|
||||
confirmText="삭제"
|
||||
cancelText="취소"
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
57
web-app/app/shared/components/modal/AlertModal.tsx
Normal file
57
web-app/app/shared/components/modal/AlertModal.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Button } from '../button/Button';
|
||||
import type { ModalRootProps } from './Modal';
|
||||
import { ModalBody, ModalFooter, ModalHeader, ModalRoot } from './Modal';
|
||||
|
||||
interface AlertModalProps {
|
||||
isOpen?: ModalRootProps['isOpen'];
|
||||
onOpenChange?: ModalRootProps['onOpenChange'];
|
||||
title?: string;
|
||||
content?: ReactNode;
|
||||
confirmText?: string;
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
export const AlertModal = (props: AlertModalProps) => {
|
||||
const { isOpen, onOpenChange, title, ...contentProps } = props;
|
||||
|
||||
return (
|
||||
<ModalRoot isOpen={isOpen} onOpenChange={onOpenChange} className="w-75" aria-label={title ?? '알림'}>
|
||||
{({ close }) => <AlertModalContent close={close} title={title} {...contentProps} />}
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
function AlertModalContent({
|
||||
close,
|
||||
title,
|
||||
content,
|
||||
confirmText = '확인',
|
||||
onConfirm,
|
||||
}: {
|
||||
close: () => void;
|
||||
title?: string;
|
||||
content?: ReactNode;
|
||||
confirmText?: string;
|
||||
onConfirm?: () => void;
|
||||
}) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm?.();
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{title && <ModalHeader>{title}</ModalHeader>}
|
||||
<ModalBody className="px-7.5 py-8">
|
||||
<p className="text-sm text-dabeeo-black-34 font-medium text-center whitespace-pre-wrap">{content}</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="gray" size="large" autoFocus onClick={handleConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
79
web-app/app/shared/components/modal/ConfirmModal.tsx
Normal file
79
web-app/app/shared/components/modal/ConfirmModal.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
import { Button } from '../button/Button';
|
||||
import { ModalBody, ModalFooter, ModalHeader, ModalRoot } from './Modal';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
title?: string;
|
||||
content?: ReactNode;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm?: () => Promise<void> | void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmModal = (props: ConfirmModalProps) => {
|
||||
const { isOpen, onOpenChange, title, ...contentProps } = props;
|
||||
|
||||
return (
|
||||
<ModalRoot isOpen={isOpen} onOpenChange={onOpenChange} className="w-75" aria-label={title ?? '확인'}>
|
||||
{({ close }) => <ConfirmModalContent close={close} title={title} {...contentProps} />}
|
||||
</ModalRoot>
|
||||
);
|
||||
};
|
||||
|
||||
function ConfirmModalContent({
|
||||
close,
|
||||
title,
|
||||
content,
|
||||
confirmText = '확인',
|
||||
cancelText = '취소',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
close: () => void;
|
||||
title?: string;
|
||||
content?: ReactNode;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm?: () => Promise<void> | void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
startTransition(async () => {
|
||||
await onConfirm();
|
||||
close();
|
||||
});
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel?.();
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{title && <ModalHeader>{title}</ModalHeader>}
|
||||
<ModalBody className="px-7.5 py-8">
|
||||
<p className="text-sm text-dabeeo-black-34 font-medium text-center whitespace-pre-wrap">{content}</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="gray" size="large" isDisabled={isPending} onClick={handleCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button color="primary" size="large" isPending={isPending} onClick={handleConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
142
web-app/app/shared/components/modal/Modal.stories.tsx
Normal file
142
web-app/app/shared/components/modal/Modal.stories.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '../button/Button'
|
||||
import { ModalBody, ModalFooter, ModalHeader, ModalRoot } from './Modal'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Modal',
|
||||
component: ModalRoot,
|
||||
argTypes: {
|
||||
isDismissable: { control: 'boolean' },
|
||||
isKeyboardDismissDisabled: { control: 'boolean' },
|
||||
},
|
||||
} satisfies Meta<typeof ModalRoot>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: function Render() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsOpen(true)}>모달 열기</Button>
|
||||
<ModalRoot isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<ModalHeader>모달 제목</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className="text-sm">모달 내용입니다.</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="gray" size="large" onClick={() => setIsOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button color="primary" size="large" onClick={() => setIsOpen(false)}>
|
||||
확인
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalRoot>
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const WithoutCloseButton: Story = {
|
||||
render: function Render() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsOpen(true)}>모달 열기</Button>
|
||||
<ModalRoot isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<ModalHeader hasCloseButton={false}>닫기 버튼 없음</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className="text-sm">헤더에 닫기 버튼이 없습니다.</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="primary" size="large" onClick={() => setIsOpen(false)}>
|
||||
확인
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalRoot>
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const LongContent: Story = {
|
||||
render: function Render() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsOpen(true)}>모달 열기</Button>
|
||||
<ModalRoot isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<ModalHeader>긴 내용</ModalHeader>
|
||||
<ModalBody className="max-h-60 overflow-y-auto">
|
||||
{Array.from({ length: 20 }, (_, i) => (
|
||||
<p key={i} className="text-sm mb-2">
|
||||
내용 {i + 1}: Lorem ipsum dolor sit amet consectetur adipisicing elit.
|
||||
</p>
|
||||
))}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="primary" size="large" onClick={() => setIsOpen(false)}>
|
||||
확인
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalRoot>
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const NotDismissable: Story = {
|
||||
render: function Render() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsOpen(true)}>모달 열기</Button>
|
||||
<ModalRoot
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
isDismissable={false}
|
||||
isKeyboardDismissDisabled={true}
|
||||
>
|
||||
<ModalHeader hasCloseButton={false}>닫을 수 없음</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className="text-sm">배경 클릭이나 ESC로 닫을 수 없습니다.</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="primary" size="large" onClick={() => setIsOpen(false)}>
|
||||
확인
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalRoot>
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const ConfirmDialog: Story = {
|
||||
render: function Render() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<Button color="primary" onClick={() => setIsOpen(true)}>삭제하기</Button>
|
||||
<ModalRoot isOpen={isOpen} onOpenChange={setIsOpen} className="w-75">
|
||||
<ModalBody className="px-7.5 py-8">
|
||||
<p className="text-sm text-dabeeo-black-34 font-medium text-center">
|
||||
정말 삭제하시겠습니까?
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="gray" size="large" onClick={() => setIsOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button color="primary" size="large" onClick={() => setIsOpen(false)}>
|
||||
삭제
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalRoot>
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
229
web-app/app/shared/components/modal/Modal.tsx
Normal file
229
web-app/app/shared/components/modal/Modal.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
'use client';
|
||||
import { use, useSyncExternalStore, useTransition } from 'react';
|
||||
import type { DialogProps, ModalOverlayProps } from 'react-aria-components';
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Dialog,
|
||||
Heading,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
OverlayTriggerStateContext,
|
||||
} from 'react-aria-components';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { Button } from '../button/Button';
|
||||
|
||||
interface ModalRootProps
|
||||
extends DialogProps,
|
||||
Pick<ModalOverlayProps, 'isOpen' | 'onOpenChange' | 'isKeyboardDismissDisabled' | 'isDismissable'> {
|
||||
className?: string;
|
||||
}
|
||||
const ModalRoot = (props: ModalRootProps) => {
|
||||
const { isOpen, onOpenChange, isKeyboardDismissDisabled, isDismissable, className, ...dialogProps } = props;
|
||||
return (
|
||||
<ModalOverlay
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
isKeyboardDismissDisabled={isKeyboardDismissDisabled}
|
||||
isDismissable={isDismissable}
|
||||
className="absolute top-0 left-0 w-full h-full bg-black/70 z-100"
|
||||
>
|
||||
<Modal className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 supports-[translate:round(up,-50%,1px)]:translate-[round(up,-50%,1px)] min-w-75 bg-white outline-none drop-shadow-modal">
|
||||
<Dialog className={twMerge('outline-0', className)} {...dialogProps}>
|
||||
{props.children}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
};
|
||||
|
||||
interface ModalHeaderProps {
|
||||
hasCloseButton?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
const ModalHeader = (props: ModalHeaderProps) => {
|
||||
const ctx = use(OverlayTriggerStateContext);
|
||||
|
||||
const { hasCloseButton = true } = props;
|
||||
return (
|
||||
<div className="px-5 py-4 flex items-center justify-between gap-1 border-b border-dabeeo-gray-eb">
|
||||
<Heading slot="title" className="text-lg text-primary font-bold">
|
||||
{props.children}
|
||||
</Heading>
|
||||
{hasCloseButton && (
|
||||
<AriaButton
|
||||
onClick={ctx?.close}
|
||||
className="text-dabeeo-black-47 cursor-pointer data-focus-visible:ring-2 ring-offset-2 ring-dabeeo-gray-34"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M18.25 2.4927L17.5074 1.75L10.0021 9.25678L2.49675 1.75L1.7542 2.4927L9.25955 9.99947L1.75 17.5073L2.49255 18.25L9.9979 10.7432L17.5032 18.25L18.2458 17.5073L10.7404 10.0005L18.25 2.4927Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
</svg>
|
||||
</AriaButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ModalBodyProps extends React.PropsWithChildren {
|
||||
className?: string;
|
||||
}
|
||||
const ModalBody = (props: ModalBodyProps) => {
|
||||
return <div className={clsx('px-6 py-2.5', props.className)}>{props.children}</div>;
|
||||
};
|
||||
|
||||
interface ModalFooterProps extends React.PropsWithChildren {}
|
||||
const ModalFooter = (props: ModalFooterProps) => {
|
||||
return <div className="px-4 py-2.5 flex justify-center gap-2 border-t border-dabeeo-gray-eb">{props.children}</div>;
|
||||
};
|
||||
|
||||
let _modals: (
|
||||
| {
|
||||
typeof: 'confirm';
|
||||
title?: string;
|
||||
content: string;
|
||||
confirm?: boolean;
|
||||
cancelText?: string;
|
||||
onCancel?: () => void;
|
||||
confirmText?: string;
|
||||
onConfirm: (close: () => void) => void;
|
||||
}
|
||||
| {
|
||||
typeof: 'alert';
|
||||
title?: string;
|
||||
content: string;
|
||||
cancelText?: string;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
)[] = [];
|
||||
let listeners: (() => void)[] = [];
|
||||
function subscribe(listener: () => void) {
|
||||
listeners = [...listeners, listener];
|
||||
return () => {
|
||||
listeners = listeners.filter((l) => l !== listener);
|
||||
};
|
||||
}
|
||||
function getSnapshot() {
|
||||
return _modals;
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
type AlertProps = {
|
||||
title?: string;
|
||||
content?: string;
|
||||
cancelText?: string;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
const alert = (props: AlertProps) => {
|
||||
_modals = [
|
||||
..._modals,
|
||||
{
|
||||
typeof: 'alert',
|
||||
content: props.content || '',
|
||||
cancelText: props.cancelText || '확인',
|
||||
onCancel: props.onCancel,
|
||||
},
|
||||
];
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const confirm = (props: {
|
||||
title?: string;
|
||||
content: string;
|
||||
cancelText?: string;
|
||||
onCancel?: () => void;
|
||||
confirmText?: string;
|
||||
onConfirm: (close: () => void) => void;
|
||||
}) => {
|
||||
_modals = [
|
||||
..._modals,
|
||||
{
|
||||
typeof: 'confirm',
|
||||
title: props.title,
|
||||
content: props.content,
|
||||
cancelText: props.cancelText,
|
||||
onCancel: props.onCancel,
|
||||
confirmText: props.confirmText,
|
||||
onConfirm: props.onConfirm,
|
||||
},
|
||||
];
|
||||
emitChange();
|
||||
};
|
||||
|
||||
function getServerSnapshot() {
|
||||
return _modals;
|
||||
}
|
||||
|
||||
const ModalRegion = () => {
|
||||
const modals = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
return (
|
||||
<>
|
||||
{modals.map((modal, idx) => (
|
||||
<ModalRoot
|
||||
key={`modal-${idx}`}
|
||||
aria-label={`모달 ${idx}`}
|
||||
className="w-75"
|
||||
isOpen
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
_modals = _modals.filter((f) => f !== modal);
|
||||
emitChange();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
{modal.title && <ModalHeader>{modal.title}</ModalHeader>}
|
||||
<ModalBody className="px-7.5 py-8">
|
||||
<p className="text-sm text-dabeeo-black-34 font-medium text-center whitespace-pre-wrap">{modal.content}</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
color="gray"
|
||||
size="large"
|
||||
autoFocus
|
||||
isDisabled={isPending}
|
||||
onClick={() => {
|
||||
close();
|
||||
if (typeof modal.onCancel === 'function') {
|
||||
modal.onCancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{modal.cancelText || '취소'}
|
||||
</Button>
|
||||
{modal.typeof === 'confirm' && (
|
||||
<Button
|
||||
color="primary"
|
||||
size="large"
|
||||
isPending={isPending}
|
||||
onClick={() => {
|
||||
startTransition(modal.onConfirm.bind(null, close));
|
||||
}}
|
||||
>
|
||||
{modal.confirmText || '확인'}
|
||||
</Button>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalRoot>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
export { alert, confirm, ModalBody, ModalFooter, ModalHeader, ModalRegion, ModalRoot };
|
||||
export type { ModalBodyProps, ModalFooterProps, ModalHeaderProps, ModalRootProps };
|
||||
|
||||
29
web-app/app/shared/components/modal/ModalRenderer.tsx
Normal file
29
web-app/app/shared/components/modal/ModalRenderer.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import { modalStore } from './store';
|
||||
|
||||
export const ModalRenderer = () => {
|
||||
const modals = useSyncExternalStore(modalStore.subscribe, modalStore.getSnapshot, modalStore.getServerSnapshot);
|
||||
|
||||
return (
|
||||
<>
|
||||
{modals.map((modal) => {
|
||||
const { Component, props, id, resolve } = modal;
|
||||
return (
|
||||
<Component
|
||||
key={id}
|
||||
{...props}
|
||||
isOpen={true}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) {
|
||||
resolve();
|
||||
modalStore.removeModal(id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
25
web-app/app/shared/components/modal/index.ts
Normal file
25
web-app/app/shared/components/modal/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalRegion as ModalRegionPrimitive,
|
||||
ModalRoot,
|
||||
alert,
|
||||
confirm,
|
||||
} from './Modal';
|
||||
|
||||
export const Modal = Object.assign(ModalRoot, {
|
||||
Header: ModalHeader,
|
||||
Body: ModalBody,
|
||||
Footer: ModalFooter,
|
||||
});
|
||||
|
||||
export const ModalRegion = Object.assign(ModalRegionPrimitive, {
|
||||
alert,
|
||||
confirm,
|
||||
});
|
||||
|
||||
export { useModal } from './useModal';
|
||||
export { ModalRenderer } from './ModalRenderer';
|
||||
export { AlertModal } from './AlertModal';
|
||||
export { ConfirmModal } from './ConfirmModal';
|
||||
43
web-app/app/shared/components/modal/store.ts
Normal file
43
web-app/app/shared/components/modal/store.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
interface ModalData {
|
||||
id: string;
|
||||
scopeId: string;
|
||||
Component: ComponentType<any>;
|
||||
props: Record<string, unknown>;
|
||||
resolve: () => void;
|
||||
}
|
||||
|
||||
let modals: ModalData[] = [];
|
||||
let listeners: (() => void)[] = [];
|
||||
|
||||
const emitChange = () => {
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
const SERVER_SNAPSHOT: ModalData[] = [];
|
||||
|
||||
export const modalStore = {
|
||||
subscribe: (listener: () => void) => {
|
||||
listeners = [...listeners, listener];
|
||||
return () => {
|
||||
listeners = listeners.filter((l) => l !== listener);
|
||||
};
|
||||
},
|
||||
getSnapshot: () => modals,
|
||||
getServerSnapshot: () => SERVER_SNAPSHOT,
|
||||
addModal: (modal: ModalData) => {
|
||||
modals = [...modals, modal];
|
||||
emitChange();
|
||||
},
|
||||
removeModal: (id: string) => {
|
||||
modals = modals.filter((m) => m.id !== id);
|
||||
emitChange();
|
||||
},
|
||||
removeByScope: (scopeId: string) => {
|
||||
modals = modals.filter((m) => m.scopeId !== scopeId);
|
||||
emitChange();
|
||||
},
|
||||
};
|
||||
|
||||
export type { ModalData };
|
||||
50
web-app/app/shared/components/modal/useModal.ts
Normal file
50
web-app/app/shared/components/modal/useModal.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { useCallback, useEffect, useId } from 'react';
|
||||
|
||||
import { modalStore } from './store';
|
||||
|
||||
interface ModalControlProps {
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const useModal = () => {
|
||||
const scopeId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
modalStore
|
||||
.getSnapshot()
|
||||
.filter((m) => m.scopeId === scopeId)
|
||||
.forEach((m) => m.resolve());
|
||||
modalStore.removeByScope(scopeId);
|
||||
};
|
||||
}, [scopeId]);
|
||||
|
||||
const show = useCallback(
|
||||
<P extends ModalControlProps>(
|
||||
Component: ComponentType<P>,
|
||||
props?: Omit<P, keyof ModalControlProps>
|
||||
): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
modalStore.addModal({
|
||||
id: crypto.randomUUID(),
|
||||
scopeId,
|
||||
Component,
|
||||
props: props ?? {},
|
||||
resolve,
|
||||
});
|
||||
}),
|
||||
[scopeId]
|
||||
);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
modalStore
|
||||
.getSnapshot()
|
||||
.filter((m) => m.scopeId === scopeId)
|
||||
.forEach((m) => m.resolve());
|
||||
modalStore.removeByScope(scopeId);
|
||||
}, [scopeId]);
|
||||
|
||||
return { show, hide };
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { Pagination } from './Pagination'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Pagination',
|
||||
component: Pagination,
|
||||
args: {
|
||||
totalPages: 100,
|
||||
currentPage: 0,
|
||||
pageCount: 10,
|
||||
onPageChange: () => {},
|
||||
},
|
||||
argTypes: {
|
||||
totalPages: { control: 'number' },
|
||||
currentPage: { control: 'number' },
|
||||
pageCount: { control: 'number' },
|
||||
},
|
||||
} satisfies Meta<typeof Pagination>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
totalPages: 100,
|
||||
currentPage: 0,
|
||||
pageCount: 10,
|
||||
onPageChange: () => {},
|
||||
},
|
||||
}
|
||||
|
||||
export const MiddlePage: Story = {
|
||||
args: {
|
||||
totalPages: 100,
|
||||
currentPage: 45,
|
||||
pageCount: 10,
|
||||
onPageChange: () => {},
|
||||
},
|
||||
}
|
||||
|
||||
export const LastPage: Story = {
|
||||
args: {
|
||||
totalPages: 100,
|
||||
currentPage: 99,
|
||||
pageCount: 10,
|
||||
onPageChange: () => {},
|
||||
},
|
||||
}
|
||||
|
||||
export const FewPages: Story = {
|
||||
args: {
|
||||
totalPages: 5,
|
||||
currentPage: 0,
|
||||
pageCount: 10,
|
||||
onPageChange: () => {},
|
||||
},
|
||||
}
|
||||
|
||||
export const SinglePage: Story = {
|
||||
args: {
|
||||
totalPages: 1,
|
||||
currentPage: 0,
|
||||
pageCount: 10,
|
||||
onPageChange: () => {},
|
||||
},
|
||||
}
|
||||
|
||||
export const Interactive: Story = {
|
||||
render: function Render() {
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<Pagination
|
||||
totalPages={50}
|
||||
currentPage={currentPage}
|
||||
pageCount={10}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
<p className="text-sm text-dabeeo-gray-44">
|
||||
현재 페이지: {currentPage + 1} / 50
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomPageCount: Story = {
|
||||
args: {
|
||||
totalPages: 100,
|
||||
currentPage: 0,
|
||||
pageCount: 5,
|
||||
onPageChange: () => {},
|
||||
},
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export const Pagination = ({ totalPages, currentPage, pageCount = 10, onPageChan
|
||||
const noNext = start + pageCount >= totalPages;
|
||||
|
||||
return (
|
||||
<nav className="text-kc-gray-99 text-sm" aria-label="pagination">
|
||||
<nav className="text-dabeeo-gray-99 text-sm" aria-label="pagination">
|
||||
<ul className="flex items-center justify-center list-none">
|
||||
<li className="flex">
|
||||
<AriaButton
|
||||
|
||||
126
web-app/app/shared/components/radio/Radio.stories.tsx
Normal file
126
web-app/app/shared/components/radio/Radio.stories.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { Radio } from './Radio'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Radio',
|
||||
component: Radio,
|
||||
args: {
|
||||
name: 'default',
|
||||
value: 'option1',
|
||||
},
|
||||
argTypes: {
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
readOnly: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Radio>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
name: 'default',
|
||||
value: 'option1',
|
||||
children: '옵션 1',
|
||||
},
|
||||
}
|
||||
|
||||
export const Checked: Story = {
|
||||
args: {
|
||||
name: 'checked',
|
||||
value: 'option1',
|
||||
checked: true,
|
||||
children: '선택됨',
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
name: 'disabled',
|
||||
value: 'option1',
|
||||
disabled: true,
|
||||
children: '비활성화',
|
||||
},
|
||||
}
|
||||
|
||||
export const DisabledChecked: Story = {
|
||||
args: {
|
||||
name: 'disabled-checked',
|
||||
value: 'option1',
|
||||
disabled: true,
|
||||
checked: true,
|
||||
children: '비활성화 (선택됨)',
|
||||
},
|
||||
}
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
name: 'readonly',
|
||||
value: 'option1',
|
||||
readOnly: true,
|
||||
checked: true,
|
||||
children: '읽기 전용',
|
||||
},
|
||||
}
|
||||
|
||||
export const RadioGroup: Story = {
|
||||
render: () => {
|
||||
const [selected, setSelected] = useState('option1')
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Radio name="group" value="option1" checked={selected === 'option1'} onChange={setSelected}>
|
||||
옵션 1
|
||||
</Radio>
|
||||
<Radio name="group" value="option2" checked={selected === 'option2'} onChange={setSelected}>
|
||||
옵션 2
|
||||
</Radio>
|
||||
<Radio name="group" value="option3" checked={selected === 'option3'} onChange={setSelected}>
|
||||
옵션 3
|
||||
</Radio>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const HorizontalRadioGroup: Story = {
|
||||
render: () => {
|
||||
const [selected, setSelected] = useState('option1')
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<Radio name="horizontal-group" value="option1" checked={selected === 'option1'} onChange={setSelected}>
|
||||
옵션 1
|
||||
</Radio>
|
||||
<Radio name="horizontal-group" value="option2" checked={selected === 'option2'} onChange={setSelected}>
|
||||
옵션 2
|
||||
</Radio>
|
||||
<Radio name="horizontal-group" value="option3" checked={selected === 'option3'} onChange={setSelected}>
|
||||
옵션 3
|
||||
</Radio>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const WithMixedStates: Story = {
|
||||
render: () => {
|
||||
const [selected, setSelected] = useState('option1')
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Radio name="mixed" value="option1" checked={selected === 'option1'} onChange={setSelected}>
|
||||
활성화 옵션 1
|
||||
</Radio>
|
||||
<Radio name="mixed" value="option2" checked={selected === 'option2'} onChange={setSelected}>
|
||||
활성화 옵션 2
|
||||
</Radio>
|
||||
<Radio name="mixed" value="option3" disabled>
|
||||
비활성화 옵션
|
||||
</Radio>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
102
web-app/app/shared/components/radio/Radio.tsx
Normal file
102
web-app/app/shared/components/radio/Radio.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { type ReactNode, useId } from 'react';
|
||||
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
export interface RadioProps {
|
||||
name: string;
|
||||
value: string;
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const radioStyles = tv({
|
||||
base: [
|
||||
'shrink-0 w-4 h-4 min-w-4 min-h-4 box-border rounded-full',
|
||||
'border-2 border-dabeeo-gray-be bg-white',
|
||||
'flex items-center justify-center',
|
||||
'transition-all duration-200',
|
||||
'peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-primary',
|
||||
],
|
||||
variants: {
|
||||
isSelected: {
|
||||
true: 'border-primary bg-primary',
|
||||
},
|
||||
isDisabled: {
|
||||
true: 'border-dabeeo-gray-be bg-dabeeo-gray-eb',
|
||||
},
|
||||
isReadOnly: {
|
||||
true: '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
isSelected: true,
|
||||
isDisabled: true,
|
||||
className: 'border-dabeeo-gray-be bg-dabeeo-gray-eb',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const innerCircleStyles = tv({
|
||||
base: 'w-2 h-2 rounded-full transition-all duration-200',
|
||||
variants: {
|
||||
isSelected: {
|
||||
true: 'bg-white',
|
||||
false: 'bg-transparent',
|
||||
},
|
||||
isDisabled: {
|
||||
true: 'bg-dabeeo-gray-eb',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
isSelected: true,
|
||||
isDisabled: true,
|
||||
className: 'bg-dabeeo-gray-eb',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const labelStyles = tv({
|
||||
base: 'text-dabeeo-black-22 text-sm font-medium inline-flex items-center',
|
||||
variants: {
|
||||
isDisabled: {
|
||||
true: 'text-dabeeo-gray-be',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const Radio = (props: RadioProps) => {
|
||||
const { name, value, checked, defaultChecked, onChange, disabled, readOnly, children, className } = props;
|
||||
const id = useId();
|
||||
const isInteractive = !disabled && !readOnly;
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={`inline-flex h-9 items-center gap-2.5 ${isInteractive ? 'cursor-pointer' : 'cursor-not-allowed'} ${className ?? ''}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id={id}
|
||||
name={name}
|
||||
value={value}
|
||||
checked={checked}
|
||||
defaultChecked={defaultChecked}
|
||||
onChange={(e) => isInteractive && onChange?.(e.target.value)}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<span className={radioStyles({ isSelected: checked, isDisabled: disabled, isReadOnly: readOnly })}>
|
||||
<span className={innerCircleStyles({ isSelected: checked, isDisabled: disabled })} />
|
||||
</span>
|
||||
{children && <span className={labelStyles({ isDisabled: disabled })}>{children}</span>}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
122
web-app/app/shared/components/radioGroup/RadioGroup.stories.tsx
Normal file
122
web-app/app/shared/components/radioGroup/RadioGroup.stories.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { RadioGroup } from './RadioGroup'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/RadioGroup',
|
||||
component: RadioGroup,
|
||||
args: {
|
||||
items: [],
|
||||
orientation: 'horizontal',
|
||||
isDisabled: false,
|
||||
isReadOnly: false,
|
||||
},
|
||||
argTypes: {
|
||||
orientation: {
|
||||
control: 'select',
|
||||
options: ['horizontal', 'vertical'],
|
||||
},
|
||||
isDisabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
isReadOnly: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof RadioGroup>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const defaultItems = [
|
||||
{ value: 'option1', label: '옵션 1' },
|
||||
{ value: 'option2', label: '옵션 2' },
|
||||
{ value: 'option3', label: '옵션 3' },
|
||||
]
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
items: defaultItems,
|
||||
},
|
||||
}
|
||||
|
||||
export const Horizontal: Story = {
|
||||
args: {
|
||||
items: defaultItems,
|
||||
orientation: 'horizontal',
|
||||
},
|
||||
}
|
||||
|
||||
export const Vertical: Story = {
|
||||
args: {
|
||||
items: defaultItems,
|
||||
orientation: 'vertical',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithDefaultValue: Story = {
|
||||
render: function Render() {
|
||||
const [value, setValue] = useState('option2')
|
||||
return (
|
||||
<RadioGroup
|
||||
items={defaultItems}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
items: defaultItems,
|
||||
isDisabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
items: defaultItems,
|
||||
isReadOnly: true,
|
||||
value: 'option1',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithDisabledOption: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ value: 'option1', label: '옵션 1' },
|
||||
{ value: 'option2', label: '옵션 2 (비활성화)', isDisabled: true },
|
||||
{ value: 'option3', label: '옵션 3' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const WithManyOptions: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ value: 'apple', label: '사과' },
|
||||
{ value: 'banana', label: '바나나' },
|
||||
{ value: 'orange', label: '오렌지' },
|
||||
{ value: 'grape', label: '포도' },
|
||||
{ value: 'strawberry', label: '딸기' },
|
||||
],
|
||||
orientation: 'vertical',
|
||||
},
|
||||
}
|
||||
|
||||
export const Controlled: Story = {
|
||||
render: function Render() {
|
||||
const [value, setValue] = useState('')
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<RadioGroup
|
||||
items={defaultItems}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
<p className="text-sm text-gray-600">선택된 값: {value || '없음'}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
77
web-app/app/shared/components/radioGroup/RadioGroup.tsx
Normal file
77
web-app/app/shared/components/radioGroup/RadioGroup.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useId } from 'react';
|
||||
import type { RadioGroupProps as AriaRadioGroupProps, RadioProps as AriaRadioProps } from 'react-aria-components';
|
||||
import { Radio as AriaRadio, RadioGroup as AriaRadioGroup, composeRenderProps } from 'react-aria-components';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
export type RadioOption = {
|
||||
value: string;
|
||||
label: ReactNode;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
export interface RadioGroupProps extends AriaRadioGroupProps {
|
||||
items: RadioOption[];
|
||||
}
|
||||
|
||||
export const RadioGroup = (props: RadioGroupProps) => {
|
||||
const radioGroupId = useId();
|
||||
const { items, className, value, ...restProps } = props;
|
||||
return (
|
||||
<AriaRadioGroup
|
||||
className={clsx('flex gap-4 data-[orientation=vertical]:flex-col data-[orientation=vertical]:gap-3', className)}
|
||||
value={value === '' ? null : value}
|
||||
{...restProps}
|
||||
>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<Radio key={`radio_group-${radioGroupId}-${item.value}`} value={item.value} isDisabled={item.isDisabled}>
|
||||
{item.label}
|
||||
</Radio>
|
||||
);
|
||||
})}
|
||||
</AriaRadioGroup>
|
||||
);
|
||||
};
|
||||
|
||||
interface RadioProps extends AriaRadioProps {}
|
||||
|
||||
const radioStyles = tv({
|
||||
base: 'shrink-0 w-4 h-4 box-border rounded-full border-[1.5px] border-dabeeo-gray-be bg-white transition-all',
|
||||
variants: {
|
||||
isSelected: {
|
||||
true: 'border-4 border-primary ',
|
||||
},
|
||||
isFocusVisible: {
|
||||
true: 'ring-2 ring-offset-2 ring-primary',
|
||||
},
|
||||
isInvalid: {
|
||||
true: 'border-dabeeo-red',
|
||||
},
|
||||
isDisabled: {
|
||||
true: 'border-dabeeo-gray-be',
|
||||
},
|
||||
},
|
||||
compoundVariants: [{ isDisabled: true, isSelected: false, className: 'bg-dabeeo-gray-eb' }],
|
||||
});
|
||||
export const Radio = (props: RadioProps) => {
|
||||
const { className, ...restProps } = props;
|
||||
return (
|
||||
<AriaRadio
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-2.5 text-dabeeo-black-22 text-sm font-medium data-disabled:text-dabeeo-gray-be',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{composeRenderProps(props.children, (children, renderProps) => (
|
||||
<>
|
||||
<div className={radioStyles(renderProps)} />
|
||||
{children}
|
||||
</>
|
||||
))}
|
||||
</AriaRadio>
|
||||
);
|
||||
};
|
||||
96
web-app/app/shared/components/section/Section.stories.tsx
Normal file
96
web-app/app/shared/components/section/Section.stories.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { Section } from './Section'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Section',
|
||||
component: Section,
|
||||
args: {
|
||||
variant: 'base',
|
||||
children: <div>Base 섹션</div>,
|
||||
},
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['base', 'card', 'list', 'searchFilterGray'],
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Section>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Base: Story = {
|
||||
args: {
|
||||
variant: 'base',
|
||||
children: (
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-2">기본 섹션</h2>
|
||||
<p>이것은 기본 섹션입니다.</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const Card: Story = {
|
||||
args: {
|
||||
variant: 'card',
|
||||
children: (
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-2">카드 섹션</h2>
|
||||
<p>이것은 카드 형태의 섹션입니다.</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const List: Story = {
|
||||
args: {
|
||||
variant: 'list',
|
||||
children: (
|
||||
<>
|
||||
<h2 className="text-lg font-bold">리스트 섹션</h2>
|
||||
<ul className="space-y-2">
|
||||
<li className="p-2 bg-gray-50 rounded">항목 1</li>
|
||||
<li className="p-2 bg-gray-50 rounded">항목 2</li>
|
||||
<li className="p-2 bg-gray-50 rounded">항목 3</li>
|
||||
</ul>
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const SearchFilterGray: Story = {
|
||||
args: {
|
||||
variant: 'searchFilterGray',
|
||||
children: (
|
||||
<>
|
||||
<span>필터:</span>
|
||||
<select className="border border-gray-300 rounded px-2 py-1">
|
||||
<option>전체</option>
|
||||
<option>옵션 1</option>
|
||||
<option>옵션 2</option>
|
||||
</select>
|
||||
<input type="text" placeholder="검색어 입력" className="border border-gray-300 rounded px-2 py-1" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<Section variant="base">
|
||||
<div>Base 섹션</div>
|
||||
</Section>
|
||||
<Section variant="card">
|
||||
<div>Card 섹션</div>
|
||||
</Section>
|
||||
<Section variant="list">
|
||||
<div>List 섹션</div>
|
||||
</Section>
|
||||
<Section variant="searchFilterGray">
|
||||
<span>SearchFilterGray 섹션</span>
|
||||
</Section>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -6,7 +6,7 @@ const variantClassMap: Record<SectionVariant, string> = {
|
||||
base: 'flex-center w-full bg-white shadow-card p-6',
|
||||
card: 'flex-center bg-white shadow-card p-6',
|
||||
list: 'flex flex-col h-full min-h-0 gap-4 bg-white shadow-card p-6',
|
||||
searchFilterGray: 'flex items-center bg-gray-100 py-3 px-7 text-kc-black-2a text-sm font-medium gap-3',
|
||||
searchFilterGray: 'flex items-center bg-gray-100 py-3 px-7 text-dabeeo-black-2a text-sm font-medium gap-3',
|
||||
};
|
||||
|
||||
export type SectionProps = {
|
||||
|
||||
98
web-app/app/shared/components/select/Select.stories.tsx
Normal file
98
web-app/app/shared/components/select/Select.stories.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { Select, type SelectOption } from './Select'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Select',
|
||||
component: Select,
|
||||
argTypes: {
|
||||
isDisabled: { control: 'boolean' },
|
||||
isReadOnly: { control: 'boolean' },
|
||||
placement: {
|
||||
control: 'select',
|
||||
options: ['bottom', 'top'],
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Select>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const sampleItems: SelectOption[] = [
|
||||
{ label: '옵션 1', value: '1' },
|
||||
{ label: '옵션 2', value: '2' },
|
||||
{ label: '옵션 3', value: '3' },
|
||||
{ label: '비활성화 옵션', value: '4', isDisabled: true },
|
||||
]
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
items: sampleItems,
|
||||
placeholder: '선택해주세요',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithSelectedValue: Story = {
|
||||
args: {
|
||||
items: sampleItems,
|
||||
defaultSelectedKey: '2',
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
items: sampleItems,
|
||||
defaultSelectedKey: '1',
|
||||
isDisabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
items: sampleItems,
|
||||
defaultSelectedKey: '1',
|
||||
isReadOnly: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const PlacementTop: Story = {
|
||||
args: {
|
||||
items: sampleItems,
|
||||
placeholder: '위로 열림',
|
||||
placement: 'top',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="pt-40">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export const Controlled: Story = {
|
||||
render: function Render() {
|
||||
const [value, setValue] = useState<string | number>('1')
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Select
|
||||
items={sampleItems}
|
||||
selectedKey={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
<p className="text-sm">선택된 값: {value}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const ManyOptions: Story = {
|
||||
args: {
|
||||
items: Array.from({ length: 20 }, (_, i) => ({
|
||||
label: `옵션 ${i + 1}`,
|
||||
value: String(i + 1),
|
||||
})),
|
||||
placeholder: '많은 옵션',
|
||||
maxHeight: 200,
|
||||
},
|
||||
}
|
||||
105
web-app/app/shared/components/select/Select.tsx
Normal file
105
web-app/app/shared/components/select/Select.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { type Ref } from 'react';
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Select as AriaSelect,
|
||||
type SelectProps as AriaSelectProps,
|
||||
FieldError,
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
Popover,
|
||||
SelectValue,
|
||||
} from 'react-aria-components';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
export type SelectOption = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
const selectStyle = tv({
|
||||
base: 'flex items-center text-start gap-4 w-full bg-white text-sm text-dabeeo-black-34 border border-dabeeo-gray-be cursor-default px-3 h-9 min-w-[120px] outline-0',
|
||||
variants: {
|
||||
isDisabled: {
|
||||
true: 'text-dabeeo-gray-99 bg-dabeeo-gray-eb border-dabeeo-gray-be cursor-not-allowed',
|
||||
false:
|
||||
'hover:text-dabeeo-black-34 hover:border-dabeeo-black-34 data-pressed:text-dabeeo-black-34 data-pressed:border-dabeeo-black-34',
|
||||
},
|
||||
isReadOnly: {
|
||||
true: 'text-dabeeo-black-34 bg-dabeeo-yellow-secondary cursor-auto',
|
||||
},
|
||||
isFocused: {
|
||||
true: 'border-dabeeo-black-34',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export interface SelectProps<T extends SelectOption>
|
||||
extends Omit<AriaSelectProps<T>, 'selectionMode' | 'onChange' | 'children'> {
|
||||
items?: Iterable<T>;
|
||||
onChange?: (value: T['value']) => void;
|
||||
maxHeight?: number;
|
||||
placement?: 'bottom' | 'top';
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export const Select = <T extends SelectOption>(props: SelectProps<T>) => {
|
||||
const { className, items, onChange, maxHeight, placement = 'bottom', ref, isReadOnly, ...restProps } = props;
|
||||
|
||||
return (
|
||||
<AriaSelect
|
||||
selectionMode="single"
|
||||
aria-label="선택"
|
||||
className={clsx('group/select', className)}
|
||||
{...restProps}
|
||||
onChange={(key) => {
|
||||
onChange?.(key as T['value']);
|
||||
}}
|
||||
>
|
||||
<AriaButton
|
||||
ref={ref}
|
||||
className={(renderProps) =>
|
||||
selectStyle({ ...renderProps, isDisabled: props.isDisabled || isReadOnly, isReadOnly })}
|
||||
{...{ isDisabled: props.isDisabled || isReadOnly }}
|
||||
>
|
||||
<SelectValue className="flex-1 text-sm data-placeholder:text-dabeeo-gray-99">
|
||||
{({ selectedText, defaultChildren }) => selectedText || defaultChildren}
|
||||
</SelectValue>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="10"
|
||||
height="5"
|
||||
viewBox="0 0 10 5"
|
||||
fill="none"
|
||||
className={clsx('group-data-open/select:rotate-180 transition', isReadOnly && 'hidden')}
|
||||
>
|
||||
<path d="M0 0H10L5 5L0 0Z" fill="#BEBEBE" />
|
||||
</svg>
|
||||
</AriaButton>
|
||||
<FieldError className="mt-0.5 text-dabeeo-red text-xs" />
|
||||
<Popover className="w-(--trigger-width)" offset={0.5} placement={placement}>
|
||||
<ListBox
|
||||
items={items}
|
||||
style={{ maxHeight: maxHeight ? `${maxHeight}px` : 'inherit' }}
|
||||
className={clsx(
|
||||
'w-full outline-0 box-border border-l border-r border-dabeeo-gray-be max-h-[inherit] bg-white overflow-auto',
|
||||
placement === 'top' ? 'border-t' : 'border-b'
|
||||
)}
|
||||
>
|
||||
{(item) => (
|
||||
<ListBoxItem
|
||||
id={item.value}
|
||||
className="outline-0 px-3 py-2 text-sm font-semibold text-dabeeo-black-34 data-selected:text-dabeeo-navy-main data-selected:bg-dabeeo-yellow-secondary data-selected:font-bold data-selected:hover:bg-dabeeo-gray-eb focus:bg-dabeeo-gray-eb hover:bg-dabeeo-gray-eb data-disabled:text-dabeeo-gray-be"
|
||||
isDisabled={!!item.isDisabled}
|
||||
>
|
||||
{item.label}
|
||||
</ListBoxItem>
|
||||
)}
|
||||
</ListBox>
|
||||
</Popover>
|
||||
</AriaSelect>
|
||||
);
|
||||
};
|
||||
132
web-app/app/shared/components/switch/Switch.stories.tsx
Normal file
132
web-app/app/shared/components/switch/Switch.stories.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { Switch } from './Switch'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Switch',
|
||||
component: Switch,
|
||||
argTypes: {
|
||||
isDisabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
isReadOnly: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Switch>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: '알림 설정',
|
||||
},
|
||||
}
|
||||
|
||||
export const Selected: Story = {
|
||||
render: function Render() {
|
||||
const [isSelected, setIsSelected] = useState(true)
|
||||
return (
|
||||
<Switch isSelected={isSelected} onChange={setIsSelected}>
|
||||
활성화됨
|
||||
</Switch>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Unselected: Story = {
|
||||
render: function Render() {
|
||||
const [isSelected, setIsSelected] = useState(false)
|
||||
return (
|
||||
<Switch isSelected={isSelected} onChange={setIsSelected}>
|
||||
비활성화됨
|
||||
</Switch>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
isDisabled: true,
|
||||
children: '비활성화된 스위치',
|
||||
},
|
||||
}
|
||||
|
||||
export const DisabledSelected: Story = {
|
||||
args: {
|
||||
isDisabled: true,
|
||||
isSelected: true,
|
||||
children: '비활성화됨 (켜짐)',
|
||||
},
|
||||
}
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
isReadOnly: true,
|
||||
isSelected: true,
|
||||
children: '읽기 전용',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithoutLabel: Story = {
|
||||
args: {},
|
||||
}
|
||||
|
||||
export const Controlled: Story = {
|
||||
render: function Render() {
|
||||
const [isSelected, setIsSelected] = useState(false)
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Switch isSelected={isSelected} onChange={setIsSelected}>
|
||||
알림 받기
|
||||
</Switch>
|
||||
<p className="text-sm text-gray-600">상태: {isSelected ? '켜짐' : '꺼짐'}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const MultipleSettings: Story = {
|
||||
render: function Render() {
|
||||
const [settings, setSettings] = useState({
|
||||
notifications: true,
|
||||
darkMode: false,
|
||||
autoSave: true,
|
||||
})
|
||||
|
||||
const handleChange = (key: keyof typeof settings) => (isSelected: boolean) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: isSelected }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Switch isSelected={settings.notifications} onChange={handleChange('notifications')}>
|
||||
알림 설정
|
||||
</Switch>
|
||||
<Switch isSelected={settings.darkMode} onChange={handleChange('darkMode')}>
|
||||
다크 모드
|
||||
</Switch>
|
||||
<Switch isSelected={settings.autoSave} onChange={handleChange('autoSave')}>
|
||||
자동 저장
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<Switch>기본 (꺼짐)</Switch>
|
||||
<Switch isSelected>기본 (켜짐)</Switch>
|
||||
<Switch isDisabled>비활성화 (꺼짐)</Switch>
|
||||
<Switch isDisabled isSelected>
|
||||
비활성화 (켜짐)
|
||||
</Switch>
|
||||
<Switch isReadOnly isSelected>
|
||||
읽기 전용
|
||||
</Switch>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
59
web-app/app/shared/components/switch/Switch.tsx
Normal file
59
web-app/app/shared/components/switch/Switch.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Switch as AriaSwitch, SwitchProps as AriaSwitchProps } from 'react-aria-components';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
export interface SwitchProps extends Omit<AriaSwitchProps, 'children'> {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const track = tv({
|
||||
base: 'flex h-4.5 w-8 box-border px-1 items-center rounded-full transition duration-200 ease-in-out',
|
||||
variants: {
|
||||
isSelected: {
|
||||
false: 'bg-dabeeo-gray-99',
|
||||
true: 'bg-dabeeo-green-main',
|
||||
},
|
||||
isDisabled: {
|
||||
true: 'bg-dabeeo-gray-eb',
|
||||
},
|
||||
isReadOnly: {
|
||||
true: 'opacity-50',
|
||||
},
|
||||
isFocusVisible: {
|
||||
true: 'ring-2 ring-offset-2 ring-primary',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handle = tv({
|
||||
base: 'h-3 w-3 transform rounded-full outline outline-1 -outline-offset-1 outline-transparent shadow-[0_1px_3px_0_rgba(5,48,48,0.35)] transition duration-200 ease-in-out',
|
||||
variants: {
|
||||
isSelected: {
|
||||
false: 'translate-x-0 bg-white',
|
||||
true: 'translate-x-[11px] bg-white',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function Switch({ children, ...props }: SwitchProps) {
|
||||
return (
|
||||
<AriaSwitch
|
||||
{...props}
|
||||
className={clsx(
|
||||
props.className,
|
||||
'relative flex gap-2 items-center text-dabeeo-black-34 text-sm transition [-webkit-tap-highlight-color:transparent] cursor-pointer'
|
||||
)}
|
||||
>
|
||||
{(renderProps) => (
|
||||
<>
|
||||
<div className={track(renderProps)}>
|
||||
<span className={handle(renderProps)} />
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</AriaSwitch>
|
||||
);
|
||||
}
|
||||
138
web-app/app/shared/components/tab/Tab.stories.tsx
Normal file
138
web-app/app/shared/components/tab/Tab.stories.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { Tab } from './Tab'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Tab',
|
||||
component: Tab,
|
||||
args: {
|
||||
items: [],
|
||||
},
|
||||
} satisfies Meta<typeof Tab>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const defaultItems = [
|
||||
{ key: 'tab1', label: '탭 1', children: <div className="p-4">탭 1의 내용입니다.</div> },
|
||||
{ key: 'tab2', label: '탭 2', children: <div className="p-4">탭 2의 내용입니다.</div> },
|
||||
{ key: 'tab3', label: '탭 3', children: <div className="p-4">탭 3의 내용입니다.</div> },
|
||||
]
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
items: defaultItems,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithDefaultSelected: Story = {
|
||||
args: {
|
||||
items: defaultItems,
|
||||
defaultSelectedKey: 'tab2',
|
||||
},
|
||||
}
|
||||
|
||||
export const Controlled: Story = {
|
||||
render: function Render() {
|
||||
const [selectedKey, setSelectedKey] = useState('tab1')
|
||||
return (
|
||||
<Tab
|
||||
items={defaultItems}
|
||||
selectedKey={selectedKey}
|
||||
onSelectionChange={(key) => setSelectedKey(key as string)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const WithDisabledTab: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ key: 'tab1', label: '탭 1', children: <div className="p-4">탭 1의 내용입니다.</div> },
|
||||
{ key: 'tab2', label: '탭 2 (비활성화)', children: <div className="p-4">탭 2의 내용입니다.</div>, disabled: true },
|
||||
{ key: 'tab3', label: '탭 3', children: <div className="p-4">탭 3의 내용입니다.</div> },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const WithRichContent: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{
|
||||
key: 'overview',
|
||||
label: '개요',
|
||||
children: (
|
||||
<div className="p-4 space-y-2">
|
||||
<h3 className="text-lg font-bold">프로젝트 개요</h3>
|
||||
<p>이 프로젝트는 항공영상 탐지 시스템입니다.</p>
|
||||
<ul className="list-disc list-inside">
|
||||
<li>항공영상 업로드</li>
|
||||
<li>AI 기반 객체 탐지</li>
|
||||
<li>결과 분석 및 리포트</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'details',
|
||||
label: '상세 정보',
|
||||
children: (
|
||||
<div className="p-4 space-y-2">
|
||||
<h3 className="text-lg font-bold">상세 정보</h3>
|
||||
<table className="w-full border-collapse">
|
||||
<tbody>
|
||||
<tr className="border-b">
|
||||
<td className="py-2 font-medium">버전</td>
|
||||
<td className="py-2">1.0.0</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="py-2 font-medium">최종 수정일</td>
|
||||
<td className="py-2">2024-01-15</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: '설정',
|
||||
children: (
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-bold mb-4">설정</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">알림 설정</label>
|
||||
<select className="border border-gray-300 rounded px-3 py-2 w-full">
|
||||
<option>모든 알림</option>
|
||||
<option>중요 알림만</option>
|
||||
<option>알림 끄기</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const ManyTabs: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ key: 'tab1', label: '첫 번째', children: <div className="p-4">첫 번째 탭</div> },
|
||||
{ key: 'tab2', label: '두 번째', children: <div className="p-4">두 번째 탭</div> },
|
||||
{ key: 'tab3', label: '세 번째', children: <div className="p-4">세 번째 탭</div> },
|
||||
{ key: 'tab4', label: '네 번째', children: <div className="p-4">네 번째 탭</div> },
|
||||
{ key: 'tab5', label: '다섯 번째', children: <div className="p-4">다섯 번째 탭</div> },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const WithCustomClassName: Story = {
|
||||
args: {
|
||||
items: defaultItems,
|
||||
className: 'bg-gray-50 p-4 rounded-lg',
|
||||
tabPanelClassName: 'bg-white rounded border',
|
||||
},
|
||||
}
|
||||
55
web-app/app/shared/components/tab/Tab.tsx
Normal file
55
web-app/app/shared/components/tab/Tab.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { TabsProps } from 'react-aria-components';
|
||||
import { Tab as AriaTab, TabList, TabPanel, TabPanels, Tabs } from 'react-aria-components';
|
||||
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
type TabItem = {
|
||||
key: string;
|
||||
label: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
// type TabProps = {
|
||||
// items: TabItem[];
|
||||
// activeKey?: string;
|
||||
// onClick?: (key: string) => void;
|
||||
// className?: string;
|
||||
// };
|
||||
|
||||
/**
|
||||
* @desc 탭 컴포넌트
|
||||
* @param items 탭 아이템 목록
|
||||
* @param activeKey 현재 활성화된 탭의 키
|
||||
* @param onClick 탭 변경 이벤트
|
||||
* @param className 추가 클래스명
|
||||
*/
|
||||
export interface TabProps extends Omit<TabsProps, 'className' | 'children'> {
|
||||
className?: string;
|
||||
items: TabItem[];
|
||||
tabPanelClassName?: string;
|
||||
tabPanelsClassName?: string;
|
||||
}
|
||||
export const Tab = ({ items, className, tabPanelClassName, tabPanelsClassName, ...restProps }: TabProps) => {
|
||||
return (
|
||||
<Tabs className={twMerge('flex flex-col gap-4', className)} {...restProps}>
|
||||
<TabList className="flex items-center text-lg font-medium cursor-pointer" items={items}>
|
||||
{(item) => (
|
||||
<AriaTab
|
||||
id={item.key}
|
||||
className="relative px-7.5 text-dabeeo-black-47 data-[selected=true]:text-primary first:pl-1.5 data-[selected=true]:font-bold after:absolute after:right-0 after:top-1/2 after:-translate-y-1/2 after:h-[18px] after:w-px after:bg-gray-300 last:after:hidden outline-none data-focus-visible:ring-2 data-focus-visible:ring-offset-2 data-focus-visible:ring-primary"
|
||||
>
|
||||
{item.label}
|
||||
</AriaTab>
|
||||
)}
|
||||
</TabList>
|
||||
<TabPanels className={tabPanelsClassName} items={items}>
|
||||
{(item) => (
|
||||
<TabPanel id={item.key} className={tabPanelClassName}>
|
||||
{item.children}
|
||||
</TabPanel>
|
||||
)}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
265
web-app/app/shared/components/table/Table.stories.tsx
Normal file
265
web-app/app/shared/components/table/Table.stories.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { Table } from './Table'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Table',
|
||||
component: Table,
|
||||
args: {
|
||||
children: null
|
||||
},
|
||||
argTypes: {
|
||||
isLayoutFixed: { control: 'boolean' },
|
||||
isFullHeight: { control: 'boolean' },
|
||||
isHoverable: { control: 'boolean' },
|
||||
isClickable: { control: 'boolean' },
|
||||
},
|
||||
} satisfies Meta<typeof Table>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const sampleData = [
|
||||
{ id: 1, name: '홍길동', email: 'hong@example.com', role: '관리자' },
|
||||
{ id: 2, name: '김철수', email: 'kim@example.com', role: '사용자' },
|
||||
{ id: 3, name: '이영희', email: 'lee@example.com', role: '사용자' },
|
||||
{ id: 4, name: '박민수', email: 'park@example.com', role: '편집자' },
|
||||
{ id: 5, name: '최지은', email: 'choi@example.com', role: '사용자' },
|
||||
]
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<div className="h-80">
|
||||
<Table>
|
||||
<Table.Caption>
|
||||
<Table.CaptionLeft>
|
||||
<Table.Total count={sampleData.length} />
|
||||
</Table.CaptionLeft>
|
||||
</Table.Caption>
|
||||
<Table.Container>
|
||||
<Table.Colgroup>
|
||||
<Table.Col width={60} />
|
||||
<Table.Col width={120} />
|
||||
<Table.Col />
|
||||
<Table.Col width={100} />
|
||||
</Table.Colgroup>
|
||||
<Table.Header>
|
||||
<Table.HeaderRow>
|
||||
<Table.HeaderCell align="center">ID</Table.HeaderCell>
|
||||
<Table.HeaderCell>이름</Table.HeaderCell>
|
||||
<Table.HeaderCell>이메일</Table.HeaderCell>
|
||||
<Table.HeaderCell align="center">역할</Table.HeaderCell>
|
||||
</Table.HeaderRow>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{sampleData.map((row) => (
|
||||
<Table.Row key={row.id}>
|
||||
<Table.Cell align="center">{row.id}</Table.Cell>
|
||||
<Table.Cell>{row.name}</Table.Cell>
|
||||
<Table.Cell>{row.email}</Table.Cell>
|
||||
<Table.Cell align="center">{row.role}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Container>
|
||||
</Table>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
render: () => (
|
||||
<div className="h-60">
|
||||
<Table>
|
||||
<Table.Container>
|
||||
<Table.Colgroup>
|
||||
<Table.Col width={60} />
|
||||
<Table.Col width={120} />
|
||||
<Table.Col />
|
||||
<Table.Col width={100} />
|
||||
</Table.Colgroup>
|
||||
<Table.Header>
|
||||
<Table.HeaderRow>
|
||||
<Table.HeaderCell align="center">ID</Table.HeaderCell>
|
||||
<Table.HeaderCell>이름</Table.HeaderCell>
|
||||
<Table.HeaderCell>이메일</Table.HeaderCell>
|
||||
<Table.HeaderCell align="center">역할</Table.HeaderCell>
|
||||
</Table.HeaderRow>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
<Table.Empty colSpan={4} />
|
||||
</Table.Body>
|
||||
</Table.Container>
|
||||
</Table>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
render: () => (
|
||||
<div className="h-60">
|
||||
<Table>
|
||||
<Table.Container>
|
||||
<Table.Colgroup>
|
||||
<Table.Col width={60} />
|
||||
<Table.Col width={120} />
|
||||
<Table.Col />
|
||||
<Table.Col width={100} />
|
||||
</Table.Colgroup>
|
||||
<Table.Header>
|
||||
<Table.HeaderRow>
|
||||
<Table.HeaderCell align="center">ID</Table.HeaderCell>
|
||||
<Table.HeaderCell>이름</Table.HeaderCell>
|
||||
<Table.HeaderCell>이메일</Table.HeaderCell>
|
||||
<Table.HeaderCell align="center">역할</Table.HeaderCell>
|
||||
</Table.HeaderRow>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
<Table.Loading colSpan={4} />
|
||||
</Table.Body>
|
||||
</Table.Container>
|
||||
</Table>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Selectable: Story = {
|
||||
render: function Render() {
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
return (
|
||||
<div className="h-80">
|
||||
<Table>
|
||||
<Table.Caption>
|
||||
<Table.CaptionLeft>
|
||||
<Table.Total count={sampleData.length} />
|
||||
{selectedId && (
|
||||
<span className="text-sm text-primary">선택: {selectedId}</span>
|
||||
)}
|
||||
</Table.CaptionLeft>
|
||||
</Table.Caption>
|
||||
<Table.Container>
|
||||
<Table.Colgroup>
|
||||
<Table.Col width={60} />
|
||||
<Table.Col width={120} />
|
||||
<Table.Col />
|
||||
<Table.Col width={100} />
|
||||
</Table.Colgroup>
|
||||
<Table.Header>
|
||||
<Table.HeaderRow>
|
||||
<Table.HeaderCell align="center">ID</Table.HeaderCell>
|
||||
<Table.HeaderCell>이름</Table.HeaderCell>
|
||||
<Table.HeaderCell>이메일</Table.HeaderCell>
|
||||
<Table.HeaderCell align="center">역할</Table.HeaderCell>
|
||||
</Table.HeaderRow>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{sampleData.map((row) => (
|
||||
<Table.Row
|
||||
key={row.id}
|
||||
isSelected={selectedId === row.id}
|
||||
onClick={() => setSelectedId(row.id)}
|
||||
>
|
||||
<Table.Cell align="center">{row.id}</Table.Cell>
|
||||
<Table.Cell>{row.name}</Table.Cell>
|
||||
<Table.Cell>{row.email}</Table.Cell>
|
||||
<Table.Cell align="center">{row.role}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Container>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const GroupedHeader: Story = {
|
||||
render: () => (
|
||||
<div className="h-80">
|
||||
<Table>
|
||||
<Table.Container>
|
||||
<Table.Colgroup>
|
||||
<Table.Col width={60} />
|
||||
<Table.Col width={120} />
|
||||
<Table.Col />
|
||||
<Table.Col />
|
||||
</Table.Colgroup>
|
||||
<Table.Header>
|
||||
<Table.HeaderRow>
|
||||
<Table.HeaderCell align="center" rowSpan={2}>ID</Table.HeaderCell>
|
||||
<Table.HeaderCell rowSpan={2}>이름</Table.HeaderCell>
|
||||
<Table.HeaderCell align="center" colSpan={2} isGroupTitle>연락처</Table.HeaderCell>
|
||||
</Table.HeaderRow>
|
||||
<Table.HeaderRow>
|
||||
<Table.HeaderCell isGroupChild>이메일</Table.HeaderCell>
|
||||
<Table.HeaderCell isGroupChild>전화번호</Table.HeaderCell>
|
||||
</Table.HeaderRow>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
<Table.Row>
|
||||
<Table.Cell align="center">1</Table.Cell>
|
||||
<Table.Cell>홍길동</Table.Cell>
|
||||
<Table.Cell>hong@example.com</Table.Cell>
|
||||
<Table.Cell>010-1234-5678</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell align="center">2</Table.Cell>
|
||||
<Table.Cell>김철수</Table.Cell>
|
||||
<Table.Cell>kim@example.com</Table.Cell>
|
||||
<Table.Cell>010-9876-5432</Table.Cell>
|
||||
</Table.Row>
|
||||
</Table.Body>
|
||||
</Table.Container>
|
||||
</Table>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const ManyRows: Story = {
|
||||
render: () => {
|
||||
const manyData = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `사용자 ${i + 1}`,
|
||||
email: `user${i + 1}@example.com`,
|
||||
role: i % 3 === 0 ? '관리자' : '사용자',
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="h-96">
|
||||
<Table>
|
||||
<Table.Caption>
|
||||
<Table.CaptionLeft>
|
||||
<Table.Total count={manyData.length} />
|
||||
</Table.CaptionLeft>
|
||||
</Table.Caption>
|
||||
<Table.Container>
|
||||
<Table.Colgroup>
|
||||
<Table.Col width={60} />
|
||||
<Table.Col width={120} />
|
||||
<Table.Col />
|
||||
<Table.Col width={100} />
|
||||
</Table.Colgroup>
|
||||
<Table.Header>
|
||||
<Table.HeaderRow>
|
||||
<Table.HeaderCell align="center">ID</Table.HeaderCell>
|
||||
<Table.HeaderCell>이름</Table.HeaderCell>
|
||||
<Table.HeaderCell>이메일</Table.HeaderCell>
|
||||
<Table.HeaderCell align="center">역할</Table.HeaderCell>
|
||||
</Table.HeaderRow>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{manyData.map((row) => (
|
||||
<Table.Row key={row.id}>
|
||||
<Table.Cell align="center">{row.id}</Table.Cell>
|
||||
<Table.Cell>{row.name}</Table.Cell>
|
||||
<Table.Cell>{row.email}</Table.Cell>
|
||||
<Table.Cell align="center">{row.role}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Container>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
124
web-app/app/shared/components/textarea/TextArea.stories.tsx
Normal file
124
web-app/app/shared/components/textarea/TextArea.stories.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { TextArea } from './TextArea'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/TextArea',
|
||||
component: TextArea,
|
||||
argTypes: {
|
||||
readOnly: {
|
||||
control: 'boolean',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof TextArea>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
placeholder: '내용을 입력하세요',
|
||||
className: 'w-80 h-32',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithValue: Story = {
|
||||
render: function Render() {
|
||||
const [value, setValue] = useState('기본 텍스트 내용입니다.')
|
||||
return (
|
||||
<TextArea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
className="w-80 h-32"
|
||||
/>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
value: '읽기 전용 텍스트입니다. 수정할 수 없습니다.',
|
||||
readOnly: true,
|
||||
className: 'w-80 h-32',
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
value: '비활성화된 텍스트입니다.',
|
||||
disabled: true,
|
||||
className: 'w-80 h-32',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithPlaceholder: Story = {
|
||||
args: {
|
||||
placeholder: '여기에 상세 설명을 입력하세요...',
|
||||
className: 'w-80 h-32',
|
||||
},
|
||||
}
|
||||
|
||||
export const LargeTextArea: Story = {
|
||||
args: {
|
||||
placeholder: '큰 텍스트 영역입니다.',
|
||||
className: 'w-full h-48',
|
||||
},
|
||||
}
|
||||
|
||||
export const SmallTextArea: Story = {
|
||||
args: {
|
||||
placeholder: '작은 텍스트 영역입니다.',
|
||||
className: 'w-60 h-20',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithMaxLength: Story = {
|
||||
render: function Render() {
|
||||
const [value, setValue] = useState('')
|
||||
const maxLength = 100
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<TextArea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
maxLength={maxLength}
|
||||
placeholder="최대 100자까지 입력 가능합니다."
|
||||
className="w-80 h-32"
|
||||
/>
|
||||
<p className="text-sm text-gray-500">
|
||||
{value.length}/{maxLength}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const WithRows: Story = {
|
||||
args: {
|
||||
placeholder: 'rows 속성으로 높이를 지정할 수 있습니다.',
|
||||
rows: 5,
|
||||
className: 'w-80',
|
||||
},
|
||||
}
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">기본</label>
|
||||
<TextArea placeholder="기본 상태" className="w-80 h-24" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">읽기 전용</label>
|
||||
<TextArea value="읽기 전용 텍스트" readOnly className="w-80 h-24" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">비활성화</label>
|
||||
<TextArea value="비활성화된 텍스트" disabled className="w-80 h-24" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
21
web-app/app/shared/components/textarea/TextArea.tsx
Normal file
21
web-app/app/shared/components/textarea/TextArea.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { TextArea as AriaTextArea, TextAreaProps as AriaTextAreaProps } from 'react-aria-components';
|
||||
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
interface TextAreaProps extends Omit<AriaTextAreaProps, 'className'> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TextArea = (props: TextAreaProps) => {
|
||||
const { className, ...restProps } = props;
|
||||
return (
|
||||
<AriaTextArea
|
||||
{...restProps}
|
||||
className={twMerge(
|
||||
'block px-3 py-2.5 text-sm text-dabeeo-black-34 border border-dabeeo-gray-be data-[hovered=true]:border-dabeeo-black-34 data-[focused=true]:text-dabeeo-black-34 data-focused:border-dabeeo-black-34 outline-0 resize-none',
|
||||
'read-only:bg-dabeeo-yellow-secondary read-only:data-[hovered=true]:border-dabeeo-gray-be read-only:data-[focused=true]:border-dabeeo-gray-be',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
1
web-app/app/shared/components/textarea/index.ts
Normal file
1
web-app/app/shared/components/textarea/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './TextArea';
|
||||
106
web-app/app/shared/components/tooltip/Tooltip.stories.tsx
Normal file
106
web-app/app/shared/components/tooltip/Tooltip.stories.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { Button, TooltipTrigger } from 'react-aria-components'
|
||||
import { Tooltip } from './Tooltip'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Tooltip',
|
||||
component: Tooltip,
|
||||
args: {
|
||||
children: '툴팁 내용',
|
||||
},
|
||||
argTypes: {
|
||||
bgColor: {
|
||||
control: 'select',
|
||||
options: ['white', 'tertiary'],
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Tooltip>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<TooltipTrigger>
|
||||
<Button className="px-4 py-2 bg-primary text-white rounded">마우스를 올려보세요</Button>
|
||||
<Tooltip>기본 툴팁 메시지입니다.</Tooltip>
|
||||
</TooltipTrigger>
|
||||
),
|
||||
}
|
||||
|
||||
export const WhiteBackground: Story = {
|
||||
render: () => (
|
||||
<TooltipTrigger>
|
||||
<Button className="px-4 py-2 bg-primary text-white rounded">흰색 배경</Button>
|
||||
<Tooltip bgColor="white">흰색 배경의 툴팁입니다.</Tooltip>
|
||||
</TooltipTrigger>
|
||||
),
|
||||
}
|
||||
|
||||
export const TertiaryBackground: Story = {
|
||||
render: () => (
|
||||
<TooltipTrigger>
|
||||
<Button className="px-4 py-2 bg-primary text-white rounded">Tertiary 배경</Button>
|
||||
<Tooltip bgColor="tertiary">Tertiary 배경의 툴팁입니다.</Tooltip>
|
||||
</TooltipTrigger>
|
||||
),
|
||||
}
|
||||
|
||||
export const LongContent: Story = {
|
||||
render: () => (
|
||||
<TooltipTrigger>
|
||||
<Button className="px-4 py-2 bg-primary text-white rounded">긴 내용</Button>
|
||||
<Tooltip>
|
||||
이것은 아주 긴 툴팁 내용입니다. 최대 너비가 200px로 제한되어 있어 자동으로 줄바꿈됩니다.
|
||||
스크롤이 필요한 경우 스크롤바가 표시됩니다.
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
),
|
||||
}
|
||||
|
||||
export const MultilineContent: Story = {
|
||||
render: () => (
|
||||
<TooltipTrigger>
|
||||
<Button className="px-4 py-2 bg-primary text-white rounded">여러 줄</Button>
|
||||
<Tooltip>{`첫 번째 줄\n두 번째 줄\n세 번째 줄`}</Tooltip>
|
||||
</TooltipTrigger>
|
||||
),
|
||||
}
|
||||
|
||||
export const PlacementVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex gap-8 p-16">
|
||||
<TooltipTrigger>
|
||||
<Button className="px-4 py-2 bg-gray-200 rounded">Top (기본)</Button>
|
||||
<Tooltip placement="top">위쪽에 표시</Tooltip>
|
||||
</TooltipTrigger>
|
||||
<TooltipTrigger>
|
||||
<Button className="px-4 py-2 bg-gray-200 rounded">Bottom</Button>
|
||||
<Tooltip placement="bottom">아래쪽에 표시</Tooltip>
|
||||
</TooltipTrigger>
|
||||
<TooltipTrigger>
|
||||
<Button className="px-4 py-2 bg-gray-200 rounded">Left</Button>
|
||||
<Tooltip placement="left">왼쪽에 표시</Tooltip>
|
||||
</TooltipTrigger>
|
||||
<TooltipTrigger>
|
||||
<Button className="px-4 py-2 bg-gray-200 rounded">Right</Button>
|
||||
<Tooltip placement="right">오른쪽에 표시</Tooltip>
|
||||
</TooltipTrigger>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const AllBackgroundColors: Story = {
|
||||
render: () => (
|
||||
<div className="flex gap-8 p-8">
|
||||
<TooltipTrigger>
|
||||
<Button className="px-4 py-2 bg-gray-200 rounded">White</Button>
|
||||
<Tooltip bgColor="white">White 배경</Tooltip>
|
||||
</TooltipTrigger>
|
||||
<TooltipTrigger>
|
||||
<Button className="px-4 py-2 bg-gray-200 rounded">Tertiary</Button>
|
||||
<Tooltip bgColor="tertiary">Tertiary 배경</Tooltip>
|
||||
</TooltipTrigger>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
44
web-app/app/shared/components/tooltip/Tooltip.tsx
Normal file
44
web-app/app/shared/components/tooltip/Tooltip.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Tooltip as AriaTooltip,
|
||||
TooltipProps as AriaTooltipProps,
|
||||
OverlayArrow,
|
||||
composeRenderProps,
|
||||
} from 'react-aria-components';
|
||||
|
||||
import { tv } from 'tailwind-variants';
|
||||
import { OverlayArrowIcon } from '../icons';
|
||||
|
||||
const style = tv({
|
||||
base: 'group/tooltip px-2 py-1.5 max-w-[200px] drop-shadow-modal rounded text-sm',
|
||||
variants: {
|
||||
bgColor: {
|
||||
white: 'bg-white [&_svg]:fill-white',
|
||||
tertiary: 'bg-primary-tertiary [&_svg]:fill-primary-tertiary',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export interface TooltipProps extends Omit<AriaTooltipProps, 'children'> {
|
||||
bgColor?: 'white' | 'tertiary';
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Tooltip({ children, ...props }: TooltipProps) {
|
||||
const { className, bgColor = 'white', offset = 10, ...restProps } = props;
|
||||
|
||||
return (
|
||||
<AriaTooltip
|
||||
offset={offset}
|
||||
{...restProps}
|
||||
className={composeRenderProps(className, (className, renderProps) =>
|
||||
style({ ...renderProps, className, bgColor })
|
||||
)}
|
||||
>
|
||||
<OverlayArrow>
|
||||
<OverlayArrowIcon className="block group-data-[placement=bottom]/tooltip:rotate-180 group-data-[placement=left]/tooltip:-rotate-90 group-data-[placement=right]/tooltip:rotate-90" />
|
||||
</OverlayArrow>
|
||||
<span className="block max-h-[200px] overflow-y-auto whitespace-pre-wrap">{children}</span>
|
||||
</AriaTooltip>
|
||||
);
|
||||
}
|
||||
1
web-app/app/shared/components/tooltip/index.ts
Normal file
1
web-app/app/shared/components/tooltip/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './Tooltip';
|
||||
156
web-app/app/shared/components/tree/Tree.stories.tsx
Normal file
156
web-app/app/shared/components/tree/Tree.stories.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import type { Selection } from 'react-stately'
|
||||
import { Tree, type TreeItemType } from './Tree'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Tree',
|
||||
component: Tree,
|
||||
args: {
|
||||
items: [],
|
||||
},
|
||||
argTypes: {
|
||||
selectionMode: {
|
||||
control: 'select',
|
||||
options: ['single', 'multiple', 'none'],
|
||||
},
|
||||
enableDragAndDrop: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Tree>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const sampleItems: TreeItemType[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: '프로젝트 A',
|
||||
type: 'directory',
|
||||
children: [
|
||||
{ id: 11, title: '문서 1', type: 'file' },
|
||||
{ id: 12, title: '문서 2', type: 'file' },
|
||||
{ id: 13, title: '문서 3', type: 'file' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '프로젝트 B',
|
||||
type: 'directory',
|
||||
children: [
|
||||
{ id: 21, title: '파일 1', type: 'file' },
|
||||
{ id: 22, title: '파일 2', type: 'file' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '프로젝트 C',
|
||||
type: 'directory',
|
||||
children: [
|
||||
{ id: 31, title: '항목 1', type: 'file' },
|
||||
{ id: 32, title: '항목 2', type: 'file' },
|
||||
{ id: 33, title: '항목 3', type: 'file' },
|
||||
{ id: 34, title: '항목 4', type: 'file' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
items: sampleItems,
|
||||
selectionMode: 'single',
|
||||
},
|
||||
}
|
||||
|
||||
export const SingleSelection: Story = {
|
||||
render: function Render() {
|
||||
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set())
|
||||
return (
|
||||
<div className="w-80 border border-gray-200">
|
||||
<Tree
|
||||
items={sampleItems}
|
||||
selectionMode="single"
|
||||
selectedKeys={selectedKeys as Set<number | string>}
|
||||
onSelectionChange={setSelectedKeys}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const MultipleSelection: Story = {
|
||||
render: function Render() {
|
||||
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set())
|
||||
return (
|
||||
<div className="w-80 border border-gray-200">
|
||||
<Tree
|
||||
items={sampleItems}
|
||||
selectionMode="multiple"
|
||||
selectedKeys={selectedKeys as Set<number | string>}
|
||||
onSelectionChange={setSelectedKeys}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const WithDragAndDrop: Story = {
|
||||
render: function Render() {
|
||||
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set())
|
||||
return (
|
||||
<div className="w-80 border border-gray-200">
|
||||
<Tree
|
||||
items={sampleItems}
|
||||
selectionMode="single"
|
||||
selectedKeys={selectedKeys as Set<number | string>}
|
||||
onSelectionChange={setSelectedKeys}
|
||||
enableDragAndDrop={true}
|
||||
onReorder={(sourceKey, targetKey, dropPosition, parentKey) => {
|
||||
console.log('Reorder:', { sourceKey, targetKey, dropPosition, parentKey })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const WithoutDragAndDrop: Story = {
|
||||
args: {
|
||||
items: sampleItems,
|
||||
selectionMode: 'single',
|
||||
enableDragAndDrop: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const NestedTree: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
title: '루트 폴더',
|
||||
type: 'directory',
|
||||
children: [
|
||||
{
|
||||
id: 11,
|
||||
title: '하위 폴더 1',
|
||||
type: 'directory',
|
||||
children: [
|
||||
{ id: 111, title: '파일 A', type: 'file' },
|
||||
{ id: 112, title: '파일 B', type: 'file' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
title: '하위 폴더 2',
|
||||
type: 'directory',
|
||||
children: [
|
||||
{ id: 121, title: '파일 C', type: 'file' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
selectionMode: 'single',
|
||||
},
|
||||
}
|
||||
481
web-app/app/shared/components/tree/Tree.tsx
Normal file
481
web-app/app/shared/components/tree/Tree.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, forwardRef, useContext, useEffect, useImperativeHandle, useMemo, useState } from 'react';
|
||||
import type { DropTarget } from 'react-aria-components';
|
||||
import {
|
||||
Button,
|
||||
Collection,
|
||||
DropIndicator,
|
||||
Tree as ReactAriaTree,
|
||||
TreeItem,
|
||||
TreeItemContent,
|
||||
type TreeItemContentProps,
|
||||
type TreeItemContentRenderProps,
|
||||
type TreeItemProps,
|
||||
useDragAndDrop,
|
||||
} from 'react-aria-components';
|
||||
import type { Key, Selection } from 'react-stately';
|
||||
import { useTreeData } from 'react-stately';
|
||||
|
||||
import clsx from 'clsx';
|
||||
|
||||
type TreeContextType = {
|
||||
onExpand?: (key: Key, item: TreeItemType) => void;
|
||||
onCollapse?: (key: Key, item: TreeItemType) => void;
|
||||
treeData?: ReturnType<typeof useTreeData<TreeItemType>>;
|
||||
treeOpenIcon?: React.ReactNode;
|
||||
treeCloseIcon?: React.ReactNode;
|
||||
};
|
||||
|
||||
const TreeContext = createContext<TreeContextType>({});
|
||||
|
||||
interface MyTreeItemProps extends Partial<TreeItemProps> {
|
||||
title: string;
|
||||
isLastChild?: boolean;
|
||||
isRoot?: boolean;
|
||||
}
|
||||
|
||||
export type TreeItemType = {
|
||||
id: number | string;
|
||||
title: string;
|
||||
type: 'directory' | 'file';
|
||||
value?: any;
|
||||
children?: TreeItemType[];
|
||||
};
|
||||
|
||||
export type TreeRef = {
|
||||
updateItem: (item: TreeItemType) => void;
|
||||
addItem: (item: TreeItemType, parentKey?: Key | null) => void;
|
||||
removeItem: (itemId: number) => void;
|
||||
expandAllItems: () => void;
|
||||
collapseAllItems: () => void;
|
||||
applyReorder: (sourceKey: number, targetKey: number, dropPosition: 'before' | 'after') => void;
|
||||
clearItemsByParentKey: (parentKey: Key) => void;
|
||||
clearAllTrees: () => void;
|
||||
};
|
||||
|
||||
export type TreeProps = {
|
||||
items: TreeItemType[];
|
||||
selectionMode?: 'single' | 'multiple' | 'none';
|
||||
selectedKeys?: 'all' | Set<number | string>;
|
||||
selectedParentKey?: Key | null;
|
||||
onSelectionChange?: (keys: Selection) => void;
|
||||
enableDragAndDrop?: boolean;
|
||||
onReorder?: (
|
||||
sourceKey: number,
|
||||
targetKey: number,
|
||||
dropPosition: 'before' | 'after',
|
||||
ParentKey: number | null
|
||||
) => void;
|
||||
preventAutoReorder?: boolean;
|
||||
onExpand?: (key: Key, item: TreeItemType) => void;
|
||||
onCollapse?: (key: Key, item: TreeItemType) => void;
|
||||
onSelect?: (key: Key | null, item: TreeItemType | null) => void;
|
||||
'aria-label'?: string;
|
||||
className?: string;
|
||||
treeOpenIcon?: React.ReactNode;
|
||||
treeCloseIcon?: React.ReactNode;
|
||||
persistSelectionOnCollapse?: boolean;
|
||||
};
|
||||
|
||||
const IconTreeBranch = () => (
|
||||
<svg width="24" height="48" viewBox="0 0 24 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="0.5" y1="0" x2="0.5" y2="48" stroke="#343434" strokeDasharray="2 2" />
|
||||
<line x1="25" y1="23.5" x2="1" y2="23.5" stroke="#343434" strokeDasharray="2 2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconTreeBranchLast = () => (
|
||||
<svg width="24" height="48" viewBox="0 0 24 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="0.5" y1="0" x2="0.5" y2="24" stroke="#343434" strokeDasharray="2 2" />
|
||||
<line x1="25" y1="23.5" x2="1" y2="23.5" stroke="#343434" strokeDasharray="2 2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MyTreeItemContentInner = ({
|
||||
level,
|
||||
hasChildItems,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
lastChild,
|
||||
children,
|
||||
isRoot,
|
||||
onExpandChange,
|
||||
}: {
|
||||
level: number;
|
||||
hasChildItems: boolean;
|
||||
isExpanded: boolean;
|
||||
isSelected: boolean;
|
||||
lastChild?: boolean;
|
||||
children?: React.ReactNode;
|
||||
isRoot?: boolean;
|
||||
actualChildCount?: number;
|
||||
onExpandChange?: (isExpanded: boolean) => void;
|
||||
}) => {
|
||||
const { treeOpenIcon, treeCloseIcon } = useContext(TreeContext);
|
||||
if (hasChildItems || isRoot) {
|
||||
return (
|
||||
<Button
|
||||
className={clsx(
|
||||
'cursor-pointer flex items-center h-10 hover:bg-dabeeo-gray-eb w-full text-dabeeo-black-34 text-sm',
|
||||
isSelected && 'bg-dabeeo-yellow'
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16}px` }}
|
||||
slot="chevron"
|
||||
onClick={() => {
|
||||
onExpandChange?.(!isExpanded);
|
||||
}}
|
||||
>
|
||||
{treeOpenIcon && treeCloseIcon ? (
|
||||
<div className="mr-3">{isExpanded ? treeOpenIcon : treeCloseIcon}</div>
|
||||
) : (
|
||||
<div className="w-6 h-6 bg-dabeeo-gray-eb border border-dabeeo-black-34 hover:bg-dabeeo-gray-da mr-4 shrink-0">
|
||||
{isExpanded ? '-' : '+'}
|
||||
</div>
|
||||
)}
|
||||
<span className="font-semibold">{children}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'cursor-pointer flex items-center h-10 hover:bg-dabeeo-gray-eb w-full text-dabeeo-black-34 text-sm pl-9',
|
||||
isSelected && 'bg-dabeeo-yellow'
|
||||
)}
|
||||
>
|
||||
<div className="mr-3 flex-shrink-0">{lastChild ? <IconTreeBranchLast /> : <IconTreeBranch />}</div>
|
||||
<span className="font-normal">{children}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MyTreeItemContent = (
|
||||
props: Omit<TreeItemContentProps, 'children'> & {
|
||||
children?: React.ReactNode;
|
||||
lastChild?: boolean;
|
||||
isRoot?: boolean;
|
||||
onExpandChange?: (isExpanded: boolean) => void;
|
||||
}
|
||||
) => {
|
||||
return (
|
||||
<TreeItemContent>
|
||||
{(renderProps: TreeItemContentRenderProps) => {
|
||||
const { hasChildItems, isExpanded, isSelected, level } = renderProps;
|
||||
return (
|
||||
<>
|
||||
<MyTreeItemContentInner
|
||||
level={level}
|
||||
hasChildItems={hasChildItems}
|
||||
isExpanded={isExpanded}
|
||||
isSelected={isSelected}
|
||||
lastChild={props.lastChild}
|
||||
isRoot={props.isRoot}
|
||||
onExpandChange={props.onExpandChange}
|
||||
>
|
||||
{props.children}
|
||||
</MyTreeItemContentInner>
|
||||
<Button slot="drag" className="sr-only">
|
||||
드래그
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</TreeItemContent>
|
||||
);
|
||||
};
|
||||
|
||||
const TreeDropIndicator = ({ target }: { target: DropTarget }) => {
|
||||
return (
|
||||
<DropIndicator
|
||||
target={target}
|
||||
className={({ isDropTarget }) =>
|
||||
clsx('w-full h-0 outline-2 outline-primary/50', isDropTarget ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const MyTreeItem = (props: MyTreeItemProps) => {
|
||||
const { onExpand, onCollapse, treeData } = useContext(TreeContext);
|
||||
|
||||
const handleExpandChange = (isExpanded: boolean) => {
|
||||
if (!props.id || !treeData) return;
|
||||
|
||||
const item = treeData.getItem(props.id);
|
||||
if (!item) return;
|
||||
|
||||
if (isExpanded) {
|
||||
onExpand?.(props.id, item.value);
|
||||
} else {
|
||||
onCollapse?.(props.id, item.value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TreeItem
|
||||
textValue={props.title}
|
||||
{...props}
|
||||
hasChildItems={props.isRoot ? true : undefined}
|
||||
data-item-id={props.id}
|
||||
className="
|
||||
data-[dragging]:opacity-60
|
||||
data-[drop-target]:outline
|
||||
data-[drop-target]:outline-2
|
||||
data-[drop-target]:outline-[var(--highlight-background)]
|
||||
data-[drop-target]:bg-[var(--highlight-overlay)]
|
||||
"
|
||||
>
|
||||
<MyTreeItemContent lastChild={props.isLastChild} isRoot={props.isRoot} onExpandChange={handleExpandChange}>
|
||||
{props.title}
|
||||
</MyTreeItemContent>
|
||||
{props.children}
|
||||
</TreeItem>
|
||||
);
|
||||
};
|
||||
|
||||
export const Tree = forwardRef<TreeRef, TreeProps>(
|
||||
(
|
||||
{
|
||||
'aria-label': ariaLabel = 'Tree',
|
||||
items,
|
||||
selectionMode = 'single',
|
||||
selectedKeys,
|
||||
selectedParentKey,
|
||||
enableDragAndDrop = true,
|
||||
className,
|
||||
onSelectionChange,
|
||||
onReorder,
|
||||
preventAutoReorder = false,
|
||||
onExpand,
|
||||
onCollapse,
|
||||
onSelect,
|
||||
treeOpenIcon,
|
||||
treeCloseIcon,
|
||||
persistSelectionOnCollapse = false,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [expandedKeys, setExpandedKeys] = useState<Set<Key>>(new Set());
|
||||
const [treeKey, setTreeKey] = useState(0);
|
||||
|
||||
const handleExpandedChange = (keys: Set<Key>) => {
|
||||
setExpandedKeys(keys);
|
||||
};
|
||||
|
||||
const tree = useTreeData({
|
||||
initialItems: items,
|
||||
getKey: (item) => item.id,
|
||||
getChildren: (item) => item.children || [],
|
||||
});
|
||||
|
||||
const updateItem = (item: TreeItemType) => {
|
||||
tree.update(item.id, item);
|
||||
};
|
||||
|
||||
const addItem = (item: TreeItemType, parentKey?: Key | null) => {
|
||||
tree.getItem(-1) && tree.remove(-1);
|
||||
// 중복 체크: 이미 존재하는 아이템은 추가하지 않음
|
||||
const targetParentKey = parentKey ?? selectedParentKey ?? null;
|
||||
const existingItem = tree.getItem(item.id);
|
||||
|
||||
if (
|
||||
(existingItem && existingItem.parentKey === targetParentKey)
|
||||
|| (targetParentKey && targetParentKey === item.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
tree.append(targetParentKey, item);
|
||||
};
|
||||
|
||||
const removeItem = (itemId: number) => {
|
||||
tree.remove(itemId);
|
||||
const childrens = tree.getItem(selectedParentKey as Key)?.children;
|
||||
itemId !== -1 && childrens?.length === 1 && updateMyParentItem();
|
||||
};
|
||||
|
||||
const expandAllItems = () => {
|
||||
setExpandedKeys(new Set([...expandedKeys, ...tree.items.map((item) => item.key)]));
|
||||
};
|
||||
|
||||
const collapseAllItems = () => {
|
||||
setExpandedKeys(new Set());
|
||||
};
|
||||
|
||||
const clearAllTrees = () => {
|
||||
const keysToRemove = tree.items.map((item) => item.key);
|
||||
keysToRemove.forEach((key) => {
|
||||
tree.remove(key);
|
||||
});
|
||||
};
|
||||
|
||||
const clearItemsByParentKey = (parentKey: Key) => {
|
||||
const removeRecursively = (key: Key) => {
|
||||
const item = tree.getItem(key);
|
||||
if (item && item.children) {
|
||||
item.children.forEach((child) => {
|
||||
removeRecursively(child.key);
|
||||
});
|
||||
}
|
||||
tree.remove(key);
|
||||
};
|
||||
|
||||
const itemsToRemove: Key[] = [];
|
||||
tree.items.forEach((item) => {
|
||||
if (item.parentKey === parentKey) {
|
||||
itemsToRemove.push(item.key);
|
||||
}
|
||||
});
|
||||
|
||||
itemsToRemove.forEach((key) => {
|
||||
removeRecursively(key);
|
||||
});
|
||||
};
|
||||
|
||||
const applyReorder = (sourceKey: number, targetKey: number, dropPosition: 'before' | 'after') => {
|
||||
if (dropPosition === 'before') {
|
||||
tree.moveBefore(targetKey, new Set([sourceKey]));
|
||||
} else {
|
||||
tree.moveAfter(targetKey, new Set([sourceKey]));
|
||||
}
|
||||
};
|
||||
|
||||
const updateMyParentItem = () => {
|
||||
if (selectedParentKey) {
|
||||
const parentItem = tree.getItem(selectedParentKey);
|
||||
if (parentItem) {
|
||||
tree.update(selectedParentKey, {
|
||||
...parentItem.value,
|
||||
children: [],
|
||||
...(parentItem.value.value && { value: { ...parentItem.value.value, children: [] } }),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
updateItem,
|
||||
addItem,
|
||||
removeItem,
|
||||
expandAllItems,
|
||||
collapseAllItems,
|
||||
applyReorder,
|
||||
clearItemsByParentKey,
|
||||
clearAllTrees,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (persistSelectionOnCollapse) return;
|
||||
if (selectedKeys) {
|
||||
setExpandedKeys(new Set([...expandedKeys, ...selectedKeys]));
|
||||
}
|
||||
}, [selectedKeys, persistSelectionOnCollapse]);
|
||||
|
||||
useEffect(() => {
|
||||
if (items) {
|
||||
setTreeKey((prev) => prev + 1);
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
const handleSelectionChange = (keys: Selection) => {
|
||||
onSelectionChange?.(keys);
|
||||
|
||||
if (onSelect && selectionMode === 'single' && keys !== 'all') {
|
||||
const selectedKey = keys instanceof Set ? Array.from(keys)[0] : null;
|
||||
if (selectedKey) {
|
||||
const item = tree.getItem(selectedKey);
|
||||
if (item) {
|
||||
onSelect(selectedKey, item ? item.value : null);
|
||||
}
|
||||
} else {
|
||||
onSelect(selectedKey, null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const { dragAndDropHooks } = useDragAndDrop({
|
||||
getItems: (keys) =>
|
||||
[...keys].map((key) => ({
|
||||
'text/plain': tree.getItem(key)?.value.title || '',
|
||||
})),
|
||||
renderDropIndicator(target) {
|
||||
return <TreeDropIndicator target={target} />;
|
||||
},
|
||||
onReorder(e) {
|
||||
const targetItem = tree.getItem(e.target.key);
|
||||
const sourceKey = Array.from(e.keys)[0] as number;
|
||||
const dropPosition = e.target.dropPosition === 'before' ? 'before' : 'after';
|
||||
const targetParentKey = Number(targetItem?.parentKey) || null;
|
||||
|
||||
if (preventAutoReorder) {
|
||||
onReorder?.(sourceKey, Number(e.target.key), dropPosition, targetParentKey);
|
||||
} else {
|
||||
if (dropPosition === 'before') {
|
||||
tree.moveBefore(e.target.key, e.keys);
|
||||
} else {
|
||||
tree.moveAfter(e.target.key, e.keys);
|
||||
}
|
||||
onReorder?.(sourceKey, Number(e.target.key), dropPosition, targetParentKey);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const calculateIsLastChild = (item: ReturnType<typeof tree.getItem>) => {
|
||||
if (!item || !item.parentKey) return false;
|
||||
|
||||
const parent = tree.getItem(item.parentKey);
|
||||
if (!parent) return false;
|
||||
|
||||
const siblings = parent.children || [];
|
||||
const index = siblings.findIndex((sibling) => sibling.key === item.key);
|
||||
return index === siblings.length - 1;
|
||||
};
|
||||
|
||||
// MEMO: tree.items가 변경될 때 전체 트리를 다시 렌더링하기 위해 사용하는 key
|
||||
// 현재는 하나의 item이 변경되어도 전체 트리가 다시 렌더링됨. 변경된 item과 같은 부모 아래의 아이템들만 선택적으로 렌더링되도록 개선 필요함
|
||||
const treeRenderKey = useMemo(() => {
|
||||
const generateTreeHash = (items: typeof tree.items): string => {
|
||||
return items
|
||||
.map((item) => {
|
||||
const childrenHash = item.children ? generateTreeHash(item.children) : '';
|
||||
return `${item.key}-${item.children?.length || 0}-${childrenHash}`;
|
||||
})
|
||||
.join('|');
|
||||
};
|
||||
return generateTreeHash(tree.items);
|
||||
}, [tree.items]);
|
||||
|
||||
return (
|
||||
<TreeContext.Provider value={{ onExpand, onCollapse, treeData: tree, treeOpenIcon, treeCloseIcon }}>
|
||||
<ReactAriaTree
|
||||
key={`${treeKey}-${treeRenderKey}`}
|
||||
aria-label={ariaLabel}
|
||||
selectionMode={selectionMode}
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
items={tree.items}
|
||||
dragAndDropHooks={enableDragAndDrop ? dragAndDropHooks : undefined}
|
||||
className={clsx(className, 'w-full')}
|
||||
expandedKeys={expandedKeys}
|
||||
onExpandedChange={handleExpandedChange}
|
||||
>
|
||||
{function renderItem(item) {
|
||||
const isLastChild = calculateIsLastChild(item);
|
||||
return (
|
||||
<MyTreeItem
|
||||
title={item.value.title || ''}
|
||||
isLastChild={isLastChild}
|
||||
isRoot={item.value.type === 'directory'}
|
||||
>
|
||||
<Collection items={item.children || []}>{renderItem}</Collection>
|
||||
</MyTreeItem>
|
||||
);
|
||||
}}
|
||||
</ReactAriaTree>
|
||||
</TreeContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Tree.displayName = 'Tree';
|
||||
17
web-app/app/shared/types/api.ts
Normal file
17
web-app/app/shared/types/api.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type ApiResponse<T = unknown> = {
|
||||
data: T;
|
||||
error?: never;
|
||||
};
|
||||
|
||||
export type PagedResponse<T> = {
|
||||
content: T[];
|
||||
number: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
size: number;
|
||||
sort: {
|
||||
empty: boolean;
|
||||
sorted: boolean;
|
||||
unsorted: boolean;
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import eslint from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import unusedImports from 'eslint-plugin-unused-imports';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
import stylistic from '@stylistic/eslint-plugin';
|
||||
|
||||
@@ -19,10 +20,28 @@ export default tseslint.config(
|
||||
}),
|
||||
reactHooks.configs.flat.recommended,
|
||||
{
|
||||
plugins: {
|
||||
'unused-imports': unusedImports,
|
||||
},
|
||||
rules: {
|
||||
'@stylistic/jsx-one-expression-per-line': 'off',
|
||||
'@stylistic/multiline-ternary': 'off',
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
'@stylistic/no-multiple-empty-lines': ['error', { max: 1, maxEOF: 1 }],
|
||||
'@stylistic/comma-dangle': 'off',
|
||||
'@stylistic/semi': 'off',
|
||||
'@stylistic/no-trailing-spaces': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'unused-imports/no-unused-vars': ['warn', { vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' }],
|
||||
'@stylistic/arrow-parens': 'off',
|
||||
'@stylistic/member-delimiter-style': 'off',
|
||||
'@typescript-eslint/consistent-type-imports': 'off',
|
||||
'@stylistic/max-statements-per-line': 'off',
|
||||
'@stylistic/operator-linebreak': 'off',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,11 +8,16 @@
|
||||
"start": "react-router-serve ./build/server/index.js",
|
||||
"typecheck": "react-router typegen && tsc",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
"lint:fix": "eslint . --fix",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.12.0",
|
||||
"@react-router/node": "7.14.0",
|
||||
"@react-router/serve": "7.14.0",
|
||||
"axios": "^1.15.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"isbot": "^5.1.37",
|
||||
"ol": "^10.8.0",
|
||||
@@ -22,6 +27,8 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-router": "7.14.0",
|
||||
"react-stately": "^3.45.0",
|
||||
"storybook": "^10.3.5",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"zod": "^4.3.6"
|
||||
@@ -29,13 +36,17 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@react-router/dev": "7.14.0",
|
||||
"@storybook/react": "^10.3.5",
|
||||
"@storybook/react-vite": "^10.3.5",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
|
||||
918
web-app/pnpm-lock.yaml
generated
918
web-app/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@
|
||||
"~/*": ["./app/*"]
|
||||
},
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
Reference in New Issue
Block a user