diff --git a/README.md b/README.md index 7a6ac13e..d5168068 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,174 @@ +

Taskify

+

협업과 일정 관리를 한 번에! 직관적인 대시보드 서비스

+
- Taskify + Taskify
+

+- 개발 기간 : 25.03.18 ~ 25.04.04
+- 리팩토링 : 25.04.08 ~ 25.04.15 +

+ +# 🗓️ Taskify는? -- Taskify 일정관리 서비스 -- 개발 기간 : 25.03.18 ~ 25.04.04 +카드를 등록해 일정을 관리할 수 있는 그룹 대시보드예요!
+멤버를 초대해 일정을 공유하고 협업을 관리해 보세요!
+정식 프로젝트 기간 종료 후 리팩토링을 진행하며 직접 이용했어요. -# Team +# 🤝 Team ### 황혜진 -- 팀장 -- 공통 컴포넌트 Button, Modal을 크기 및 버튼 개수 설정 등 유동적인 UI로 구성 -- 주요 컴포넌트 작성: Card, CardList, Column -- CardList, Column의 스크롤 바닥 감지를 통한 무한 스크롤 기능 구현 -- [dashboardId]index에서 칼럼 및 카드 데이터 동적 렌더링 -- 카드 생성 / 삭제 / 상세조회 기능을 포함한 모달 기반 UI 작성 (AddColumnModal, ColumnDeleteModal, ColumnManageModal) -- 프로젝트 초기 컨벤션(파일명, 커밋 메시지, 브랜치 네이밍 등) 설정 및 팀 내 공유 +**팀장** + +1. **공통 컴포넌트** + +- `Button`, `Modal`을 크기 및 버튼 개수 설정 등 유동적인 UI로 구성 +- `Card`, `CardList`, `Column` 작성 +- `CardList`, `Column`의 스크롤 바닥 감지를 통한 무한 스크롤 기능 구현 +- 카드 생성 / 삭제 / 상세조회 기능을 포함한 모달 기반 UI 작성 (`AddColumnModal`, `ColumnDeleteModal`, `ColumnManageModal`) + +2. **페이지** + +- `[dashboardId]index`에서 칼럼 및 카드 데이터 동적 렌더링 +3. **기타 기여 사항** + +- 프로젝트 초기 컨벤션(파일명, 커밋 메시지, 브랜치 네이밍 등) 설정 및 팀 내 공유 ### 임용균 -- 프로젝트 세팅 -- 컴포넌트 작성 Input, SideMenu, TodoModal, TaskModal -- 페이지 작성 landing, MyDashboard -- SideMenu 접기/펴기 기능 및 반응형 +1. **공통 컴포넌트** + +- `Input`, `SideMenu`, `TodoModal`, `TaskModal` +- `SideMenu` 접기/펴기 기능 및 반응형 +- `TodoModal`, `TaskModal` Api 연동 및 업로드 기능 구현 + +2. **페이지** + +- `landing`, `MyDashboard` - MyDashboard Page 검색어 기반 필터링 및 페이지네이션 연동 -- TodoModal, TaskModal Api 연동 및 업로드 기능 구현 +- `MydashBoardPage` D&D 적용 +- `dexie` 를 활용하여 DB를 생성하여 D&D로 대시보드의 순서변경을 저장 +3. **로직 분리 및 성능 개선** + +- `useDashboardDragHandler` 커스텀 훅으로 `handleDragEnd` 로직 분리 +- `MydashBoardPage` 검색 필터링 useMemo최적화, 대시보드 리스트 필터링 useMemo 적용 +- `CardDetailModal` 상태 분리 및 로직분리, `useCardDetailState`, `useCardDetail` 상태분리 및 로직분리 +- `SideMenu` 페이지네이션`usePagination` 로직분리 및 `DashboardItem`로 사이드 메뉴 아이탬 관리 + +4. **리팩토링** + +- `sideMunu`, `landingPage` 등 CSS 리펙토링 +- `TaskModal` 담당자 선택에 프로필 이미지 적용 +- `Modalinput` DatePicker 리팩토링 및 한국어화 커스터마이징. 및 모바일 뷰 최적화 + +5. **기타 기여 사항** + +- 프로젝트 세팅 +- 프로젝트 전반 QA 및 리팩토링 ### 조민지 -- Style: globals.css, custom toast -- 컴포넌트 작성 Gnb -- 페이지 작성 login/signup -- login/logout 전역 상태 관리 -Zustand, UseAuthGuard -- mydashboard에 대시보드 편집 모드 추가 -- 대시보드 멤버 목록 드롭다운 메뉴 기능 -- 404 페이지 작성 -- QA +1. **전역 스타일링** + +- `globals.css`, `custom toast`, 전체 페이지 및 컴포넌트 전반 CSS + +2. **공통 컴포넌트** + +- Gnb (`HeaderDefalt`, `HeaderDashboard`), 모달, 태그 등 재사용 컴포넌트 공통화 +- `MemberListMenu` 드롭다운 메뉴 추가(타인의 대시보드 멤버 목록 확인 가능) + +3. **페이지** + +- 로그인 / 회원가입 / 404페이지 UI 및 기능 구현 + +4. **전역 상태 관리 & 예외 처리** +- Zustand, `useAuthGuard`를 활용한 전역 인증 상태 관리 및 비로그인 접근 제한 +- `postAuthGuard`로 중복 POST 요청 방지 +- 전역 `LoadingSpinner` 적용으로 페이지 이동 중 사용자 경험 개선 +- 카드, 대시보드, 멤버 등 생성/수정/삭제 시 새로고침 없이 상태가 즉시 반영되도록 구조 리팩토링 + +5. **UI/UX 리팩토링** + +- 전역 UI 통일: 버튼 순서(`취소` 왼쪽 / `확인` 오른쪽), `cursor`, `disabled`, 경고 메시지 style +- `SideMenu`, `Header`, `Card` 등에서 제목 생략 처리 및 리스트 overflow 대응 +- 이미지 팝업 모달 구현(클릭 시 원본 확인 가능) +- 모바일 대응: `window.innerHeight` 기반 높이 계산 적용 → 스크롤 버그 해결 +- 모바일 대응: input text size 16px 이상으로 조정 -> IOS 확대 방지 + +6. **에러 핸들링 및 사용자 경험 개선** + +- 글자수 제한, 이미지 용량 제한, 중복 이메일 가입 등 에러 toast 처리 +- 대시보드 삭제 재확인 모달 및 댓글/카드 삭제 시 확인 모달 추가 +- 대시보드 수정/삭제 접근성 개선을 위해 `mydashboard`에 대시보드 편집 모드 추가 +- 게스트 모드 초기 진입 시, 초대 내역 로딩 전 `EmptyInvitations` 컴포넌트가 깜빡이는 렌더링 버그 해결 (`setTimeout`을 활용한 상태 업데이트 defer 처리로 해결) + +7. **기타 기여 사항** + +- 게스트 모드 구현 +- 프로젝트 전반 QA 수행: 정렬 오류, 반응형 깨짐, 유효성 처리 미흡 등 직접 디버깅 혹은 팀원 수정 요청 +- **Taskify를 실사용**하며 리팩토링 작업 진행, 자체 보드를 통해 일정 관리
+ → 사용성 기반 QA를 병행하여 실사용자 관점에서의 불편함을 개선 ### 김교연 -- 컴포넌트 작성 invited/ MemberList, inviteRecords, invitedDashBoard, card, Modal -- invitedDashBoard 검색, 무한스크롤, 데이터 별 컴포넌트 분리 -- MemberList 프로필이미지 출력, Modal 대시보드 이름 변경 기능 +1. **공통 컴포넌트** + +- invited (`MemberList`, `inviteRecords`, `invitedDashBoard`), `card`, `Modal` +- `invitedDashBoard` 검색, 무한스크롤, 데이터 별 컴포넌트 분리 +- `MemberList` 프로필 이미지 출력, `Modal` 대시보드 이름 변경 기능 - 카드 프로필 및 비밀번호 변경 -- 대시보드 수정 페이지- 이름 변경, 구성원 관리, 대시보드 초대, 삭제 기능 디자인 및 기능 + +2. **페이지** + +- 대시보드 수정 페이지: 이름 변경, 구성원 관리, 대시보드 초대, 삭제 기능 디자인 및 기능 - toast 알람으로 피드백 추가 ### 정종우 -- apiRoutes 설정 -- 컴포넌트 작성 ModalDashBoard, Button(card, Columns,Todo) -- 페이지 작성 mypage +1. **공통 컴포넌트** + +- 컴포넌트 작성 `ModalDashBoard`, Button (`card`, `Columns`, `Todo`) - mypage 프로필 변경, 비밀번호 변경 기능 작성 - 대시보드 카드 모달 삭제기능 +2. **페이지** -# Images +- 페이지 작성 `mypage` -https://github.com/user-attachments/assets/64c0e04f-a5da-42c0-a576-1f27519447fb +3. **기타 기여 사항** +- `apiRoutes` 설정 +# Images + +
+ + Taskify + +
# Skill Stacks ## Environment - - Git GitHub VSCode Vercel Figma - - ## Development - - Tailwind CSS TypeScript Next.js ## Libraries -Axios clsx - +Axios clsx # Package Structure - ``` taskify ├─ public @@ -126,6 +205,7 @@ npm install ```bash npm start dev +npm run dev ``` 4. Open the project in your browser diff --git a/package-lock.json b/package-lock.json index 10b0d109..21b81b61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,14 @@ "name": "taskify", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", "@tanstack/react-query": "^5.68.0", "axios": "^1.8.3", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "dexie": "^4.0.11", "lodash": "^4.17.21", "lucide-react": "^0.485.0", "moment": "^2.30.1", @@ -57,6 +61,73 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.0.tgz", @@ -2491,6 +2562,12 @@ "node": ">=8" } }, + "node_modules/dexie": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.0.11.tgz", + "integrity": "sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A==", + "license": "Apache-2.0" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", diff --git a/package.json b/package.json index 53255b5c..007fd373 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,14 @@ "lint": "next lint" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", "@tanstack/react-query": "^5.68.0", "axios": "^1.8.3", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "dexie": "^4.0.11", "lodash": "^4.17.21", "lucide-react": "^0.485.0", "moment": "^2.30.1", diff --git a/public/images/README.gif b/public/images/README.gif new file mode 100644 index 00000000..0f0939c4 Binary files /dev/null and b/public/images/README.gif differ diff --git a/src/api/card.ts b/src/api/card.ts index afc6bb9d..d7551395 100644 --- a/src/api/card.ts +++ b/src/api/card.ts @@ -1,8 +1,19 @@ import axiosInstance from "./axiosInstance"; -import type { CardDetailType } from "@/types/cards"; // Dashboard 타입 import +import type { CardDetailType } from "@/types/cards"; import { apiRoutes } from "@/api/apiRoutes"; import { TEAM_ID } from "@/constants/team"; +/** 카드 수정용 타입 */ +export interface EditCardPayload { + columnId?: number; + assigneeUserId?: number; + title?: string; + description?: string; + dueDate?: string; + tags?: string[]; + imageUrl?: string; +} + /** 1. 카드 이미지 업로드 */ export const uploadCardImage = async ({ columnId, @@ -43,7 +54,7 @@ export const createCard = async ({ columnId: number; title: string; description: string; - dueDate: string; + dueDate?: string; tags: string[]; imageUrl?: string; }) => { @@ -83,47 +94,16 @@ export const getDashboardMembers = async ({ }; /** 4. 카드 수정 */ -export const updateCard = async ( - id: number, - data: Partial, - { - cardId, - columnId, - assigneeUserId, - title, - description, - dueDate, - tags, - imageUrl, - }: { - cardId: number; - columnId: number; - assigneeUserId: number; - title: string; - description: string; - dueDate: string; - tags: string[]; - imageUrl?: string; - } -) => { - const response = await axiosInstance.put(apiRoutes.cardDetail(cardId), { - columnId, - assigneeUserId, - title, - description, - dueDate, - tags, - imageUrl, - }); - +export const editCard = async (cardId: number, data: EditCardPayload) => { + const response = await axiosInstance.put(apiRoutes.cardDetail(cardId), data); return response.data; }; -// 카드 목록 조회 +/** 5. 카드 목록 조회 */ export const getCardsByColumn = async ({ columnId, cursorId, - size = 10, + size = 300, }: { columnId: number; cursorId?: number; @@ -140,10 +120,9 @@ export const getCardsByColumn = async ({ return res.data; }; -// 카드 상세 조회 +/** 6. 카드 상세 조회 */ export async function getCardDetail(cardId: number): Promise { try { - // apiRoutes를 사용하여 URL 동적 생성 const url = apiRoutes.cardDetail(cardId); const response = await axiosInstance.get(url); return response.data as CardDetailType; @@ -153,17 +132,9 @@ export async function getCardDetail(cardId: number): Promise { } } -// 카드 삭제 +/** 7. 카드 삭제 */ export const deleteCard = async (cardId: number) => { const url = apiRoutes.cardDetail(cardId); const response = await axiosInstance.delete(url); return response.data; }; -//카드 수정저장 -export const EditCard = async ( - cardId: number, - data: Partial -) => { - const response = await axiosInstance.put(apiRoutes.cardDetail(cardId), data); - return response.data; -}; diff --git a/src/api/members.ts b/src/api/members.ts index b37f1e0d..3a85b666 100644 --- a/src/api/members.ts +++ b/src/api/members.ts @@ -1,21 +1,34 @@ import axiosInstance from "./axiosInstance"; import { apiRoutes } from "./apiRoutes"; +import { MemberType } from "@/types/users"; -// 대시보드 멤버 목록 조회 -export const getMembers = async ({ dashboardId }: { dashboardId: number }) => { +// 🔹 대시보드 멤버 목록 조회 +export const getMembers = async ({ + dashboardId, +}: { + dashboardId: number; +}): Promise => { if (!dashboardId) { - console.error("dashboardID가 없습니다."); + console.error("dashboardId가 없습니다."); + return []; + } + + try { + const response = await axiosInstance.get(apiRoutes.members(), { + params: { + dashboardId, + }, + }); + + const members: MemberType[] = response.data.members || []; + return members; + } catch (error) { + console.error("getMembers API 실패:", error); return []; } - const response = await axiosInstance.get(apiRoutes.members(), { - params: { - dashboardId, - }, - }); - return response.data.members || []; }; -// 대시보드 멤버 삭제 +// 🔹 대시보드 멤버 삭제 export const deleteMembers = async (memberId: number) => { const response = await axiosInstance.delete(apiRoutes.memberDetail(memberId)); return response.data; diff --git a/src/api/users.ts b/src/api/users.ts index 934928c7..bf56f8a3 100644 --- a/src/api/users.ts +++ b/src/api/users.ts @@ -20,7 +20,7 @@ export const signUp = async ({ payload }: { payload: SignUpRequest }) => { }; // 내 정보 조회 (GET) -export const getUserInfo = async () => { +export const getUserInfo = async (): Promise => { const response = await axiosInstance.get(apiRoutes.usersMe()); return response.data; }; diff --git a/src/components/Layouts/DashboardLayout.tsx b/src/components/Layouts/DashboardLayout.tsx index 283eb82d..949f42f5 100644 --- a/src/components/Layouts/DashboardLayout.tsx +++ b/src/components/Layouts/DashboardLayout.tsx @@ -16,7 +16,7 @@ const DashboardLayout = ({ dashboardId, }: DashboardLayoutProps) => { return ( -
+
diff --git a/src/components/button/BackButton.tsx b/src/components/button/BackButton.tsx new file mode 100644 index 00000000..077083d5 --- /dev/null +++ b/src/components/button/BackButton.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import Image from "next/image"; + +const BackButton = () => { + return ( +
+ history.back()} + src="/svgs/arrow-backward-black.svg" + alt="돌아가기" + width={20} + height={20} + className="cursor-pointer" + /> + +
+ ); +}; + +export default BackButton; diff --git a/src/components/button/CardButton.tsx b/src/components/button/CardButton.tsx index a60009b6..11763174 100644 --- a/src/components/button/CardButton.tsx +++ b/src/components/button/CardButton.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; import clsx from "clsx"; import Image from "next/image"; -interface CardButtonProps extends React.ButtonHTMLAttributes { +interface CardButtonProps extends React.HTMLAttributes { title?: string; showCrown?: boolean; color?: string; @@ -12,29 +12,31 @@ interface CardButtonProps extends React.ButtonHTMLAttributes { createdByMe?: boolean; onDeleteClick?: (id: number) => void; onLeaveClick?: (id: number) => void; + attributes?: React.HTMLAttributes; + listeners?: React.HTMLAttributes; } const CardButton: React.FC = ({ className, title = "비브리지", showCrown = true, - color = "#7ac555", // 기본 색상 + color = "#7ac555", isEditMode = false, dashboardId, createdByMe, onDeleteClick, onLeaveClick, + attributes, + listeners, ...props }) => { const router = useRouter(); const handleCardClick = (e: React.MouseEvent) => { - // 관리 상태에서 카드 클릭 이벤트 차단 if (isEditMode) { e.preventDefault(); return; } - // 카드 클릭 시 해당 대시보드로 이동 router.push(`/dashboard/${dashboardId}`); }; @@ -46,16 +48,16 @@ const CardButton: React.FC = ({ const handleDelete = (e: React.MouseEvent) => { e.stopPropagation(); if (createdByMe) { - // 실제 삭제 API 요청 - if (onDeleteClick) onDeleteClick(dashboardId); + onDeleteClick?.(dashboardId); } else { - // 나만 탈퇴 - if (onLeaveClick) onLeaveClick(dashboardId); + onLeaveClick?.(dashboardId); } }; return (
= ({ "border border-[var(--color-gray3)]", "min-w-0 w-full max-w-[260px] md:max-w-[247px] lg:max-w-[332px]", "h-[58px] md:h-[68px] lg:h-[70px]", - "mt-[10px] md:mt-[16px] lg:mt-[20px]", + "mt-[2px]", "text-lg md:text-2lg lg:text-2lg", isEditMode ? "cursor-default hover:border-gray-300" @@ -73,32 +75,31 @@ const CardButton: React.FC = ({ )} > {/* 왼쪽: 색상 도트 + 제목 + 왕관 */} -
- {/* 색상 원 */} +
- - {/* 제목 */} - + {title} - - {/* 왕관 */} {showCrown && ( - crown Icon +
+ crown Icon +
)}
- {/* 오른쪽: 화살표 아이콘 or 수정/삭제 버튼 */} + {/* 오른쪽: 수정/삭제 버튼 또는 아이콘 */} {isEditMode ? ( -
+
{createdByMe && ( ); diff --git a/src/components/button/GuestModeButton.tsx b/src/components/button/GuestModeButton.tsx new file mode 100644 index 00000000..72daec04 --- /dev/null +++ b/src/components/button/GuestModeButton.tsx @@ -0,0 +1,43 @@ +import { useRouter } from "next/router"; +import useUserStore from "@/store/useUserStore"; +import { postAuthData } from "@/api/auth"; +import { getUserInfo } from "@/api/users"; +import { toast } from "react-toastify"; + +const GUEST_CREDENTIALS = { + email: "guest@gmail.com", + password: "qwer1155", +}; + +export default function GuestModeButton() { + const router = useRouter(); + const setUser = useUserStore((state) => state.setUser); + + const handleGuestLogin = async () => { + try { + const response = await postAuthData(GUEST_CREDENTIALS); + const token = response.accessToken; + localStorage.setItem("accessToken", token); + + const userData = await getUserInfo(); + setUser(userData); + router.push("/mydashboard"); + toast.success("게스트 모드로 로그인되었습니다."); + } catch (error) { + console.error("게스트 로그인 실패:", error); + toast.error("게스트 로그인에 실패했습니다."); + } + }; + + return ( + + ); +} diff --git a/src/components/button/PaginationButton.tsx b/src/components/button/PaginationButton.tsx index 2c22d539..70dc1d52 100644 --- a/src/components/button/PaginationButton.tsx +++ b/src/components/button/PaginationButton.tsx @@ -12,11 +12,12 @@ export const PaginationButton: React.FC = ({ ...props }) => { const baseStyle = - "w-[40px] h-[40px] flex justify-center items-center border border-[var(--color-gray3)] rounded-md text-[16px] font-medium transition"; + "w-[40px] h-[40px] flex justify-center items-center bg-white border border-[var(--color-gray3)] rounded-md text-[16px] font-medium transition"; const enabledTextColor = "text-[var(--color-gray1)] bg-white hover:bg-[var(--color-gray5)] cursor-pointer"; - const disabledTextColor = "text-[var(--color-gray3)] cursor-default"; + const disabledTextColor = + "bg-[var(--color-gray4)] text-[var(--color-gray3)] cursor-default"; const finalStyle = `${baseStyle} ${disabled ? disabledTextColor : enabledTextColor}`; return ( diff --git a/src/components/button/SortableCardButton.tsx b/src/components/button/SortableCardButton.tsx new file mode 100644 index 00000000..019695c8 --- /dev/null +++ b/src/components/button/SortableCardButton.tsx @@ -0,0 +1,40 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import CardButton from "./CardButton"; +import { Dashboard } from "@/api/dashboards"; + +export default function SortableCardButton({ + dashboard, + ...rest +}: { + dashboard: Dashboard; + isEditMode?: boolean; + onDeleteClick?: (id: number) => void; + onLeaveClick?: (id: number) => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ + id: dashboard.id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: 10, + }; + + return ( +
+ +
+ ); +} diff --git a/src/components/button/TodoButton.tsx b/src/components/button/TodoButton.tsx index 592c77b2..ddbaf82d 100644 --- a/src/components/button/TodoButton.tsx +++ b/src/components/button/TodoButton.tsx @@ -5,7 +5,7 @@ interface ButtonProps extends React.ButtonHTMLAttributes { fullWidth?: boolean; } -const TodoButton: React.FC = ({ +export const TodoButton: React.FC = ({ fullWidth = false, className, children, @@ -17,7 +17,7 @@ const TodoButton: React.FC = ({ "flex justify-center items-center gap-[10px] bg-white transition-all", "rounded-lg px-4 py-3 font-semibold", "border border-gray-200 hover:border-purple-500", - fullWidth ? "w-full" : "w-[284px] md:w-[544px] lg:w-[314px]", + fullWidth ? "w-full" : "w-[225px] sm:w-[525px] lg:w-[314px]", "h-[32px] md:h-[40px] lg:h-[40px]", "mt-[10px] md:mt-[16px] lg:mt-[20px]", "text-lg md:text-2lg lg:text-2lg", @@ -34,4 +34,23 @@ const TodoButton: React.FC = ({ ); }; -export default TodoButton; +export const ShortTodoButton = () => { + return ( + + ); +}; diff --git a/src/components/card/ChangePassword.tsx b/src/components/card/ChangePassword.tsx index 7cfff3bc..c61f9ad8 100644 --- a/src/components/card/ChangePassword.tsx +++ b/src/components/card/ChangePassword.tsx @@ -1,16 +1,17 @@ import { useState } from "react"; -import { useRouter } from "next/router"; import { changePassword } from "@/api/changepassword"; import Input from "@/components/input/Input"; import { toast } from "react-toastify"; +import { useUserPermission } from "@/hooks/useUserPermission"; export default function ChangePassword() { - const router = useRouter(); const [password, setPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [checkNewpassword, setCheckNewPassword] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); + const isGuest = useUserPermission(); + const isPasswordMismatch = !!checkNewpassword && checkNewpassword !== newPassword; const isDisabled = @@ -23,6 +24,11 @@ export default function ChangePassword() { const handleChangePassword = async () => { if (isDisabled) return; + if (isGuest) { + toast.error("게스트 계정은 정보를 변경할 수 없습니다."); + return; + } + setIsSubmitting(true); const result = await changePassword({ password, newPassword }); @@ -42,15 +48,12 @@ export default function ChangePassword() { setNewPassword(""); setCheckNewPassword(""); setIsSubmitting(false); - setTimeout(() => { - router.reload(); - }, 1500); }; return (

비밀번호 변경 @@ -77,7 +80,7 @@ export default function ChangePassword() { value={newPassword} onChange={setNewPassword} pattern=".{8,}" - invalidMessage="8자 이상 입력해주세요." + invalidMessage="8자 이상 입력해 주세요." className="max-w-[624px]" /> - {isPasswordMismatch && ( -

- 비밀번호가 일치하지 않습니다. -

- )}

); -} +}; diff --git a/src/components/columnCard/AddColumnModal.tsx b/src/components/columnCard/AddColumnModal.tsx deleted file mode 100644 index c97c96a6..00000000 --- a/src/components/columnCard/AddColumnModal.tsx +++ /dev/null @@ -1,64 +0,0 @@ -// components/modal/AddColumnModal.tsx -import { Modal } from "@/components/modal/Modal"; -import Input from "@/components/input/Input"; -import { CustomBtn } from "@/components/button/CustomButton"; - -type AddColumnModalProps = { - isOpen: boolean; - onClose: () => void; - newColumnTitle: string; - setNewColumnTitle: (value: string) => void; - onSubmit: () => void; - isCreateDisabled: boolean; - invalidMessage: string; - pattern: string; -}; - -export default function AddColumnModal({ - isOpen, - onClose, - newColumnTitle, - setNewColumnTitle, - onSubmit, - isCreateDisabled, - invalidMessage, - pattern, -}: AddColumnModalProps) { - return ( - -
-

새 칼럼 생성

- - - -
- - 취소 - - - 생성 - -
-
-
- ); -} diff --git a/src/components/columnCard/Card.tsx b/src/components/columnCard/Card.tsx index a4da666f..f7e478d5 100644 --- a/src/components/columnCard/Card.tsx +++ b/src/components/columnCard/Card.tsx @@ -1,5 +1,7 @@ import { AssigneeType, CardType } from "@/types/task"; import Image from "next/image"; +import { getTagColor } from "../modalInput/chips/ColorTagChip"; +import RandomProfile from "@/components/common/RandomProfile"; type CardProps = CardType & { imageUrl?: string | null; @@ -19,9 +21,12 @@ export default function Card({
{/* 이미지 영역 */} @@ -30,10 +35,11 @@ export default function Card({ className={` mb-2 md:mb-0 md:mr-4 lg:mr-0 shrink-0 - w-full h-40 - md:w-[91px] md:h-[53px] - lg:w-full lg:h-40 - `} + w-full lg:w-full + lg:max-w-[100%] max-w-[77%] + lg:h-40 md:h-[53px] h-25 + md:w-[90px] + `} > )} - {/* 텍스트 콘텐츠 영역 */} -
+
{/* 제목 */}

{title} @@ -62,7 +66,8 @@ export default function Card({ {/* 태그 + 날짜 + 닉네임 */}
{/* 태그들 */}
- {tags.map((tag, idx) => ( - - {tag} - - ))} + {tags.map((tag, idx) => { + const { textColor, bgColor } = getTagColor(idx); + return ( + + {tag} + + ); + })}
- {/* 날짜 + 닉네임 */} -
-
+ {/* 마감일 */} +
+
calendar - {dueDate} + {dueDate ?? "마감일 없음"} +
+ {/* 프로필 아이콘 */} +
+ {assignee.profileImageUrl ? ( + 프로필 이미지 + ) : ( +
+ +
+ )}
- {assignee.profileImageUrl ? ( - 프로필 이미지 - ) : ( -
- {assignee.nickname[0]} -
- )}
diff --git a/src/components/columnCard/CardList.tsx b/src/components/columnCard/CardList.tsx index 0469486e..32d882b0 100644 --- a/src/components/columnCard/CardList.tsx +++ b/src/components/columnCard/CardList.tsx @@ -1,100 +1,74 @@ -import { useEffect, useRef, useState, useCallback } from "react"; +import { useEffect, useState, useRef } from "react"; import { CardType } from "@/types/task"; -import Card from "./Card"; import { getCardsByColumn } from "@/api/card"; +import SortableCard from "@/components/columnCard/SortableCard"; +import { toast } from "react-toastify"; -type CardListProps = { +interface CardListProps { columnId: number; teamId: string; initialTasks: CardType[]; onCardClick: (card: CardType) => void; -}; - -const ITEMS_PER_PAGE = 6; + scrollRoot?: React.RefObject; +} -export default function CardList({ - columnId, +export const CardList = ({ initialTasks, + columnId, onCardClick, -}: CardListProps) { - const [cards, setCards] = useState(initialTasks); - const [cursorId, setCursorId] = useState( - initialTasks.length > 0 ? initialTasks[initialTasks.length - 1].id : null - ); - const [hasMore, setHasMore] = useState(true); + scrollRoot, +}: CardListProps) => { + const [cards, setCards] = useState([]); const observerRef = useRef(null); - const isFetchingRef = useRef(false); - - /* cursorId 업데이트 방식 변경 */ - const fetchMoreCards = useCallback(async () => { - if (isFetchingRef.current || !hasMore) return; - - isFetchingRef.current = true; - - try { - const res = await getCardsByColumn({ - columnId, - size: ITEMS_PER_PAGE, - cursorId: cursorId ?? undefined, // 최신 cursorId 사용 - }); - const newCards = res.cards as CardType[]; - - if (newCards.length > 0) { - setCards((prev) => { - const existingIds = new Set(prev.map((card) => card.id)); - const uniqueCards = newCards.filter( - (card) => !existingIds.has(card.id) - ); - return [...prev, ...uniqueCards]; - }); - - // cursorId 안전하게 업데이트 - setCursorId((prevCursorId) => { - const newCursor = newCards[newCards.length - 1]?.id ?? prevCursorId; - return newCursor; + // 카드 목록 api 호출 (마감일 빠른 순 정렬) + useEffect(() => { + const fetchCards = async () => { + try { + const res = await getCardsByColumn({ columnId }); + const sorted = [...res.cards].sort((a, b) => { + const dateA = a.dueDate ? new Date(a.dueDate).getTime() : Infinity; + const dateB = b.dueDate ? new Date(b.dueDate).getTime() : Infinity; + return dateA - dateB; }); + setCards(sorted); + } catch (error) { + console.error("카드 불러오기 실패:", error); + toast.error("카드를 불러오는 데 실패했습니다."); } + }; - if (newCards.length < ITEMS_PER_PAGE) { - setHasMore(false); - } - } catch (error) { - console.error("카드 로딩 실패:", error); - } finally { - isFetchingRef.current = false; - } - }, [columnId, cursorId, hasMore]); + fetchCards(); + }, [columnId, initialTasks]); - /* 무한 스크롤 */ + // 스크롤 useEffect(() => { - if (!observerRef.current) return; + if (!scrollRoot?.current || !observerRef.current) return; const observer = new IntersectionObserver( (entries) => { - if (entries[0].isIntersecting && hasMore) { - fetchMoreCards(); + if (entries[0].isIntersecting) { } }, - { threshold: 0.5 } + { + root: scrollRoot.current, + threshold: 1.0, + } ); observer.observe(observerRef.current); - return () => observer.disconnect(); - }, [fetchMoreCards, hasMore]); + return () => { + observer.disconnect(); + }; + }, [scrollRoot]); return ( -
- {cards.map((task) => ( - onCardClick(task)} - /> +
+ {cards.map((card) => ( + ))} - {hasMore &&
} +
); -} +}; diff --git a/src/components/columnCard/Column.tsx b/src/components/columnCard/Column.tsx index 3150e957..eb6c0a51 100644 --- a/src/components/columnCard/Column.tsx +++ b/src/components/columnCard/Column.tsx @@ -1,25 +1,29 @@ // Column.tsx -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import Image from "next/image"; import { CardType } from "@/types/task"; -import TodoModal from "@/components/modalInput/ToDoModal"; -import TodoButton from "@/components/button/TodoButton"; -import ColumnManageModal from "@/components/columnCard/ColumnManageModal"; +import TaskModal from "@/components/modalInput/TaskModal"; +import { TodoButton, ShortTodoButton } from "@/components/button/TodoButton"; import ColumnDeleteModal from "@/components/columnCard/ColumnDeleteModal"; import { updateColumn, deleteColumn } from "@/api/columns"; import { getDashboardMembers, getCardDetail } from "@/api/card"; import { MemberType } from "@/types/users"; import { TEAM_ID } from "@/constants/team"; -import CardList from "./CardList"; +import { CardList } from "./CardList"; import CardDetailModal from "@/components/modalDashboard/CardDetailModal"; import { CardDetailType } from "@/types/cards"; import { toast } from "react-toastify"; +import { useDashboardPermission } from "@/hooks/useDashboardPermission"; +import FormModal from "@/components/modal/FormModal"; type ColumnProps = { columnId: number; title?: string; tasks?: CardType[]; dashboardId: number; + createdByMe: boolean; + columnDelete: (columnId: number) => void; + fetchColumnsAndCards: () => void; }; export default function Column({ @@ -27,17 +31,34 @@ export default function Column({ title = "new Task", tasks = [], dashboardId, + createdByMe, + columnDelete, + fetchColumnsAndCards, }: ColumnProps) { + const { canEditColumns } = useDashboardPermission(dashboardId, createdByMe); + const [columnTitle, setColumnTitle] = useState(title); + const [editTitle, setEditTitle] = useState(columnTitle); + const [titleLength, setTitleLength] = useState(0); const [isColumnModalOpen, setIsColumnModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [isTodoModalOpen, setIsTodoModalOpen] = useState(false); + const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); const [isCardDetailModalOpen, setIsCardDetailModalOpen] = useState(false); const [selectedCard, setSelectedCard] = useState(null); const [members, setMembers] = useState< - { id: number; userId: number; nickname: string }[] + { + id: number; + userId: number; + nickname: string; + profileImageUrl: string | null; + }[] >([]); + const maxColumnTitleLength = 15; + + // 카드리스트의 스크롤을 칼럼 영역으로 이동 + const scrollRef = useRef(null); + // ✅ 멤버 불러오기 useEffect(() => { const fetchMembers = async () => { @@ -48,6 +69,7 @@ export default function Column({ id: m.id, userId: m.userId, nickname: m.nickname || m.email, + profileImageUrl: m.profileImageUrl, })); setMembers(parsed); @@ -61,18 +83,20 @@ export default function Column({ const handleEditColumn = async (newTitle: string) => { if (!newTitle.trim()) { - toast.error("칼럼 이름을 입력해주세요."); + toast.error("칼럼 제목을 입력해 주세요."); return; } + setIsColumnModalOpen(false); try { const updated = await updateColumn({ columnId, title: newTitle }); setColumnTitle(updated.title); setIsColumnModalOpen(false); - toast.success("칼럼이 변경되었습니다."); + toast.success("칼럼 제목이 변경되었습니다."); + fetchColumnsAndCards(); } catch (error) { - console.error("칼럼 이름 수정 실패:", error); - toast.error("칼럼 변경에 실패했습니다."); + console.error("칼럼 제목 수정 실패:", error); + toast.error("칼럼 제목 변경에 실패했습니다."); } }; @@ -80,6 +104,7 @@ export default function Column({ try { await deleteColumn({ columnId }); setIsDeleteModalOpen(false); + if (columnDelete) columnDelete(columnId); toast.success("칼럼이 삭제되었습니다."); } catch (error) { console.error("칼럼 삭제 실패:", error); @@ -100,72 +125,145 @@ export default function Column({ return (
{/* 칼럼 헤더 */} -
-
-

- {columnTitle} -

- - {tasks.length} - +
+
+ {/* 왼쪽: 타이틀 + 카드 개수 */} +
+ circle +

+ {columnTitle} +

+ + {tasks.length} + +
+ {/* 오른쪽: 생성 버튼 + 설정 버튼 */} +
+
{ + if (!canEditColumns) { + toast.error("읽기 전용 대시보드입니다."); + return; + } + setIsTaskModalOpen(true); + }} + className="block lg:hidden" + > + +
+
+ setting icon { + if (!canEditColumns) { + toast.error("읽기 전용 대시보드입니다."); + return; + } + setEditTitle(columnTitle); + setTitleLength(columnTitle.length); + setIsColumnModalOpen(true); + }} + /> +
+
- setting icon setIsColumnModalOpen(true)} - /> -
- - {/* 카드 영역 */} -
-
setIsTodoModalOpen(true)} className="mb-2"> - +
+
{ + if (!canEditColumns) { + toast.error("읽기 전용 대시보드입니다."); + return; + } + setIsTaskModalOpen(true); + }} + className="mb-2 hidden lg:block" + > + +
+
- {/* 무한스크롤 카드 리스트로 대체 */} -
+ {/* 스크롤바 컨테이너 */} +
+ {/* 카드 리스트 */} +
handleCardClick(card.id)} + scrollRoot={scrollRef} />
- {/* Todo 모달 */} - {isTodoModalOpen && ( - setIsTodoModalOpen(false)} - teamId={TEAM_ID} + {/* 카드 생성 모달 */} + {isTaskModalOpen && ( + setIsTaskModalOpen(false)} dashboardId={dashboardId} columnId={columnId} members={members} + initialData={{ + status: columnTitle, + }} + onSubmit={fetchColumnsAndCards} /> )} {/* 칼럼 관리 모달 */} - setIsColumnModalOpen(false)} - onDeleteClick={() => { - setIsColumnModalOpen(false); - setIsDeleteModalOpen(true); - }} - columnTitle={columnTitle} - onEditSubmit={handleEditColumn} - /> + {isColumnModalOpen && ( + { + if (value.length <= maxColumnTitleLength) { + setEditTitle(value); + setTitleLength(value.length); + } + }} + isInputValid={columnTitle.trim().length > 0} + charCount={{ + current: titleLength, + max: maxColumnTitleLength, + }} + onSubmit={() => { + handleEditColumn(editTitle); + setIsColumnModalOpen(false); + }} + submitText="변경" + leftButtonText="삭제" + onLeftButtonClick={() => { + setIsColumnModalOpen(false); + setIsDeleteModalOpen(true); + }} + onClose={() => setIsColumnModalOpen(false)} + /> + )} {/* 칼럼 삭제 확인 모달 */} )}
diff --git a/src/components/columnCard/ColumnDeleteModal.tsx b/src/components/columnCard/ColumnDeleteModal.tsx index 2e076ef9..d8851226 100644 --- a/src/components/columnCard/ColumnDeleteModal.tsx +++ b/src/components/columnCard/ColumnDeleteModal.tsx @@ -21,7 +21,9 @@ export default function ColumnDeleteModal({ height="h-[160px] sm:h-[174px]" >
-

칼럼의 모든 카드가 삭제됩니다.

+

+ 칼럼의 모든 카드가 삭제됩니다. +

취소 diff --git a/src/components/columnCard/ColumnManageModal.tsx b/src/components/columnCard/ColumnManageModal.tsx deleted file mode 100644 index 46af35b5..00000000 --- a/src/components/columnCard/ColumnManageModal.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useState } from "react"; -import { Modal } from "../modal/Modal"; -import { CustomBtn } from "../button/CustomButton"; -import Input from "../input/Input"; -import Image from "next/image"; - -type ColumnManageModalProps = { - isOpen: boolean; - onClose: () => void; - onDeleteClick: () => void; - columnTitle: string; - onEditSubmit: (newTitle: string) => void; -}; - -export default function ColumnManageModal({ - isOpen, - onClose, - onDeleteClick, - columnTitle, - onEditSubmit, -}: ColumnManageModalProps) { - const [newTitle, setNewTile] = useState(columnTitle); - - return ( - -
- close icon -

칼럼 관리

- -
- - 삭제 - - onEditSubmit(newTitle)}>변경 -
-
-
- ); -} diff --git a/src/components/columnCard/SortableCard.tsx b/src/components/columnCard/SortableCard.tsx new file mode 100644 index 00000000..f55ad71e --- /dev/null +++ b/src/components/columnCard/SortableCard.tsx @@ -0,0 +1,28 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import Card from "@/components/columnCard/Card"; +import { CardType } from "@/types/task"; + +interface SortableCardProps { + card: CardType; + onClick: (card: CardType) => void; +} + +export default function SortableCard({ card, onClick }: SortableCardProps) { + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ + id: card.id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: 5, + }; + + return ( +
+ onClick(card)} assignee={card.assignee} /> +
+ ); +} diff --git a/src/components/common/CustomToastContainer.tsx b/src/components/common/CustomToastContainer.tsx index f6714dba..568aa00d 100644 --- a/src/components/common/CustomToastContainer.tsx +++ b/src/components/common/CustomToastContainer.tsx @@ -5,9 +5,10 @@ import { ToastContainer, Slide } from "react-toastify"; const CustomToastContainer = () => { return ( { + router.events.on("routeChangeStart", startLoading); + router.events.on("routeChangeComplete", stopLoading); + router.events.on("routeChangeError", stopLoading); + + return () => { + router.events.off("routeChangeStart", startLoading); + router.events.off("routeChangeComplete", stopLoading); + router.events.off("routeChangeError", stopLoading); + }; + }, [router]); + + return null; +} diff --git a/src/components/common/LoadingSpinner.tsx b/src/components/common/LoadingSpinner.tsx index 2daca5bc..30211e60 100644 --- a/src/components/common/LoadingSpinner.tsx +++ b/src/components/common/LoadingSpinner.tsx @@ -2,7 +2,10 @@ import React from "react"; const LoadingSpinner = () => { return ( -
+
); diff --git a/src/components/common/RandomProfile.tsx b/src/components/common/RandomProfile.tsx new file mode 100644 index 00000000..a5459770 --- /dev/null +++ b/src/components/common/RandomProfile.tsx @@ -0,0 +1,32 @@ +interface RandomProfileProps { + userId: number; + name: string; + className?: string; +} + +// 4개의 고정된 색상 배열 +const colors = ["bg-[#C4B1A2]", "bg-[#9DD7ED]", "bg-[#FDD446]", "bg-[#FFC85A]"]; + +// id 숫자 기반 고정 색상 인덱스 생성 +function numberToIndex(id: number, length: number): number { + return id % length; +} + +export default function RandomProfile({ + userId, + name, + className, +}: RandomProfileProps) { + const index = numberToIndex(userId, colors.length); + const bgColor = colors[index]; + + return ( +
+ {name[0]} +
+ ); +} diff --git a/src/components/gnb/HeaderDashboard.tsx b/src/components/gnb/HeaderDashboard.tsx index 9944a903..1968e17e 100644 --- a/src/components/gnb/HeaderDashboard.tsx +++ b/src/components/gnb/HeaderDashboard.tsx @@ -1,17 +1,18 @@ import React, { useState, useEffect } from "react"; import { useRouter } from "next/router"; +import useUserStore from "@/store/useUserStore"; import clsx from "clsx"; import Image from "next/image"; import SkeletonUser from "@/shared/skeletonUser"; -import { MemberType, UserType } from "@/types/users"; +import { MemberType } from "@/types/users"; import { getMembers } from "@/api/members"; -import { getUserInfo } from "@/api/users"; import { getDashboardById } from "@/api/dashboards"; import { UserProfileIcon } from "@/components/gnb/ProfileIcon"; import MembersProfileIconList from "@/components/gnb/MembersProfileIconList"; import UserMenu from "@/components/gnb/UserMenu"; import MemberListMenu from "@/components/gnb/MemberListMenu"; import InviteDashboard from "@/components/modal/InviteDashboard"; +import { toast } from "react-toastify"; interface HeaderDashboardProps { variant?: "mydashboard" | "dashboard" | "edit" | "mypage"; @@ -28,12 +29,13 @@ const HeaderDashboard: React.FC = ({ onToggleEditMode, }) => { const router = useRouter(); + const user = useUserStore((state) => state.user); + const isUserLoading = !user; + const nickname = useUserStore((state) => state.user?.nickname); const [isLoading, setIsLoading] = useState(true); - const [user, setUser] = useState(null); const [members, setMembers] = useState([]); const [isMenuOpen, setIsMenuOpen] = useState(false); const [isListOpen, setIsListOpen] = useState(false); - const [errorMessage, setErrorMessage] = useState(""); const [dashboard, setDashboard] = useState<{ title: string; createdByMe: boolean; @@ -58,37 +60,17 @@ const HeaderDashboard: React.FC = ({ setMembers(members); } catch (error) { console.error("멤버 불러오기 실패:", error); - setErrorMessage("멤버 정보를 불러오지 못했습니다."); + toast.error("멤버 정보를 불러오지 못했습니다."); } finally { setIsLoading(false); } }; - if ( - (variant === "dashboard" || variant === "mypage" || variant === "edit") && - dashboardId - ) { + if (variant === "mypage" || variant === "mydashboard") { + setIsLoading(false); + } else if ((variant === "dashboard" || variant === "edit") && dashboardId) { fetchMembers(); } - }, [dashboardId, variant]); - - /*유저 정보 api 호출*/ - useEffect(() => { - const fetchUser = async () => { - try { - const user = await getUserInfo(); - setUser(user); - } catch (error) { - console.error("유저 정보 불러오기 실패", error); - setErrorMessage("유저 정보를 불러오지 못했습니다."); - } finally { - setIsLoading(false); - } - }; - const token = localStorage.getItem("accessToken"); - if (token) { - fetchUser(); - } - }, []); + }, [variant, dashboardId]); /*대시보드 api 호출*/ useEffect(() => { @@ -101,7 +83,7 @@ const HeaderDashboard: React.FC = ({ setDashboard(dashboardData); } catch (error) { console.error("대시보드 정보 불러오기 실패", error); - setErrorMessage("대시보드를 불러오지 못했습니다."); + toast.error("대시보드를 불러오지 못했습니다."); } finally { setIsLoading(false); } @@ -127,18 +109,20 @@ const HeaderDashboard: React.FC = ({ "border-b-[1px] border-b-[var(--color-gray3)]" )} > -
- {errorMessage && ( -

- {errorMessage} -

+
{/*헤더 제목*/} -
+

= ({ {/*멤버 목록*/} {variant !== "mydashboard" && ( -

+
{isLoading ? ( ) : ( members && (
e.stopPropagation()} onClick={() => setIsListOpen((prev) => !prev)} - className="flex items-center pl-[15px] md:pl-[25px] lg:pl-[30px] pr-[15px] md:pr-[25px] lg:pr-[30px] cursor-pointer" + className="flex items-center pl-[15px] md:pl-[25px] lg:pl-[30px] pr-[10px] md:pr-[25px] lg:pr-[30px] cursor-pointer" > = ({ )} {/*드롭다운 메뉴 너비 지정 목적의 유저 섹션 div*/} -
+
{/*구분선*/}
{/*유저 드롭다운 메뉴*/}
e.stopPropagation()} onClick={() => setIsMenuOpen((prev) => !prev)} - className="flex items-center gap-[12px] pl-[20px] md:pl-[30px] lg:pl-[35px] cursor-pointer overflow-hidden" + className="flex items-center gap-[12px] + pl-[15px] sm:pl-[30px] lg:pl-[35px] cursor-pointer overflow-hidden" > = ({ setIsMenuOpen={setIsMenuOpen} /> {/*유저 프로필*/} - {isLoading ? ( + {isUserLoading ? ( ) : ( user && ( <> - {user.nickname} + {nickname} ) diff --git a/src/components/gnb/MemberListMenu.tsx b/src/components/gnb/MemberListMenu.tsx index a396424a..2a4ffae5 100644 --- a/src/components/gnb/MemberListMenu.tsx +++ b/src/components/gnb/MemberListMenu.tsx @@ -24,14 +24,14 @@ const MemberListMenu: React.FC = ({ ref={ref} className={clsx( "absolute top-full right-0 w-[140px] sm:w-[190px] z-50", - "bg-white border border-[var(--color-gray3)] shadow", + "bg-white border border-[var(--color-gray3)] rounded-lg shadow", "transition-all duration-200 ease-out", isListOpen ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2 pointer-events-none" )} > -
    +
      {members.map((member) => (
    • = ({ members, isLoading, }) => { - // 출력할 프로필 아이콘 최대 개수 + // 표시할 프로필 아이콘 최대 개수 const [maxVisibleMembers, setMaxVisibleMembers] = useState(4); useEffect(() => { const handleResize = () => { - // Tailwind 기준 sm 이하 (모바일) + // 모바일 아이콘 최대 개수 if (window.innerWidth < 640) { setMaxVisibleMembers(2); } else { @@ -42,25 +42,40 @@ export const MembersProfileIconList: React.FC = ({ {members.slice(0, maxVisibleMembers).map((member) => (
      {member.profileImageUrl ? ( -
      +
      {member.nickname}
      ) : ( -
      - -
      + )}
      ))} {/* 출력되지 않은 나머지 멤버 수 */} {members.length > maxVisibleMembers && ( -
      +
      +{members.length - maxVisibleMembers}
      )} diff --git a/src/components/gnb/ProfileIcon.tsx b/src/components/gnb/ProfileIcon.tsx index e1d08b82..80849b77 100644 --- a/src/components/gnb/ProfileIcon.tsx +++ b/src/components/gnb/ProfileIcon.tsx @@ -1,5 +1,5 @@ import React from "react"; -import RandomProfile from "../table/member/RandomProfile"; +import RandomProfile from "@/components/common/RandomProfile"; import Image from "next/image"; import { MemberType, UserType } from "@/types/users"; @@ -10,7 +10,10 @@ interface MemberIconProps { } export const MemberProfileIcon: React.FC = ({ members }) => ( -
      +
      {members.profileImageUrl ? ( = ({ members }) => ( className="object-cover" /> ) : ( - + )}
      ); @@ -30,7 +37,11 @@ interface UserIconProps { } export const UserProfileIcon: React.FC = ({ user }) => ( -
      +
      {user.profileImageUrl ? ( = ({ user }) => ( className="object-cover" /> ) : ( - + )}
      ); diff --git a/src/components/gnb/UserMenu.tsx b/src/components/gnb/UserMenu.tsx index a87c58a6..88b68e7d 100644 --- a/src/components/gnb/UserMenu.tsx +++ b/src/components/gnb/UserMenu.tsx @@ -16,7 +16,7 @@ const dropdownButtonStyles = clsx( "flex justify-center md:justify-start items-center", "w-full px-3 py-3 gap-3", "text-sm lg:text-base text-black3", - "hover:text-[var(--primary)] hover:bg-[#f9f9f9] cursor-pointer" + "hover:text-[var(--primary)] hover:bg-[var(--color-violet8)] cursor-pointer" ); const UserMenu: React.FC = ({ isMenuOpen, setIsMenuOpen }) => { @@ -38,7 +38,7 @@ const UserMenu: React.FC = ({ isMenuOpen, setIsMenuOpen }) => { ref={ref} className={clsx( "absolute top-full right-0 w-full z-50", - "bg-white border border-[var(--color-gray3)] shadow", + "bg-white border border-[var(--color-gray3)] rounded-lg shadow", "transition-all duration-200 ease-out", isMenuOpen ? "opacity-100 translate-y-0" diff --git a/src/components/input/Input.tsx b/src/components/input/Input.tsx index 9da618cf..cc642b38 100644 --- a/src/components/input/Input.tsx +++ b/src/components/input/Input.tsx @@ -83,7 +83,7 @@ export default function Input(props: InputProps) { }; return ( -
      +
      {label && (