Skip to content
45 changes: 45 additions & 0 deletions src/components/common/CardView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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;
ariaLabel?: string;
}

export function CardView<T>({
items,
getKey,
renderCard,
className,
itemClassName,
empty,
ariaLabel,
}: 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={ariaLabel}>
{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 transition group-hover:bg-gray-900">
<UploadIcon className="h-5 w-5 text-white" />
</div>
<div className="space-y-2 text-center">
Expand Down
55 changes: 55 additions & 0 deletions src/components/common/ListView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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;

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) => (
<div
key={getKey(item, index)}
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';
97 changes: 86 additions & 11 deletions src/components/home/ProjectsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,36 @@
import type { CardItems } from '@/types/project';
import { useState } from 'react';

import type { ViewMode } from '@/types/home';
import type { ProjectItem } from '@/types/project';

import { CardView, Modal } 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;
const [deleteTarget, setDeleteTarget] = useState<ProjectItem | null>(null);

if (!isLoading && !hasProjects) return null;

Expand All @@ -26,16 +42,75 @@ export default function ProjectsSection({ isLoading, query, onChangeQuery, proje
</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} onDeleteClick={(target) => setDeleteTarget(target)} />
)}
/>
)
) : 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 +66 to +73
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

๋ฆฌ์ŠคํŠธ ๋ทฐ์˜ ๋กœ๋”ฉ ์Šค์ผˆ๋ ˆํ†ค UI๊ฐ€ ์ธ๋ผ์ธ์œผ๋กœ ์ž‘์„ฑ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ProjectCardSkeleton์ฒ˜๋Ÿผ ๋ณ„๋„์˜ ProjectListItemSkeleton ์ปดํฌ๋„ŒํŠธ๋กœ ๋ถ„๋ฆฌํ•˜๋ฉด ์ฝ”๋“œ์˜ ์ผ๊ด€์„ฑ๊ณผ ์žฌ์‚ฌ์šฉ์„ฑ์„ ๋†’์ผ ์ˆ˜ ์žˆ์„ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, src/components/projects/ ๋””๋ ‰ํ† ๋ฆฌ์— ProjectListSkeleton.tsx ํŒŒ์ผ์„ ๋งŒ๋“ค์–ด ๊ด€๋ฆฌํ•˜๋Š” ๊ฒƒ์„ ๊ณ ๋ คํ•ด ๋ณด์„ธ์š”.

) : (
<ProjectList items={projects} className="mt-6" onDeleteClick={setDeleteTarget} />
)}

<Modal
isOpen={Boolean(deleteTarget)}
onClose={() => setDeleteTarget(null)}
title="๋ฐœํ‘œ ์‚ญ์ œ"
size="sm"
>
<div className="flex flex-col gap-1">
<p className="text-body-m-bold">{deleteTarget?.title}</p>
<p className="text-body-m">ํ•ด๋‹น ๋ฐœํ‘œ๋ฅผ ์ •๋ง ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?</p>
</div>

<div className="mt-7 flex items-center justify-center gap-3 text-body-s">
<button
className="flex-1 rounded-lg bg-gray-200 py-3 text-main"
type="button"
onClick={() => setDeleteTarget(null)}
>
์ทจ์†Œ
</button>
<button
className="flex-1 rounded-lg py-3 bg-main text-gray-100"
type="button"
onClick={() => {
// TODO : ์‹ค์ œ ์‚ญ์ œ ํ˜ธ์ถœ : deleteTarget?.id
// ์„œ๋ฒ„/์Šคํ† ์–ด ๋ถ™์ด๋ฉด ์—ฌ๊ธฐ์—์„œ mutation ํ˜ธ์ถœํ•˜๊ณ  ์„ฑ๊ณต ์‹œ ๋ชฉ๋ก ๊ฐฑ์‹ 
setDeleteTarget(null);
}}
>
์‚ญ์ œ
</button>
</div>
</Modal>
</section>
);
}
Loading