Skip to content

Commit 9f16b4f

Browse files
authored
Merge pull request #143 from hhjin1/card-hj
[Feat] Card: 무한스크롤 기능 추가, 카드 클릭 시 카드 상세모달 열림 기능 추가
2 parents 9794d11 + b086c40 commit 9f16b4f

File tree

10 files changed

+218
-112
lines changed

10 files changed

+218
-112
lines changed

src/components/button/ColumnsButton.tsx

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import React from "react";
22
import clsx from "clsx";
3-
import Image from "next/image";
43

54
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
65
fullWidth?: boolean;
@@ -18,22 +17,19 @@ const ColumnsButton: React.FC<ButtonProps> = ({
1817
"flex justify-center items-center gap-[10px] bg-white transition-all",
1918
"rounded-lg px-4 py-3 font-semibold",
2019
"border border-gray-200 hover:border-purple-500",
21-
fullWidth ? "w-full" : "w-[284px] md:w-[544px] lg:w-[354px]", // 반응형 너비
22-
"h-[70px] md:h-[70px] lg:h-[70px]", // 반응형 높이
23-
"mt-[10px] md:mt-[16px] lg:mt-[20px]", // 여백
20+
fullWidth ? "w-full" : "w-[284px] md:w-[544px] lg:w-[354px]",
21+
"h-[70px] md:h-[70px] lg:h-[70px]",
22+
"mt-[10px] md:mt-[16px] lg:mt-[20px]",
2423
"text-lg md:text-2lg lg:text-2lg",
2524
className
2625
)}
2726
{...props}
2827
>
2928
<span className="font-semibold">{children}</span>
30-
<Image
31-
src="/svgs/add.svg"
32-
alt="Plus Icon"
33-
width={24}
34-
height={24}
35-
className="w-[18px] h-[18px] md:w-[20px] md:h-[20px] lg:w-[22px] lg:h-[22px] p-1 rounded-md bg-purple-100"
36-
/>
29+
30+
<span className="w-[23px] h-[23px] flex items-center justify-center text-[#5534DA] text-[20px] font-extralight rounded-md bg-[#F1EFFD]">
31+
+
32+
</span>
3733
</button>
3834
);
3935
};

src/components/button/TodoButton.tsx

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import React from "react";
22
import clsx from "clsx";
3-
import Image from "next/image";
43

54
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
65
fullWidth?: boolean;
@@ -18,22 +17,18 @@ const TodoButton: React.FC<ButtonProps> = ({
1817
"flex justify-center items-center gap-[10px] bg-white transition-all",
1918
"rounded-lg px-4 py-3 font-semibold",
2019
"border border-gray-200 hover:border-purple-500",
21-
fullWidth ? "w-full" : "w-[284px] md:w-[544px] lg:w-[314px]", // 반응형 너비
22-
"h-[32px] md:h-[40px] lg:h-[40px]", // 반응형 높이
23-
"mt-[10px] md:mt-[16px] lg:mt-[20px]", // 여백
20+
fullWidth ? "w-full" : "w-[284px] md:w-[544px] lg:w-[314px]",
21+
"h-[32px] md:h-[40px] lg:h-[40px]",
22+
"mt-[10px] md:mt-[16px] lg:mt-[20px]",
2423
"text-lg md:text-2lg lg:text-2lg",
2524
className
2625
)}
2726
{...props}
2827
>
2928
<span className="truncate">{children}</span>
30-
<Image
31-
src="/svgs/add.svg"
32-
alt="Plus Icon"
33-
width={24}
34-
height={24}
35-
className="w-5 h-5 md:w-6 md:h-6 lg:w-7 lg:h-7 p-1 bg-purple-100 rounded-md"
36-
/>
29+
<span className="w-5 h-5 md:w-6 md:h-6 lg:w-6 lg:h-6 flex items-center justify-center rounded-md bg-[#F1EFFD] text-[#5534DA] text-[16px] md:text-[18px] lg:text-[20px] font-light">
30+
+
31+
</span>
3732
</button>
3833
);
3934
};

src/components/columnCard/Card.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Image from "next/image";
44
type CardProps = CardType & {
55
imageUrl?: string | null;
66
assignee: AssigneeType;
7+
onClick?: () => void;
78
};
89

