diff --git a/src/components/common/CardView.tsx b/src/components/common/CardView.tsx new file mode 100644 index 00000000..304ed74a --- /dev/null +++ b/src/components/common/CardView.tsx @@ -0,0 +1,43 @@ +import type { Key, ReactNode } from 'react'; + +import clsx from 'clsx'; + +export interface CardViewProps { + items: readonly T[]; + getKey: (item: T, index: number) => Key; + renderCard: (item: T) => ReactNode; + className?: string; + itemClassName?: string; + empty?: ReactNode; + ariaLabal?: string; +} + +export function CardView({ + items, + getKey, + renderCard, + className, + itemClassName, + empty, + ariaLabal, +}: CardViewProps) { + if (items.length === 0) { + return
{empty ?? 'No items'}
; + } + + return ( +
+ {items.map((item, index) => ( +
+ {renderCard(item)} +
+ ))} +
+ ); +} + +export default CardView; diff --git a/src/components/common/FileDropzone.tsx b/src/components/common/FileDropzone.tsx index b5823668..c78ac184 100644 --- a/src/components/common/FileDropzone.tsx +++ b/src/components/common/FileDropzone.tsx @@ -1,7 +1,10 @@ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; + +import clsx from 'clsx'; import UploadIcon from '@/assets/icons/icon-upload.svg?react'; import type { UploadState } from '@/types/uploadFile'; +import { showToast } from '@/utils/toast'; import ProgressBar from './ProgressBar'; @@ -11,6 +14,7 @@ interface FileDropProps { disabled?: boolean; uploadState?: UploadState; progress?: number; + error?: string | null; } export default function FileDropzone({ @@ -19,10 +23,18 @@ export default function FileDropzone({ disabled, uploadState = 'idle', progress = 0, + error, }: FileDropProps) { const inputRef = useRef(null); + // dragCounter : 실제로 영역을 완전히 벗어났을 때만 카운터를 false로 바꿈 + const dragCounter = useRef(0); const [isDragging, setIsDragging] = useState(false); + useEffect(() => { + if (!error) return; + showToast.warning('업로드에 실패했어요.', error); + }, [error]); + const openFileDialog = () => { if (disabled) return; inputRef.current?.click(); @@ -35,21 +47,34 @@ export default function FileDropzone({ onFilesSelected(files); }; - const handleDragEnterOrOver = (e: React.DragEvent) => { + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (disabled) return; + // 드래그가 영역 안에 있는 동안 지속적으로 true 유지 + dragCounter.current += 1; + setIsDragging(true); + }; + + const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - if (!disabled) setIsDragging(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - setIsDragging(false); + if (disabled) return; + // 자식 요소 진입/이탈에서 발생하는 잦은 leave 이벤트로 하이라이트가 꺼지는 현상을 방지 + dragCounter.current = Math.max(0, dragCounter.current - 1); + if (dragCounter.current === 0) setIsDragging(false); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); + // 드롭 시 카운터 초기화해서 다음 드래그 상태가 꼬이지 않도록 함 + dragCounter.current = 0; setIsDragging(false); handleFiles(e.dataTransfer.files); }; @@ -72,27 +97,24 @@ export default function FileDropzone({ type="button" onClick={openFileDialog} disabled={disabled} - onDragEnter={handleDragEnterOrOver} - onDragOver={handleDragEnterOrOver} + onDragEnter={handleDragEnter} + onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} - className={[ + className={clsx( 'group relative w-full overflow-hidden rounded-2xl border bg-white px-8 py-14 shadow-sm transition focus:ring-1 focus:ring-gray-200', disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-gray-100', showDragOverlay ? 'border-gray-900 ring-1 ring-gray-200' : 'border-gray-200', - ].join(' ')} + )} > {/* 드래그/업로드 중이면 블러/흐리게 */}
-
+
diff --git a/src/components/common/ListView.tsx b/src/components/common/ListView.tsx new file mode 100644 index 00000000..20d0eca6 --- /dev/null +++ b/src/components/common/ListView.tsx @@ -0,0 +1,54 @@ +import type { Key, ReactNode } from 'react'; + +import clsx from 'clsx'; + +export interface ListViewProps { + items: readonly T[]; + getKey: (item: T, index: number) => Key; + renderLeading?: (item: T) => ReactNode; + renderTrailing?: (item: T) => ReactNode; + renderInfo: (item: T) => ReactNode; + onItemClick?: (item: T) => void; + className?: string; + itemClassName?: string; + empty?: ReactNode; + ariaLabel?: string; +} + +export function ListView({ + items, + getKey, + renderLeading, + renderTrailing, + renderInfo, + className, + itemClassName, + empty, + ariaLabel, +}: ListViewProps) { + if (items.length === 0) { + return
{empty ?? 'No items'}
; + } + + return ( +
+ {items.map((item, index) => { + const key = getKey(item, index); + + return ( +
+ {renderLeading &&
{renderLeading(item)}
} + +
{renderInfo(item)}
+ + {renderTrailing && ( +
{renderTrailing(item)}
+ )} +
+ ); + })} +
+ ); +} + +export default ListView; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 2b5c40c6..38bcbb8b 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -16,3 +16,5 @@ export { default as SlideImage } from './SlideImage'; export { Spinner } from './Spinner'; export { default as FileDropzone } from './FileDropzone'; export { default as ProgressBar } from './ProgressBar'; +export { default as ListView } from './ListView'; +export { default as CardView } from './CardView'; diff --git a/src/components/home/IntroSection.tsx b/src/components/home/IntroSection.tsx index e63e88d6..8743e6a2 100644 --- a/src/components/home/IntroSection.tsx +++ b/src/components/home/IntroSection.tsx @@ -45,6 +45,7 @@ export default function IntroSection({ uploadState={uploadState} progress={progress} onFilesSelected={onFilesSelected} + error={error} /> {error &&

업로드 실패: {error}

} diff --git a/src/components/home/ProjectsSection.tsx b/src/components/home/ProjectsSection.tsx index c832dfb3..e177dd28 100644 --- a/src/components/home/ProjectsSection.tsx +++ b/src/components/home/ProjectsSection.tsx @@ -1,11 +1,14 @@ import type { SortMode, ViewMode } from '@/types/home'; import type { Project } from '@/types/project'; +import { CardView, ListView } from '../common'; import ProjectCard from '../projects/ProjectCard'; import { ProjectCardSkeleton } from '../projects/ProjectCardSkeleton'; import ProjectHeader from '../projects/ProjectHeader'; +import ProjectList from '../projects/ProjectList'; const SKELETON_CARD_COUNT = 9; +const SKELETON_LIST_COUNT = 6; type Props = { isLoading: boolean; @@ -46,14 +49,40 @@ export default function ProjectsSection({ onChangeViewMode={onChangeViewMode} /> - {/* 프레젠테이션 목록 */} -
- {isLoading - ? Array.from({ length: SKELETON_CARD_COUNT }).map((_, index) => ( + {viewMode === 'card' ? ( + isLoading ? ( +
+ {Array.from({ length: SKELETON_CARD_COUNT }).map((_, index) => ( - )) - : projects.map((project) => )} -
+ ))} +
+ ) : ( + item.id} + className="mt-6 grid grid-cols-2 gap-4 lg:grid-cols-3" + renderCard={(item) => } + /> + ) + ) : isLoading ? ( +
+ {Array.from({ length: SKELETON_LIST_COUNT }).map((_, index) => ( + // TODO + // ㄴ ProjectListSkeleton도 따로? +
+ ))} +
+ ) : ( + item.id} + className="mt-6 flex flex-col gap-3" + renderInfo={(item) => } + /> + )} ); } diff --git a/src/components/projects/ProjectCard.tsx b/src/components/projects/ProjectCard.tsx index 4f5e663e..c9f1152e 100644 --- a/src/components/projects/ProjectCard.tsx +++ b/src/components/projects/ProjectCard.tsx @@ -89,18 +89,18 @@ export default function ProjectCard({
{/* 반응 모음 */} -
-
+
+
- {commentCount} + {commentCount}
-
+
- {reactionCount} + {reactionCount}
-
+
- {viewCount} + {viewCount}
diff --git a/src/components/projects/ProjectList.tsx b/src/components/projects/ProjectList.tsx new file mode 100644 index 00000000..be1b0c33 --- /dev/null +++ b/src/components/projects/ProjectList.tsx @@ -0,0 +1,110 @@ +import { useNavigate } from 'react-router-dom'; + +import clsx from 'clsx'; + +import CommentCountIcon from '@/assets/icons/icon-comment-count.svg?react'; +import MoreIcon from '@/assets/icons/icon-more.svg?react'; +import PageCountIcon from '@/assets/icons/icon-page-count.svg?react'; +import ReactionCountIcon from '@/assets/icons/icon-reaction-count.svg?react'; +import ViewCountIcon from '@/assets/icons/icon-view-count.svg?react'; +import { getTabPath } from '@/constants/navigation'; +import type { Project } from '@/types/project'; +import { formatRelativeTime } from '@/utils/format'; + +import { Dropdown, type DropdownItem } from '../common/Dropdown'; + +export default function ProjectList({ + id, + title, + updatedAt, + pageCount, + commentCount, + reactionCount, + viewCount = 0, + thumbnailUrl, +}: Project) { + const navigate = useNavigate(); + + const dropdownItems: DropdownItem[] = [ + { + id: 'rename', + label: '이름 변경', + onClick: () => { + // TODO: 이름 변경 로직 구현 + }, + }, + { + id: 'delete', + label: '삭제', + variant: 'danger', + onClick: () => { + // TODO: 삭제 로직 구현 + }, + }, + ]; + + const handleListClick = () => { + navigate(getTabPath(id, 'slide')); + }; + + return ( +
+ {/* 썸네일 */} +
+ {thumbnailUrl && ( + {title} + )} +
+ + {/* 본문 */} +
+ {/* 제목 */} +
{title}
+ + {/* 날짜 | 페이지 수 + 댓글/이모티콘/조회 */} +
+
+ {formatRelativeTime(updatedAt)} + | + + + {pageCount} 페이지 + +
+ +
+ + + {commentCount} + + + + {reactionCount} + + + + {viewCount} + +
+
+
+ + {/* 더보기 */} +
e.stopPropagation()}> + ( + + )} + items={dropdownItems} + position="bottom" + align="end" + ariaLabel="더보기" + menuClassName="w-32" + /> +
+
+ ); +}