From 7a7ff5a0c7aab3991ce0554edb9c18dc2c8a83a7 Mon Sep 17 00:00:00 2001 From: YeoEunnn Date: Sun, 18 Jan 2026 21:09:20 +0900 Subject: [PATCH 01/11] =?UTF-8?q?fix:=20FileDropZone=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=ED=98=B8=EB=B2=84=20=EC=8B=9C=20=ED=95=98=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/FileDropzone.tsx | 42 +++++++++++++++++--------- 1 file changed, 28 insertions(+), 14 deletions(-) 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(' ')} + )} > {/* 드래그/업로드 중이면 블러/흐리게 */}
-
+
From 76067520296fb2d9d40027585b458016ebb59f31 Mon Sep 17 00:00:00 2001 From: YeoEunnn Date: Sun, 18 Jan 2026 22:50:45 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20=EB=A6=AC=EC=8A=A4=ED=8A=B8&?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EA=B3=B5=EB=8F=99=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/CardView.tsx | 43 +++++++++++ src/components/common/ListView.tsx | 90 +++++++++++++++++++++++ src/components/common/index.ts | 2 + src/components/projects/ProjectCard.tsx | 15 ++-- src/components/projects/ProjectHeader.tsx | 26 ++++++- src/types/project.ts | 4 + 6 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 src/components/common/CardView.tsx create mode 100644 src/components/common/ListView.tsx 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/ListView.tsx b/src/components/common/ListView.tsx new file mode 100644 index 00000000..ad97cc8e --- /dev/null +++ b/src/components/common/ListView.tsx @@ -0,0 +1,90 @@ +import type { Key, ReactNode } from 'react'; + +import clsx from 'clsx'; + +export interface ListViewProps { + items: readonly T[]; + getKey: (item: T, index: number) => Key; + renderTitle: (item: T) => ReactNode; + renderUpdatedAt?: (item: T) => ReactNode; + renderMeta?: (item: T) => ReactNode; + renderLeading?: (item: T) => ReactNode; + renderTrailing?: (item: T) => ReactNode; + renderStats?: (item: T) => ReactNode; + onItemClick?: (item: T) => void; + className?: string; + itemClassName?: string; + empty?: ReactNode; + ariaLabel?: string; +} + +export function ListView({ + items, + getKey, + renderTitle, + renderUpdatedAt, + renderMeta, + renderLeading, + renderTrailing, + renderStats, + 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)}
} +
+ + {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/projects/ProjectCard.tsx b/src/components/projects/ProjectCard.tsx index a7b97bb9..b0f59d34 100644 --- a/src/components/projects/ProjectCard.tsx +++ b/src/components/projects/ProjectCard.tsx @@ -9,6 +9,10 @@ import type { CardItems } from '@/types/project'; import { Popover } from '../common'; +type ProjectCardProps = CardItems & { + className?: string; +}; + export default function ProjectCard({ title, updatedAt, @@ -17,16 +21,13 @@ export default function ProjectCard({ reactionCount, viewCount = 0, thumbnailUrl, -}: CardItems) { + className, +}: ProjectCardProps) { return ( -
+
{thumbnailUrl && ( - {`${title}`} + {title} )}
diff --git a/src/components/projects/ProjectHeader.tsx b/src/components/projects/ProjectHeader.tsx index 3e091fb1..d535b396 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/types/project.ts b/src/types/project.ts index fb85ba73..35c8bb80 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -1,6 +1,10 @@ +import type { ViewMode } from './home'; + export interface ProjectHeaderProps { value: string; onChange: (value: string) => void; + viewMode: ViewMode; + onChangeViewMode: (viewMode: ViewMode) => void; } // 프로젝트 전체의 코멘트 수, 리액션 수 => 슬라이드와 연결할 것.. From f00f013bd16e1a0e6849ae5fd9257dab9cda5790 Mon Sep 17 00:00:00 2001 From: YeoEunnn Date: Mon, 19 Jan 2026 20:43:05 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20=EB=A6=AC=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=B7=B0=20=EC=B9=B4=EB=93=9C=EB=B7=B0=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B3=B5=EB=8F=99=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/ListView.tsx | 21 +++-- src/components/home/ProjectsSection.tsx | 60 +++++++++++--- src/components/projects/ProjectCard.tsx | 26 ++++--- src/components/projects/ProjectHeader.tsx | 4 +- src/components/projects/ProjectList.tsx | 95 +++++++++++++++++++++++ src/pages/HomePage.tsx | 7 +- src/types/project.ts | 2 +- 7 files changed, 176 insertions(+), 39 deletions(-) create mode 100644 src/components/projects/ProjectList.tsx diff --git a/src/components/common/ListView.tsx b/src/components/common/ListView.tsx index ad97cc8e..7880a712 100644 --- a/src/components/common/ListView.tsx +++ b/src/components/common/ListView.tsx @@ -5,12 +5,11 @@ import clsx from 'clsx'; export interface ListViewProps { items: readonly T[]; getKey: (item: T, index: number) => Key; - renderTitle: (item: T) => ReactNode; - renderUpdatedAt?: (item: T) => ReactNode; - renderMeta?: (item: T) => ReactNode; + renderLeading?: (item: T) => ReactNode; renderTrailing?: (item: T) => ReactNode; - renderStats?: (item: T) => ReactNode; + renderInfo: (item: T) => ReactNode; + onItemClick?: (item: T) => void; className?: string; itemClassName?: string; @@ -21,12 +20,9 @@ export interface ListViewProps { export function ListView({ items, getKey, - renderTitle, - renderUpdatedAt, - renderMeta, renderLeading, renderTrailing, - renderStats, + renderInfo, onItemClick, className, itemClassName, @@ -64,7 +60,7 @@ export function ListView({ > {renderLeading &&
{renderLeading(item)}
} -
+ {/*
{renderTitle(item)}
{(renderUpdatedAt || renderMeta) && ( @@ -77,9 +73,12 @@ export function ListView({ )} {renderStats &&
{renderStats(item)}
} -
+
*/} +
{renderInfo(item)}
- {renderTrailing &&
{renderTrailing(item)}
} + {renderTrailing && ( +
{renderTrailing(item)}
+ )}
); })} 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 b0f59d34..ea094e35 100644 --- a/src/components/projects/ProjectCard.tsx +++ b/src/components/projects/ProjectCard.tsx @@ -5,24 +5,26 @@ 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'; -type ProjectCardProps = CardItems & { +type ProjectCardProps = { + item: ProjectItem; className?: string; }; -export default function ProjectCard({ - title, - updatedAt, - pageCount, - commentCount, - reactionCount, - viewCount = 0, - thumbnailUrl, - className, -}: ProjectCardProps) { +export default function ProjectCard({ item, className }: ProjectCardProps) { + const { + title, + updatedAt, + pageCount, + commentCount, + reactionCount, + viewCount = 0, + thumbnailUrl, + } = item; + return (
diff --git a/src/components/projects/ProjectHeader.tsx b/src/components/projects/ProjectHeader.tsx index d535b396..1c62d9b1 100644 --- a/src/components/projects/ProjectHeader.tsx +++ b/src/components/projects/ProjectHeader.tsx @@ -94,7 +94,7 @@ export default function ProjectHeader({ + +
+ + )} + /> + ); +} 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 35c8bb80..6d247665 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -8,7 +8,7 @@ export interface ProjectHeaderProps { } // 프로젝트 전체의 코멘트 수, 리액션 수 => 슬라이드와 연결할 것.. -export interface CardItems { +export interface ProjectItem { id: string; title: string; updatedAt: string; From f54951081e97e6d8a1d72d7d1d23fbed76a9e6bb Mon Sep 17 00:00:00 2001 From: YeoEunnn Date: Mon, 19 Jan 2026 22:04:39 +0900 Subject: [PATCH 04/11] =?UTF-8?q?fix:=20gemini=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=88=98=EC=A0=95=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/CardView.tsx | 6 +++--- src/components/common/FileDropzone.tsx | 2 +- src/components/common/ListView.tsx | 14 -------------- src/components/projects/ProjectHeader.tsx | 10 ++++++++-- src/hooks/useProjectList.ts | 6 +++--- 5 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/components/common/CardView.tsx b/src/components/common/CardView.tsx index 304ed74a..e7053cbf 100644 --- a/src/components/common/CardView.tsx +++ b/src/components/common/CardView.tsx @@ -9,7 +9,7 @@ export interface CardViewProps { className?: string; itemClassName?: string; empty?: ReactNode; - ariaLabal?: string; + ariaLabel?: string; } export function CardView({ @@ -19,14 +19,14 @@ export function CardView({ className, itemClassName, empty, - ariaLabal, + ariaLabel, }: CardViewProps) { if (items.length === 0) { return
{empty ?? 'No items'}
; } return ( -
+
{items.map((item, index) => (
-
+
diff --git a/src/components/common/ListView.tsx b/src/components/common/ListView.tsx index 7880a712..d8b39290 100644 --- a/src/components/common/ListView.tsx +++ b/src/components/common/ListView.tsx @@ -60,20 +60,6 @@ export function ListView({ > {renderLeading &&
{renderLeading(item)}
} - {/*
-
{renderTitle(item)}
- - {(renderUpdatedAt || renderMeta) && ( -
- {renderUpdatedAt && ( - {renderUpdatedAt(item)} - )} - {renderMeta && {renderMeta(item)}} -
- )} - - {renderStats &&
{renderStats(item)}
} -
*/}
{renderInfo(item)}
{renderTrailing && ( diff --git a/src/components/projects/ProjectHeader.tsx b/src/components/projects/ProjectHeader.tsx index 1c62d9b1..b01f023e 100644 --- a/src/components/projects/ProjectHeader.tsx +++ b/src/components/projects/ProjectHeader.tsx @@ -94,7 +94,10 @@ export default function ProjectHeader({ - -
- + <> +
+
+ {thumbnailUrl && ( + {title} + )}
-

{updatedAt}

-
-
- - {pageCount} 페이지 +
+ {/* 제목 및 업데이트 날짜 */} +
+

{title}

+ ( + + )} + position="bottom" + align="end" + ariaLabel="더보기" + className="border border-gray-200 w-32 overflow-hidden" + > + {({ close }) => ( +
+ + +
+ )} +
+

{updatedAt}

- {/* 반응 모음 */} -
-
- - {commentCount} -
+
- - {reactionCount} + + {pageCount} 페이지
-
- - {viewCount} + + {/* 반응 모음 */} +
+
+ + {commentCount} +
+
+ + {reactionCount} +
+
+ + {viewCount} +
-
-
+
+ + setIsDeleteModalOpen(false)} + title="발표 삭제" + size="sm" + > +
+

{title}

+

해당 발표를 정말 삭제하시겠습니까?

+
+
+ + +
+
+ ); } diff --git a/src/components/projects/ProjectList.tsx b/src/components/projects/ProjectList.tsx index ceba323f..6bb59d0f 100644 --- a/src/components/projects/ProjectList.tsx +++ b/src/components/projects/ProjectList.tsx @@ -67,7 +67,7 @@ export default function ProjectList({ items, className, itemClassName }: Project
)} - renderTrailing={(item) => ( + renderTrailing={() => ( ( From 4e38b45aecdac7565d20ea7f79385d6ca1909db0 Mon Sep 17 00:00:00 2001 From: YeoEunnn Date: Mon, 19 Jan 2026 22:58:19 +0900 Subject: [PATCH 06/11] =?UTF-8?q?refactor:=20=EC=B9=B4=EB=93=9C=EB=B7=B0,?= =?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=B7=B0=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=ED=86=B5=EC=9D=BC=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/CardView.tsx | 2 ++ src/components/common/ListView.tsx | 50 +++++++++--------------------- 2 files changed, 17 insertions(+), 35 deletions(-) diff --git a/src/components/common/CardView.tsx b/src/components/common/CardView.tsx index e7053cbf..36235dfe 100644 --- a/src/components/common/CardView.tsx +++ b/src/components/common/CardView.tsx @@ -5,7 +5,9 @@ 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; diff --git a/src/components/common/ListView.tsx b/src/components/common/ListView.tsx index d8b39290..a746aa56 100644 --- a/src/components/common/ListView.tsx +++ b/src/components/common/ListView.tsx @@ -10,7 +10,6 @@ export interface ListViewProps { renderTrailing?: (item: T) => ReactNode; renderInfo: (item: T) => ReactNode; - onItemClick?: (item: T) => void; className?: string; itemClassName?: string; empty?: ReactNode; @@ -23,7 +22,6 @@ export function ListView({ renderLeading, renderTrailing, renderInfo, - onItemClick, className, itemClassName, empty, @@ -33,41 +31,23 @@ export function ListView({ 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)}
} - -
{renderInfo(item)}
- - {renderTrailing && ( -
{renderTrailing(item)}
- )} -
- ); - })} + {items.map((item, index) => ( +
+ {renderLeading &&
{renderLeading(item)}
} + +
{renderInfo(item)}
+ + {renderTrailing && ( +
{renderTrailing(item)}
+ )} +
+ ))}
); } From ef18b03c493aca94802b919805ee40b4d6ba8814 Mon Sep 17 00:00:00 2001 From: YeoEunnn Date: Tue, 20 Jan 2026 02:20:02 +0900 Subject: [PATCH 07/11] =?UTF-8?q?refactor:=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=EC=9D=84=20PjSection=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/home/ProjectsSection.tsx | 45 ++++++- src/components/projects/ProjectCard.tsx | 171 +++++++++--------------- src/components/projects/ProjectList.tsx | 48 ++++--- 3 files changed, 137 insertions(+), 127 deletions(-) diff --git a/src/components/home/ProjectsSection.tsx b/src/components/home/ProjectsSection.tsx index 768be1f5..ecbca130 100644 --- a/src/components/home/ProjectsSection.tsx +++ b/src/components/home/ProjectsSection.tsx @@ -1,7 +1,9 @@ +import { useState } from 'react'; + import type { ViewMode } from '@/types/home'; import type { ProjectItem } from '@/types/project'; -import { CardView, ListView } from '../common'; +import { CardView, Modal } from '../common'; import ProjectCard from '../projects/ProjectCard'; import { ProjectCardSkeleton } from '../projects/ProjectCardSkeleton'; import ProjectHeader from '../projects/ProjectHeader'; @@ -28,6 +30,7 @@ export default function ProjectsSection({ onChangeViewMode, }: Props) { const hasProjects = projects.length > 0; + const [deleteTarget, setDeleteTarget] = useState(null); if (!isLoading && !hasProjects) return null; @@ -58,7 +61,9 @@ export default function ProjectsSection({ items={projects} getKey={(item) => item.id} className="mt-6 grid grid-cols-2 gap-4 lg:grid-cols-3" - renderCard={(item) => } + renderCard={(item) => ( + setDeleteTarget(target)} /> + )} /> ) ) : isLoading ? ( @@ -71,9 +76,41 @@ export default function ProjectsSection({ ))}
) : ( - + )} - {/* 프레젠테이션 목록 */} + + setDeleteTarget(null)} + title="발표 삭제" + size="sm" + > +
+

{deleteTarget?.title}

+

해당 발표를 정말 삭제하시겠습니까?

+
+ +
+ + +
+
); } diff --git a/src/components/projects/ProjectCard.tsx b/src/components/projects/ProjectCard.tsx index a4075d86..fad9ad58 100644 --- a/src/components/projects/ProjectCard.tsx +++ b/src/components/projects/ProjectCard.tsx @@ -1,5 +1,3 @@ -import { useState } from 'react'; - import clsx from 'clsx'; import CommentCountIcon from '@/assets/icons/icon-comment-count.svg?react'; @@ -9,127 +7,84 @@ 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 { Modal, Popover } from '../common'; +import { Popover } from '../common'; type ProjectCardProps = { item: ProjectItem; className?: string; + onDeleteClick?: (item: ProjectItem) => void; // 상위로 올릴 콜백 (플젝 삭제) }; -export default function ProjectCard({ item, className }: ProjectCardProps) { - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - - const { - title, - updatedAt, - pageCount, - commentCount, - reactionCount, - viewCount = 0, - thumbnailUrl, - } = item; +export default function ProjectCard({ item, className, onDeleteClick }: ProjectCardProps) { + const { title, updatedAt, pageCount, commentCount, reactionCount, viewCount, thumbnailUrl } = + item; return ( - <> -
-
- {thumbnailUrl && ( - {title} - )} +
+
+ {thumbnailUrl && ( + {title} + )} +
+ +
+ {/* 제목 및 업데이트 날짜 */} +
+

{title}

+ ( + + )} + position="bottom" + align="end" + ariaLabel="더보기" + className="border border-gray-200 w-32 overflow-hidden" + > + {({ close }) => ( +
+ + +
+ )} +
-
- {/* 제목 및 업데이트 날짜 */} -
-

{title}

- ( - - )} - position="bottom" - align="end" - ariaLabel="더보기" - className="border border-gray-200 w-32 overflow-hidden" - > - {({ close }) => ( -
- - -
- )} -
+