910
export default function Card({
@@ -12,9 +13,11 @@ export default function Card({
1213
tags,
1314
assignee,
1415
imageUrl,
16+
onClick,
1517
}: CardProps) {
1618
return (
1719
<div
20+
onClick={onClick}
1821
className={`
1922
flex flex-col md:flex-row lg:flex-col
2023
items-start rounded-md bg-white border border-gray-200 p-4
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { useEffect, useRef, useState, useCallback } from "react";
2+
import { CardType } from "@/types/task";
3+
import Card from "./Card";
4+
import { getCardsByColumn } from "@/api/card";
5+
6+
type CardListProps = {
7+
columnId: number;
8+
teamId: string;
9+
initialTasks: CardType[];
10+
onCardClick: (card: CardType) => void;
11+
};
12+
13+
const ITEMS_PER_PAGE = 6;
14+
15+
export default function CardList({
16+
columnId,
17+
initialTasks,
18+
onCardClick,
19+
}: CardListProps) {
20+
const [cards, setCards] = useState<CardType[]>(initialTasks);
21+
const [cursorId, setCursorId] = useState<number | null>(
22+
initialTasks.length > 0 ? initialTasks[initialTasks.length - 1].id : null
23+
);
24+
const [hasMore, setHasMore] = useState(true);
25+
const observerRef = useRef<HTMLDivElement | null>(null);
26+
const isFetchingRef = useRef(false);
27+
28+
/* cursorId 업데이트 방식 변경 */
29+
const fetchMoreCards = useCallback(async () => {
30+
if (isFetchingRef.current || !hasMore) return;
31+
32+
isFetchingRef.current = true;
33+
34+
try {
35+
const res = await getCardsByColumn({
36+
columnId,
37+
size: ITEMS_PER_PAGE,
38+
cursorId: cursorId ?? undefined, // 최신 cursorId 사용
39+
});
40+
41+
const newCards = res.cards as CardType[];
42+
43+
if (newCards.length > 0) {
44+
setCards((prev) => {
45+
const existingIds = new Set(prev.map((card) => card.id));
46+
const uniqueCards = newCards.filter(
47+
(card) => !existingIds.has(card.id)
48+
);
49+
return [...prev, ...uniqueCards];
50+
});
51+
52+
// cursorId 안전하게 업데이트
53+
setCursorId((prevCursorId) => {
54+
const newCursor = newCards[newCards.length - 1]?.id ?? prevCursorId;
55+
return newCursor;
56+
});
57+
}
58+
59+
if (newCards.length < ITEMS_PER_PAGE) {
60+
setHasMore(false);
61+
}
62+
} catch (error) {
63+
console.error("카드 로딩 실패:", error);
64+
} finally {
65+
isFetchingRef.current = false;
66+
}
67+
}, [columnId, cursorId, hasMore]);
68+
69+
/* 무한 스크롤 */
70+
useEffect(() => {
71+
if (!observerRef.current) return;
72+
73+
const observer = new IntersectionObserver(
74+
(entries) => {
75+
if (entries[0].isIntersecting && hasMore) {
76+
fetchMoreCards();
77+
}
78+
},
79+
{ threshold: 0.5 }
80+
);
81+
82+
observer.observe(observerRef.current);
83+
84+
return () => observer.disconnect();
85+
}, [fetchMoreCards, hasMore]);
86+
87+
return (
88+
<div className="grid gap-3 w-full grid-cols-1">
89+
{cards.map((task) => (
90+
<Card
91+
key={task.id}
92+
{...task}
93+
assignee={task.assignee}
94+
onClick={() => onCardClick(task)}
95+
/>
96+
))}
97+
{hasMore && <div ref={observerRef} className="h-20 " />}
98+
</div>
99+
);
100+
}

src/components/columnCard/Column.tsx

Lines changed: 39 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1+
// Column.tsx
12
import { useEffect, useState } from "react";
23
import Image from "next/image";
34
import { CardType } from "@/types/task";
4-
import { CardDetailType } from "@/types/cards";
5-
import Card from "./Card";
65
import TodoModal from "@/components/modalInput/ToDoModal";
76
import TodoButton from "@/components/button/TodoButton";
87
import ColumnManageModal from "@/components/columnCard/ColumnManageModal";
98
import ColumnDeleteModal from "@/components/columnCard/ColumnDeleteModal";
109
import { updateColumn, deleteColumn } from "@/api/columns";
11-
import { getDashboardMembers } from "@/api/card";
10+
import { getDashboardMembers, getCardDetail } from "@/api/card";
1211
import { MemberType } from "@/types/users";
13-
import CardDetailModal from "../modalDashboard/CardDetailModal";
1412
import { TEAM_ID } from "@/constants/team";
13+
import CardList from "./CardList";
14+
import CardDetailModal from "@/components/modalDashboard/CardDetailModal";
15+
import { CardDetailType } from "@/types/cards";
1516

1617
type ColumnProps = {
1718
columnId: number;
@@ -30,21 +31,17 @@ export default function Column({
3031
const [isColumnModalOpen, setIsColumnModalOpen] = useState(false);
3132
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
3233
const [isTodoModalOpen, setIsTodoModalOpen] = useState(false);
34+
const [isCardDetailModalOpen, setIsCardDetailModalOpen] = useState(false);
35+
const [selectedCard, setSelectedCard] = useState<CardDetailType | null>(null);
3336
const [members, setMembers] = useState<
3437
{ id: number; userId: number; nickname: string }[]
3538
>([]);
36-
const [selectedCard, setSelectedCard] = useState<CardDetailType | null>(null);
37-
38-
const handleCloseDetailModal = () => {
39-
setSelectedCard(null);
40-
};
4139

40+
// ✅ 멤버 불러오기
4241
useEffect(() => {
4342
const fetchMembers = async () => {
4443
try {
45-
const result = await getDashboardMembers({
46-
dashboardId,
47-
});
44+
const result = await getDashboardMembers({ dashboardId });
4845

4946
const parsed = result.map((m: MemberType) => ({
5047
id: m.id,
@@ -89,13 +86,23 @@ export default function Column({
8986
}
9087
};
9188

89+
const handleCardClick = async (cardId: number) => {
90+
try {
91+
const detail = await getCardDetail(cardId);
92+
setSelectedCard(detail);
93+
setIsCardDetailModalOpen(true);
94+
} catch (e) {
95+
console.error("카드 상세 불러오기 실패:", e);
96+
}
97+
};
98+
9299
return (
93100
<div
94101
className={`
95-
flex flex-col border-r border-gray-200 bg-gray-50 rounded-md p-4
96-
h-auto sm:m-h-screen
97-
max-h-[401px] sm:max-h-none
98-
`}
102+
flex flex-col border-r border-[#EEEEEE] bg-gray-50 rounded-md p-4
103+
h-auto sm:m-h-screen
104+
max-h-[401px] sm:max-h-none w-full lg:w-[360px]
105+
`}
99106
>
100107
{/* 칼럼 헤더 */}
101108
<div className="flex items-center justify-between">
@@ -119,49 +126,19 @@ export default function Column({
119126
</div>
120127

121128
{/* 카드 영역 */}
122-
<div className=" flex-1 pb-4 flex flex-col items-center gap-3 ">
129+
<div className="flex-1 pb-4 flex flex-col items-center gap-3">
123130
<div onClick={() => setIsTodoModalOpen(true)} className="mb-2">
124131
<TodoButton />
125132
</div>
126133

127-
{/* 카드 1개만 렌더링 (모바일), 전체 렌더링 (md 이상) */}
128-
<div className="w-full flex flex-wrap justify-center gap-3">
129-
{tasks.map((task, index) => {
130-
const isMobile =
131-
typeof window !== "undefined" && window.innerWidth < 768;
132-
if (isMobile && index > 0) return null;
133-
return (
134-
<div
135-
key={task.id}
136-
onClick={() =>
137-
setSelectedCard({
138-
id: task.id,
139-
title: task.title,
140-
tags: task.tags,
141-
dueDate: task.dueDate,
142-
assignee: {
143-
...task.assignee,
144-
profileImageUrl: task.assignee.profileImageUrl ?? "",
145-
},
146-
imageUrl: task.imageUrl ?? null,
147-
description: task.description ?? "",
148-
columnId: task.columnId,
149-
dashboardId: task.dashboardId,
150-
status: columnTitle,
151-
createdAt: new Date().toISOString(),
152-
updatedAt: new Date().toISOString(),
153-
})
154-
}
155-
>
156-
<Card
157-
key={task.id}
158-
{...task}
159-
imageUrl={task.imageUrl}
160-
assignee={task.assignee}
161-
/>
162-
</div>
163-
);
164-
})}
134+
{/* 무한스크롤 카드 리스트로 대체 */}
135+
<div className="w-full max-h-[800px] overflow-y-auto">
136+
<CardList
137+
columnId={columnId}
138+
teamId={TEAM_ID}
139+
initialTasks={tasks}
140+
onCardClick={(card) => handleCardClick(card.id)}
141+
/>
165142
</div>
166143
</div>
167144

@@ -195,12 +172,17 @@ export default function Column({
195172
onClose={() => setIsDeleteModalOpen(false)}
196173
onDelete={handleDeleteColumn}
197174
/>
198-
{selectedCard && (
175+
176+
{/* 카드 상세 모달 */}
177+
{isCardDetailModalOpen && selectedCard && (
199178
<CardDetailModal
200179
card={selectedCard}
201-
onClose={handleCloseDetailModal}
202180
currentUserId={selectedCard.assignee.id}
203181
dashboardId={dashboardId}
182+
onClose={() => {
183+
setIsCardDetailModalOpen(false);
184+
setSelectedCard(null);
185+
}}
204186
/>
205187
)}
206188
</div>

0 commit comments

Comments
 (0)