diff --git a/web-app/app/app.css b/web-app/app/app.css index 5ba9722..55af49e 100644 --- a/web-app/app/app.css +++ b/web-app/app/app.css @@ -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,16 @@ --color-dabeeo-navy-tertiary01: #d4dde9; --color-dabeeo-navy-tertiary02: #f0f3f7; + /* 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 +71,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, diff --git a/web-app/app/features/imagery/api/aerial.ts b/web-app/app/features/imagery/api/aerial.ts index a3121f9..9d0dd0f 100644 --- a/web-app/app/features/imagery/api/aerial.ts +++ b/web-app/app/features/imagery/api/aerial.ts @@ -1,13 +1,10 @@ import axios from 'axios'; import type { ApiResponse, PagedResponse } from '~/shared/types/api'; import type { + AerialData, AerialDetail, AerialItem, AerialListParams, - ChunkUploadParams, - ChunkUploadResponse, - FolderListResponse, - Region, } from '../types/aerial'; /** @@ -77,72 +74,14 @@ export const fetchAerialImage = async (uuid: string, imageType: 'before' | 'afte }; /** - * 업로드 지역 조회 (시/도) + * 항공영상 데이터 등록 */ -export const fetchRegions = async () => { +export const registerAerialData = async (data: AerialData) => { try { - const response = await axios.get>('/api/imagery/regions/provinces'); + const response = await axios.post('/api/imagery/aerial', data); return response.data.data; } catch (error) { - console.error('fetchRegions error:', error); + console.error('registerAerialData error:', error); throw error; } -}; - -/** - * 업로드 폴더 조회 - */ -export const fetchFolderList = async (dirPath: string) => { - try { - const response = await axios.post>('/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>( - '/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>( - `/api/imagery/upload/chunk-upload-complete/${uuid}`, - ); - return response.data.data; - } catch (error) { - console.error('completeChunkUpload error:', error); - throw error; - } -}; +} diff --git a/web-app/app/features/imagery/api/imagery.ts b/web-app/app/features/imagery/api/imagery.ts new file mode 100644 index 0000000..b1359ed --- /dev/null +++ b/web-app/app/features/imagery/api/imagery.ts @@ -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>('/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>('/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>( + '/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>( + `/api/imagery/upload/chunk-upload-complete/${uuid}`, + ); + return response.data.data; + } catch (error) { + console.error('completeChunkUpload error:', error); + throw error; + } +}; diff --git a/web-app/app/features/imagery/components/AerialList.tsx b/web-app/app/features/imagery/components/AerialList.tsx index 07e1d88..7e8225c 100644 --- a/web-app/app/features/imagery/components/AerialList.tsx +++ b/web-app/app/features/imagery/components/AerialList.tsx @@ -3,12 +3,14 @@ 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(null); const [isLoading, setIsLoading] = useState(false); const [data, setData] = useState([]); const [totalCount, setTotalCount] = useState(0); + const [isRegisterModalOpen, setIsRegisterModalOpen] = useState(false); useEffect(() => { const load = async () => { @@ -37,74 +39,76 @@ export function AerialList() { }, []); return ( -
-
-

항공영상관리

+ <> +
+
+

항공영상관리

