Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/components/common/CardView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Key, ReactNode } from 'react';

import clsx from 'clsx';

export interface CardViewProps<T> {
items: readonly T[];
getKey: (item: T, index: number) => Key;
renderCard: (item: T) => ReactNode;
className?: string;
itemClassName?: string;
empty?: ReactNode;
ariaLabal?: string;
}

export function CardView<T>({
items,
getKey,
renderCard,
className,
itemClassName,
empty,
ariaLabal,
}: CardViewProps<T>) {
if (items.length === 0) {
return <div className="cardView__empty">{empty ?? 'No items'}</div>;
}

return (
<div className={clsx('cardView', className)} role="list" aria-label={ariaLabal}>
{items.map((item, index) => (
<div
key={getKey(item, index)}
className={clsx('cardView__item', itemClassName)}
role="listitem"
>
{renderCard(item)}
</div>
))}
</div>
);
}

export default CardView;
52 changes: 37 additions & 15 deletions src/components/common/FileDropzone.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -11,6 +14,7 @@ interface FileDropProps {
disabled?: boolean;
uploadState?: UploadState;
progress?: number;
error?: string | null;
}

export default function FileDropzone({
Expand All @@ -19,10 +23,18 @@ export default function FileDropzone({
disabled,
uploadState = 'idle',
progress = 0,
error,
}: FileDropProps) {
const inputRef = useRef<HTMLInputElement | null>(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();
Expand All @@ -35,21 +47,34 @@ export default function FileDropzone({
onFilesSelected(files);
};

const handleDragEnterOrOver = (e: React.DragEvent<HTMLButtonElement>) => {
const handleDragEnter = (e: React.DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
// 드래그가 영역 안에 있는 동안 지속적으로 true 유지
dragCounter.current += 1;
setIsDragging(true);
};

const handleDragOver = (e: React.DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) setIsDragging(true);
};

const handleDragLeave = (e: React.DragEvent<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
// 드롭 시 카운터 초기화해서 다음 드래그 상태가 꼬이지 않도록 함
dragCounter.current = 0;
setIsDragging(false);
handleFiles(e.dataTransfer.files);
};
Expand All @@ -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(' ')}
)}
>
{/* 드래그/업로드 중이면 블러/흐리게 */}
<div
className={[
className={clsx(
'flex flex-col items-center gap-4 transition',
showDragOverlay || showUploadOverlay ? 'blur-sm opacity-40' : '',
].join(' ')}
(showDragOverlay || showUploadOverlay) && 'blur-sm opacity-40',
)}
>
<div
className="flex h-12 w-12 items-center justify-center rounded-full bg-gray-800
transition group-hover:bg-gray-900"
>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gray-800 not-visited:transition group-hover:bg-gray-900">
<UploadIcon className="h-5 w-5 text-white" />
</div>
<div className="space-y-2 text-center">
Expand Down
54 changes: 54 additions & 0 deletions src/components/common/ListView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { Key, ReactNode } from 'react';

import clsx from 'clsx';

export interface ListViewProps<T> {
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<T>({
items,
getKey,
renderLeading,
renderTrailing,
renderInfo,
className,
itemClassName,
empty,
ariaLabel,
}: ListViewProps<T>) {
if (items.length === 0) {
return <div className="listView__empty">{empty ?? 'No items'}</div>;
}

return (
<div className={clsx('listView', className)} role="list" aria-label={ariaLabel}>
{items.map((item, index) => {
const key = getKey(item, index);

return (
<div key={key} className={clsx('listView__item', itemClassName)} role="listitem">
{renderLeading && <div className="listView__leading">{renderLeading(item)}</div>}

<div className="listView__content">{renderInfo(item)}</div>

{renderTrailing && (
<div className="listView__trailing ml-auto shrink-0">{renderTrailing(item)}</div>
)}
</div>
);
})}
</div>
);
}

export default ListView;
2 changes: 2 additions & 0 deletions src/components/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
1 change: 1 addition & 0 deletions src/components/home/IntroSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export default function IntroSection({
uploadState={uploadState}
progress={progress}
onFilesSelected={onFilesSelected}
error={error}
/>

{error && <p className="mt-3 text-body-s text-red-500">업로드 실패: {error}</p>}
Expand Down
43 changes: 36 additions & 7 deletions src/components/home/ProjectsSection.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -46,14 +49,40 @@ export default function ProjectsSection({
onChangeViewMode={onChangeViewMode}
/>

{/* 프레젠테이션 목록 */}
<div className="mt-6 grid grid-cols-2 gap-4 lg:grid-cols-3">
{isLoading
? Array.from({ length: SKELETON_CARD_COUNT }).map((_, index) => (
{viewMode === 'card' ? (
isLoading ? (
<div className="mt-6 grid grid-cols-2 gap-4 lg:grid-cols-3">
{Array.from({ length: SKELETON_CARD_COUNT }).map((_, index) => (
<ProjectCardSkeleton key={index} />
))
: projects.map((project) => <ProjectCard key={project.id} {...project} />)}
</div>
))}
</div>
) : (
<CardView
items={projects}
getKey={(item) => item.id}
className="mt-6 grid grid-cols-2 gap-4 lg:grid-cols-3"
renderCard={(item) => <ProjectCard {...item} />}
/>
)
) : isLoading ? (
<div className="mt-6 flex flex-col gap-3">
{Array.from({ length: SKELETON_LIST_COUNT }).map((_, index) => (
// TODO
// ㄴ ProjectListSkeleton도 따로?
<div
key={index}
className="h-20 rounded-2xl border border-gray-200 bg-white p-4 animate-pulse"
/>
))}
</div>
Comment on lines 68 to 77
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

리스트 뷰의 스켈레톤 UI가 컴포넌트 내에 인라인으로 구현되어 있습니다. ProjectCardSkeleton처럼 ProjectListSkeleton과 같은 별도의 컴포넌트로 분리하면 코드의 재사용성과 유지보수성이 향상될 것입니다.

) : (
<ListView
items={projects}
getKey={(item) => item.id}
className="mt-6 flex flex-col gap-3"
renderInfo={(item) => <ProjectList {...item} />}
/>
)}
</section>
);
}
14 changes: 7 additions & 7 deletions src/components/projects/ProjectCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,18 +89,18 @@ export default function ProjectCard({
</div>

{/* 반응 모음 */}
<div className="flex gap-3">
<div className="flex gap-1">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<CommentCountIcon />
<span>{commentCount}</span>
{commentCount}
</div>
<div className="flex gap-1">
<div className="flex items-center gap-1">
<ReactionCountIcon />
<span>{reactionCount}</span>
{reactionCount}
</div>
<div className="flex gap-1">
<div className="flex items-center gap-1">
<ViewCountIcon />
<span>{viewCount}</span>
{viewCount}
</div>
</div>
</div>
Expand Down
Loading
Loading