From e053e1b5dcc36b8342ca5a265e63475eaeec3a58 Mon Sep 17 00:00:00 2001 From: Nago730 Date: Fri, 16 May 2025 19:25:29 +0900 Subject: [PATCH 001/208] =?UTF-8?q?feat:=20=EC=9E=91=EC=84=B1=EC=9E=90=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EB=A7=81=20=EB=93=9C=EB=A1=AD=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/entities/issue/{ => api}/issueAPI.ts | 2 +- .../issue/hooks}/useIssueList.ts | 4 +- .../entities/issue/{ => model}/issue.types.ts | 0 frontend/src/entities/user/api/authorApi.ts | 24 ++++++++ .../src/entities/user/{ => api}/userApi.ts | 3 +- .../src/entities/user/hooks/useAuthorList.ts | 39 +++++++++++++ .../entities/user/{ => hooks}/useUserList.ts | 5 +- .../src/entities/user/model/author.types.ts | 25 +++++++++ .../entities/user/{ => model}/user.types.ts | 0 frontend/src/features/issueList/index.ts | 1 - .../src/features/issueList/ui/IssueItem.tsx | 2 +- .../src/features/issueList/ui/IssueList.tsx | 2 +- .../FilteringPanel/AssigneeDropdown.tsx | 2 +- .../widget/FilteringPanel/AuthorDropdown.tsx | 55 +++++++++++++++++++ .../IssueListHeader/IssueListHeader.tsx | 6 +- frontend/src/pages/issues/IssueListPage.tsx | 3 +- frontend/src/shared/api/mock/authorFixture.ts | 32 +++++++++++ frontend/src/shared/api/mock/index.ts | 3 + .../api/mock}/issueFixtures.ts | 4 +- .../user => shared/api/mock}/userFixtures.ts | 9 ++- frontend/src/shared/api/mockData.ts | 14 ++--- 21 files changed, 205 insertions(+), 30 deletions(-) rename frontend/src/entities/issue/{ => api}/issueAPI.ts (73%) rename frontend/src/{features/issueList/model => entities/issue/hooks}/useIssueList.ts (84%) rename frontend/src/entities/issue/{ => model}/issue.types.ts (100%) create mode 100644 frontend/src/entities/user/api/authorApi.ts rename frontend/src/entities/user/{ => api}/userApi.ts (87%) create mode 100644 frontend/src/entities/user/hooks/useAuthorList.ts rename frontend/src/entities/user/{ => hooks}/useUserList.ts (84%) create mode 100644 frontend/src/entities/user/model/author.types.ts rename frontend/src/entities/user/{ => model}/user.types.ts (100%) create mode 100644 frontend/src/features/issueList/widget/FilteringPanel/AuthorDropdown.tsx create mode 100644 frontend/src/shared/api/mock/authorFixture.ts create mode 100644 frontend/src/shared/api/mock/index.ts rename frontend/src/{entities/issue => shared/api/mock}/issueFixtures.ts (88%) rename frontend/src/{entities/user => shared/api/mock}/userFixtures.ts (71%) diff --git a/frontend/src/entities/issue/issueAPI.ts b/frontend/src/entities/issue/api/issueAPI.ts similarity index 73% rename from frontend/src/entities/issue/issueAPI.ts rename to frontend/src/entities/issue/api/issueAPI.ts index 60d47e3f1..28dc17ce3 100644 --- a/frontend/src/entities/issue/issueAPI.ts +++ b/frontend/src/entities/issue/api/issueAPI.ts @@ -1,5 +1,5 @@ import { getJSON } from '@/shared/api/client'; -import type { ApiResponse, IssueListData } from './issue.types'; +import type { ApiResponse, IssueListData } from '../model/issue.types'; export async function fetchIssues(): Promise { const res = await getJSON>('/api/issues'); diff --git a/frontend/src/features/issueList/model/useIssueList.ts b/frontend/src/entities/issue/hooks/useIssueList.ts similarity index 84% rename from frontend/src/features/issueList/model/useIssueList.ts rename to frontend/src/entities/issue/hooks/useIssueList.ts index e2b4b8918..98b3919a1 100644 --- a/frontend/src/features/issueList/model/useIssueList.ts +++ b/frontend/src/entities/issue/hooks/useIssueList.ts @@ -1,5 +1,5 @@ -import type { IssueListData } from '@/entities/issue/issue.types'; -import { fetchIssues } from '@/entities/issue/issueAPI'; +import { fetchIssues } from '@/entities/issue/api/issueAPI'; +import type { IssueListData } from '@/entities/issue/model/issue.types'; import { useEffect, useState } from 'react'; export function useIssueList() { diff --git a/frontend/src/entities/issue/issue.types.ts b/frontend/src/entities/issue/model/issue.types.ts similarity index 100% rename from frontend/src/entities/issue/issue.types.ts rename to frontend/src/entities/issue/model/issue.types.ts diff --git a/frontend/src/entities/user/api/authorApi.ts b/frontend/src/entities/user/api/authorApi.ts new file mode 100644 index 000000000..37c4bcc31 --- /dev/null +++ b/frontend/src/entities/user/api/authorApi.ts @@ -0,0 +1,24 @@ +import { getJSON } from '@/shared/api/client'; +import type { ApiResponse } from '@/shared/api/types'; +import type { AuthorApiDto, AuthorsResponseDto } from '../model/author.types'; + +/** API에서 반환되는 data 필드 타입 */ +export type AuthorListData = AuthorsResponseDto['data']; + +/** + * 전체 작성자(Author) 목록을 가져옵니다. + */ +export async function fetchAuthors(): Promise { + const res = await getJSON>('/api/issues/author'); + return res.data; +} + +/** + * 단일 작성자를 ID로 조회합니다. + */ +export async function fetchAuthorById(id: number): Promise { + const res = await getJSON>( + `/api/issues/author/${id}`, + ); + return res.data; +} diff --git a/frontend/src/entities/user/userApi.ts b/frontend/src/entities/user/api/userApi.ts similarity index 87% rename from frontend/src/entities/user/userApi.ts rename to frontend/src/entities/user/api/userApi.ts index 213d89ec8..8a91de38c 100644 --- a/frontend/src/entities/user/userApi.ts +++ b/frontend/src/entities/user/api/userApi.ts @@ -1,7 +1,6 @@ -// src/entities/user/api/userApi.ts import { getJSON } from '@/shared/api/client'; import type { ApiResponse } from '@/shared/api/types'; -import type { UserApiDto, UsersResponseDto } from './user.types'; +import type { UserApiDto, UsersResponseDto } from '../model/user.types'; /** API에서 반환되는 data 필드 타입 */ export type UserListData = UsersResponseDto['data']; diff --git a/frontend/src/entities/user/hooks/useAuthorList.ts b/frontend/src/entities/user/hooks/useAuthorList.ts new file mode 100644 index 000000000..2323fe5df --- /dev/null +++ b/frontend/src/entities/user/hooks/useAuthorList.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from 'react'; +import type { AuthorListData } from '../api/authorApi'; +import { fetchAuthors } from '../api/authorApi'; + +export function useAuthorList() { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + + async function load() { + setIsLoading(true); + try { + const result = await fetchAuthors(); + if (mounted) { + setData(result); + } + } catch (err: unknown) { + if (mounted) { + setError(err as Error); + } + } finally { + if (mounted) { + setIsLoading(false); + } + } + } + + load(); + + return () => { + mounted = false; + }; + }, []); + + return { data, isLoading, error }; +} diff --git a/frontend/src/entities/user/useUserList.ts b/frontend/src/entities/user/hooks/useUserList.ts similarity index 84% rename from frontend/src/entities/user/useUserList.ts rename to frontend/src/entities/user/hooks/useUserList.ts index 16334e9c1..1dc4aa6de 100644 --- a/frontend/src/entities/user/useUserList.ts +++ b/frontend/src/entities/user/hooks/useUserList.ts @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react'; -// src/entities/user/hooks/useUserList.ts -import type { UserListData } from './userApi'; -import { fetchUsers } from './userApi'; +import type { UserListData } from '../api/userApi'; +import { fetchUsers } from '../api/userApi'; export function useUserList() { const [data, setData] = useState(null); diff --git a/frontend/src/entities/user/model/author.types.ts b/frontend/src/entities/user/model/author.types.ts new file mode 100644 index 000000000..f0252a9fe --- /dev/null +++ b/frontend/src/entities/user/model/author.types.ts @@ -0,0 +1,25 @@ +// API 레이어가 반환하는 raw 타입 +export interface AuthorApiDto { + id: number; + username: string; + imageUrl: string; +} + +// API wrapper 전체 응답 +export interface AuthorsResponseDto { + success: boolean; + data: { + total: number; + page: number; + perPage: number; + authors: AuthorApiDto[]; + }; + error: string | null; +} + +// 프론트엔드에서 사용할 Author 타입 +export interface Author { + id: number; + username: string; + avatarUrl: string; // DTO의 imageUrl을 매핑 +} diff --git a/frontend/src/entities/user/user.types.ts b/frontend/src/entities/user/model/user.types.ts similarity index 100% rename from frontend/src/entities/user/user.types.ts rename to frontend/src/entities/user/model/user.types.ts diff --git a/frontend/src/features/issueList/index.ts b/frontend/src/features/issueList/index.ts index 38aa43f8e..dc401157d 100644 --- a/frontend/src/features/issueList/index.ts +++ b/frontend/src/features/issueList/index.ts @@ -1,2 +1 @@ export * from './ui/IssueList'; -export * from './model/useIssueList'; diff --git a/frontend/src/features/issueList/ui/IssueItem.tsx b/frontend/src/features/issueList/ui/IssueItem.tsx index f9cc7193d..542d2485d 100644 --- a/frontend/src/features/issueList/ui/IssueItem.tsx +++ b/frontend/src/features/issueList/ui/IssueItem.tsx @@ -1,6 +1,6 @@ import IconInfo from '@/assets/icon_info.svg?react'; import IconMilestone from '@/assets/milestone.svg?react'; -import type { Issue } from '@/entities/issue/issue.types'; +import type { Issue } from '@/entities/issue/model/issue.types'; import { Avatar, AvatarFallback, AvatarImage } from '@/shared/ui/avatar'; import { Badge } from '@/shared/ui/badge'; import { formatDistanceToNow } from 'date-fns'; diff --git a/frontend/src/features/issueList/ui/IssueList.tsx b/frontend/src/features/issueList/ui/IssueList.tsx index c44a7cae5..60448a99c 100644 --- a/frontend/src/features/issueList/ui/IssueList.tsx +++ b/frontend/src/features/issueList/ui/IssueList.tsx @@ -1,4 +1,4 @@ -import type { Issue } from '@/entities/issue/issue.types'; +import type { Issue } from '@/entities/issue/model/issue.types'; import { IssueListHeader } from '../widget'; import { IssueItem } from './IssueItem'; diff --git a/frontend/src/features/issueList/widget/FilteringPanel/AssigneeDropdown.tsx b/frontend/src/features/issueList/widget/FilteringPanel/AssigneeDropdown.tsx index b6bf45d23..eb3f0f9ed 100644 --- a/frontend/src/features/issueList/widget/FilteringPanel/AssigneeDropdown.tsx +++ b/frontend/src/features/issueList/widget/FilteringPanel/AssigneeDropdown.tsx @@ -1,4 +1,4 @@ -import { useUserList } from '@/entities/user/useUserList'; +import { useUserList } from '@/entities/user/hooks/useUserList'; // src/entities/user/ui/AssigneeDropdown.tsx import { CustomDropdownPanel, diff --git a/frontend/src/features/issueList/widget/FilteringPanel/AuthorDropdown.tsx b/frontend/src/features/issueList/widget/FilteringPanel/AuthorDropdown.tsx new file mode 100644 index 000000000..21a231bf5 --- /dev/null +++ b/frontend/src/features/issueList/widget/FilteringPanel/AuthorDropdown.tsx @@ -0,0 +1,55 @@ +// src/entities/user/ui/AuthorDropdown.tsx +import { useAuthorList } from '@/entities/user/hooks/useAuthorList'; +import { + CustomDropdownPanel, + type DropdownOption, +} from '@/shared/ui/CustomDropdownPanel'; +import { useMemo, useState } from 'react'; + +export default function AuthorDropdown() { + const [selected, setSelected] = useState(null); + const { data, isLoading, error } = useAuthorList(); + + // 항상 최상단에서 useMemo 호출 + const userOptions = useMemo(() => { + const noneOption: DropdownOption = { + id: 0, + value: 'none', + display: '작성자가 없는 이슈', + }; + + const fetchedOptions: DropdownOption[] = + data?.authors.map((author) => ({ + id: author.id, + value: author.username, + display: author.username, + imageUrl: author.imageUrl, + })) ?? []; + + return [noneOption, ...fetchedOptions]; + }, [data]); + + // 로딩 및 에러 상태 처리 + if (isLoading) { + return
작성자 목록 로딩 중…
; + } + if (error) { + return ( +
+ 작성자 목록을 불러오는 중 에러가 발생했습니다. +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/frontend/src/features/issueList/widget/IssueListHeader/IssueListHeader.tsx b/frontend/src/features/issueList/widget/IssueListHeader/IssueListHeader.tsx index fbafebd2c..09f5c7511 100644 --- a/frontend/src/features/issueList/widget/IssueListHeader/IssueListHeader.tsx +++ b/frontend/src/features/issueList/widget/IssueListHeader/IssueListHeader.tsx @@ -1,5 +1,6 @@ -import ExampleAssigneeDropdown from '@/features/issueList/widget/FilteringPanel/AssigneeDropdown'; +import AssigneeDropdown from '@/features/issueList/widget/FilteringPanel/AssigneeDropdown'; import { Checkbox } from '../../shared/CheckBox'; +import AuthorDropdown from '../FilteringPanel/AuthorDropdown'; export function IssueListHeader() { return ( @@ -9,7 +10,8 @@ export function IssueListHeader() { 열린이슈/닫힌이슈
- + +
); diff --git a/frontend/src/pages/issues/IssueListPage.tsx b/frontend/src/pages/issues/IssueListPage.tsx index f34e5f4e3..c39e34412 100644 --- a/frontend/src/pages/issues/IssueListPage.tsx +++ b/frontend/src/pages/issues/IssueListPage.tsx @@ -1,4 +1,5 @@ -import { IssueList, useIssueList } from '@/features/issueList'; +import { useIssueList } from '@/entities/issue/hooks/useIssueList'; +import { IssueList } from '@/features/issueList'; import { IssueCreationButton, IssueFilter, diff --git a/frontend/src/shared/api/mock/authorFixture.ts b/frontend/src/shared/api/mock/authorFixture.ts new file mode 100644 index 000000000..bb72c9cbc --- /dev/null +++ b/frontend/src/shared/api/mock/authorFixture.ts @@ -0,0 +1,32 @@ +import type { AuthorListData } from '@/entities/user/api/authorApi'; +import type { AuthorApiDto } from '@/entities/user/model/author.types'; +import type { ApiResponse } from '@/shared/api/types'; + +// Mock author data +const authors: AuthorApiDto[] = [ + { + id: 1, + username: 'Author1', + imageUrl: 'https://example.com/avatar/alice.png', + }, + { id: 2, username: 'bob', imageUrl: 'https://example.com/avatar/bob.png' }, + { + id: 3, + username: 'Athour2', + imageUrl: 'https://example.com/avatar/charlie.png', + }, + { id: 4, username: 'dave', imageUrl: 'https://example.com/avatar/dave.png' }, + { id: 5, username: 'eve', imageUrl: 'https://example.com/avatar/eve.png' }, +]; + +// Mock API response for author list +export const mockAuthorListResponse: ApiResponse = { + success: true, + data: { + total: authors.length, + page: 0, // 0-based page index + perPage: authors.length, + authors, + }, + error: null, +}; diff --git a/frontend/src/shared/api/mock/index.ts b/frontend/src/shared/api/mock/index.ts new file mode 100644 index 000000000..c3e969fb4 --- /dev/null +++ b/frontend/src/shared/api/mock/index.ts @@ -0,0 +1,3 @@ +export * from './authorFixture'; +export * from './issueFixtures'; +export * from './userFixtures'; diff --git a/frontend/src/entities/issue/issueFixtures.ts b/frontend/src/shared/api/mock/issueFixtures.ts similarity index 88% rename from frontend/src/entities/issue/issueFixtures.ts rename to frontend/src/shared/api/mock/issueFixtures.ts index 386843e87..08b754f88 100644 --- a/frontend/src/entities/issue/issueFixtures.ts +++ b/frontend/src/shared/api/mock/issueFixtures.ts @@ -1,5 +1,5 @@ -// src/entities/issue/issueFixtures.ts -import type { ApiResponse, Issue, IssueListData } from './issue.types'; +import type { Issue, IssueListData } from '@/entities/issue/model/issue.types'; +import type { ApiResponse } from '@/shared/api/types'; const issues: Issue[] = [ { diff --git a/frontend/src/entities/user/userFixtures.ts b/frontend/src/shared/api/mock/userFixtures.ts similarity index 71% rename from frontend/src/entities/user/userFixtures.ts rename to frontend/src/shared/api/mock/userFixtures.ts index b3329e880..f85cd0b9a 100644 --- a/frontend/src/entities/user/userFixtures.ts +++ b/frontend/src/shared/api/mock/userFixtures.ts @@ -1,18 +1,17 @@ -// src/entities/user/userFixtures.ts +import type { UserListData } from '@/entities/user/api/userApi'; +import type { UserApiDto } from '@/entities/user/model/user.types'; import type { ApiResponse } from '@/shared/api/types'; -import type { UserApiDto } from './user.types'; -import type { UserListData } from './userApi'; // Mock user data const users: UserApiDto[] = [ { id: 1, - username: 'alice', + username: 'user1', imageUrl: 'https://example.com/alice.png', }, { id: 2, - username: 'bob', + username: 'user2', imageUrl: 'https://example.com/bob.png', }, ]; diff --git a/frontend/src/shared/api/mockData.ts b/frontend/src/shared/api/mockData.ts index af9453ad9..a0271af9b 100644 --- a/frontend/src/shared/api/mockData.ts +++ b/frontend/src/shared/api/mockData.ts @@ -1,5 +1,3 @@ -// src/shared/api/mockData.ts - /** * 경로별 mock 데이터를 반환하는 유틸 */ @@ -8,17 +6,17 @@ export type MockData = unknown; // 경로에 따른 mock 데이터 로더 매핑 const mockLoaders: Record Promise> = { '/api/issues': async () => { - const { mockIssueListResponse } = await import( - '@/entities/issue/issueFixtures' - ); + const { mockIssueListResponse } = await import('./mock/issueFixtures'); return mockIssueListResponse; }, '/api/users': async () => { - const { mockUserListResponse } = await import( - '@/entities/user/userFixtures' - ); + const { mockUserListResponse } = await import('./mock/userFixtures'); return mockUserListResponse; }, + '/api/issues/author': async () => { + const { mockAuthorListResponse } = await import('./mock/authorFixture'); + return mockAuthorListResponse; + }, }; /** From ab317ed505fd5e7cc262cda7a8d249c04762b09c Mon Sep 17 00:00:00 2001 From: Nago730 Date: Fri, 16 May 2025 19:35:13 +0900 Subject: [PATCH 002/208] =?UTF-8?q?fix:=20authorApi=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=20=EC=98=A4=ED=83=88=EC=9E=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/entities/user/api/authorApi.ts | 4 ++-- frontend/src/shared/api/mockData.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/entities/user/api/authorApi.ts b/frontend/src/entities/user/api/authorApi.ts index 37c4bcc31..36662755f 100644 --- a/frontend/src/entities/user/api/authorApi.ts +++ b/frontend/src/entities/user/api/authorApi.ts @@ -9,7 +9,7 @@ export type AuthorListData = AuthorsResponseDto['data']; * 전체 작성자(Author) 목록을 가져옵니다. */ export async function fetchAuthors(): Promise { - const res = await getJSON>('/api/issues/author'); + const res = await getJSON>('/api/issues/authors'); return res.data; } @@ -18,7 +18,7 @@ export async function fetchAuthors(): Promise { */ export async function fetchAuthorById(id: number): Promise { const res = await getJSON>( - `/api/issues/author/${id}`, + `/api/issues/authors/${id}`, ); return res.data; } diff --git a/frontend/src/shared/api/mockData.ts b/frontend/src/shared/api/mockData.ts index a0271af9b..5db4325cf 100644 --- a/frontend/src/shared/api/mockData.ts +++ b/frontend/src/shared/api/mockData.ts @@ -13,7 +13,7 @@ const mockLoaders: Record Promise> = { const { mockUserListResponse } = await import('./mock/userFixtures'); return mockUserListResponse; }, - '/api/issues/author': async () => { + '/api/issues/authors': async () => { const { mockAuthorListResponse } = await import('./mock/authorFixture'); return mockAuthorListResponse; }, From 7bf2b68e052fec9ad8a6b9dc272312b4e4b5f310 Mon Sep 17 00:00:00 2001 From: Nago730 Date: Fri, 16 May 2025 19:41:55 +0900 Subject: [PATCH 003/208] =?UTF-8?q?fix:=20=EC=9D=B4=EC=A0=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=A1=A4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/entities/user/api/authorApi.ts | 4 ++-- frontend/src/shared/api/mockData.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/entities/user/api/authorApi.ts b/frontend/src/entities/user/api/authorApi.ts index 36662755f..37c4bcc31 100644 --- a/frontend/src/entities/user/api/authorApi.ts +++ b/frontend/src/entities/user/api/authorApi.ts @@ -9,7 +9,7 @@ export type AuthorListData = AuthorsResponseDto['data']; * 전체 작성자(Author) 목록을 가져옵니다. */ export async function fetchAuthors(): Promise { - const res = await getJSON>('/api/issues/authors'); + const res = await getJSON>('/api/issues/author'); return res.data; } @@ -18,7 +18,7 @@ export async function fetchAuthors(): Promise { */ export async function fetchAuthorById(id: number): Promise { const res = await getJSON>( - `/api/issues/authors/${id}`, + `/api/issues/author/${id}`, ); return res.data; } diff --git a/frontend/src/shared/api/mockData.ts b/frontend/src/shared/api/mockData.ts index 5db4325cf..a0271af9b 100644 --- a/frontend/src/shared/api/mockData.ts +++ b/frontend/src/shared/api/mockData.ts @@ -13,7 +13,7 @@ const mockLoaders: Record Promise> = { const { mockUserListResponse } = await import('./mock/userFixtures'); return mockUserListResponse; }, - '/api/issues/authors': async () => { + '/api/issues/author': async () => { const { mockAuthorListResponse } = await import('./mock/authorFixture'); return mockAuthorListResponse; }, From 082331b0ae31bdccc0ad6dc69b876f05649650e9 Mon Sep 17 00:00:00 2001 From: "DESKTOP-4N4R3LH\\jqk17" Date: Sun, 18 May 2025 02:14:42 +0900 Subject: [PATCH 004/208] =?UTF-8?q?style:=20=EB=93=9C=EB=A1=AD=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=ED=8C=A8=EB=84=90=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/shared/ui/CustomDropdownPanel.tsx | 44 +++++++------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/frontend/src/shared/ui/CustomDropdownPanel.tsx b/frontend/src/shared/ui/CustomDropdownPanel.tsx index 061496364..d66f0c60d 100644 --- a/frontend/src/shared/ui/CustomDropdownPanel.tsx +++ b/frontend/src/shared/ui/CustomDropdownPanel.tsx @@ -3,6 +3,7 @@ import CheckOnCircleIcon from '@/assets/checkOnCircle.svg?react'; import ChevronDownIcon from '@/assets/chevronDown.svg?react'; import { useEffect, useRef, useState } from 'react'; import { cn } from '../utils/shadcn-utils'; +import { Avatar, AvatarFallback, AvatarImage } from './avatar'; export interface DropdownOption { id: number; @@ -34,9 +35,6 @@ export function CustomDropdownPanel({ const selectRef = useRef(null); const triggerRef = useRef(null); - // value: null → 'none' (미선택 시 구분) - const currentValue = value ?? 'none'; - // open 시 트리거 버튼의 x 위치로 정렬 방식 결정 useEffect(() => { if (open && triggerRef.current) { @@ -48,6 +46,7 @@ export function CustomDropdownPanel({ }, [open]); // 패널 열기/닫기 애니메이션 제어 + // biome-ignore lint: animating은 effect 실행 조건에 영향을 주지 않으므로 의도적으로 의존성 배열에서 제외함 useEffect(() => { if (open) { setAnimating('in'); @@ -103,12 +102,13 @@ export function CustomDropdownPanel({ visibility: open ? 'visible' : 'hidden', pointerEvents: open ? 'auto' : 'none', }} + // biome-ignore lint: 커스텀 UI 사용을 위한 ARIA role 사용 role='listbox' tabIndex={-1} > {/* 헤더 */}
{options.map((opt, idx) => { - const isSelected = currentValue === opt.value; + const isSelected = value === opt.value; return ( ); } -export function MilestoneListButton() { +export function MilestoneListButton({ + total, + className, +}: { total: number; className?: string }) { const navigate = useNavigate(); const handleClick = () => { navigate('/milestones'); @@ -35,10 +41,10 @@ export function MilestoneListButton() { size='sm' pattern='icon-text' onClick={handleClick} - className=' text=[var(--neutral-text-default)] font-available-medium-12 rounded-l-none' + className={`text=[var(--neutral-text-default)] font-available-medium-16 rounded-l-none ${className}`} > - 마일스톤 목록 + 마일스톤{`(${total && 0})`} ); } diff --git a/frontend/src/pages/issues/IssueListPage.tsx b/frontend/src/pages/issues/IssueListPage.tsx index 6b15aba4c..4a9eaab4b 100644 --- a/frontend/src/pages/issues/IssueListPage.tsx +++ b/frontend/src/pages/issues/IssueListPage.tsx @@ -35,7 +35,7 @@ const IssueListPage: FC = () => {
- +
@@ -44,11 +44,12 @@ const IssueListPage: FC = () => {
-
- +
+
- +
+
From e5bc2ec9d95ece76d318b7194208b778dbc06b57 Mon Sep 17 00:00:00 2001 From: "DESKTOP-4N4R3LH\\jqk17" Date: Sun, 18 May 2025 04:42:42 +0900 Subject: [PATCH 009/208] =?UTF-8?q?style:=20=EB=A1=9C=EA=B3=A0=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=8F=20=ED=85=8C=EB=A7=88=20=EB=B3=80=ED=99=98?= =?UTF-8?q?=20=ED=8A=B8=EB=9E=9C=EC=A7=80=EC=85=98=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/assets/light_logo_large.svg | 3 --- frontend/src/assets/light_logo_medium.svg | 3 --- frontend/src/assets/{dark_logo_large.svg => logo_large.svg} | 4 ++-- .../src/assets/{dark_logo_medium.svg => logo_medium.svg} | 4 ++-- frontend/src/pages/LoginPage.tsx | 2 ++ frontend/src/shared/theme/useThemeStore.ts | 4 ++-- frontend/src/widgets/header/Header.tsx | 5 +++-- 7 files changed, 11 insertions(+), 14 deletions(-) delete mode 100644 frontend/src/assets/light_logo_large.svg delete mode 100644 frontend/src/assets/light_logo_medium.svg rename frontend/src/assets/{dark_logo_large.svg => logo_large.svg} (98%) rename frontend/src/assets/{dark_logo_medium.svg => logo_medium.svg} (98%) diff --git a/frontend/src/assets/light_logo_large.svg b/frontend/src/assets/light_logo_large.svg deleted file mode 100644 index 3b2a9b728..000000000 --- a/frontend/src/assets/light_logo_large.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/src/assets/light_logo_medium.svg b/frontend/src/assets/light_logo_medium.svg deleted file mode 100644 index 13740df45..000000000 --- a/frontend/src/assets/light_logo_medium.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/src/assets/dark_logo_large.svg b/frontend/src/assets/logo_large.svg similarity index 98% rename from frontend/src/assets/dark_logo_large.svg rename to frontend/src/assets/logo_large.svg index 5a1d82b15..c4f2fe41e 100644 --- a/frontend/src/assets/dark_logo_large.svg +++ b/frontend/src/assets/logo_large.svg @@ -1,3 +1,3 @@ - - + + diff --git a/frontend/src/assets/dark_logo_medium.svg b/frontend/src/assets/logo_medium.svg similarity index 98% rename from frontend/src/assets/dark_logo_medium.svg rename to frontend/src/assets/logo_medium.svg index 177fd1a3f..f0cd39c43 100644 --- a/frontend/src/assets/dark_logo_medium.svg +++ b/frontend/src/assets/logo_medium.svg @@ -1,3 +1,3 @@ - - + + diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 781282971..19fb391d9 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,3 +1,4 @@ +import Logo from '@/assets/logo_large.svg?react'; import { Button } from '@/shared/ui/button'; // src/pages/LoginPage.tsx import type { FC } from 'react'; @@ -20,6 +21,7 @@ const LoginPage: FC = () => { return (
+ {/* 실제 폼 대신 버튼으로 간단 테스트 */}
diff --git a/frontend/src/shared/theme/useThemeStore.ts b/frontend/src/shared/theme/useThemeStore.ts index aafa2cf10..1a4a6d5b5 100644 --- a/frontend/src/shared/theme/useThemeStore.ts +++ b/frontend/src/shared/theme/useThemeStore.ts @@ -31,7 +31,7 @@ export const useThemeStore = create()( } localStorage.setItem('theme', name); applyCssVariables(themesMap[name]); - flashThemeTransition(300); + flashThemeTransition(0); set((state) => { state.theme = name; }); @@ -45,7 +45,7 @@ export const useThemeStore = create()( localStorage.setItem('theme', nextTheme); applyCssVariables(themesMap[nextTheme]); - flashThemeTransition(300); + flashThemeTransition(0); set((state) => { state.theme = nextTheme; }); diff --git a/frontend/src/widgets/header/Header.tsx b/frontend/src/widgets/header/Header.tsx index 82cbb71b2..53021b75e 100644 --- a/frontend/src/widgets/header/Header.tsx +++ b/frontend/src/widgets/header/Header.tsx @@ -1,3 +1,4 @@ +import Logo from '@/assets/logo_medium.svg?react'; import { ThemeToggleButton } from '@/shared/theme/ThemeToggleButton'; import { User } from 'lucide-react'; import type { FC } from 'react'; @@ -5,8 +6,8 @@ import type { FC } from 'react'; const Header: FC = () => { return (
-
-

IssueTracker

+
+
From 38216d1e7e733295f0a4f99f81e220c7485f7703 Mon Sep 17 00:00:00 2001 From: "DESKTOP-4N4R3LH\\jqk17" Date: Sun, 18 May 2025 13:55:13 +0900 Subject: [PATCH 010/208] =?UTF-8?q?style:=20=EC=9D=B4=EC=8A=88=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=ED=94=84=EB=A1=9C=ED=95=84=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/issueList/ui/IssueItem.tsx | 18 ++++-------------- frontend/src/shared/ui/CustomDropdownPanel.tsx | 2 +- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/frontend/src/features/issueList/ui/IssueItem.tsx b/frontend/src/features/issueList/ui/IssueItem.tsx index 542d2485d..131e52b6f 100644 --- a/frontend/src/features/issueList/ui/IssueItem.tsx +++ b/frontend/src/features/issueList/ui/IssueItem.tsx @@ -55,20 +55,10 @@ export function IssueItem({ issue }: IssueItemProps) {
{/* 3. 프로필 아이콘 영역 (겹쳐서) */} -
-
- {/* 여러명일 때 map; here single author */} - - - - {issue.author.username[0].toUpperCase()} - - -
-
+ + + +
); } diff --git a/frontend/src/shared/ui/CustomDropdownPanel.tsx b/frontend/src/shared/ui/CustomDropdownPanel.tsx index 015a8788b..0c8ab5f7a 100644 --- a/frontend/src/shared/ui/CustomDropdownPanel.tsx +++ b/frontend/src/shared/ui/CustomDropdownPanel.tsx @@ -149,7 +149,7 @@ export function CustomDropdownPanel({ > {/* 아바타 */} {opt.imageUrl && ( - + From bcf59425db35af6973fc23778163f4d696941a44 Mon Sep 17 00:00:00 2001 From: Nago730 Date: Sun, 18 May 2025 19:32:04 +0900 Subject: [PATCH 011/208] =?UTF-8?q?chore:=20useQuery=20=EB=B0=8F=20dev-too?= =?UTF-8?q?ls=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 61 ++++++++++++++++++++++++++++++++++++-- frontend/package.json | 2 ++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c69598d9d..a16be5a2a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,8 @@ "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-slot": "^1.2.2", + "@tanstack/react-query": "^5.76.1", + "@tanstack/react-query-devtools": "^5.76.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -2520,6 +2522,59 @@ "vite": "^5.2.0 || ^6" } }, + "node_modules/@tanstack/query-core": { + "version": "5.76.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.76.0.tgz", + "integrity": "sha512-FN375hb8ctzfNAlex5gHI6+WDXTNpe0nbxp/d2YJtnP+IBM6OUm7zcaoCW6T63BawGOYZBbKC0iPvr41TteNVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.76.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.76.0.tgz", + "integrity": "sha512-1p92nqOBPYVqVDU0Ua5nzHenC6EGZNrLnB2OZphYw8CNA1exuvI97FVgIKON7Uug3uQqvH/QY8suUKpQo8qHNQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.76.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.76.1.tgz", + "integrity": "sha512-YxdLZVGN4QkT5YT1HKZQWiIlcgauIXEIsMOTSjvyD5wLYK8YVvKZUPAysMqossFJJfDpJW3pFn7WNZuPOqq+fw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.76.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.76.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.76.1.tgz", + "integrity": "sha512-LFVWgk/VtXPkerNLfYIeuGHh0Aim/k9PFGA+JxLdRaUiroQ4j4eoEqBrUpQ1Pd/KXoG4AB9vVE/M6PUQ9vwxBQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.76.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.76.1", + "react": "^18 || ^19" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2586,7 +2641,7 @@ "version": "19.1.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.3.tgz", "integrity": "sha512-dLWQ+Z0CkIvK1J8+wrDPwGxEYFA4RAyHoZPxHVGspYmFVnwGSNT24cGIhFJrtfRnWVuW8X7NO52gCXmhkVUWGQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -2596,7 +2651,7 @@ "version": "19.1.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.3.tgz", "integrity": "sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -2796,7 +2851,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/date-fns": { diff --git a/frontend/package.json b/frontend/package.json index 0a207555e..d1cb18bac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,8 @@ "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-slot": "^1.2.2", + "@tanstack/react-query": "^5.76.1", + "@tanstack/react-query-devtools": "^5.76.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", From 974d232131eec9cd5c4ab294805680f08141b08d Mon Sep 17 00:00:00 2001 From: Nago730 Date: Sun, 18 May 2025 19:37:37 +0900 Subject: [PATCH 012/208] =?UTF-8?q?feat:=20=EC=A0=84=EC=97=AD=EC=97=90=20Q?= =?UTF-8?q?ueryProvider=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/app/App.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 12aa584d6..c51e0a84a 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,7 +1,15 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import AppRouter from './providers/Router'; +const queryClient = new QueryClient(); + const App = () => { - return ; + return ( + + + + + ); }; - export default App; From 0e782fbd41fdc2fce0db33a7c6db7627a11e371c Mon Sep 17 00:00:00 2001 From: Nago730 Date: Mon, 19 May 2025 10:01:50 +0900 Subject: [PATCH 013/208] =?UTF-8?q?refactor:=20mock=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=ED=95=A8=EC=88=98?= =?UTF-8?q?,=20=EA=B0=9D=EC=B2=B4=EB=AA=85=20=EA=B0=9C=EC=84=A0=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/shared/api/client.ts | 10 ++-------- frontend/src/shared/api/mock/author.mock.ts | 4 +--- frontend/src/shared/api/mock/index.ts | 22 +++++++++++---------- frontend/src/shared/api/mock/issue.mock.ts | 2 +- frontend/src/shared/api/mock/user.mock.ts | 4 +--- 5 files changed, 17 insertions(+), 25 deletions(-) diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts index 1842d3437..f4b38f590 100644 --- a/frontend/src/shared/api/client.ts +++ b/frontend/src/shared/api/client.ts @@ -1,4 +1,4 @@ -import { getMockData } from './mock'; +import { getMockResponse } from './mock'; const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true'; @@ -14,12 +14,6 @@ async function realGetJSON(path: string): Promise { return res.json(); } -async function mockGetJSON(path: string): Promise { - // mockData 유틸에서 데이터 로드 - const data = await getMockData(path); - return data as T; -} - /** * 주어진 경로(path)에 GET 요청을 보내고, JSON 응답을 파싱하여 반환합니다. * @@ -31,4 +25,4 @@ async function mockGetJSON(path: string): Promise { * * 실제 통신 또는 mock 중 하나를 자동으로 선택하여 사용합니다. */ -export const getJSON = USE_MOCK ? mockGetJSON : realGetJSON; +export const getJSON = USE_MOCK ? getMockResponse : realGetJSON; diff --git a/frontend/src/shared/api/mock/author.mock.ts b/frontend/src/shared/api/mock/author.mock.ts index cba95deee..221e905b1 100644 --- a/frontend/src/shared/api/mock/author.mock.ts +++ b/frontend/src/shared/api/mock/author.mock.ts @@ -2,7 +2,6 @@ import type { AuthorListData } from '@/entities/user/api/authorApi'; import type { AuthorApiDto } from '@/entities/user/model/author.types'; import type { ApiResponse } from '@/shared/api/types'; -// Mock author data const authors: AuthorApiDto[] = [ { id: 1, @@ -19,8 +18,7 @@ const authors: AuthorApiDto[] = [ { id: 5, username: 'eve', imageUrl: 'https://example.com/avatar/eve.png' }, ]; -// Mock API response for author list -export const mockAuthors: ApiResponse = { +export const mockAuthorsResponse: ApiResponse = { success: true, data: { total: authors.length, diff --git a/frontend/src/shared/api/mock/index.ts b/frontend/src/shared/api/mock/index.ts index 091d2aa1b..04666cf35 100644 --- a/frontend/src/shared/api/mock/index.ts +++ b/frontend/src/shared/api/mock/index.ts @@ -1,28 +1,30 @@ import type { ApiResponse } from '../types'; -import { mockAuthors } from './author.mock'; -import { mockIssues } from './issue.mock'; -import { mockUsers } from './user.mock'; +import { mockAuthorsResponse } from './author.mock'; +import { mockIssuesResponse } from './issue.mock'; +import { mockUsersResponse } from './user.mock'; /**s * 경로별 mock 데이터를 반환하는 유틸 */ // 경로에 따른 mock 데이터 로더 매핑 -const mockLoaders: Record> = { - '/api/issues': mockIssues, - '/api/users': mockUsers, - '/api/issues/authors': mockAuthors, -}; +export const mockLoaders = { + '/api/issues': mockIssuesResponse, // ApiResponse + '/api/users': mockUsersResponse, // ApiResponse + '/api/issues/authors': mockAuthorsResponse, // ApiResponse +} satisfies Record>; /** * 주어진 경로에 매핑된 mock 데이터를 반환합니다. * @param path API 엔드포인트 경로 * @throws 정의되지 않은 경로일 경우 Error */ -export async function getMockData(path: string) { +export async function getMockResponse( + path: keyof typeof mockLoaders, +): Promise { const loader = mockLoaders[path]; if (!loader) { throw new Error(`알 수 없는 mock 경로: ${path}`); } - return loader; + return loader as T; } diff --git a/frontend/src/shared/api/mock/issue.mock.ts b/frontend/src/shared/api/mock/issue.mock.ts index a906b8254..7030f7e0d 100644 --- a/frontend/src/shared/api/mock/issue.mock.ts +++ b/frontend/src/shared/api/mock/issue.mock.ts @@ -39,7 +39,7 @@ const issues: Issue[] = [ }, ]; -export const mockIssues: ApiResponse = { +export const mockIssuesResponse: ApiResponse = { success: true, data: { total: issues.length, diff --git a/frontend/src/shared/api/mock/user.mock.ts b/frontend/src/shared/api/mock/user.mock.ts index 3a9a147a2..60beac3d3 100644 --- a/frontend/src/shared/api/mock/user.mock.ts +++ b/frontend/src/shared/api/mock/user.mock.ts @@ -2,7 +2,6 @@ import type { UserListData } from '@/entities/user/api/userApi'; import type { UserApiDto } from '@/entities/user/model/user.types'; import type { ApiResponse } from '@/shared/api/types'; -// Mock user data const users: UserApiDto[] = [ { id: 1, @@ -16,8 +15,7 @@ const users: UserApiDto[] = [ }, ]; -// Mock API response for user list -export const mockUsers: ApiResponse = { +export const mockUsersResponse: ApiResponse = { success: true, data: { total: users.length, From 2112d55a9f284b71c3f3a8f24978553d6e55e250 Mon Sep 17 00:00:00 2001 From: Nago730 Date: Mon, 19 May 2025 11:36:55 +0900 Subject: [PATCH 014/208] =?UTF-8?q?refactor:=20=ED=95=84=ED=84=B0=EB=A7=81?= =?UTF-8?q?=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=EC=9D=98=20isLoading,=20error=EC=97=90=20=EB=8C=80=ED=95=9C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=B2=98=EB=A6=AC=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=EB=A5=BC=20=EA=B3=B5=ED=86=B5=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FilteringPanel/AssigneeDropdown.tsx | 15 +-- .../widget/FilteringPanel/AuthorDropdown.tsx | 15 +-- .../src/shared/ui/CustomDropdownPanel.tsx | 92 ++++++++++++------- 3 files changed, 61 insertions(+), 61 deletions(-) diff --git a/frontend/src/features/issueList/widget/FilteringPanel/AssigneeDropdown.tsx b/frontend/src/features/issueList/widget/FilteringPanel/AssigneeDropdown.tsx index eb3f0f9ed..418595927 100644 --- a/frontend/src/features/issueList/widget/FilteringPanel/AssigneeDropdown.tsx +++ b/frontend/src/features/issueList/widget/FilteringPanel/AssigneeDropdown.tsx @@ -10,7 +10,6 @@ export default function AssigneeDropdown() { const [selected, setSelected] = useState(null); const { data, isLoading, error } = useUserList(); - // ✅ useMemo를 항상 호출하도록 최상단에 선언 const userOptions = useMemo(() => { const noneOption: DropdownOption = { id: 0, @@ -29,18 +28,6 @@ export default function AssigneeDropdown() { return [noneOption, ...fetchedOptions]; }, [data]); - // 로딩·에러 UI는 그 다음에 처리 - if (isLoading) { - return
담당자 목록 로딩 중…
; - } - if (error) { - return ( -
- 담당자 목록을 불러오는 중 에러가 발생했습니다. -
- ); - } - return (
); diff --git a/frontend/src/features/issueList/widget/FilteringPanel/AuthorDropdown.tsx b/frontend/src/features/issueList/widget/FilteringPanel/AuthorDropdown.tsx index 73fb8a682..0b6eae608 100644 --- a/frontend/src/features/issueList/widget/FilteringPanel/AuthorDropdown.tsx +++ b/frontend/src/features/issueList/widget/FilteringPanel/AuthorDropdown.tsx @@ -10,7 +10,6 @@ export default function AuthorDropdown() { const [selected, setSelected] = useState(null); const { data, isLoading, error } = useAuthorList(); - // 항상 최상단에서 useMemo 호출 const userOptions = useMemo(() => { const noneOption: DropdownOption = { id: 0, @@ -29,18 +28,6 @@ export default function AuthorDropdown() { return [noneOption, ...fetchedOptions]; }, [data]); - // 로딩 및 에러 상태 처리 - if (isLoading) { - return
작성자 목록 로딩 중…
; - } - if (error) { - return ( -
- 작성자 목록을 불러오는 중 에러가 발생했습니다. -
- ); - } - return (
); diff --git a/frontend/src/shared/ui/CustomDropdownPanel.tsx b/frontend/src/shared/ui/CustomDropdownPanel.tsx index 0c8ab5f7a..a8d016bda 100644 --- a/frontend/src/shared/ui/CustomDropdownPanel.tsx +++ b/frontend/src/shared/ui/CustomDropdownPanel.tsx @@ -1,7 +1,9 @@ import CheckOffCircleIcon from '@/assets/checkOffCircle.svg?react'; import CheckOnCircleIcon from '@/assets/checkOnCircle.svg?react'; import ChevronDownIcon from '@/assets/chevronDown.svg?react'; +import { Spinner } from '@/shared/ui/spinner'; import { useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; import { cn } from '../utils/shadcn-utils'; import { Avatar, AvatarFallback, AvatarImage } from './avatar'; @@ -19,6 +21,8 @@ interface CustomDropdownPanelProps { value: string | null; onChange: (value: string | null) => void; className?: string; + isLoading?: boolean; + error?: boolean; } export function CustomDropdownPanel({ @@ -28,6 +32,8 @@ export function CustomDropdownPanel({ value, onChange, className = '', + isLoading, + error, }: CustomDropdownPanelProps) { const [open, setOpen] = useState(false); const [animating, setAnimating] = useState<'in' | 'out' | null>(null); @@ -35,6 +41,13 @@ export function CustomDropdownPanel({ const selectRef = useRef(null); const triggerRef = useRef(null); + // 드롭다운 열 때 에러 발생 시 토스트 알림 + useEffect(() => { + if (error && open) { + toast.error('목록을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.'); + } + }, [error, open]); + // open 시 트리거 버튼의 x 위치로 정렬 방식 결정 useEffect(() => { if (open && triggerRef.current) { @@ -124,13 +137,21 @@ export function CustomDropdownPanel({
{/* 옵션 목록 */}
- {options.map((opt, idx) => { - const isSelected = value === opt.value; - return ( - - ); - })} + {/* 텍스트 */} + {opt.display} + {/* 체크박스 아이콘 */} + + {isSelected ? ( + + ) : ( + + )} + + + ); + }) + )}
)} From 9a6ad8dd9e84d5ac9f262dc63f0121b361fb8a67 Mon Sep 17 00:00:00 2001 From: Nago730 Date: Mon, 19 May 2025 11:43:37 +0900 Subject: [PATCH 015/208] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=ED=85=80=ED=9B=85=EC=9D=84=20useQuery=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 리팩토링 커스텀훅 목록 - useIssueList.ts - useUserList.ts - useAuthorList.ts --- .../src/entities/issue/hooks/useIssueList.ts | 40 +++--------------- .../src/entities/user/hooks/useAuthorList.ts | 42 ++++--------------- .../src/entities/user/hooks/useUserList.ts | 42 ++++--------------- 3 files changed, 20 insertions(+), 104 deletions(-) diff --git a/frontend/src/entities/issue/hooks/useIssueList.ts b/frontend/src/entities/issue/hooks/useIssueList.ts index 98b3919a1..4d20ffdb6 100644 --- a/frontend/src/entities/issue/hooks/useIssueList.ts +++ b/frontend/src/entities/issue/hooks/useIssueList.ts @@ -1,39 +1,11 @@ import { fetchIssues } from '@/entities/issue/api/issueAPI'; import type { IssueListData } from '@/entities/issue/model/issue.types'; -import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; export function useIssueList() { - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - let mounted = true; - - async function load() { - setIsLoading(true); - try { - const result = await fetchIssues(); - if (mounted) { - setData(result); - } - } catch (err: unknown) { - if (mounted) { - setError(err as Error); - } - } finally { - if (mounted) { - setIsLoading(false); - } - } - } - - load(); - - return () => { - mounted = false; - }; - }, []); - - return { data, isLoading, error }; + return useQuery({ + queryKey: ['issues'], + queryFn: fetchIssues, + // 필요에 따라 staleTime, enabled, refetchInterval 등 옵션 추가 가능 + }); } diff --git a/frontend/src/entities/user/hooks/useAuthorList.ts b/frontend/src/entities/user/hooks/useAuthorList.ts index 2323fe5df..8ca22024c 100644 --- a/frontend/src/entities/user/hooks/useAuthorList.ts +++ b/frontend/src/entities/user/hooks/useAuthorList.ts @@ -1,39 +1,11 @@ -import { useEffect, useState } from 'react'; -import type { AuthorListData } from '../api/authorApi'; +import { useQuery } from '@tanstack/react-query'; import { fetchAuthors } from '../api/authorApi'; +import type { AuthorListData } from '../api/authorApi'; export function useAuthorList() { - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - let mounted = true; - - async function load() { - setIsLoading(true); - try { - const result = await fetchAuthors(); - if (mounted) { - setData(result); - } - } catch (err: unknown) { - if (mounted) { - setError(err as Error); - } - } finally { - if (mounted) { - setIsLoading(false); - } - } - } - - load(); - - return () => { - mounted = false; - }; - }, []); - - return { data, isLoading, error }; + return useQuery({ + queryKey: ['authors'], + queryFn: fetchAuthors, + // 필요 시 staleTime, enabled 등 옵션 추가 가능 + }); } diff --git a/frontend/src/entities/user/hooks/useUserList.ts b/frontend/src/entities/user/hooks/useUserList.ts index 1dc4aa6de..269e5a36e 100644 --- a/frontend/src/entities/user/hooks/useUserList.ts +++ b/frontend/src/entities/user/hooks/useUserList.ts @@ -1,39 +1,11 @@ -import { useEffect, useState } from 'react'; -import type { UserListData } from '../api/userApi'; +import { useQuery } from '@tanstack/react-query'; import { fetchUsers } from '../api/userApi'; +import type { UserListData } from '../api/userApi'; export function useUserList() { - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - let mounted = true; - - async function load() { - setIsLoading(true); - try { - const result = await fetchUsers(); - if (mounted) { - setData(result); - } - } catch (err: unknown) { - if (mounted) { - setError(err as Error); - } - } finally { - if (mounted) { - setIsLoading(false); - } - } - } - - load(); - - return () => { - mounted = false; - }; - }, []); - - return { data, isLoading, error }; + return useQuery({ + queryKey: ['users'], + queryFn: fetchUsers, + // staleTime, retry, enabled 등 옵션 필요에 따라 추가 + }); } From 74be51f75653e31b2d2133468215a3785657a7e4 Mon Sep 17 00:00:00 2001 From: Nago730 Date: Mon, 19 May 2025 18:12:24 +0900 Subject: [PATCH 016/208] =?UTF-8?q?feat:=20=EC=9D=B4=EC=8A=88=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9D=84=20=EC=9C=84=ED=95=9C=20API=20=EB=B0=8F=20?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=ED=85=80=ED=9B=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/entities/issue/api/issueAPI.ts | 20 +++++++++- .../entities/issue/hooks/useCreateIssue.ts | 37 +++++++++++++++++++ .../src/entities/issue/model/issue.types.ts | 14 +++++-- .../widget/IssueCreateModal/index.ts | 1 - .../issues}/IssueCreateModal.tsx | 0 5 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 frontend/src/entities/issue/hooks/useCreateIssue.ts delete mode 100644 frontend/src/features/issueList/widget/IssueCreateModal/index.ts rename frontend/src/{features/issueList/widget/IssueCreateModal => pages/issues}/IssueCreateModal.tsx (100%) diff --git a/frontend/src/entities/issue/api/issueAPI.ts b/frontend/src/entities/issue/api/issueAPI.ts index 28dc17ce3..9a9c97a11 100644 --- a/frontend/src/entities/issue/api/issueAPI.ts +++ b/frontend/src/entities/issue/api/issueAPI.ts @@ -1,7 +1,25 @@ import { getJSON } from '@/shared/api/client'; -import type { ApiResponse, IssueListData } from '../model/issue.types'; +import type { ApiResponse } from '@/shared/api/types'; +import type { + IssueCreateRequest, + IssueCreateResponse, + IssueListData, +} from '../model/issue.types'; export async function fetchIssues(): Promise { const res = await getJSON>('/api/issues'); return res.data; } +export async function createIssue( + payload: IssueCreateRequest, +): Promise { + const response = await fetch('/api/issues', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + return response.json(); +} diff --git a/frontend/src/entities/issue/hooks/useCreateIssue.ts b/frontend/src/entities/issue/hooks/useCreateIssue.ts new file mode 100644 index 000000000..91c317ab1 --- /dev/null +++ b/frontend/src/entities/issue/hooks/useCreateIssue.ts @@ -0,0 +1,37 @@ +import { useMutation } from '@tanstack/react-query'; +import { createIssue } from '../api/issueAPI'; +import type { + IssueCreateRequest, + IssueCreateResponse, +} from '../model/issue.types'; + +/** + * 이슈 생성 API를 호출하는 커스텀 훅입니다. + * + * @returns {object} React Query의 mutation 객체를 반환합니다. + * + * @example + * const { mutate, isPending, isSuccess, error, data } = useCreateIssue(); + * mutate({ + * title: "이슈 제목", + * body: "이슈 내용", + * assigneeId: 1, + * labelIds: [1, 2], + * milestoneId: null + * }); + * + * // isPending: 요청 중 여부 + * // isSuccess: 성공 여부 + * // error: 에러 객체 + * // data: 응답 데이터 (이슈 ID 등) + * + * @description + * - 'mutation'은 서버의 데이터(리소스)를 생성, 수정, 삭제하는 작업을 의미합니다. + * - 이 훅은 React Query의 useMutation을 래핑해서, 이슈 생성 요청을 보낼 수 있게 합니다. + * - 요청 성공/실패/진행 상태 등을 쉽게 관리할 수 있습니다. + */ +export function useCreateIssue() { + return useMutation({ + mutationFn: createIssue, + }); +} diff --git a/frontend/src/entities/issue/model/issue.types.ts b/frontend/src/entities/issue/model/issue.types.ts index 209c6d472..759f56786 100644 --- a/frontend/src/entities/issue/model/issue.types.ts +++ b/frontend/src/entities/issue/model/issue.types.ts @@ -35,8 +35,16 @@ export interface IssueListData { issues: Issue[]; } -export interface ApiResponse { +export interface IssueCreateRequest { + title: string; + body: string; + assigneeId: number; + labelIds: number[]; + milestoneId: number | null; +} + +export interface IssueCreateResponse { success: boolean; - data: T; - error: E | null; + data: number; + error: string | null; } diff --git a/frontend/src/features/issueList/widget/IssueCreateModal/index.ts b/frontend/src/features/issueList/widget/IssueCreateModal/index.ts deleted file mode 100644 index d19b832f4..000000000 --- a/frontend/src/features/issueList/widget/IssueCreateModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './IssueCreateModal'; diff --git a/frontend/src/features/issueList/widget/IssueCreateModal/IssueCreateModal.tsx b/frontend/src/pages/issues/IssueCreateModal.tsx similarity index 100% rename from frontend/src/features/issueList/widget/IssueCreateModal/IssueCreateModal.tsx rename to frontend/src/pages/issues/IssueCreateModal.tsx From 0a06cec9809f08ae8506d01b5fdab811e2fcc195 Mon Sep 17 00:00:00 2001 From: Nago730 Date: Tue, 20 May 2025 11:22:46 +0900 Subject: [PATCH 017/208] =?UTF-8?q?feat:=20Input=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/app/providers/Router.tsx | 2 +- .../widget/IssueSearchBar/IssueSearch.tsx | 22 ---- .../issueList/widget/IssueSearchBar/index.ts | 1 - .../src/features/issueList/widget/index.ts | 2 - .../src/pages/issues/IssueCreateModal.tsx | 4 +- frontend/src/shared/ui/input.tsx | 111 ++++++++++++++---- 6 files changed, 95 insertions(+), 47 deletions(-) delete mode 100644 frontend/src/features/issueList/widget/IssueSearchBar/IssueSearch.tsx delete mode 100644 frontend/src/features/issueList/widget/IssueSearchBar/index.ts diff --git a/frontend/src/app/providers/Router.tsx b/frontend/src/app/providers/Router.tsx index 04564cfac..c2550499f 100644 --- a/frontend/src/app/providers/Router.tsx +++ b/frontend/src/app/providers/Router.tsx @@ -15,7 +15,7 @@ import MilestoneListPage from '@/pages/MilestoneListPage'; import IssueDetailPage from '@/pages/issues/IssueDetailPage'; import IssueListPage from '@/pages/issues/IssueListPage'; -import { IssueCreateModal } from '@/features/issueList/widget'; +import IssueCreateModal from '@/pages/issues/IssueCreateModal'; import AuthGuard from '@/shared/auth/AuthGuard'; diff --git a/frontend/src/features/issueList/widget/IssueSearchBar/IssueSearch.tsx b/frontend/src/features/issueList/widget/IssueSearchBar/IssueSearch.tsx deleted file mode 100644 index fb347c3c3..000000000 --- a/frontend/src/features/issueList/widget/IssueSearchBar/IssueSearch.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Input } from '@/shared/ui/input'; -import { SearchIcon } from 'lucide-react'; -import { useSearchParams } from 'react-router-dom'; -export function IssueSearch() { - const [searchParams] = useSearchParams(); - const queryParams = searchParams.get('status') ?? ''; - - return ( -
- {/* 왼쪽에 검색 아이콘 */} - - - {/* shadcn Input */} - -
- ); -} diff --git a/frontend/src/features/issueList/widget/IssueSearchBar/index.ts b/frontend/src/features/issueList/widget/IssueSearchBar/index.ts deleted file mode 100644 index 53a334608..000000000 --- a/frontend/src/features/issueList/widget/IssueSearchBar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './IssueSearch'; diff --git a/frontend/src/features/issueList/widget/index.ts b/frontend/src/features/issueList/widget/index.ts index 84f03292b..523373de0 100644 --- a/frontend/src/features/issueList/widget/index.ts +++ b/frontend/src/features/issueList/widget/index.ts @@ -1,5 +1,3 @@ -export * from './IssueCreateModal'; export * from './IssueFilterBar'; -export * from './IssueSearchBar'; export * from './IssueListHeader'; export * from './Buttons'; diff --git a/frontend/src/pages/issues/IssueCreateModal.tsx b/frontend/src/pages/issues/IssueCreateModal.tsx index 7a6d52408..2d0a5211b 100644 --- a/frontend/src/pages/issues/IssueCreateModal.tsx +++ b/frontend/src/pages/issues/IssueCreateModal.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react'; -export const IssueCreateModal: FC = () => { +const IssueCreateModal: FC = () => { return (

이슈 생성

@@ -8,3 +8,5 @@ export const IssueCreateModal: FC = () => {
); }; + +export default IssueCreateModal; diff --git a/frontend/src/shared/ui/input.tsx b/frontend/src/shared/ui/input.tsx index 46e3b8fb4..79a5739df 100644 --- a/frontend/src/shared/ui/input.tsx +++ b/frontend/src/shared/ui/input.tsx @@ -1,27 +1,98 @@ -import type * as React from 'react'; +import { type InputHTMLAttributes, useId } from 'react'; -import { cn } from '@/shared/utils/shadcn-utils'; +type TextInputStyleType = 'floating' | 'basic'; -function Input({ - className, - type, - text, +interface TextInputProps extends InputHTMLAttributes { + type?: TextInputStyleType; + label?: string; + value: string; + fixedValue?: string; +} + +export function Input({ + type = 'floating', + label, + placeholder = '', + fixedValue, + id, + value, + onChange, ...props -}: React.ComponentProps<'input'> & { text: string }) { +}: TextInputProps) { + const inputId = id ?? useId(); + + // floating 모드일 때 label 표시 여부 + const showFloatingLabel = type === 'floating' && value.length > 0; + + // ─── basic 모드 ─────────────────────────────── + if (type === 'basic') { + return ( +
+ {/* 왼쪽에 고정 텍스트 */} + {fixedValue != null && ( + + {fixedValue} + + )} + + {/* 오른쪽에만 editable input */} + +
+ ); + } + + // ─── floating 모드 ─────────────────────────── return ( - + {showFloatingLabel && ( + )} - {...props} - /> + + +
); } - -export { Input }; From d8bcd19749e2276866a7716deb2cc71a2a1cbad4 Mon Sep 17 00:00:00 2001 From: Nago730 Date: Tue, 20 May 2025 12:20:24 +0900 Subject: [PATCH 018/208] =?UTF-8?q?feat:=20TextArea=20=EA=B3=B5=EC=9A=A9?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/assets/grip.svg | 4 ++ frontend/src/assets/paperclip.svg | 10 +++ frontend/src/shared/ui/TextArea.tsx | 103 ++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 frontend/src/assets/grip.svg create mode 100644 frontend/src/assets/paperclip.svg create mode 100644 frontend/src/shared/ui/TextArea.tsx diff --git a/frontend/src/assets/grip.svg b/frontend/src/assets/grip.svg new file mode 100644 index 000000000..2c08cb7ef --- /dev/null +++ b/frontend/src/assets/grip.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/paperclip.svg b/frontend/src/assets/paperclip.svg new file mode 100644 index 000000000..c8404cca0 --- /dev/null +++ b/frontend/src/assets/paperclip.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/src/shared/ui/TextArea.tsx b/frontend/src/shared/ui/TextArea.tsx new file mode 100644 index 000000000..2f4fdc2f5 --- /dev/null +++ b/frontend/src/shared/ui/TextArea.tsx @@ -0,0 +1,103 @@ +import GripIcon from '@/assets/Grip.svg?react'; +import PaperCilpIcon from '@/assets/paperclip.svg?react'; +import { type ChangeEvent, type InputHTMLAttributes, useId } from 'react'; + +interface TextAreaProps extends InputHTMLAttributes { + label?: string; + value: string; + onFileSelect: (files: FileList) => void; + showCounter: boolean; +} + +export function TextArea({ + label, + placeholder = '', + id, + value, + onChange, + onFileSelect, + showCounter, + ...props +}: TextAreaProps) { + const baseId = id ?? useId(); + + // floating 모드일 때 label 표시 여부 + const showLabel = value.length > 0; + + const handleChange = (e: ChangeEvent) => { + if (e.target.files) { + onFileSelect(e.target.files); + } + }; + + // ─── floating 모드 ─────────────────────────── + return ( +
+
+ {showLabel && ( + + )} + + +
+ +
+
+ {`띄어쓰기 포함 ${value.length}자`} + +
+ + {/* division */} +
+ + + +
+
+ ); +} From fc78b964511a717a2fc25a7b936ab79e55181d17 Mon Sep 17 00:00:00 2001 From: Nago730 Date: Tue, 20 May 2025 14:26:09 +0900 Subject: [PATCH 019/208] =?UTF-8?q?style:=20TextArea=20=EC=97=90=20flex-1?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/shared/ui/TextArea.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/shared/ui/TextArea.tsx b/frontend/src/shared/ui/TextArea.tsx index 2f4fdc2f5..19020da33 100644 --- a/frontend/src/shared/ui/TextArea.tsx +++ b/frontend/src/shared/ui/TextArea.tsx @@ -34,7 +34,7 @@ export function TextArea({ return (
}, - { - path: '/issues', - element: , - children: [{ path: 'new', element: }], - }, + { path: '/issues', element: }, + { path: '/issues/new', element: }, { path: '/issues/:id', element: }, { path: '/labels', element: }, { path: '/milestones', element: }, diff --git a/frontend/src/pages/issues/IssueCreateModal.tsx b/frontend/src/pages/issues/IssueCreateModal.tsx deleted file mode 100644 index 2d0a5211b..000000000 --- a/frontend/src/pages/issues/IssueCreateModal.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { FC } from 'react'; - -const IssueCreateModal: FC = () => { - return ( -
-

이슈 생성

- {/* TODO: 이슈 생성 컴포넌트 구현 */} -
- ); -}; - -export default IssueCreateModal; diff --git a/frontend/src/pages/issues/IssueListPage.tsx b/frontend/src/pages/issues/IssueListPage.tsx index 4a9eaab4b..28ea15a3d 100644 --- a/frontend/src/pages/issues/IssueListPage.tsx +++ b/frontend/src/pages/issues/IssueListPage.tsx @@ -9,7 +9,6 @@ import { import IssueDropdown from '@/features/issueList/widget/FilteringPanel/IssueDropdown'; import { Spinner } from '@/shared/ui/spinner'; import type { FC } from 'react'; -import { Outlet } from 'react-router-dom'; const IssueListPage: FC = () => { const { data, isLoading, error } = useIssueList(); @@ -32,7 +31,6 @@ const IssueListPage: FC = () => { return ( <> -
From 414727e296adaf14c38c487830b07a7ef60e0763 Mon Sep 17 00:00:00 2001 From: Nago730 Date: Tue, 20 May 2025 14:37:37 +0900 Subject: [PATCH 021/208] =?UTF-8?q?style:=20input=20>=20textarear=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=ED=81=B4=EB=A6=AD=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=9C=20=EB=B6=80=EB=B6=84=EC=9D=84=20=EC=B5=9C=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=EC=B1=84=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/shared/ui/TextArea.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/frontend/src/shared/ui/TextArea.tsx b/frontend/src/shared/ui/TextArea.tsx index 19020da33..9042ca8e2 100644 --- a/frontend/src/shared/ui/TextArea.tsx +++ b/frontend/src/shared/ui/TextArea.tsx @@ -1,8 +1,8 @@ import GripIcon from '@/assets/Grip.svg?react'; import PaperCilpIcon from '@/assets/paperclip.svg?react'; -import { type ChangeEvent, type InputHTMLAttributes, useId } from 'react'; +import { type ChangeEvent, type TextareaHTMLAttributes, useId } from 'react'; -interface TextAreaProps extends InputHTMLAttributes { +interface TextAreaProps extends TextareaHTMLAttributes { label?: string; value: string; onFileSelect: (files: FileList) => void; @@ -30,7 +30,6 @@ export function TextArea({ } }; - // ─── floating 모드 ─────────────────────────── return (
From bdb7dbc030cb3d79eb9a34bfd88333133cf9e62a Mon Sep 17 00:00:00 2001 From: Nago730 Date: Tue, 20 May 2025 15:43:11 +0900 Subject: [PATCH 022/208] =?UTF-8?q?feat:=20=EC=9D=B4=EC=8A=88=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EB=B0=94=20mock=EC=9C=BC=EB=A1=9C=20UI=20=EB=B0=8F=20=EC=83=81?= =?UTF-8?q?=ED=98=B8=EC=9E=91=EC=9A=A9=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Widget/sidebar/DropdownOption.ts | 8 ++ .../Widget/sidebar/DropdownPanel.tsx | 124 ++++++++++++++++++ .../Widget/sidebar/DropdownTrigger.tsx | 39 ++++++ .../Widget/sidebar/IssueCreationForm.tsx | 91 +++++++++++++ .../Widget/sidebar/SidebarDropdown.tsx | 80 +++++++++++ frontend/src/pages/issues/IssueCreatePage.tsx | 72 ++++++++++ 6 files changed, 414 insertions(+) create mode 100644 frontend/src/features/IssueCreation/Widget/sidebar/DropdownOption.ts create mode 100644 frontend/src/features/IssueCreation/Widget/sidebar/DropdownPanel.tsx create mode 100644 frontend/src/features/IssueCreation/Widget/sidebar/DropdownTrigger.tsx create mode 100644 frontend/src/features/IssueCreation/Widget/sidebar/IssueCreationForm.tsx create mode 100644 frontend/src/features/IssueCreation/Widget/sidebar/SidebarDropdown.tsx create mode 100644 frontend/src/pages/issues/IssueCreatePage.tsx diff --git a/frontend/src/features/IssueCreation/Widget/sidebar/DropdownOption.ts b/frontend/src/features/IssueCreation/Widget/sidebar/DropdownOption.ts new file mode 100644 index 000000000..a4a5fb8d0 --- /dev/null +++ b/frontend/src/features/IssueCreation/Widget/sidebar/DropdownOption.ts @@ -0,0 +1,8 @@ +export interface DropdownOption { + id: number; + value: string; + display: string; + imageUrl?: string; + color?: string; + progress?: number; +} diff --git a/frontend/src/features/IssueCreation/Widget/sidebar/DropdownPanel.tsx b/frontend/src/features/IssueCreation/Widget/sidebar/DropdownPanel.tsx new file mode 100644 index 000000000..6a31657cb --- /dev/null +++ b/frontend/src/features/IssueCreation/Widget/sidebar/DropdownPanel.tsx @@ -0,0 +1,124 @@ +import CheckOffCircleIcon from '@/assets/checkOffCircle.svg?react'; +import CheckOnCircleIcon from '@/assets/checkOnCircle.svg?react'; +import { Avatar, AvatarFallback, AvatarImage } from '@/shared/ui/avatar'; +import { Spinner } from '@/shared/ui/spinner'; +import { cn } from '@/shared/utils/shadcn-utils'; +import type { DropdownOption } from './DropdownOption'; + +interface DropdownPanelProps { + open: boolean; + alignRight?: boolean; + options: DropdownOption[]; + value: string | null; + onSelect: (value: string) => void; + isLoading?: boolean; + error?: boolean; + panelLabel?: string; + renderOption?: (opt: DropdownOption, isSelected: boolean) => React.ReactNode; + className?: string; +} + +export function DropdownPanel({ + open, + alignRight, + options, + value, + onSelect, + isLoading, + error, + panelLabel, + renderOption, + className, +}: DropdownPanelProps) { + if (!open) return null; + + return ( +
+ role='listbox' + tabIndex={-1} + style={{ + boxShadow: 'var(--shadow-light)', + }} + > + {/* 헤더 */} + {panelLabel && ( +
+ {panelLabel} +
+ )} + + {/* 옵션 목록 */} +
+ {isLoading ? ( + + ) : error ? ( +
+ 데이터를 불러올 수 없습니다. +
+ ) : options.length === 0 ? ( +
+ 항목이 없습니다. +
+ ) : ( + options.map((opt, idx) => { + const isSelected = value === opt.value; + return ( + + ); + }) + )} +
+
+ ); +} diff --git a/frontend/src/features/IssueCreation/Widget/sidebar/DropdownTrigger.tsx b/frontend/src/features/IssueCreation/Widget/sidebar/DropdownTrigger.tsx new file mode 100644 index 000000000..46568b539 --- /dev/null +++ b/frontend/src/features/IssueCreation/Widget/sidebar/DropdownTrigger.tsx @@ -0,0 +1,39 @@ +import ChevronDownIcon from '@/assets/chevronDown.svg?react'; +import { cn } from '@/shared/utils/shadcn-utils'; +import React from 'react'; + +interface DropdownTriggerProps { + label: string; + open: boolean; + disabled?: boolean; + onClick: () => void; + className?: string; + children?: React.ReactNode; // 아래에 선택된 항목 등 렌더링할 때 +} + +export const DropdownTrigger = React.forwardRef< + HTMLButtonElement, + DropdownTriggerProps +>(({ label, open, disabled, onClick, className, children }, ref) => { + return ( + + ); +}); diff --git a/frontend/src/features/IssueCreation/Widget/sidebar/IssueCreationForm.tsx b/frontend/src/features/IssueCreation/Widget/sidebar/IssueCreationForm.tsx new file mode 100644 index 000000000..80035796d --- /dev/null +++ b/frontend/src/features/IssueCreation/Widget/sidebar/IssueCreationForm.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react'; +import type { DropdownOption } from './DropdownOption'; +import { SidebarDropdown } from './SidebarDropdown'; + +const Division = () => ( +
+); + +const Area: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( +
{children}
+); + +// 더미 데이터 정의 +const userOptions: DropdownOption[] = [ + { + id: 1, + value: 'user1', + display: '사용자 1', + imageUrl: 'https://via.placeholder.com/150', + }, + { + id: 2, + value: 'user2', + display: '사용자 2', + imageUrl: 'https://via.placeholder.com/150', + }, + { + id: 3, + value: 'user3', + display: '사용자 3', + imageUrl: 'https://via.placeholder.com/150', + }, +]; + +const labelOptions: DropdownOption[] = [ + { id: 1, value: 'bug', display: '버그', color: '#FF0000' }, + { id: 2, value: 'feature', display: '기능 개선', color: '#00FF00' }, + { id: 3, value: 'enhancement', display: '개선', color: '#0000FF' }, +]; + +const milestoneOptions: DropdownOption[] = [ + { id: 1, value: 'v1.0', display: '버전 1.0', progress: 50 }, + { id: 2, value: 'v1.1', display: '버전 1.1', progress: 0 }, +]; + +export function IssueCreationForm() { + const [assignee, setAssignee] = useState(null); + const [label, setLabel] = useState(null); + const [milestone, setMilestone] = useState(null); + + return ( +
+ + + + + + + + + + + +
+ ); +} diff --git a/frontend/src/features/IssueCreation/Widget/sidebar/SidebarDropdown.tsx b/frontend/src/features/IssueCreation/Widget/sidebar/SidebarDropdown.tsx new file mode 100644 index 000000000..1107a063a --- /dev/null +++ b/frontend/src/features/IssueCreation/Widget/sidebar/SidebarDropdown.tsx @@ -0,0 +1,80 @@ +import { useEffect, useRef, useState } from 'react'; +import type { DropdownOption } from './DropdownOption'; +import { DropdownPanel } from './DropdownPanel'; +import { DropdownTrigger } from './DropdownTrigger'; + +interface SidebarDropdownProps { + label: string; + panelLabel?: string; + options: DropdownOption[]; + value: string | null; + onChange: (value: string | null) => void; + isLoading?: boolean; + error?: boolean; + renderOption?: (opt: DropdownOption, isSelected: boolean) => React.ReactNode; + disabled?: boolean; + className?: string; +} + +export function SidebarDropdown({ + label, + panelLabel, + options, + value, + onChange, + isLoading, + error, + renderOption, + disabled, + className, +}: SidebarDropdownProps) { + const [open, setOpen] = useState(false); + const [alignRight, setAlignRight] = useState(false); + const triggerRef = useRef(null); + + // 정렬 방향 결정 + useEffect(() => { + if (open && triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + const windowWidth = window.innerWidth; + setAlignRight(rect.left > windowWidth * 0.7); + } + }, [open]); + + // 바깥 클릭 닫기 + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (!triggerRef.current?.parentElement?.contains(e.target as Node)) + setOpen(false); + }; + window.addEventListener('mousedown', handler); + return () => window.removeEventListener('mousedown', handler); + }, [open]); + + return ( +
+ setOpen((o) => !o)} + disabled={disabled} + ref={triggerRef} + /> + { + setOpen(false); + onChange(v); + }} + isLoading={isLoading} + error={error} + panelLabel={panelLabel} + renderOption={renderOption} + /> +
+ ); +} diff --git a/frontend/src/pages/issues/IssueCreatePage.tsx b/frontend/src/pages/issues/IssueCreatePage.tsx new file mode 100644 index 000000000..fef5730a3 --- /dev/null +++ b/frontend/src/pages/issues/IssueCreatePage.tsx @@ -0,0 +1,72 @@ +import { IssueCreationForm } from '@/features/IssueCreation/Widget/sidebar/IssueCreationForm'; +import { Input } from '@/shared/ui/Input'; +import { TextArea } from '@/shared/ui/TextArea'; +import { Button } from '@/shared/ui/button'; +import { Avatar, AvatarFallback, AvatarImage } from '@radix-ui/react-avatar'; +import { type FC, useState } from 'react'; + +const Division = () => ( +
+); + +const IssueCreateModal: FC = () => { + // 1. 제목 입력값 + const [title, setTitle] = useState(''); + + // 2. 본문(코멘트) 입력값 + const [comment, setComment] = useState(''); + + // 3. 파일 첨부 리스트 + const [files, setFiles] = useState(null); + + // 4. 사이드바: 담당자, 레이블, 마일스톤 (이하 예시) + const [assignee, setAssignee] = useState(null); // 담당자 id + const [labelIds, setLabelIds] = useState([]); // 레이블 id 배열 + const [milestoneId, setMilestoneId] = useState(null); // 마일스톤 id + + return ( +
+ + 새로운 이슈 작성 + + + + + {/* 아바타, 입력, 사이드바 */} +
+ + + + + +
+ setTitle(e.target.value)} + /> +