- - - - - - -
- - -
-
-
+
+ + + + + +
+ +
+
+
- - - - - - - - - - + + + + + + + + + + - - - No - 파일명 - 지역 - 촬영일시 - 축적 - 용량 - 전처리 - - + + + No + 파일명 + 지역 + 촬영일시 + 축적 + 용량 + 전처리 + + - - {isLoading ? ( - - ) : data.length === 0 ? ( - 등록된 항공영상이 없습니다. - ) : ( - data.map((item, index) => ( - setSelectedId(item.mapId)} - > - {index + 1} - {item.fileName} - {item.region} - {item.capturedDttm} - {item.scale} - {item.fileSize} - - {item.status} - - - )) - )} - - -
-
-
+ + {isLoading ? ( + + ) : data.length === 0 ? ( + 등록된 항공영상이 없습니다. + ) : ( + data.map((item, index) => ( + setSelectedId(item.mapId)} + > + {index + 1} + {item.fileName} + {item.region} + {item.capturedDttm} + {item.scale} + {item.fileSize} + + {item.status} + + + )) + )} + + + +
+
+ {isRegisterModalOpen && setIsRegisterModalOpen(false)} />} + ); } diff --git a/web-app/app/features/imagery/components/AerialRegisterModal.tsx b/web-app/app/features/imagery/components/AerialRegisterModal.tsx new file mode 100644 index 0000000..ecc5d5c --- /dev/null +++ b/web-app/app/features/imagery/components/AerialRegisterModal.tsx @@ -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 ( +
+
+
+ ); +}; + +const FileItem = ({ + file, + folderPath, + onRemove, +}: { + file: UploadingFile; + folderPath: string; + onRemove: (id: string) => void; +}) => { + const fullPath = folderPath ? `${folderPath}/${file.fileName}` : file.fileName; + return ( +
+ {fullPath} + +
+ ); +}; + +export const AerialRegisterModal = ({ isOpen, onClose, onUploadSuccess }: AerialRegisterModalProps) => { + const treeRef = useRef(null); + const fileInputRef = useRef(null); + + const { control, setValue, watch, reset: resetForm } = useForm({ + defaultValues: { + type: 'mapFrame', + region: '', + folderPath: '', + }, + }); + + const selectedRegion = watch('region'); + const selectedFolder = watch('folderPath'); + + const [regions, setRegions] = useState([]); + const [treeItems, setTreeItems] = useState([]); + 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) => { + 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 ( + + {() => ( +
{ e.preventDefault(); handleUpload(); }}> + 업로드 폴더 선택 + +
+ {/* 타입 선택 */} +
+ + ( + { + onChange(v); + setValue('folderPath', ''); + }} + placeholder="지역선택" + className="w-40" + /> + )} + /> +
+ + {/* 폴더 선택 */} +
+ +
+ {isLoadingFolders ? ( +
+ 로딩 중... +
+ ) : treeItems.length > 0 ? ( + ( + { + handleExpandFolder(key); + onChange(String(key)); + }} + onCollapse={(key) => { + handleCollapseFolder(key); + }} + onSelect={(key) => { + if (key) onChange(String(key)); + }} + enableDragAndDrop={false} + persistSelectionOnCollapse + /> + )} + /> + ) : selectedRegion ? ( +
+ 폴더가 없습니다. +
+ ) : ( +
+ 지역을 선택해주세요. +
+ )} +
+
+ +

+ ※ 업로드 대상 폴더를 정확하게 지정하시기 바랍니다. +

