Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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;
42 changes: 28 additions & 14 deletions src/components/common/FileDropzone.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -13,6 +15,8 @@ export default function FileDropzone({
progress = 0,
}: FileDropProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
// dragCounter : 실제로 영역을 완전히 벗어났을 때만 카운터를 false로 바꿈
const dragCounter = useRef(0);
const [isDragging, setIsDragging] = useState(false);

const openFileDialog = () => {
Expand All @@ -27,21 +31,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 @@ -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(' ')}
)}
>
{/* 드래그/업로드 중이면 블러/흐리게 */}
<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
89 changes: 89 additions & 0 deletions src/components/common/ListView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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,
onItemClick,
className,
itemClassName,
empty,
ariaLabel,
}: ListViewProps<T>) {
if (items.length === 0) {
return <div className="listView__empty">{empty ?? 'No items'}</div>;
}

const isClickable = Boolean(onItemClick);

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, isClickable && 'is-clickable')}
role="listitem"
tabIndex={isClickable ? 0 : undefined}
onClick={isClickable ? () => onItemClick?.(item) : undefined}
onKeyDown={
isClickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onItemClick?.(item);
}
}
: undefined
}
>
{renderLeading && <div className="listView__leading">{renderLeading(item)}</div>}

{/* <div className="listView__content">
<div className="listView__title">{renderTitle(item)}</div>

{(renderUpdatedAt || renderMeta) && (
<div className="listView__meta">
{renderUpdatedAt && (
<span className="listView__updated">{renderUpdatedAt(item)}</span>
)}
{renderMeta && <span className="listView__metaExtra">{renderMeta(item)}</span>}
</div>
)}

{renderStats && <div className="listView__stats">{renderStats(item)}</div>}
</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';
60 changes: 49 additions & 11 deletions src/components/home/ProjectsSection.tsx
Original file line number Diff line number Diff line change
@@ -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';

Check failure on line 4 in src/components/home/ProjectsSection.tsx

View workflow job for this annotation

GitHub Actions / build_and_preview

'ListView' is declared but its value is never read.

Check warning on line 4 in src/components/home/ProjectsSection.tsx

View workflow job for this annotation

GitHub Actions / Lint

'ListView' is defined but never used

Check failure on line 4 in src/components/home/ProjectsSection.tsx

View workflow job for this annotation

GitHub Actions / Build

'ListView' is declared but its value is never read.

Check warning on line 4 in src/components/home/ProjectsSection.tsx

View workflow job for this annotation

GitHub Actions / Lint

'ListView' is defined but never used

Check failure on line 4 in src/components/home/ProjectsSection.tsx

View workflow job for this annotation

GitHub Actions / build_and_preview

'ListView' is declared but its value is never read.

Check failure on line 4 in src/components/home/ProjectsSection.tsx

View workflow job for this annotation

GitHub Actions / Build

'ListView' is declared but its value is never read.
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;
Expand All @@ -26,16 +39,41 @@
</div>

{/* 검색 */}
<ProjectHeader value={query} onChange={onChangeQuery} />
<ProjectHeader
value={query}
onChange={onChangeQuery}
viewMode={viewMode}
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={item} />}
/>
)
) : isLoading ? (
<div className="mt-6 flex flex-col gap-3">
{Array.from({ length: SKELETON_LIST_COUNT }).map((_, index) => (
<div
key={index}
className="h-20 rounded-2xl border border-gray-200 bg-white p-4 animate-pulse"
/>
))}
</div>
Comment on lines +65 to +72
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이라는 별도의 컴포넌트로 분리하면 코드의 모듈성을 높이고 재사용성을 개선할 수 있습니다.

) : (
<ProjectList items={projects} className="mt-6" />
)}
{/* 프레젠테이션 목록 */}
</section>
);
}
35 changes: 19 additions & 16 deletions src/components/projects/ProjectCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<article className="overflow-hidden rounded-2xl border-none bg-white">
<article className={clsx('overflow-hidden rounded-2xl border-none bg-white', className)}>
<div className="w-full aspect-video bg-gray-200">
{thumbnailUrl && (
<img
className="h-full w-full object-cover outline-none"
src={thumbnailUrl}
alt={`${title}`}
/>
<img className="h-full w-full object-cover outline-none" src={thumbnailUrl} alt={title} />
)}
</div>

Expand Down
Loading
Loading