{updatedAt}

+ +
+
+ + {pageCount} 페이지
-

{updatedAt}

-
-
- - {pageCount} 페이지 + {/* 반응 모음 */} +
+
+ + {commentCount}
- - {/* 반응 모음 */} -
-
- - {commentCount} -
-
- - {reactionCount} -
-
- - {viewCount} -
+
+ + {reactionCount} +
+
+ + {viewCount}
-
- - setIsDeleteModalOpen(false)} - title="발표 삭제" - size="sm" - > -
-

{title}

-

해당 발표를 정말 삭제하시겠습니까?

-
-
- - -
-
- +
+
); } diff --git a/src/components/projects/ProjectList.tsx b/src/components/projects/ProjectList.tsx index 6bb59d0f..e31b51a1 100644 --- a/src/components/projects/ProjectList.tsx +++ b/src/components/projects/ProjectList.tsx @@ -13,9 +13,15 @@ type ProjectListProps = { items: ProjectItem[]; className?: string; itemClassName?: string; + onDeleteClick?: (item: ProjectItem) => void; // 상위로 올릴 콜백 (플젝 삭제) }; -export default function ProjectList({ items, className, itemClassName }: ProjectListProps) { +export default function ProjectList({ + items, + className, + itemClassName, + onDeleteClick, +}: ProjectListProps) { return ( -
+
{item.updatedAt} | @@ -67,27 +73,39 @@ export default function ProjectList({ items, className, itemClassName }: Project
)} - renderTrailing={() => ( + renderTrailing={(item) => ( ( - + )} position="bottom" align="end" ariaLabel="더보기" className="border border-gray-200 w-32 overflow-hidden" > -
- - -
+ {({ close }) => ( +
+ + +
+ )}
)} /> From b68e0c9232effecc05b82a5f778628e863d2e977 Mon Sep 17 00:00:00 2001 From: YeoEunnn Date: Fri, 23 Jan 2026 21:22:46 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix:=20conflict=20=EC=88=98=EC=A0=95=20(#?= =?UTF-8?q?56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/home/ProjectsSection.tsx | 5 ++--- src/components/projects/ProjectList.tsx | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/home/ProjectsSection.tsx b/src/components/home/ProjectsSection.tsx index 9e024512..620b9683 100644 --- a/src/components/home/ProjectsSection.tsx +++ b/src/components/home/ProjectsSection.tsx @@ -1,7 +1,7 @@ import type { SortMode, ViewMode } from '@/types/home'; import type { Project } from '@/types/project'; -import { CardView, ListView } from '../common'; +import { CardView } from '../common'; import ProjectCard from '../projects/ProjectCard'; import { ProjectCardSkeleton } from '../projects/ProjectCardSkeleton'; import ProjectHeader from '../projects/ProjectHeader'; @@ -30,7 +30,6 @@ export default function ProjectsSection({ projects, }: Props) { const hasProjects = projects.length > 0; - const [deleteTarget, setDeleteTarget] = useState(null); if (!isLoading && !hasProjects) return null; @@ -62,7 +61,7 @@ export default function ProjectsSection({ items={projects} getKey={(item) => item.id} className="mt-6 grid grid-cols-2 gap-4 lg:grid-cols-3" - renderCard={(item) => } + renderCard={(item) => } /> ) ) : isLoading ? ( diff --git a/src/components/projects/ProjectList.tsx b/src/components/projects/ProjectList.tsx index ceba323f..fa7ddb43 100644 --- a/src/components/projects/ProjectList.tsx +++ b/src/components/projects/ProjectList.tsx @@ -5,12 +5,12 @@ 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 type { Project } from '@/types/project'; import { ListView, Popover } from '../common'; type ProjectListProps = { - items: ProjectItem[]; + items: Project[]; className?: string; itemClassName?: string; }; From fd958c5dcad41613f543dbd6a5de70109f407873 Mon Sep 17 00:00:00 2001 From: YeoEunnn Date: Fri, 23 Jan 2026 22:55:18 +0900 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20conflict=20=EC=9E=AC=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/HomePage.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index d8e7869c..10616629 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -56,8 +56,6 @@ export default function HomePage() { viewMode={viewMode} onChangeViewMode={setViewMode} projects={projects} - viewMode={viewMode} - onChangeViewMode={setViewMode} /> ); From bf3819bd16aa0209cd326d68db8f9be1fd12cdd4 Mon Sep 17 00:00:00 2001 From: YeoEunnn Date: Sat, 24 Jan 2026 00:03:06 +0900 Subject: [PATCH 10/11] =?UTF-8?q?refactor:=20Card=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EC=97=90=20=EB=A7=9E=EA=B2=8C=20List=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B5=AC=EC=A1=B0=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/ListView.tsx | 37 +----- src/components/home/ProjectsSection.tsx | 12 +- src/components/projects/ProjectList.tsx | 159 +++++++++++++----------- 3 files changed, 97 insertions(+), 111 deletions(-) diff --git a/src/components/common/ListView.tsx b/src/components/common/ListView.tsx index 7880a712..20d0eca6 100644 --- a/src/components/common/ListView.tsx +++ b/src/components/common/ListView.tsx @@ -5,11 +5,9 @@ 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; @@ -23,7 +21,6 @@ export function ListView({ renderLeading, renderTrailing, renderInfo, - onItemClick, className, itemClassName, empty, @@ -33,47 +30,15 @@ export function ListView({ 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 && ( diff --git a/src/components/home/ProjectsSection.tsx b/src/components/home/ProjectsSection.tsx index 620b9683..e177dd28 100644 --- a/src/components/home/ProjectsSection.tsx +++ b/src/components/home/ProjectsSection.tsx @@ -1,7 +1,7 @@ import type { SortMode, ViewMode } from '@/types/home'; import type { Project } from '@/types/project'; -import { CardView } from '../common'; +import { CardView, ListView } from '../common'; import ProjectCard from '../projects/ProjectCard'; import { ProjectCardSkeleton } from '../projects/ProjectCardSkeleton'; import ProjectHeader from '../projects/ProjectHeader'; @@ -67,6 +67,8 @@ export default function ProjectsSection({ ) : 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/ProjectList.tsx b/src/components/projects/ProjectList.tsx index fa7ddb43..be1b0c33 100644 --- a/src/components/projects/ProjectList.tsx +++ b/src/components/projects/ProjectList.tsx @@ -1,3 +1,5 @@ +import { useNavigate } from 'react-router-dom'; + import clsx from 'clsx'; import CommentCountIcon from '@/assets/icons/icon-comment-count.svg?react'; @@ -5,91 +7,104 @@ 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(); -import { ListView, Popover } from '../common'; + const dropdownItems: DropdownItem[] = [ + { + id: 'rename', + label: '이름 변경', + onClick: () => { + // TODO: 이름 변경 로직 구현 + }, + }, + { + id: 'delete', + label: '삭제', + variant: 'danger', + onClick: () => { + // TODO: 삭제 로직 구현 + }, + }, + ]; -type ProjectListProps = { - items: Project[]; - className?: string; - itemClassName?: string; -}; + const handleListClick = () => { + navigate(getTabPath(id, 'slide')); + }; -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}
+
+ {/* 썸네일 */} +
+ {thumbnailUrl && ( + {title} + )} +
- {/* 날짜 | 페이지 수 + 댓글/이모티콘/조회 */} -
-
- {item.updatedAt} - | - - - {item.pageCount} 페이지 - -
+ {/* 본문 */} +
+ {/* 제목 */} +
{title}
-
- - - {item.commentCount} - - - - {item.reactionCount} - - - - {item.viewCount} - -
+ {/* 날짜 | 페이지 수 + 댓글/이모티콘/조회 */} +
+
+ {formatRelativeTime(updatedAt)} + | + + + {pageCount} 페이지 + +
+ +
+ + + {commentCount} + + + + {reactionCount} + + + + {viewCount} +
- )} - renderTrailing={(item) => ( - + + {/* 더보기 */} +
e.stopPropagation()}> + ( - + )} + items={dropdownItems} position="bottom" align="end" ariaLabel="더보기" - className="border border-gray-200 w-32 overflow-hidden" - > -
- - -
- - )} - /> + menuClassName="w-32" + /> +
+
); } From 849ae762bb76d4534ec949e59797c97098cd8c8c Mon Sep 17 00:00:00 2001 From: YeoEunnn Date: Sat, 24 Jan 2026 00:31:29 +0900 Subject: [PATCH 11/11] =?UTF-8?q?feat:=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20warning=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=95=8C=EB=A6=BC=20=EC=B6=94=EA=B0=80=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/FileDropzone.tsx | 10 +++++++++- src/components/home/IntroSection.tsx | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/common/FileDropzone.tsx b/src/components/common/FileDropzone.tsx index 37841542..c78ac184 100644 --- a/src/components/common/FileDropzone.tsx +++ b/src/components/common/FileDropzone.tsx @@ -1,9 +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'; @@ -13,6 +14,7 @@ interface FileDropProps { disabled?: boolean; uploadState?: UploadState; progress?: number; + error?: string | null; } export default function FileDropzone({ @@ -21,12 +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(); 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}

}