+ + {/* 파일명 */} +
+ +
+ {files.length > 0 ? ( + files.map((file) => ( + + )) + ) : ( + 파일이 업로드 되면 자동 입력됩니다. + )} +
+
+ + {/* 파일 선택 */} +
+ +
+
+ + {currentUploadingFile?.fileName || ''} + +
+ + +
+
+ + {/* 진행률 표시 */} + {hasFiles && ( +
+ + {overallProgress}% +
+ )} + + {hasFiles && ( +
+ Loading {formatFileSize(uploadedSize)} / {formatFileSize(totalSize)} +
+ )} +
+
+ + + + +
+ )} +
+ ); +}; diff --git a/web-app/app/features/imagery/hooks/useAerialChunkUpload.ts b/web-app/app/features/imagery/hooks/useAerialChunkUpload.ts new file mode 100644 index 0000000..7c0c310 --- /dev/null +++ b/web-app/app/features/imagery/hooks/useAerialChunkUpload.ts @@ -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([]) + const abortControllersRef = useRef>(new Map()) + + const updateFileState = useCallback((id: string, updates: Partial) => { + 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, + } +} diff --git a/web-app/app/features/imagery/types/aerial.ts b/web-app/app/features/imagery/types/aerial.ts index f6b6bb2..6b5084f 100644 --- a/web-app/app/features/imagery/types/aerial.ts +++ b/web-app/app/features/imagery/types/aerial.ts @@ -25,47 +25,6 @@ export type AerialDetail = { longitude: number; }; -// 지역 (시/도) -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; -}; - // 목록 조회 파라미터 export type AerialListParams = { dateRangeType: 'capturedDttm' | 'createdDttm'; @@ -74,3 +33,14 @@ export type AerialListParams = { page?: number; size?: number; }; + +// 항공영상 데이터 등록 +export type AerialData = { + fileName: string; + region: string; + scale: string; + capturedDttm: string; + fileSize: string; + status: string; + type: '도곽'; +}; diff --git a/web-app/app/features/imagery/types/imageryRegister.ts b/web-app/app/features/imagery/types/imageryRegister.ts new file mode 100644 index 0000000..7483eda --- /dev/null +++ b/web-app/app/features/imagery/types/imageryRegister.ts @@ -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; +}; diff --git a/web-app/app/shared/components/button/Button.tsx b/web-app/app/shared/components/button/Button.tsx index d9eda45..f25ff51 100644 --- a/web-app/app/shared/components/button/Button.tsx +++ b/web-app/app/shared/components/button/Button.tsx @@ -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', }, ], }); diff --git a/web-app/app/shared/components/calendar/Calendar.tsx b/web-app/app/shared/components/calendar/Calendar.tsx index d63e673..a15b1e6 100644 --- a/web-app/app/shared/components/calendar/Calendar.tsx +++ b/web-app/app/shared/components/calendar/Calendar.tsx @@ -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]', @@ -143,8 +143,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 +161,7 @@ export function CalendarHeader() { navButton(renderProps)}> - + navButton(renderProps)}> @@ -173,7 +173,7 @@ export function CalendarGridHeader() { return ( {(day) => ( - + {day} )} diff --git a/web-app/app/shared/components/calendar/RangeCalendar.tsx b/web-app/app/shared/components/calendar/RangeCalendar.tsx index 987cab1..fb52045 100644 --- a/web-app/app/shared/components/calendar/RangeCalendar.tsx +++ b/web-app/app/shared/components/calendar/RangeCalendar.tsx @@ -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]', diff --git a/web-app/app/shared/components/datePicker/DatePicker.tsx b/web-app/app/shared/components/datePicker/DatePicker.tsx index f3b1987..7003aaf 100644 --- a/web-app/app/shared/components/datePicker/DatePicker.tsx +++ b/web-app/app/shared/components/datePicker/DatePicker.tsx @@ -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 > - + diff --git a/web-app/app/shared/components/datePicker/DateRangePicker.tsx b/web-app/app/shared/components/datePicker/DateRangePicker.tsx index c88f176..41b99e1 100644 --- a/web-app/app/shared/components/datePicker/DateRangePicker.tsx +++ b/web-app/app/shared/components/datePicker/DateRangePicker.tsx @@ -16,16 +16,16 @@ 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({ > - + diff --git a/web-app/app/shared/components/descriptions/Descriptions.tsx b/web-app/app/shared/components/descriptions/Descriptions.tsx new file mode 100644 index 0000000..13f682e --- /dev/null +++ b/web-app/app/shared/components/descriptions/Descriptions.tsx @@ -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 ( +
+ {items.map((item, index) => { + const { key, ...itemProps } = item; + return ( + + ); + })} +
+ ); +}; diff --git a/web-app/app/shared/components/descriptions/items/DescriptionsItem.tsx b/web-app/app/shared/components/descriptions/items/DescriptionsItem.tsx new file mode 100644 index 0000000..eb53f00 --- /dev/null +++ b/web-app/app/shared/components/descriptions/items/DescriptionsItem.tsx @@ -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 ( +
+
+ {title} +
+
+ {content} +
+
+ ); +}; diff --git a/web-app/app/shared/components/inputGroup/InputGroup.tsx b/web-app/app/shared/components/inputGroup/InputGroup.tsx index 04f7ac5..b087edc 100644 --- a/web-app/app/shared/components/inputGroup/InputGroup.tsx +++ b/web-app/app/shared/components/inputGroup/InputGroup.tsx @@ -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', }, }, }, diff --git a/web-app/app/shared/components/modal/AlertModal.tsx b/web-app/app/shared/components/modal/AlertModal.tsx new file mode 100644 index 0000000..5b30229 --- /dev/null +++ b/web-app/app/shared/components/modal/AlertModal.tsx @@ -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 ( + + {({ close }) => } + + ); +}; + +function AlertModalContent({ + close, + title, + content, + confirmText = '확인', + onConfirm, +}: { + close: () => void; + title?: string; + content?: ReactNode; + confirmText?: string; + onConfirm?: () => void; +}) { + const handleConfirm = () => { + onConfirm?.(); + close(); + }; + + return ( + <> + {title && {title}} + +

{content}

