Compare commits
4 Commits
57a6e4b51e
...
b390777af0
| Author | SHA1 | Date | |
|---|---|---|---|
| b390777af0 | |||
| 8b33161284 | |||
| 35f6023f5a | |||
| a836333512 |
@@ -36,6 +36,10 @@
|
|||||||
--font-sans: "Pretendard", ui-sans-serif, system-ui, sans-serif,
|
--font-sans: "Pretendard", ui-sans-serif, system-ui, sans-serif,
|
||||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
"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) */
|
/* Primary (Navy) */
|
||||||
--color-primary: var(--color-dabeeo-navy-main);
|
--color-primary: var(--color-dabeeo-navy-main);
|
||||||
--color-primary-secondary: var(--color-dabeeo-navy-secondary);
|
--color-primary-secondary: var(--color-dabeeo-navy-secondary);
|
||||||
@@ -50,6 +54,16 @@
|
|||||||
--color-dabeeo-navy-tertiary01: #d4dde9;
|
--color-dabeeo-navy-tertiary01: #d4dde9;
|
||||||
--color-dabeeo-navy-tertiary02: #f0f3f7;
|
--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 */
|
/* Gray */
|
||||||
--color-dabeeo-gray-44: #444444;
|
--color-dabeeo-gray-44: #444444;
|
||||||
--color-dabeeo-gray-99: #999999;
|
--color-dabeeo-gray-99: #999999;
|
||||||
@@ -57,6 +71,12 @@
|
|||||||
--color-dabeeo-gray-da: #dadada;
|
--color-dabeeo-gray-da: #dadada;
|
||||||
--color-dabeeo-gray-eb: #ebebeb;
|
--color-dabeeo-gray-eb: #ebebeb;
|
||||||
--color-dabeeo-gray-f9: #f9f9f9;
|
--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,
|
html,
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { ApiResponse, PagedResponse } from '~/shared/types/api';
|
import type { ApiResponse, PagedResponse } from '~/shared/types/api';
|
||||||
import type {
|
import type {
|
||||||
|
AerialData,
|
||||||
AerialDetail,
|
AerialDetail,
|
||||||
AerialItem,
|
AerialItem,
|
||||||
AerialListParams,
|
AerialListParams,
|
||||||
ChunkUploadParams,
|
|
||||||
ChunkUploadResponse,
|
|
||||||
FolderListResponse,
|
|
||||||
Region,
|
|
||||||
} from '../types/aerial';
|
} 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 {
|
try {
|
||||||
const response = await axios.get<ApiResponse<Region[]>>('/api/imagery/regions/provinces');
|
const response = await axios.post('/api/imagery/aerial', data);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('fetchRegions error:', error);
|
console.error('registerAerialData error:', error);
|
||||||
throw 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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,12 +3,14 @@ import { Button } from '~/shared/components/button/Button';
|
|||||||
import { Section } from '~/shared/components/section/Section';
|
import { Section } from '~/shared/components/section/Section';
|
||||||
import { Table } from '~/shared/components/table';
|
import { Table } from '~/shared/components/table';
|
||||||
import type { AerialItem } from '../types/aerial';
|
import type { AerialItem } from '../types/aerial';
|
||||||
|
import { AerialRegisterModal } from './AerialRegisterModal';
|
||||||
|
|
||||||
export function AerialList() {
|
export function AerialList() {
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [data, setData] = useState<AerialItem[]>([]);
|
const [data, setData] = useState<AerialItem[]>([]);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [isRegisterModalOpen, setIsRegisterModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -37,74 +39,76 @@ export function AerialList() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="w-full pb-4" variant="list">
|
<>
|
||||||
<div className="flex flex-col h-full p-4 gap-4">
|
<Section className="w-full pb-4" variant="list">
|
||||||
<h1 className="text-xl font-bold">항공영상관리</h1>
|
<div className="flex flex-col h-full p-4 gap-4">
|
||||||
|
<h1 className="text-xl font-bold">항공영상관리</h1>
|
||||||
|
|
||||||
<Table isLayoutFixed isFullHeight>
|
<Table isLayoutFixed isFullHeight>
|
||||||
<Table.Caption>
|
<Table.Caption>
|
||||||
<Table.CaptionLeft>
|
<Table.CaptionLeft>
|
||||||
<Table.Total count={totalCount} />
|
<Table.Total count={totalCount} />
|
||||||
</Table.CaptionLeft>
|
</Table.CaptionLeft>
|
||||||
<Table.CaptionRight>
|
<Table.CaptionRight>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button color="light" size="small">엑셀 다운로드</Button>
|
<Button color="primary" onClick={() => setIsRegisterModalOpen(true)}>항공영상 등록</Button>
|
||||||
<Button color="primary" size="small">영상 등록</Button>
|
</div>
|
||||||
</div>
|
</Table.CaptionRight>
|
||||||
</Table.CaptionRight>
|
</Table.Caption>
|
||||||
</Table.Caption>
|
|
||||||
|
|
||||||
<Table.Container>
|
<Table.Container>
|
||||||
<Table.Colgroup>
|
<Table.Colgroup>
|
||||||
<Table.Col width={60} />
|
<Table.Col width={60} />
|
||||||
<Table.Col width={180} />
|
<Table.Col width={180} />
|
||||||
<Table.Col width={200} />
|
<Table.Col width={200} />
|
||||||
<Table.Col width={120} />
|
<Table.Col width={120} />
|
||||||
<Table.Col width={100} />
|
<Table.Col width={100} />
|
||||||
<Table.Col width={120} />
|
<Table.Col width={120} />
|
||||||
<Table.Col width={80} />
|
<Table.Col width={80} />
|
||||||
</Table.Colgroup>
|
</Table.Colgroup>
|
||||||
|
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.HeaderRow>
|
<Table.HeaderRow>
|
||||||
<Table.HeaderCell align="center">No</Table.HeaderCell>
|
<Table.HeaderCell align="center">No</Table.HeaderCell>
|
||||||
<Table.HeaderCell>파일명</Table.HeaderCell>
|
<Table.HeaderCell>파일명</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="center">축적</Table.HeaderCell>
|
<Table.HeaderCell align="center">축적</Table.HeaderCell>
|
||||||
<Table.HeaderCell align="right">용량</Table.HeaderCell>
|
<Table.HeaderCell align="right">용량</Table.HeaderCell>
|
||||||
<Table.HeaderCell align="center">전처리</Table.HeaderCell>
|
<Table.HeaderCell align="center">전처리</Table.HeaderCell>
|
||||||
</Table.HeaderRow>
|
</Table.HeaderRow>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
|
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Table.Loading colSpan={7} />
|
<Table.Loading colSpan={7} />
|
||||||
) : data.length === 0 ? (
|
) : data.length === 0 ? (
|
||||||
<Table.Empty colSpan={7}>등록된 항공영상이 없습니다.</Table.Empty>
|
<Table.Empty colSpan={7}>등록된 항공영상이 없습니다.</Table.Empty>
|
||||||
) : (
|
) : (
|
||||||
data.map((item, index) => (
|
data.map((item, index) => (
|
||||||
<Table.Row
|
<Table.Row
|
||||||
key={item.mapId}
|
key={item.mapId}
|
||||||
isSelected={selectedId === item.mapId}
|
isSelected={selectedId === item.mapId}
|
||||||
onClick={() => setSelectedId(item.mapId)}
|
onClick={() => setSelectedId(item.mapId)}
|
||||||
>
|
>
|
||||||
<Table.Cell align="center">{index + 1}</Table.Cell>
|
<Table.Cell align="center">{index + 1}</Table.Cell>
|
||||||
<Table.Cell>{item.fileName}</Table.Cell>
|
<Table.Cell>{item.fileName}</Table.Cell>
|
||||||
<Table.Cell>{item.region}</Table.Cell>
|
<Table.Cell>{item.region}</Table.Cell>
|
||||||
<Table.Cell align="center">{item.capturedDttm}</Table.Cell>
|
<Table.Cell align="center">{item.capturedDttm}</Table.Cell>
|
||||||
<Table.Cell align="center">{item.scale}</Table.Cell>
|
<Table.Cell align="center">{item.scale}</Table.Cell>
|
||||||
<Table.Cell align="right">{item.fileSize}</Table.Cell>
|
<Table.Cell align="right">{item.fileSize}</Table.Cell>
|
||||||
<Table.Cell align="center">
|
<Table.Cell align="center">
|
||||||
{item.status}
|
{item.status}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Container>
|
</Table.Container>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,47 +25,6 @@ export type AerialDetail = {
|
|||||||
longitude: number;
|
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 = {
|
export type AerialListParams = {
|
||||||
dateRangeType: 'capturedDttm' | 'createdDttm';
|
dateRangeType: 'capturedDttm' | 'createdDttm';
|
||||||
@@ -74,3 +33,14 @@ export type AerialListParams = {
|
|||||||
page?: number;
|
page?: number;
|
||||||
size?: 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;
|
||||||
|
};
|
||||||
@@ -19,21 +19,21 @@ const button = tv({
|
|||||||
large: 'min-w-30 h-13 px-5 text-sm font-semibold',
|
large: 'min-w-30 h-13 px-5 text-sm font-semibold',
|
||||||
},
|
},
|
||||||
color: {
|
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:
|
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:
|
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:
|
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:
|
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',
|
'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-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',
|
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:
|
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',
|
'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-kc-navy-main border-kc-navy-main disabled:bg-kc-gray-be disabled:border-kc-gray-be ring-kc-navy-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:
|
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: {
|
isDisabled: {
|
||||||
true: 'cursor-not-allowed',
|
true: 'cursor-not-allowed',
|
||||||
@@ -62,48 +62,48 @@ const button = tv({
|
|||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
className:
|
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',
|
color: 'lightGreen',
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
className:
|
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',
|
color: 'black',
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
isPending: 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',
|
color: 'gray',
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
className:
|
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',
|
color: 'orange',
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
className:
|
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',
|
color: 'navy',
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
className:
|
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',
|
color: 'lightNavy',
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
className:
|
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',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ const cellStyles = tv({
|
|||||||
false: '',
|
false: '',
|
||||||
},
|
},
|
||||||
isDisabled: {
|
isDisabled: {
|
||||||
true: 'cursor-default text-kc-gray-be',
|
true: 'cursor-default text-dabeeo-gray-be',
|
||||||
},
|
},
|
||||||
isUnavailable: {
|
isUnavailable: {
|
||||||
true: 'cursor-default text-kc-gray-be line-through',
|
true: 'cursor-default text-dabeeo-gray-be line-through',
|
||||||
},
|
},
|
||||||
isOutsideMonth: {
|
isOutsideMonth: {
|
||||||
true: 'text-kc-gray-be',
|
true: 'text-dabeeo-gray-be',
|
||||||
},
|
},
|
||||||
isSunday: {
|
isSunday: {
|
||||||
true: 'text-[#e48686]',
|
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',
|
base: 'flex h-7 w-7 items-center justify-center rounded-sm border-none bg-transparent outline-none',
|
||||||
variants: {
|
variants: {
|
||||||
isDisabled: {
|
isDisabled: {
|
||||||
true: 'cursor-default text-kc-gray-be',
|
true: 'cursor-default text-dabeeo-gray-be',
|
||||||
false: 'cursor-pointer text-kc-black-34 hover:bg-kc-gray-eb',
|
false: 'cursor-pointer text-dabeeo-black-34 hover:bg-dabeeo-gray-eb',
|
||||||
},
|
},
|
||||||
isFocusVisible: {
|
isFocusVisible: {
|
||||||
true: 'ring-2 ring-offset-2 ring-primary',
|
true: 'ring-2 ring-offset-2 ring-primary',
|
||||||
@@ -161,7 +161,7 @@ export function CalendarHeader() {
|
|||||||
<AriaButton slot="previous" className={(renderProps) => navButton(renderProps)}>
|
<AriaButton slot="previous" className={(renderProps) => navButton(renderProps)}>
|
||||||
<ChevronLeftIcon className="h-4 w-4" />
|
<ChevronLeftIcon className="h-4 w-4" />
|
||||||
</AriaButton>
|
</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)}>
|
<AriaButton slot="next" className={(renderProps) => navButton(renderProps)}>
|
||||||
<ChevronRightIcon className="h-4 w-4" />
|
<ChevronRightIcon className="h-4 w-4" />
|
||||||
</AriaButton>
|
</AriaButton>
|
||||||
@@ -173,7 +173,7 @@ export function CalendarGridHeader() {
|
|||||||
return (
|
return (
|
||||||
<AriaCalendarGridHeader>
|
<AriaCalendarGridHeader>
|
||||||
{(day) => (
|
{(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}
|
{day}
|
||||||
</CalendarHeaderCell>
|
</CalendarHeaderCell>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ const rangeCell = tv({
|
|||||||
cap: 'bg-primary font-bold text-white',
|
cap: 'bg-primary font-bold text-white',
|
||||||
},
|
},
|
||||||
isDisabled: {
|
isDisabled: {
|
||||||
true: 'text-kc-gray-be',
|
true: 'text-dabeeo-gray-be',
|
||||||
},
|
},
|
||||||
isOutsideMonth: {
|
isOutsideMonth: {
|
||||||
true: 'text-kc-gray-be',
|
true: 'text-dabeeo-gray-be',
|
||||||
},
|
},
|
||||||
isSunday: {
|
isSunday: {
|
||||||
true: 'text-[#e48686]',
|
true: 'text-[#e48686]',
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ import { calendarDateToDate, dateToCalendarDate } from '../calendar/utils';
|
|||||||
import { CalendarIcon } from '../icons';
|
import { CalendarIcon } from '../icons';
|
||||||
|
|
||||||
const trigger = tv({
|
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: {
|
variants: {
|
||||||
isHovered: {
|
isHovered: {
|
||||||
true: 'border-kc-black-34',
|
true: 'border-dabeeo-black-34',
|
||||||
},
|
},
|
||||||
isFocused: {
|
isFocused: {
|
||||||
true: 'border-kc-black-34',
|
true: 'border-dabeeo-black-34',
|
||||||
},
|
},
|
||||||
isDisabled: {
|
isDisabled: {
|
||||||
true: 'cursor-default border-kc-gray-99 bg-kc-gray-eb',
|
true: 'cursor-default border-dabeeo-gray-99 bg-dabeeo-gray-eb',
|
||||||
},
|
},
|
||||||
isFocusVisible: {
|
isFocusVisible: {
|
||||||
true: 'ring-2 ring-offset-2 ring-primary',
|
true: 'ring-2 ring-offset-2 ring-primary',
|
||||||
@@ -51,21 +51,21 @@ export function DatePicker({ value, onChange, maxValue, minValue, isDisabled, cl
|
|||||||
>
|
>
|
||||||
<Group>
|
<Group>
|
||||||
<Button className={(renderProps) => trigger(renderProps)}>
|
<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'}
|
{value ? dayjs(value).format('YYYY.MM.DD') : 'YYYY.MM.DD'}
|
||||||
</span>
|
</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" />
|
<CalendarIcon className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</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">
|
<OverlayArrow className="group/arrow">
|
||||||
<svg
|
<svg
|
||||||
width="12"
|
width="12"
|
||||||
height="8"
|
height="8"
|
||||||
viewBox="0 0 12 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" />
|
<path d="M0 0 L6 8 L12 0" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -16,16 +16,16 @@ import { RangeCalendar } from '../calendar/RangeCalendar';
|
|||||||
import { calendarDateToDate, dateToCalendarDate } from '../calendar/utils';
|
import { calendarDateToDate, dateToCalendarDate } from '../calendar/utils';
|
||||||
|
|
||||||
const trigger = tv({
|
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: {
|
variants: {
|
||||||
isHovered: {
|
isHovered: {
|
||||||
true: 'border-kc-black-34',
|
true: 'border-dabeeo-black-34',
|
||||||
},
|
},
|
||||||
isFocused: {
|
isFocused: {
|
||||||
true: 'border-kc-black-34',
|
true: 'border-dabeeo-black-34',
|
||||||
},
|
},
|
||||||
isDisabled: {
|
isDisabled: {
|
||||||
true: 'cursor-default border-kc-gray-99 bg-kc-gray-eb',
|
true: 'cursor-default border-dabeeo-gray-99 bg-dabeeo-gray-eb',
|
||||||
},
|
},
|
||||||
isFocusVisible: {
|
isFocusVisible: {
|
||||||
true: 'ring-2 ring-offset-2 ring-primary',
|
true: 'ring-2 ring-offset-2 ring-primary',
|
||||||
@@ -81,25 +81,25 @@ export function DateRangePicker({
|
|||||||
>
|
>
|
||||||
<Group>
|
<Group>
|
||||||
<Button className={(renderProps) => trigger(renderProps)}>
|
<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'}
|
{value?.start ? dayjs(value.start).format('YYYY. MM. DD') : 'YYYY.MM.DD'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-kc-gray-99">~</span>
|
<span className="text-sm text-dabeeo-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={`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'}
|
{value?.end ? dayjs(value.end).format('YYYY. MM. DD') : 'YYYY.MM.DD'}
|
||||||
</span>
|
</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" />
|
<CalendarIcon className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</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">
|
<OverlayArrow className="group/arrow">
|
||||||
<svg
|
<svg
|
||||||
width="12"
|
width="12"
|
||||||
height="8"
|
height="8"
|
||||||
viewBox="0 0 12 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" />
|
<path d="M0 0 L6 8 L12 0" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
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-kc-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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -16,18 +16,18 @@ const style = tv({
|
|||||||
'flex',
|
'flex',
|
||||||
'items-center',
|
'items-center',
|
||||||
'text-sm',
|
'text-sm',
|
||||||
'text-kc-black-34',
|
'text-dabeeo-black-34',
|
||||||
'leading-[18px]',
|
'leading-[18px]',
|
||||||
'border',
|
'border',
|
||||||
'border-kc-gray-be',
|
'border-dabeeo-gray-be',
|
||||||
'has-data-focused:border-kc-black-34',
|
'has-data-focused:border-dabeeo-black-34',
|
||||||
'has-data-hovered:border-kc-black-34',
|
'has-data-hovered:border-dabeeo-black-34',
|
||||||
'has-data-disabled:bg-kc-gray-eb',
|
'has-data-disabled:bg-dabeeo-gray-eb',
|
||||||
'has-data-disabled:text-kc-gray-99',
|
'has-data-disabled:text-dabeeo-gray-99',
|
||||||
'has-data-disabled:border-kc-gray-be',
|
'has-data-disabled:border-dabeeo-gray-be',
|
||||||
'has-data-invalid:border-kc-red',
|
'has-data-invalid:border-dabeeo-red',
|
||||||
'has-data-invalid:has-data-focused:border-kc-red',
|
'has-data-invalid:has-data-focused:border-dabeeo-red',
|
||||||
'has-data-invalid:has-data-hovered:border-kc-red',
|
'has-data-invalid:has-data-hovered:border-dabeeo-red',
|
||||||
'w-full',
|
'w-full',
|
||||||
'has-[[type=number]]:leading-6',
|
'has-[[type=number]]:leading-6',
|
||||||
],
|
],
|
||||||
@@ -36,7 +36,7 @@ const style = tv({
|
|||||||
variants: {
|
variants: {
|
||||||
isReadOnly: {
|
isReadOnly: {
|
||||||
true: {
|
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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 };
|
||||||
|
};
|
||||||
@@ -49,7 +49,7 @@ export const Pagination = ({ totalPages, currentPage, pageCount = 10, onPageChan
|
|||||||
const noNext = start + pageCount >= totalPages;
|
const noNext = start + pageCount >= totalPages;
|
||||||
|
|
||||||
return (
|
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">
|
<ul className="flex items-center justify-center list-none">
|
||||||
<li className="flex">
|
<li className="flex">
|
||||||
<AriaButton
|
<AriaButton
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const variantClassMap: Record<SectionVariant, string> = {
|
|||||||
base: 'flex-center w-full bg-white shadow-card p-6',
|
base: 'flex-center w-full bg-white shadow-card p-6',
|
||||||
card: 'flex-center 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',
|
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 = {
|
export type SectionProps = {
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
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';
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import eslint from '@eslint/js';
|
import eslint from '@eslint/js';
|
||||||
import tseslint from 'typescript-eslint';
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks';
|
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';
|
import stylistic from '@stylistic/eslint-plugin';
|
||||||
|
|
||||||
@@ -19,10 +20,28 @@ export default tseslint.config(
|
|||||||
}),
|
}),
|
||||||
reactHooks.configs.flat.recommended,
|
reactHooks.configs.flat.recommended,
|
||||||
{
|
{
|
||||||
|
plugins: {
|
||||||
|
'unused-imports': unusedImports,
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'@stylistic/jsx-one-expression-per-line': 'off',
|
'@stylistic/jsx-one-expression-per-line': 'off',
|
||||||
'@stylistic/multiline-ternary': 'off',
|
'@stylistic/multiline-ternary': 'off',
|
||||||
'react-hooks/set-state-in-effect': '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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-router/node": "7.14.0",
|
"@react-router/node": "7.14.0",
|
||||||
"@react-router/serve": "7.14.0",
|
"@react-router/serve": "7.14.0",
|
||||||
|
"axios": "^1.15.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"isbot": "^5.1.37",
|
"isbot": "^5.1.37",
|
||||||
"ol": "^10.8.0",
|
"ol": "^10.8.0",
|
||||||
@@ -22,6 +24,7 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-hook-form": "^7.72.1",
|
"react-hook-form": "^7.72.1",
|
||||||
"react-router": "7.14.0",
|
"react-router": "7.14.0",
|
||||||
|
"react-stately": "^3.45.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwind-variants": "^3.2.2",
|
"tailwind-variants": "^3.2.2",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
@@ -36,6 +39,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"eslint": "^10.2.0",
|
"eslint": "^10.2.0",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-unused-imports": "^4.4.1",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.58.0",
|
"typescript-eslint": "^8.58.0",
|
||||||
|
|||||||
105
web-app/pnpm-lock.yaml
generated
105
web-app/pnpm-lock.yaml
generated
@@ -14,6 +14,12 @@ importers:
|
|||||||
'@react-router/serve':
|
'@react-router/serve':
|
||||||
specifier: 7.14.0
|
specifier: 7.14.0
|
||||||
version: 7.14.0(react-router@7.14.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
|
version: 7.14.0(react-router@7.14.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
|
||||||
|
axios:
|
||||||
|
specifier: ^1.15.0
|
||||||
|
version: 1.15.0
|
||||||
|
clsx:
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.1
|
||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.20
|
specifier: ^1.11.20
|
||||||
version: 1.11.20
|
version: 1.11.20
|
||||||
@@ -41,6 +47,9 @@ importers:
|
|||||||
react-router:
|
react-router:
|
||||||
specifier: 7.14.0
|
specifier: 7.14.0
|
||||||
version: 7.14.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 7.14.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
react-stately:
|
||||||
|
specifier: ^3.45.0
|
||||||
|
version: 3.45.0(react@19.2.4)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.5.0
|
specifier: ^3.5.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
@@ -78,6 +87,9 @@ importers:
|
|||||||
eslint-plugin-react-hooks:
|
eslint-plugin-react-hooks:
|
||||||
specifier: ^7.0.1
|
specifier: ^7.0.1
|
||||||
version: 7.0.1(eslint@10.2.0(jiti@2.6.1))
|
version: 7.0.1(eslint@10.2.0(jiti@2.6.1))
|
||||||
|
eslint-plugin-unused-imports:
|
||||||
|
specifier: ^4.4.1
|
||||||
|
version: 4.4.1(@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1))
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.2.2
|
specifier: ^4.2.2
|
||||||
version: 4.2.2
|
version: 4.2.2
|
||||||
@@ -1568,6 +1580,12 @@ packages:
|
|||||||
array-flatten@1.1.1:
|
array-flatten@1.1.1:
|
||||||
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
|
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
|
||||||
|
|
||||||
|
asynckit@0.4.0:
|
||||||
|
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||||
|
|
||||||
|
axios@1.15.0:
|
||||||
|
resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==}
|
||||||
|
|
||||||
babel-dead-code-elimination@1.0.12:
|
babel-dead-code-elimination@1.0.12:
|
||||||
resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
|
resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
|
||||||
|
|
||||||
@@ -1630,6 +1648,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
combined-stream@1.0.8:
|
||||||
|
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
compressible@2.0.18:
|
compressible@2.0.18:
|
||||||
resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==}
|
resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -1704,6 +1726,10 @@ packages:
|
|||||||
deep-is@0.1.4:
|
deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0:
|
||||||
|
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||||
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
depd@2.0.0:
|
depd@2.0.0:
|
||||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -1752,6 +1778,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-set-tostringtag@2.1.0:
|
||||||
|
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
esbuild@0.27.7:
|
esbuild@0.27.7:
|
||||||
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
|
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1774,6 +1804,15 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
|
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
|
||||||
|
|
||||||
|
eslint-plugin-unused-imports@4.4.1:
|
||||||
|
resolution: {integrity: sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@typescript-eslint/eslint-plugin': ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0
|
||||||
|
eslint: ^10.0.0 || ^9.0.0 || ^8.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@typescript-eslint/eslint-plugin':
|
||||||
|
optional: true
|
||||||
|
|
||||||
eslint-scope@9.1.2:
|
eslint-scope@9.1.2:
|
||||||
resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==}
|
resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==}
|
||||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||||
@@ -1879,6 +1918,19 @@ packages:
|
|||||||
flatted@3.4.2:
|
flatted@3.4.2:
|
||||||
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
||||||
|
|
||||||
|
follow-redirects@1.15.11:
|
||||||
|
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
|
||||||
|
engines: {node: '>=4.0'}
|
||||||
|
peerDependencies:
|
||||||
|
debug: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
debug:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
form-data@4.0.5:
|
||||||
|
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
forwarded@0.2.0:
|
forwarded@0.2.0:
|
||||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -1933,6 +1985,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
has-tostringtag@1.0.2:
|
||||||
|
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
hasown@2.0.2:
|
hasown@2.0.2:
|
||||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2274,6 +2330,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
|
proxy-from-env@2.1.0:
|
||||||
|
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
punycode@2.3.1:
|
punycode@2.3.1:
|
||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -4555,6 +4615,16 @@ snapshots:
|
|||||||
|
|
||||||
array-flatten@1.1.1: {}
|
array-flatten@1.1.1: {}
|
||||||
|
|
||||||
|
asynckit@0.4.0: {}
|
||||||
|
|
||||||
|
axios@1.15.0:
|
||||||
|
dependencies:
|
||||||
|
follow-redirects: 1.15.11
|
||||||
|
form-data: 4.0.5
|
||||||
|
proxy-from-env: 2.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
babel-dead-code-elimination@1.0.12:
|
babel-dead-code-elimination@1.0.12:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
@@ -4627,6 +4697,10 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
combined-stream@1.0.8:
|
||||||
|
dependencies:
|
||||||
|
delayed-stream: 1.0.0
|
||||||
|
|
||||||
compressible@2.0.18:
|
compressible@2.0.18:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-db: 1.54.0
|
mime-db: 1.54.0
|
||||||
@@ -4683,6 +4757,8 @@ snapshots:
|
|||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0: {}
|
||||||
|
|
||||||
depd@2.0.0: {}
|
depd@2.0.0: {}
|
||||||
|
|
||||||
destroy@1.2.0: {}
|
destroy@1.2.0: {}
|
||||||
@@ -4718,6 +4794,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
|
|
||||||
|
es-set-tostringtag@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
es-errors: 1.3.0
|
||||||
|
get-intrinsic: 1.3.0
|
||||||
|
has-tostringtag: 1.0.2
|
||||||
|
hasown: 2.0.2
|
||||||
|
|
||||||
esbuild@0.27.7:
|
esbuild@0.27.7:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/aix-ppc64': 0.27.7
|
'@esbuild/aix-ppc64': 0.27.7
|
||||||
@@ -4764,6 +4847,12 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1)):
|
||||||
|
dependencies:
|
||||||
|
eslint: 10.2.0(jiti@2.6.1)
|
||||||
|
optionalDependencies:
|
||||||
|
'@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3)
|
||||||
|
|
||||||
eslint-scope@9.1.2:
|
eslint-scope@9.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/esrecurse': 4.3.1
|
'@types/esrecurse': 4.3.1
|
||||||
@@ -4920,6 +5009,16 @@ snapshots:
|
|||||||
|
|
||||||
flatted@3.4.2: {}
|
flatted@3.4.2: {}
|
||||||
|
|
||||||
|
follow-redirects@1.15.11: {}
|
||||||
|
|
||||||
|
form-data@4.0.5:
|
||||||
|
dependencies:
|
||||||
|
asynckit: 0.4.0
|
||||||
|
combined-stream: 1.0.8
|
||||||
|
es-set-tostringtag: 2.1.0
|
||||||
|
hasown: 2.0.2
|
||||||
|
mime-types: 2.1.35
|
||||||
|
|
||||||
forwarded@0.2.0: {}
|
forwarded@0.2.0: {}
|
||||||
|
|
||||||
fresh@0.5.2: {}
|
fresh@0.5.2: {}
|
||||||
@@ -4977,6 +5076,10 @@ snapshots:
|
|||||||
|
|
||||||
has-symbols@1.1.0: {}
|
has-symbols@1.1.0: {}
|
||||||
|
|
||||||
|
has-tostringtag@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
has-symbols: 1.1.0
|
||||||
|
|
||||||
hasown@2.0.2:
|
hasown@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
@@ -5253,6 +5356,8 @@ snapshots:
|
|||||||
forwarded: 0.2.0
|
forwarded: 0.2.0
|
||||||
ipaddr.js: 1.9.1
|
ipaddr.js: 1.9.1
|
||||||
|
|
||||||
|
proxy-from-env@2.1.0: {}
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
qs@6.14.2:
|
qs@6.14.2:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"~/*": ["./app/*"]
|
"~/*": ["./app/*"]
|
||||||
},
|
},
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": false,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user