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 13896ee6..9f7f9858 100644 --- a/src/components/common/FileDropzone.tsx +++ b/src/components/common/FileDropzone.tsx @@ -1,5 +1,7 @@ import { useRef, useState } from 'react'; +import clsx from 'clsx'; + import UploadIcon from '@/assets/icons/icon-upload.svg?react'; import type { FileDropProps } from '@/types/uploadFile'; @@ -13,6 +15,8 @@ export default function FileDropzone({ progress = 0, }: FileDropProps) { const inputRef = useRef(null); + // dragCounter : 실제로 영역을 완전히 벗어났을 때만 카운터를 false로 바꿈 + const dragCounter = useRef(0); const [isDragging, setIsDragging] = useState(false); const openFileDialog = () => { @@ -27,21 +31,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); }; @@ -64,27 +81,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..7880a712 --- /dev/null +++ b/src/components/common/ListView.tsx @@ -0,0 +1,89 @@ +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, + onItemClick, + className, + itemClassName, + empty, + ariaLabel, +}: ListViewProps) { + if (items.length === 0) { + return
{empty ?? 'No items'}
; + } + + const isClickable = Boolean(onItemClick); + + return ( +
+ {items.map((item, index) => { + const key = getKey(item, index); + + return ( +
onItemClick?.(item) : undefined} + onKeyDown={ + isClickable + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onItemClick?.(item); + } + } + : undefined + } + > + {renderLeading &&
{renderLeading(item)}
} + + {/*
+
{renderTitle(item)}
+ + {(renderUpdatedAt || renderMeta) && ( +
+ {renderUpdatedAt && ( + {renderUpdatedAt(item)} + )} + {renderMeta && {renderMeta(item)}} +
+ )} + + {renderStats &&
{renderStats(item)}
} +
*/} +
{renderInfo(item)}
+ + {renderTrailing && ( +
{renderTrailing(item)}
+ )} +
+ ); + })} +
+ ); +} + +export default ListView; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index c532f2ef..c9f8d97a 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/ProjectsSection.tsx b/src/components/home/ProjectsSection.tsx index b2e8b99c..768be1f5 100644 --- a/src/components/home/ProjectsSection.tsx +++ b/src/components/home/ProjectsSection.tsx @@ -1,19 +1,32 @@ -import type { CardItems } from '@/types/project'; +import type { ViewMode } from '@/types/home'; +import type { ProjectItem } 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; query: string; onChangeQuery: (value: string) => void; - projects: CardItems[]; + projects: ProjectItem[]; + viewMode: ViewMode; + onChangeViewMode: (viewMode: ViewMode) => void; }; -export default function ProjectsSection({ isLoading, query, onChangeQuery, projects }: Props) { +export default function ProjectsSection({ + isLoading, + query, + onChangeQuery, + projects, + viewMode, + onChangeViewMode, +}: Props) { const hasProjects = projects.length > 0; if (!isLoading && !hasProjects) return null; @@ -26,16 +39,41 @@ export default function ProjectsSection({ isLoading, query, onChangeQuery, proje
{/* 검색 */} - + - {/* 프레젠테이션 목록 */} -
- {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) => ( +
+ ))} +
+ ) : ( + + )} + {/* 프레젠테이션 목록 */} ); } diff --git a/src/components/projects/ProjectCard.tsx b/src/components/projects/ProjectCard.tsx index a7b97bb9..ea094e35 100644 --- a/src/components/projects/ProjectCard.tsx +++ b/src/components/projects/ProjectCard.tsx @@ -5,28 +5,31 @@ 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 type { CardItems } from '@/types/project'; +import type { ProjectItem } from '@/types/project'; import { Popover } from '../common'; -export default function ProjectCard({ - title, - updatedAt, - pageCount, - commentCount, - reactionCount, - viewCount = 0, - thumbnailUrl, -}: CardItems) { +type ProjectCardProps = { + item: ProjectItem; + className?: string; +}; + +export default function ProjectCard({ item, className }: ProjectCardProps) { + const { + title, + updatedAt, + pageCount, + commentCount, + reactionCount, + viewCount = 0, + thumbnailUrl, + } = item; + return ( -
+
{thumbnailUrl && ( - {`${title}`} + {title} )}
diff --git a/src/components/projects/ProjectHeader.tsx b/src/components/projects/ProjectHeader.tsx index 3e091fb1..1c62d9b1 100644 --- a/src/components/projects/ProjectHeader.tsx +++ b/src/components/projects/ProjectHeader.tsx @@ -14,7 +14,15 @@ import { Dropdown } from '../common'; // ㄴ> onClick에 상태 업데이트 추가 //검색 + 우측 컨트롤 -export default function ProjectHeader({ value, onChange }: ProjectHeaderProps) { +export default function ProjectHeader({ + value, + onChange, + viewMode, + onChangeViewMode, +}: ProjectHeaderProps) { + const isCard = viewMode === 'card'; + const isList = viewMode === 'list'; + return (
{/* 검색 부분 */} @@ -83,10 +91,22 @@ export default function ProjectHeader({ value, onChange }: ProjectHeaderProps) { {/* 보기 방식 | 카드 or 리스트 */}
- -
diff --git a/src/components/projects/ProjectList.tsx b/src/components/projects/ProjectList.tsx new file mode 100644 index 00000000..ceba323f --- /dev/null +++ b/src/components/projects/ProjectList.tsx @@ -0,0 +1,95 @@ +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 type { ProjectItem } from '@/types/project'; + +import { ListView, Popover } from '../common'; + +type ProjectListProps = { + items: ProjectItem[]; + className?: string; + itemClassName?: string; +}; + +export default function ProjectList({ items, className, itemClassName }: ProjectListProps) { + return ( + item.id} + className={clsx('flex flex-col gap-2', className)} + itemClassName={clsx( + 'flex items-center gap-4 rounded-lg border border-gray-200 bg-white px-5 py-3', + itemClassName, + )} + // 프로젝트 슬라이드 이미지 + renderLeading={(item) => ( +
+ {item.thumbnailUrl && ( + {item.title} + )} +
+ )} + // 프로젝트 정보 (제목, 업데이트 날짜, 페이지 수, 반응 수) + renderInfo={(item) => ( +
+ {/* 제목 */} +
{item.title}
+ + {/* 날짜 | 페이지 수 + 댓글/이모티콘/조회 */} +
+
+ {item.updatedAt} + | + + + {item.pageCount} 페이지 + +
+ +
+ + + {item.commentCount} + + + + {item.reactionCount} + + + + {item.viewCount} + +
+
+
+ )} + renderTrailing={(item) => ( + ( + + )} + position="bottom" + align="end" + ariaLabel="더보기" + className="border border-gray-200 w-32 overflow-hidden" + > +
+ + +
+
+ )} + /> + ); +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 553baa53..dcca9124 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import IntroSection from '@/components/home/IntroSection'; import ProjectsSection from '@/components/home/ProjectsSection'; import { useDebounce } from '@/hooks/useDebounce'; -import { useHomeActions, useHomeQuery } from '@/hooks/useHomeSelectors'; +import { useHomeActions, useHomeQuery, useHomeViewMode } from '@/hooks/useHomeSelectors'; import { useProjectList } from '@/hooks/useProjectList'; import { useUpload } from '@/hooks/useUpload'; import { MOCK_PROJECTS } from '@/mocks/projects'; @@ -14,7 +14,8 @@ export default function HomePage() { const { progress, state, error, uploadFiles } = useUpload(); const [isLoading, setIsLoading] = useState(true); const query = useHomeQuery(); - const { setQuery } = useHomeActions(); + const viewMode = useHomeViewMode(); + const { setQuery, setViewMode } = useHomeActions(); const debouncedQuery = useDebounce(query, 300); // TODO : 나중에 mock_projects 말고 서버데이터로 바꿔주기.. @@ -46,6 +47,8 @@ export default function HomePage() { query={query} onChangeQuery={setQuery} projects={projects} + viewMode={viewMode} + onChangeViewMode={setViewMode} /> ); diff --git a/src/types/project.ts b/src/types/project.ts index fb85ba73..6d247665 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -1,10 +1,14 @@ +import type { ViewMode } from './home'; + export interface ProjectHeaderProps { value: string; onChange: (value: string) => void; + viewMode: ViewMode; + onChangeViewMode: (viewMode: ViewMode) => void; } // 프로젝트 전체의 코멘트 수, 리액션 수 => 슬라이드와 연결할 것.. -export interface CardItems { +export interface ProjectItem { id: string; title: string; updatedAt: string;