+
+ + + + + ); +} diff --git a/web-app/app/shared/components/modal/ConfirmModal.tsx b/web-app/app/shared/components/modal/ConfirmModal.tsx new file mode 100644 index 0000000..f1dcd04 --- /dev/null +++ b/web-app/app/shared/components/modal/ConfirmModal.tsx @@ -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; + onCancel?: () => void; +} + +export const ConfirmModal = (props: ConfirmModalProps) => { + const { isOpen, onOpenChange, title, ...contentProps } = props; + + return ( + + {({ close }) => } + + ); +}; + +function ConfirmModalContent({ + close, + title, + content, + confirmText = '확인', + cancelText = '취소', + onConfirm, + onCancel, +}: { + close: () => void; + title?: string; + content?: ReactNode; + confirmText?: string; + cancelText?: string; + onConfirm?: () => Promise | void; + onCancel?: () => void; +}) { + const [isPending, startTransition] = useTransition(); + + const handleConfirm = () => { + if (onConfirm) { + startTransition(async () => { + await onConfirm(); + close(); + }); + } else { + close(); + } + }; + + const handleCancel = () => { + onCancel?.(); + close(); + }; + + return ( + <> + {title && {title}} + +

{content}

+
+ + + + + + ); +} diff --git a/web-app/app/shared/components/modal/Modal.tsx b/web-app/app/shared/components/modal/Modal.tsx new file mode 100644 index 0000000..c44bc8c --- /dev/null +++ b/web-app/app/shared/components/modal/Modal.tsx @@ -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 { + className?: string; +} +const ModalRoot = (props: ModalRootProps) => { + const { isOpen, onOpenChange, isKeyboardDismissDisabled, isDismissable, className, ...dialogProps } = props; + return ( + + + + {props.children} + + + + ); +}; + +interface ModalHeaderProps { + hasCloseButton?: boolean; + children: React.ReactNode; +} +const ModalHeader = (props: ModalHeaderProps) => { + const ctx = use(OverlayTriggerStateContext); + + const { hasCloseButton = true } = props; + return ( +
+ + {props.children} + + {hasCloseButton && ( + + + + + + )} +
+ ); +}; + +interface ModalBodyProps extends React.PropsWithChildren { + className?: string; +} +const ModalBody = (props: ModalBodyProps) => { + return
{props.children}
; +}; + +interface ModalFooterProps extends React.PropsWithChildren {} +const ModalFooter = (props: ModalFooterProps) => { + return
{props.children}
; +}; + +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) => ( + { + if (!isOpen) { + _modals = _modals.filter((f) => f !== modal); + emitChange(); + } + }} + > + {({ close }) => ( + <> + {modal.title && {modal.title}} + +

{modal.content}

+
+ + + {modal.typeof === 'confirm' && ( + + )} + + + )} +
+ ))} + + ); +}; +export { alert, confirm, ModalBody, ModalFooter, ModalHeader, ModalRegion, ModalRoot }; +export type { ModalBodyProps, ModalFooterProps, ModalHeaderProps, ModalRootProps }; + diff --git a/web-app/app/shared/components/modal/ModalRenderer.tsx b/web-app/app/shared/components/modal/ModalRenderer.tsx new file mode 100644 index 0000000..ec4f41b --- /dev/null +++ b/web-app/app/shared/components/modal/ModalRenderer.tsx @@ -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 ( + { + if (!open) { + resolve(); + modalStore.removeModal(id); + } + }} + /> + ); + })} + + ); +}; diff --git a/web-app/app/shared/components/modal/index.ts b/web-app/app/shared/components/modal/index.ts new file mode 100644 index 0000000..d5d6383 --- /dev/null +++ b/web-app/app/shared/components/modal/index.ts @@ -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'; diff --git a/web-app/app/shared/components/modal/store.ts b/web-app/app/shared/components/modal/store.ts new file mode 100644 index 0000000..ac2d9dc --- /dev/null +++ b/web-app/app/shared/components/modal/store.ts @@ -0,0 +1,43 @@ +import type { ComponentType } from 'react'; + +interface ModalData { + id: string; + scopeId: string; + Component: ComponentType; + props: Record; + 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 }; diff --git a/web-app/app/shared/components/modal/useModal.ts b/web-app/app/shared/components/modal/useModal.ts new file mode 100644 index 0000000..d637246 --- /dev/null +++ b/web-app/app/shared/components/modal/useModal.ts @@ -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( +

( + Component: ComponentType

, + props?: Omit + ): Promise => + 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 }; +}; diff --git a/web-app/app/shared/components/pagination/Pagination.tsx b/web-app/app/shared/components/pagination/Pagination.tsx index 294555f..1037bd7 100644 --- a/web-app/app/shared/components/pagination/Pagination.tsx +++ b/web-app/app/shared/components/pagination/Pagination.tsx @@ -49,7 +49,7 @@ export const Pagination = ({ totalPages, currentPage, pageCount = 10, onPageChan const noNext = start + pageCount >= totalPages; return ( -