diff --git a/apps/client/.eslintrc b/apps/client/.eslintrc index 684ff1e..9a6add9 100644 --- a/apps/client/.eslintrc +++ b/apps/client/.eslintrc @@ -3,6 +3,7 @@ "parserOptions": { "project": ["./tsconfig.json"], + "tsconfigRootDir": "./apps/client", "ecmaVersion": 12, "sourceType": "module", "ecmaFeatures": { @@ -15,7 +16,13 @@ "es2021": true }, - "extends": ["airbnb", "airbnb/hooks", "plugin:@typescript-eslint/recommended", "prettier"], + "extends": [ + "airbnb", + "airbnb/hooks", + "plugin:@typescript-eslint/recommended", + "plugin:@tanstack/query/recommended", + "prettier" + ], "settings": { "react": { @@ -23,8 +30,6 @@ } }, - "plugins": ["prettier"], - "rules": { // React 관련 규칙 "react/react-in-jsx-scope": "off", @@ -59,6 +64,10 @@ // 접근성 관련 규칙 "jsx-a11y/media-has-caption": "off", + // tanstack query 관련 규칙 + "@tanstack/query/exhaustive-deps": "error", + "@tanstack/query/stable-query-client": "error", + // 기타 규칙 "no-param-reassign": [ "warn", diff --git a/apps/client/package.json b/apps/client/package.json index 7c4e3e1..f8629e1 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -16,6 +16,8 @@ "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.2", + "@tanstack/react-query": "^5.66.0", + "@tanstack/react-query-devtools": "^5.66.0", "axios": "^1.7.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -31,6 +33,7 @@ "tailwindcss-animate": "^1.0.7" }, "devDependencies": { + "@tanstack/eslint-plugin-query": "^5.66.1", "@types/node": "^20.3.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", diff --git a/apps/client/src/app/App.tsx b/apps/client/src/app/App.tsx new file mode 100644 index 0000000..3c874ad --- /dev/null +++ b/apps/client/src/app/App.tsx @@ -0,0 +1,15 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { RouterProvider } from 'react-router-dom'; +import { router } from './routes'; + +const queryClient = new QueryClient(); + +export function App() { + return ( + + + + + ); +} diff --git a/apps/client/src/app/index.ts b/apps/client/src/app/index.ts new file mode 100644 index 0000000..a56e30d --- /dev/null +++ b/apps/client/src/app/index.ts @@ -0,0 +1,2 @@ +export { App } from './App'; +export { Providers } from './providers'; diff --git a/apps/client/src/entities/attendance/api/attendanceApi.ts b/apps/client/src/entities/attendance/api/attendanceApi.ts new file mode 100644 index 0000000..c8ede9e --- /dev/null +++ b/apps/client/src/entities/attendance/api/attendanceApi.ts @@ -0,0 +1,10 @@ +import { axiosInstance } from '@/shared/api'; +import { AttendanceData, AttendanceResponse } from '../model/types'; + +export const fetchAttendance = async (): Promise => { + const { data } = await axiosInstance.get('/v1/members/attendance'); + if (!data.success) { + throw new Error(data.message || '출석부 조회에 실패했습니다.'); + } + return data.data.attendances; +}; diff --git a/apps/client/src/entities/attendance/index.ts b/apps/client/src/entities/attendance/index.ts new file mode 100644 index 0000000..7ea2a72 --- /dev/null +++ b/apps/client/src/entities/attendance/index.ts @@ -0,0 +1,3 @@ +export type { AttendanceData, AttendanceResponse } from './model/types'; +export { useAttendanceList } from './model/queries'; +export { fetchAttendance } from './api/attendanceApi'; diff --git a/apps/client/src/entities/attendance/model/queries.ts b/apps/client/src/entities/attendance/model/queries.ts new file mode 100644 index 0000000..284953a --- /dev/null +++ b/apps/client/src/entities/attendance/model/queries.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchAttendance } from '@/entities/attendance/api/attendanceApi'; +import { AttendanceData } from './types'; + +export const useAttendanceList = () => { + const { + data: attendanceList, + error, + isLoading, + } = useQuery({ + queryKey: ['attendance'], + queryFn: fetchAttendance, + staleTime: 1000 * 60, + }); + + return { + attendanceList, + error, + isLoading, + }; +}; diff --git a/apps/client/src/entities/attendance/model/types.ts b/apps/client/src/entities/attendance/model/types.ts new file mode 100644 index 0000000..0a75f68 --- /dev/null +++ b/apps/client/src/entities/attendance/model/types.ts @@ -0,0 +1,17 @@ +export type AttendanceData = { + attendanceId: number; + date: string; + startTime: string; + endTime: string; + isAttendance: boolean; +}; + +export type AttendanceResponse = { + success: boolean; + status: string; + message: string; + data: { + memberId: number; + attendances: AttendanceData[]; + }; +}; diff --git a/apps/client/src/entities/record/api/recordApi.ts b/apps/client/src/entities/record/api/recordApi.ts new file mode 100644 index 0000000..da9e5bf --- /dev/null +++ b/apps/client/src/entities/record/api/recordApi.ts @@ -0,0 +1,14 @@ +import { axiosInstance } from '@/shared/api'; + +export const getRecordList = async (attendanceId: string | undefined) => { + if (!attendanceId) { + throw new Error('attendanceId가 없습니다.'); + } + + const response = await axiosInstance.get(`/v1/records/${attendanceId}`); + + if (!response.data.success) { + throw new Error(response.data.message); + } + return response.data.data; +}; diff --git a/apps/client/src/entities/record/index.ts b/apps/client/src/entities/record/index.ts new file mode 100644 index 0000000..391a3b1 --- /dev/null +++ b/apps/client/src/entities/record/index.ts @@ -0,0 +1,2 @@ +export type { RecordData } from './model/types'; +export { useRecordList } from './model/useRecordList'; diff --git a/apps/client/src/entities/record/model/types.ts b/apps/client/src/entities/record/model/types.ts new file mode 100644 index 0000000..5a6ae61 --- /dev/null +++ b/apps/client/src/entities/record/model/types.ts @@ -0,0 +1,6 @@ +export type RecordData = { + recordId: number; + title: string; + video: string; + date: string; +}; diff --git a/apps/client/src/entities/record/model/useRecordList.ts b/apps/client/src/entities/record/model/useRecordList.ts new file mode 100644 index 0000000..63a5cf6 --- /dev/null +++ b/apps/client/src/entities/record/model/useRecordList.ts @@ -0,0 +1,9 @@ +import { useQuery } from '@tanstack/react-query'; +import { getRecordList } from '../api/recordApi'; + +export const useRecordList = (attendanceId: string | undefined) => useQuery({ + queryKey: ['record-list', attendanceId], + queryFn: () => getRecordList(attendanceId), + staleTime: 1000 * 60 * 60, + enabled: !!attendanceId, + }); diff --git a/apps/client/src/entities/user/api/userApi.ts b/apps/client/src/entities/user/api/userApi.ts new file mode 100644 index 0000000..4411535 --- /dev/null +++ b/apps/client/src/entities/user/api/userApi.ts @@ -0,0 +1,28 @@ +import { UserData, MutationUserData } from '@/entities/user'; +import { axiosInstance } from '@/shared/api'; + +export const getUserInfo = async (): Promise => { + const response = await axiosInstance.get('/v1/members/info'); + if (!response.data.success) { + throw new Error(response.data.message); + } + return response.data.data; +}; + +export const getUserProfileImage = async (): Promise => { + const response = await axiosInstance.get('/v1/members/profile-image'); + if (!response.data.success) { + throw new Error(response.data.message); + } + + return response.data.data.profileImage; +}; + +export const patchUserInfo = async (formData: MutationUserData) => { + const response = await axiosInstance.patch('/v1/members/info', formData); + if (!response.data.success) { + throw new Error(response.data.message); + } + + return response.data; +}; diff --git a/apps/client/src/entities/user/index.ts b/apps/client/src/entities/user/index.ts new file mode 100644 index 0000000..f6765a8 --- /dev/null +++ b/apps/client/src/entities/user/index.ts @@ -0,0 +1,3 @@ +export type { UserData, MutationUserData } from './model/types'; +export { getUserInfo, getUserProfileImage, patchUserInfo } from './api/userApi'; +export { useUserData, useProfileImage, useUserDataMutation } from './model/queries'; diff --git a/apps/client/src/entities/user/model/queries.ts b/apps/client/src/entities/user/model/queries.ts new file mode 100644 index 0000000..0cba428 --- /dev/null +++ b/apps/client/src/entities/user/model/queries.ts @@ -0,0 +1,55 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { getUserInfo, getUserProfileImage, patchUserInfo } from '@/entities/user'; +import { userKeys } from './queryFactory'; +import { useToast } from '@/shared/lib'; +import { MutationUserData } from './types'; + +export const useUserData = () => { + const { + data: userData, + isLoading, + error, + } = useQuery({ queryKey: userKeys.profile(), queryFn: getUserInfo, staleTime: 1000 * 60 * 10 }); + + return { userData, isLoading, error }; +}; + +export const useProfileImage = (isLoggedIn: boolean) => { + const { data, isLoading, error } = useQuery({ + queryKey: userKeys.profileImage(), + queryFn: getUserProfileImage, + enabled: isLoggedIn, + staleTime: 1000 * 60 * 10, + }); + + return { profileImgUrl: data, isLoading, error }; +}; + +export const useUserDataMutation = (onSuccessCallback: () => void) => { + const queryClient = useQueryClient(); + const { toast } = useToast(); + + return useMutation({ + mutationFn: (formData: MutationUserData) => patchUserInfo(formData), + onSuccess: async data => { + if (data.success) { + toast({ title: '프로필 업데이트 성공', variant: 'default' }); + await queryClient.invalidateQueries({ queryKey: userKeys.profile(), refetchType: 'active' }); + onSuccessCallback(); + } else { + toast({ + title: '프로필 업데이트 실패', + description: data.message || '알 수 없는 오류가 발생했습니다', + variant: 'destructive', + }); + } + }, + onError: error => { + toast({ + title: '프로필 업데이트 실패', + description: error instanceof Error ? error.message : '네트워크 오류가 발생했습니다', + variant: 'destructive', + }); + }, + }); +}; diff --git a/apps/client/src/entities/user/model/queryFactory.ts b/apps/client/src/entities/user/model/queryFactory.ts new file mode 100644 index 0000000..438e769 --- /dev/null +++ b/apps/client/src/entities/user/model/queryFactory.ts @@ -0,0 +1,5 @@ +export const userKeys = { + all: ['user'] as const, + profile: () => [...userKeys.all, 'profile'] as const, + profileImage: () => [...userKeys.all, 'profile-image'] as const, +}; diff --git a/apps/client/src/pages/Profile/model/types.ts b/apps/client/src/entities/user/model/types.ts similarity index 60% rename from apps/client/src/pages/Profile/model/types.ts rename to apps/client/src/entities/user/model/types.ts index 5b54fd7..19d0c8c 100644 --- a/apps/client/src/pages/Profile/model/types.ts +++ b/apps/client/src/entities/user/model/types.ts @@ -15,3 +15,12 @@ export type UserData = { contacts: Contacts; profileImage: string; }; + +export type MutationUserData = { + contacts: { + email: string; + github: string; + blog: string; + linkedin: string; + }; +} & Pick; diff --git a/apps/client/src/features/bookmark/api/bookmarkApi.ts b/apps/client/src/features/bookmark/api/bookmarkApi.ts new file mode 100644 index 0000000..0d272bc --- /dev/null +++ b/apps/client/src/features/bookmark/api/bookmarkApi.ts @@ -0,0 +1,10 @@ +import { axiosInstance } from '@/shared/api'; +import { BookmarkData } from '@/features/bookmark'; + +export const getBookmarks = () => axiosInstance.get('/v1/bookmarks').then(res => res.data.data.bookmarks); + +export const addBookmark = (newBookmark: BookmarkData) => + axiosInstance.post('/v1/bookmarks', newBookmark).then(res => res.data.data); + +export const deleteBookmark = (bookmarkId: number) => + axiosInstance.delete(`/v1/bookmarks/${bookmarkId}`).then(res => res.data); diff --git a/apps/client/src/features/bookmark/index.ts b/apps/client/src/features/bookmark/index.ts new file mode 100644 index 0000000..1bf47fe --- /dev/null +++ b/apps/client/src/features/bookmark/index.ts @@ -0,0 +1,4 @@ +export { Bookmark } from './ui/Bookmark'; +export type { BookmarkData } from './model/types'; +export { useBookmarkMutation } from './model/queries'; +export { getBookmarks, addBookmark, deleteBookmark } from './api/bookmarkApi'; diff --git a/apps/client/src/features/bookmark/model/queries.ts b/apps/client/src/features/bookmark/model/queries.ts new file mode 100644 index 0000000..b020f19 --- /dev/null +++ b/apps/client/src/features/bookmark/model/queries.ts @@ -0,0 +1,44 @@ +// src/features/bookmark/model/useBookmarkMutation.ts +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useToast } from '@/shared/lib'; +import { addBookmark, deleteBookmark } from '@/features/bookmark/api/bookmarkApi'; +import { BookmarkData } from './types'; + +export const useBookmarkMutation = ({ onAddSuccess }: { onAddSuccess?: () => void }) => { + const queryClient = useQueryClient(); + const { toast } = useToast(); + + const { mutate: mutateAdd } = useMutation({ + mutationFn: addBookmark, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bookmarks'] }); + onAddSuccess?.(); + }, + onError: (error: Error) => { + toast({ + variant: 'destructive', + title: '북마크 생성 실패', + description: error.message, + }); + }, + }); + + const { mutate: mutateDelete } = useMutation({ + mutationFn: deleteBookmark, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bookmarks'] }); + }, + onError: (error: Error) => { + toast({ + variant: 'destructive', + title: '북마크 삭제 실패', + description: error.message, + }); + }, + }); + + return { + mutateAdd, + mutateDelete, + }; +}; diff --git a/apps/client/src/widgets/Banner/types.ts b/apps/client/src/features/bookmark/model/types.ts similarity index 100% rename from apps/client/src/widgets/Banner/types.ts rename to apps/client/src/features/bookmark/model/types.ts diff --git a/apps/client/src/widgets/Banner/Bookmark.tsx b/apps/client/src/features/bookmark/ui/Bookmark.tsx similarity index 74% rename from apps/client/src/widgets/Banner/Bookmark.tsx rename to apps/client/src/features/bookmark/ui/Bookmark.tsx index f9a8084..d3851a0 100644 --- a/apps/client/src/widgets/Banner/Bookmark.tsx +++ b/apps/client/src/features/bookmark/ui/Bookmark.tsx @@ -1,16 +1,14 @@ import { createPortal } from 'react-dom'; import { useForm } from 'react-hook-form'; -import { useContext, useEffect, useState } from 'react'; +import { useContext, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Modal, CloseIcon } from '@/shared/ui'; import { Button } from '@/shared/ui/shadcn/button'; -import { useToast } from '@/shared/lib'; import { AuthContext } from '@/shared/contexts'; -import { axiosInstance } from '@/shared/api'; -import { BookmarkData } from './types'; +import { BookmarkData, getBookmarks, useBookmarkMutation } from '@/features/bookmark'; export function Bookmark() { const { isLoggedIn } = useContext(AuthContext); - const [bookmarkList, setBookmarkList] = useState([]); const [showModal, setShowModal] = useState(false); const { register, @@ -18,7 +16,20 @@ export function Bookmark() { formState: { errors }, reset, } = useForm(); - const { toast } = useToast(); + + const { data: bookmarkList = [] } = useQuery({ + queryKey: ['bookmarks'], + queryFn: getBookmarks, + enabled: isLoggedIn, + staleTime: 1000 * 50 * 5, + }); + + const { mutateAdd, mutateDelete } = useBookmarkMutation({ + onAddSuccess: () => { + reset(); + setShowModal(false); + }, + }); const handleClickBookmarkButton = (url: string) => { window.open(url); @@ -26,47 +37,15 @@ export function Bookmark() { const handleAddBookmark = (newBookmark: BookmarkData) => { if (!isLoggedIn) return; - axiosInstance - .post('/v1/bookmarks', newBookmark) - .then(response => { - if (response.data.success) { - const addedBookmark = { ...newBookmark, bookmarkId: response.data.data.bookmarkId }; - const newBookmarkList = [...bookmarkList, addedBookmark]; - setBookmarkList(newBookmarkList); - } else { - toast({ variant: 'destructive', title: '북마크 생성 실패' }); - } - }) - .finally(() => { - reset(); - setShowModal(false); - }); + mutateAdd(newBookmark); }; const handleDeleteBookmark = (e: React.MouseEvent, bookmarkId: number) => { e.stopPropagation(); - if (!isLoggedIn) return; - axiosInstance.delete(`/v1/bookmarks/${bookmarkId}`).then(response => { - if (response.data.success) { - const newBookmarkList = bookmarkList.filter((data, _) => data.bookmarkId !== bookmarkId); - setBookmarkList(newBookmarkList); - } else { - toast({ variant: 'destructive', title: '북마크 삭제 실패' }); - } - }); + mutateDelete(bookmarkId); }; - useEffect(() => { - axiosInstance.get('/v1/bookmarks').then(response => { - if (response.data.success) { - setBookmarkList(response.data.data.bookmarks); - } else { - toast({ variant: 'destructive', title: '북마크 조회 실패' }); - } - }); - }, [toast]); - return ( <> {isLoggedIn && ( diff --git a/apps/client/src/features/editProfile/lib/index.ts b/apps/client/src/features/editProfile/lib/index.ts new file mode 100644 index 0000000..a7b45ac --- /dev/null +++ b/apps/client/src/features/editProfile/lib/index.ts @@ -0,0 +1,2 @@ +export type { FormInput } from './types'; +export { transformFormToApiData } from './utils'; diff --git a/apps/client/src/features/editProfile/lib/types.ts b/apps/client/src/features/editProfile/lib/types.ts new file mode 100644 index 0000000..6486267 --- /dev/null +++ b/apps/client/src/features/editProfile/lib/types.ts @@ -0,0 +1,11 @@ +import { Field } from '@/shared/types'; + +export type FormInput = { + camperId: string | undefined; + name: string | undefined; + field: Field | undefined; + email: string | undefined; + github: string | undefined; + blog: string | undefined; + linkedIn: string | undefined; +}; diff --git a/apps/client/src/features/editProfile/lib/utils.ts b/apps/client/src/features/editProfile/lib/utils.ts new file mode 100644 index 0000000..e12784f --- /dev/null +++ b/apps/client/src/features/editProfile/lib/utils.ts @@ -0,0 +1,15 @@ +import { Field } from '@/shared/types'; +import { FormInput } from './types'; +import { MutationUserData } from '@/entities/user'; + +export const transformFormToApiData = (data: FormInput, selectedField: Field | undefined): MutationUserData => ({ + name: data.name!, + camperId: data.camperId!, + field: selectedField!, + contacts: { + email: data.email ? data.email : '', + github: data.github ? data.github : '', + blog: data.blog ? data.blog : '', + linkedin: data.linkedIn ? data.linkedIn : '', + }, + }); diff --git a/apps/client/src/features/editProfile/ui/EditUserInfo.tsx b/apps/client/src/features/editProfile/ui/EditUserInfo.tsx index 6cffb6c..ff50419 100644 --- a/apps/client/src/features/editProfile/ui/EditUserInfo.tsx +++ b/apps/client/src/features/editProfile/ui/EditUserInfo.tsx @@ -1,27 +1,16 @@ import { useForm } from 'react-hook-form'; import { useState } from 'react'; import { Avatar, AvatarFallback, AvatarImage } from '@/shared/ui/shadcn/avatar'; -import { UserData } from '@/pages/Profile'; -import { Field } from '@/shared/types/sharedTypes'; +import { UserData, useUserDataMutation } from '@/entities/user'; +import { Field } from '@/shared/types'; import { Button } from '@/shared/ui/shadcn/button'; -import { axiosInstance } from '@/shared/api'; -import { useToast } from '@/shared/lib'; +import { FormInput, transformFormToApiData } from '../lib'; type EditUserInfoProps = Readonly<{ userData: UserData | undefined; toggleEditing: () => void; }>; -export type FormInput = { - camperId: string | undefined; - name: string | undefined; - field: Field | undefined; - email: string | undefined; - github: string | undefined; - blog: string | undefined; - linkedIn: string | undefined; -}; - export function EditUserInfo({ userData, toggleEditing }: EditUserInfoProps) { const [selectedField, setSelectedField] = useState(userData?.field); const { @@ -39,34 +28,18 @@ export function EditUserInfo({ userData, toggleEditing }: EditUserInfoProps) { linkedIn: userData?.contacts.linkedIn, }, }); - const { toast } = useToast(); + const { mutateAsync } = useUserDataMutation(toggleEditing); const handleSelectField = (field: Field) => { setSelectedField(selectedField === field ? '' : field); }; const handlePatchUserInfo = (data: FormInput) => { - const formData = { - name: data.name, - camperId: data.camperId, - field: selectedField, - contacts: { - email: data.email ? data.email : '', - github: data.github ? data.github : '', - blog: data.blog ? data.blog : '', - linkedin: data.linkedIn ? data.linkedIn : '', - }, - }; + const formData = transformFormToApiData(data, selectedField); if (!formData.field) return; - axiosInstance.patch('/v1/members/info', formData).then(response => { - if (response.data.success) { - toggleEditing(); - } else { - toast({ variant: 'destructive', title: '유저 정보 수정 실패' }); - } - }); + mutateAsync(formData); }; return ( diff --git a/apps/client/src/features/index.ts b/apps/client/src/features/index.ts new file mode 100644 index 0000000..8c732e6 --- /dev/null +++ b/apps/client/src/features/index.ts @@ -0,0 +1 @@ +export { useAuth, AuthContext } from './auth'; diff --git a/apps/client/src/features/livePreview/api/index.ts b/apps/client/src/features/livePreview/api/index.ts new file mode 100644 index 0000000..0858fac --- /dev/null +++ b/apps/client/src/features/livePreview/api/index.ts @@ -0,0 +1 @@ +export { getLivePreviewList, searchLivePreviewList } from './livePreviewListApi'; diff --git a/apps/client/src/features/livePreview/api/livePreviewListApi.ts b/apps/client/src/features/livePreview/api/livePreviewListApi.ts new file mode 100644 index 0000000..20574cc --- /dev/null +++ b/apps/client/src/features/livePreview/api/livePreviewListApi.ts @@ -0,0 +1,21 @@ +import { axiosInstance } from '@/shared/api'; +import { Field } from '@/shared/types'; +import { Cursor, LivePreviewInfo, LivePreviewListInfo } from '../model'; + +const LIMIT = 12; + +export const getLivePreviewList = async (field: Field, cursor: Cursor): Promise => { + const response = await axiosInstance.get('/v1/broadcasts', { params: { field, cursor, limit: LIMIT } }); + if (!response.data.success) { + throw new Error('방송 목록 조회에 실패했습니다.'); + } + return response.data.data; +}; + +export const searchLivePreviewList = async (keyword: string): Promise => { + const response = await axiosInstance.get('/v1/broadcasts/search', { params: { keyword: keyword.trim() } }); + if (!response.data.success) { + throw new Error('방송 목록 검색에 실패했습니다.'); + } + return response.data.data; +}; diff --git a/apps/client/src/features/livePreview/index.ts b/apps/client/src/features/livePreview/index.ts new file mode 100644 index 0000000..b2ada99 --- /dev/null +++ b/apps/client/src/features/livePreview/index.ts @@ -0,0 +1,2 @@ +export type { LivePreviewInfo, LivePreviewListInfo } from './model'; +export { FieldFilter, LivePreviewCard, Search } from './ui'; diff --git a/apps/client/src/features/livePreview/model/index.ts b/apps/client/src/features/livePreview/model/index.ts new file mode 100644 index 0000000..65aeeaf --- /dev/null +++ b/apps/client/src/features/livePreview/model/index.ts @@ -0,0 +1 @@ +export type { LivePreviewInfo, LivePreviewListInfo, Cursor } from './types'; diff --git a/apps/client/src/features/livePreview/model/queries.ts b/apps/client/src/features/livePreview/model/queries.ts new file mode 100644 index 0000000..e44cecd --- /dev/null +++ b/apps/client/src/features/livePreview/model/queries.ts @@ -0,0 +1,21 @@ +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { getLivePreviewList, searchLivePreviewList } from '../api'; +import { Field } from '@/shared/types'; +import { Cursor } from './types'; + +export const useLivePreviewList = (field: Field) => + useInfiniteQuery({ + queryKey: ['live-preview-list', field], + queryFn: ({ pageParam }) => getLivePreviewList(field, pageParam), + initialPageParam: null as Cursor, + getNextPageParam: lastPage => lastPage.nextCursor, + refetchOnWindowFocus: true, + }); + +export const useSearchLivePreviewList = (keyword: string) => + useQuery({ + queryKey: ['live-preview-search', keyword], + queryFn: () => searchLivePreviewList(keyword), + enabled: !!keyword && keyword.trim().length > 0, + refetchOnWindowFocus: false, + }); diff --git a/apps/client/src/pages/Home/model/homeTypes.ts b/apps/client/src/features/livePreview/model/types.ts similarity index 83% rename from apps/client/src/pages/Home/model/homeTypes.ts rename to apps/client/src/features/livePreview/model/types.ts index 20b5047..1a7207a 100644 --- a/apps/client/src/pages/Home/model/homeTypes.ts +++ b/apps/client/src/features/livePreview/model/types.ts @@ -9,7 +9,9 @@ export type LivePreviewInfo = { field: Field; }; +export type Cursor = string | null; + export type LivePreviewListInfo = { broadcasts: LivePreviewInfo[]; - nextCursor: string | null; + nextCursor: Cursor; }; diff --git a/apps/client/src/widgets/LiveList/FieldFilter.tsx b/apps/client/src/features/livePreview/ui/FieldFilter.tsx similarity index 100% rename from apps/client/src/widgets/LiveList/FieldFilter.tsx rename to apps/client/src/features/livePreview/ui/FieldFilter.tsx diff --git a/apps/client/src/widgets/LiveList/LiveCard.tsx b/apps/client/src/features/livePreview/ui/LivePreviewCard.tsx similarity index 94% rename from apps/client/src/widgets/LiveList/LiveCard.tsx rename to apps/client/src/features/livePreview/ui/LivePreviewCard.tsx index 3354470..9b18690 100644 --- a/apps/client/src/widgets/LiveList/LiveCard.tsx +++ b/apps/client/src/features/livePreview/ui/LivePreviewCard.tsx @@ -8,7 +8,7 @@ type LiveCardProps = Readonly<{ thumbnailUrl: string; }>; -export function LiveCard({ liveId, title, userId, profileUrl, thumbnailUrl }: LiveCardProps) { +export function LivePreviewCard({ liveId, title, userId, profileUrl, thumbnailUrl }: LiveCardProps) { const navigate = useNavigate(); const handleClick = () => { diff --git a/apps/client/src/widgets/LiveList/Search.tsx b/apps/client/src/features/livePreview/ui/Search.tsx similarity index 96% rename from apps/client/src/widgets/LiveList/Search.tsx rename to apps/client/src/features/livePreview/ui/Search.tsx index d3fa755..b8e329f 100644 --- a/apps/client/src/widgets/LiveList/Search.tsx +++ b/apps/client/src/features/livePreview/ui/Search.tsx @@ -27,7 +27,7 @@ export function Search({ onSearch }: SearchProps) { className="flex-1 bg-transparent focus-visible:outline-none" placeholder="검색할 방송 제목을 입력해주세요" /> - + diff --git a/apps/client/src/features/livePreview/ui/index.ts b/apps/client/src/features/livePreview/ui/index.ts new file mode 100644 index 0000000..3e6579c --- /dev/null +++ b/apps/client/src/features/livePreview/ui/index.ts @@ -0,0 +1,3 @@ +export { FieldFilter } from './FieldFilter'; +export { LivePreviewCard } from './LivePreviewCard'; +export { Search } from './Search'; diff --git a/apps/client/src/features/watching/index.ts b/apps/client/src/features/watching/index.ts index 3ba4257..f791aef 100644 --- a/apps/client/src/features/watching/index.ts +++ b/apps/client/src/features/watching/index.ts @@ -1,2 +1,2 @@ export { LiveCamperInfo, LivePlayer } from './ui'; -export { useConsume } from './model'; +export { useConsume, useLiveInfo } from './model'; diff --git a/apps/client/src/features/watching/model/index.ts b/apps/client/src/features/watching/model/index.ts index e5bfd95..b1fea80 100644 --- a/apps/client/src/features/watching/model/index.ts +++ b/apps/client/src/features/watching/model/index.ts @@ -1 +1,2 @@ export { useConsume } from './useConsume'; +export { useLiveInfo } from './queries'; diff --git a/apps/client/src/features/watching/model/liveCamperInfoApi.ts b/apps/client/src/features/watching/model/liveCamperInfoApi.ts new file mode 100644 index 0000000..907eb4d --- /dev/null +++ b/apps/client/src/features/watching/model/liveCamperInfoApi.ts @@ -0,0 +1,10 @@ +import { axiosInstance } from '@/shared/api'; +import { LiveInfo } from './types'; + +export const getLiveCamperInfo = async (liveId: string): Promise => { + const response = await axiosInstance.get(`v1/broadcasts/${liveId}/info`); + if (!response.data.success) { + throw new Error(response.data.message); + } + return response.data.data; +}; diff --git a/apps/client/src/features/watching/model/queries.ts b/apps/client/src/features/watching/model/queries.ts new file mode 100644 index 0000000..8d53bd0 --- /dev/null +++ b/apps/client/src/features/watching/model/queries.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { getLiveCamperInfo } from './liveCamperInfoApi'; + +export const useLiveInfo = (liveId: string) => { + const { data, isLoading, isError } = useQuery({ + queryKey: ['liveInfo', liveId], + queryFn: () => getLiveCamperInfo(liveId), + staleTime: 1000 * 60, + refetchInterval: 1000 * 30, + }); + + return { data, isLoading, isError }; +}; diff --git a/apps/client/src/features/watching/model/types.ts b/apps/client/src/features/watching/model/types.ts new file mode 100644 index 0000000..050021d --- /dev/null +++ b/apps/client/src/features/watching/model/types.ts @@ -0,0 +1,6 @@ +import { UserData } from '@/entities/user'; + +export type LiveInfo = { + title: string; + viewers: number; +} & UserData; diff --git a/apps/client/src/features/watching/ui/LiveCamperInfo/LiveCamperInfo.tsx b/apps/client/src/features/watching/ui/LiveCamperInfo/LiveCamperInfo.tsx index 8242e00..4cb39a4 100644 --- a/apps/client/src/features/watching/ui/LiveCamperInfo/LiveCamperInfo.tsx +++ b/apps/client/src/features/watching/ui/LiveCamperInfo/LiveCamperInfo.tsx @@ -1,6 +1,5 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/shared/ui/shadcn/avatar'; import { Badge } from '@/shared/ui/shadcn/badge'; -import { useAPI } from '@/shared/api'; import { LoadingCharacter, ErrorCharacter, @@ -10,12 +9,12 @@ import { BlogIcon, LinkedInIcon, } from '@/shared/ui'; -import { LiveInfo } from './types'; +import { useLiveInfo } from '@/features/watching'; export function LiveCamperInfo({ liveId }: Readonly<{ liveId: string }>) { - const { data, isLoading, error } = useAPI(`v1/broadcasts/${liveId}/info`); + const { data, isLoading, isError } = useLiveInfo(liveId); - if (error || !data) { + if (isError || !data) { return (
@@ -79,7 +78,7 @@ export function LiveCamperInfo({ liveId }: Readonly<{ liveId: string }>) { - window.open(data.contacts.linkedin, '_blank')}> + window.open(data.contacts.linkedIn, '_blank')}>
diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx index 07116fa..84a22b6 100644 --- a/apps/client/src/main.tsx +++ b/apps/client/src/main.tsx @@ -1,6 +1,5 @@ import { createRoot } from 'react-dom/client'; import './index.css'; -import { RouterProvider } from 'react-router-dom'; -import { router } from '@/app/routes'; +import { App } from './app'; -createRoot(document.getElementById('root')!).render(); +createRoot(document.getElementById('root')!).render(); diff --git a/apps/client/src/pages/Home/index.ts b/apps/client/src/pages/Home/index.ts index 0799f47..9d44c0c 100644 --- a/apps/client/src/pages/Home/index.ts +++ b/apps/client/src/pages/Home/index.ts @@ -1 +1 @@ -export { HomePage } from './HomePage'; +export { HomePage } from './ui'; diff --git a/apps/client/src/pages/Home/model/index.ts b/apps/client/src/pages/Home/model/index.ts deleted file mode 100644 index e62cd6e..0000000 --- a/apps/client/src/pages/Home/model/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { useIntersect } from './useIntersect'; -export type { LivePreviewInfo, LivePreviewListInfo } from './homeTypes'; diff --git a/apps/client/src/pages/Home/HomePage.tsx b/apps/client/src/pages/Home/ui/HomePage.tsx similarity index 66% rename from apps/client/src/pages/Home/HomePage.tsx rename to apps/client/src/pages/Home/ui/HomePage.tsx index abc49be..8033883 100644 --- a/apps/client/src/pages/Home/HomePage.tsx +++ b/apps/client/src/pages/Home/ui/HomePage.tsx @@ -1,10 +1,10 @@ -import { Banner, LiveList } from '@/widgets'; +import { Banner, LivePreviewList } from '@/widgets'; export function HomePage() { return (
- +
); } diff --git a/apps/client/src/pages/Home/ui/index.ts b/apps/client/src/pages/Home/ui/index.ts new file mode 100644 index 0000000..0799f47 --- /dev/null +++ b/apps/client/src/pages/Home/ui/index.ts @@ -0,0 +1 @@ +export { HomePage } from './HomePage'; diff --git a/apps/client/src/pages/Profile/ProfilePage.tsx b/apps/client/src/pages/Profile/ProfilePage.tsx index f48d75f..00b46fe 100644 --- a/apps/client/src/pages/Profile/ProfilePage.tsx +++ b/apps/client/src/pages/Profile/ProfilePage.tsx @@ -1,30 +1,13 @@ import { useEffect, useState } from 'react'; import { Attendance, UserInfo } from './ui'; -import { UserData } from './model'; import { EditUserInfo } from '@/features/editProfile'; -import { axiosInstance } from '@/shared/api'; import { ErrorCharacter, LoadingCharacter } from '@/shared/ui'; +import { useUserData } from '@/entities/user'; export function ProfilePage() { - const [userData, setUserData] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); const [isEditing, setIsEditing] = useState(false); - const [showLoading, setShowLoading] = useState(true); - useEffect(() => { - axiosInstance - .get('/v1/members/info') - .then(response => { - if (response.data.success) { - setUserData(response.data.data); - } else { - setError(new Error(response.data.message)); - } - }) - .catch(err => setError(err instanceof Error ? err : new Error(err))) - .finally(() => setIsLoading(false)); - }, [isEditing]); + const { userData, isLoading, error } = useUserData(); useEffect(() => { if (!userData) return; @@ -33,19 +16,11 @@ export function ProfilePage() { } }, [userData, isEditing]); - useEffect(() => { - const timeoutId = setTimeout(() => { - setShowLoading(false); - }, 250); - - return () => clearTimeout(timeoutId); - }, []); - const toggleEditing = () => { setIsEditing(prev => !prev); }; - if (showLoading && isLoading) { + if (isLoading) { return (
@@ -53,7 +28,7 @@ export function ProfilePage() { ); } - if (error || !userData) { + if (error) { return (
diff --git a/apps/client/src/pages/Profile/index.ts b/apps/client/src/pages/Profile/index.ts index ce68016..a2ab4b8 100644 --- a/apps/client/src/pages/Profile/index.ts +++ b/apps/client/src/pages/Profile/index.ts @@ -1,2 +1 @@ export { ProfilePage as default } from './ProfilePage'; -export type { UserData } from './model'; diff --git a/apps/client/src/pages/Profile/model/index.ts b/apps/client/src/pages/Profile/model/index.ts deleted file mode 100644 index 66de0f4..0000000 --- a/apps/client/src/pages/Profile/model/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type { UserData } from './types'; diff --git a/apps/client/src/pages/Profile/ui/Attendance.tsx b/apps/client/src/pages/Profile/ui/Attendance.tsx index 7ebc9b4..575df21 100644 --- a/apps/client/src/pages/Profile/ui/Attendance.tsx +++ b/apps/client/src/pages/Profile/ui/Attendance.tsx @@ -1,54 +1,19 @@ -import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { ErrorCharacter, LoadingCharacter } from '@/shared/ui'; import { PlayIcon } from '@/shared/ui/Icons'; -import { axiosInstance } from '@/shared/api'; +import { useAttendanceList } from '@/entities/attendance'; -type AttendanceData = { - attendanceId: number; - date: string; - startTime: string; - endTime: string; - isAttendance: boolean; -}; +const ATTENDANCE_TABLE_HEADER = ['학습일', '시작 시간', '종료 시간', '출석 여부']; export function Attendance() { - const [attendanceList, setAttendanceList] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [showLoading, setShowLoading] = useState(false); - const [error, setError] = useState(null); - + const { attendanceList, error, isLoading } = useAttendanceList(); const navigate = useNavigate(); - useEffect(() => { - axiosInstance - .get('/v1/members/attendance') - .then(response => { - if (response.data.success) { - setAttendanceList(response.data.data.attendances); - } else { - setError(new Error(response.data.message)); - } - }) - .catch(err => setError(err instanceof Error ? err : new Error(err))) - .finally(() => { - setIsLoading(false); - }); - }, [setAttendanceList, setError]); - - useEffect(() => { - const timer = setTimeout(() => { - setShowLoading(true); - }, 250); - - return () => clearTimeout(timer); - }); - const handlePlayRecord = (attendanceId: number) => { navigate(`/record/${attendanceId}`); }; - if (showLoading && isLoading) { + if (isLoading) { return (
@@ -68,7 +33,7 @@ export function Attendance() {
- {['학습일', '시작 시간', '종료 시간', '출석 여부'].map((data: string) => ( + {ATTENDANCE_TABLE_HEADER.map((data: string) => (
{data}
diff --git a/apps/client/src/pages/Profile/ui/UserInfo.tsx b/apps/client/src/pages/Profile/ui/UserInfo.tsx index 437d980..910c7e1 100644 --- a/apps/client/src/pages/Profile/ui/UserInfo.tsx +++ b/apps/client/src/pages/Profile/ui/UserInfo.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { ErrorCharacter, LoadingCharacter, BlogIcon, EditIcon, GithubIcon, LinkedInIcon, MailIcon } from '@/shared/ui'; import { Avatar, AvatarFallback, AvatarImage } from '@/shared/ui/shadcn/avatar'; -import { UserData } from '../model'; +import { UserData } from '@/entities/user'; type UserInfoProps = Readonly<{ userData: UserData | undefined; diff --git a/apps/client/src/pages/Record/RecordPage.tsx b/apps/client/src/pages/Record/RecordPage.tsx index add84e2..5d2aa58 100644 --- a/apps/client/src/pages/Record/RecordPage.tsx +++ b/apps/client/src/pages/Record/RecordPage.tsx @@ -1,12 +1,6 @@ import { useState } from 'react'; import { RecordInfo, RecordList, RecordPlayer } from './ui'; - -export type RecordData = { - recordId: number; - title: string; - video: string; - date: string; -}; +import { RecordData } from '@/entities/record'; export function RecordPage() { const [nowPlaying, setNowPlaying] = useState({ recordId: 0, title: '', video: '', date: '' }); diff --git a/apps/client/src/pages/Record/ui/RecordList.tsx b/apps/client/src/pages/Record/ui/RecordList.tsx index 9590d40..79dac64 100644 --- a/apps/client/src/pages/Record/ui/RecordList.tsx +++ b/apps/client/src/pages/Record/ui/RecordList.tsx @@ -1,28 +1,34 @@ -import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { PlayIcon, ErrorCharacter } from '@/shared/ui'; -import { RecordData } from '../RecordPage'; -import { axiosInstance } from '@/shared/api'; +import { PlayIcon, ErrorCharacter, LoadingCharacter } from '@/shared/ui'; +import { RecordData, useRecordList } from '@/entities/record'; type RecordListProps = Readonly<{ onClickList: (data: RecordData) => void; }>; export function RecordList({ onClickList }: RecordListProps) { - const [recordList, setRecordList] = useState([]); const { attendanceId } = useParams<{ attendanceId: string }>(); - const [error, setError] = useState(''); + const { data: recordList, isLoading, isError } = useRecordList(attendanceId); - useEffect(() => { - axiosInstance.get(`/v1/records/${attendanceId}`).then(response => { - if (response.data.success) setRecordList(response.data.data.records); - else setError(response.data.message); - }); - }, [attendanceId]); + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( +
+ +
+ ); + } return (
- {error ? ( + {isError ? (
diff --git a/apps/client/src/shared/api/axios.ts b/apps/client/src/shared/api/axios.ts index ed2abf6..85adcfa 100644 --- a/apps/client/src/shared/api/axios.ts +++ b/apps/client/src/shared/api/axios.ts @@ -19,3 +19,18 @@ axiosInstance.interceptors.request.use( }, error => Promise.reject(error instanceof Error ? error : new Error(error)), ); + +axiosInstance.interceptors.response.use( + response => { + if (!response.data.success) { + throw new Error(response.data.message); + } + return response; + }, + error => { + if (axios.isAxiosError(error) && error.response?.data) { + throw new Error(error.response.data.message); + } + throw new Error('서버와 통신 중 오류가 발생했습니다.'); + }, +); diff --git a/apps/client/src/shared/api/index.ts b/apps/client/src/shared/api/index.ts index a2123fd..c3b123d 100644 --- a/apps/client/src/shared/api/index.ts +++ b/apps/client/src/shared/api/index.ts @@ -1,2 +1 @@ export { axiosInstance } from './axios'; -export { useAPI } from './useAPI'; diff --git a/apps/client/src/shared/api/useAPI.ts b/apps/client/src/shared/api/useAPI.ts deleted file mode 100644 index 32fe424..0000000 --- a/apps/client/src/shared/api/useAPI.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { axiosInstance } from '@/shared/api'; - -type APIOptions = { - method?: 'GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH'; - params?: Record; - data?: unknown; -}; - -type APIQueryState = { - data: T | null; - fetchData: () => Promise; - isLoading: boolean; - error: Error | null; -}; - -export const useAPI = (endpoint: string, options: APIOptions = {}): APIQueryState => { - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [data, setData] = useState(null); - - const fetchData = useCallback(async () => { - setIsLoading(true); - try { - const result = await axiosInstance.request({ - url: endpoint, - method: options.method ?? 'GET', - params: options.params, - data: options.data, - }); - setData(result.data.data); - setError(null); - } catch (err) { - setError(err instanceof Error ? err : new Error('Failed to fetch data.')); - } finally { - setIsLoading(false); - } - }, [endpoint, options.method, options.params, options.data]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - return { data, fetchData, isLoading, error }; -}; diff --git a/apps/client/src/shared/lib/index.ts b/apps/client/src/shared/lib/index.ts index 1f4c153..6a71a2e 100644 --- a/apps/client/src/shared/lib/index.ts +++ b/apps/client/src/shared/lib/index.ts @@ -3,3 +3,4 @@ export { useTheme } from './useTheme'; export { useToast } from './useToast'; export { getRtpCapabilities, createDevice, connectTransport } from './mediasoupHelpers'; export { cn, checkDependencies } from './utils'; +export { useIntersect } from './useIntersect'; diff --git a/apps/client/src/pages/Home/model/useIntersect.ts b/apps/client/src/shared/lib/useIntersect.ts similarity index 74% rename from apps/client/src/pages/Home/model/useIntersect.ts rename to apps/client/src/shared/lib/useIntersect.ts index 361be51..dcd6f57 100644 --- a/apps/client/src/pages/Home/model/useIntersect.ts +++ b/apps/client/src/shared/lib/useIntersect.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; type IntersectHandler = (entry: IntersectionObserverEntry, observer: IntersectionObserver) => void; @@ -8,11 +8,15 @@ type UseIntersectProps = { }; export const useIntersect = ({ onIntersect, options }: UseIntersectProps) => { + const [inView, setInView] = useState(false); const ref = useRef(null); const callback = useCallback( (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => { entries.forEach(entry => { - if (entry.isIntersecting) onIntersect(entry, observer); + setInView(entry.isIntersecting); + if (entry.isIntersecting) { + onIntersect(entry, observer); + } }); }, [onIntersect], @@ -27,5 +31,5 @@ export const useIntersect = ({ onIntersect, options }: UseIntersectProps) => { }; }, [ref, options, callback]); - return ref; + return { ref, inView }; }; diff --git a/apps/client/src/shared/types/index.ts b/apps/client/src/shared/types/index.ts index e69de29..cea55c0 100644 --- a/apps/client/src/shared/types/index.ts +++ b/apps/client/src/shared/types/index.ts @@ -0,0 +1,2 @@ +export type { TransportInfo, ConnectTransportResponse } from './mediasoupTypes'; +export type { Field } from './sharedTypes'; diff --git a/apps/client/src/shared/ui/IconButton.tsx b/apps/client/src/shared/ui/IconButton.tsx index 62fe9d4..9562b1c 100644 --- a/apps/client/src/shared/ui/IconButton.tsx +++ b/apps/client/src/shared/ui/IconButton.tsx @@ -1,3 +1,5 @@ +import { ButtonHTMLAttributes } from 'react'; + type IconButtonProps = Readonly<{ children: React.ReactNode; title?: string; @@ -5,9 +7,10 @@ type IconButtonProps = Readonly<{ onClick?: () => void; disabled?: boolean; className?: string; -}>; +}> & + ButtonHTMLAttributes; -export function IconButton({ children, title, ariaLabel, onClick, disabled, className }: IconButtonProps) { +export function IconButton({ children, title, ariaLabel, onClick, disabled, className, ...props }: IconButtonProps) { return ( diff --git a/apps/client/src/widgets/Banner/Banner.tsx b/apps/client/src/widgets/Banner/Banner.tsx index 34b5330..17f1866 100644 --- a/apps/client/src/widgets/Banner/Banner.tsx +++ b/apps/client/src/widgets/Banner/Banner.tsx @@ -1,5 +1,5 @@ import { MoveCharacter } from '@/shared/ui'; -import { Bookmark } from './Bookmark'; +import { Bookmark } from '@/features/bookmark'; export function Banner() { return ( diff --git a/apps/client/src/widgets/Header/Header.tsx b/apps/client/src/widgets/Header/Header.tsx index 3e5ea73..b4241e7 100644 --- a/apps/client/src/widgets/Header/Header.tsx +++ b/apps/client/src/widgets/Header/Header.tsx @@ -1,20 +1,20 @@ -import { useContext, useEffect, useRef, useState } from 'react'; +import { useContext, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Avatar, AvatarFallback, AvatarImage } from '@/shared/ui/shadcn/avatar'; -import { cn } from '@/shared/lib'; -import { AuthContext, useAuth } from '@/features/auth'; -import { axiosInstance } from '@/shared/api'; import { Button } from '@/shared/ui/shadcn/button'; +import { cn } from '@/shared/lib'; +import { useProfileImage } from '@/entities/user'; +import { AuthContext, useAuth } from '@/features'; import { LogoButton } from './LogoButton'; import { LogInButton } from './LogInButton'; export function Header() { const [isCheckedIn, setIsCheckedIn] = useState(false); - const [profileImgUrl, setProfileImgUrl] = useState(''); const broadcastRef = useRef(null); const { isLoggedIn } = useContext(AuthContext); const { logout } = useAuth(); const navigate = useNavigate(); + const { profileImgUrl } = useProfileImage(isLoggedIn); const handleCheckInClick = () => { if (broadcastRef.current && !broadcastRef.current.closed) { @@ -54,14 +54,6 @@ export function Header() { navigate('/'); }; - useEffect(() => { - if (!isLoggedIn) return; - axiosInstance.get('/v1/members/profile-image').then(response => { - if (!response.data.success) return; - setProfileImgUrl(response.data.data.profileImage); - }); - }, [isLoggedIn]); - return (
diff --git a/apps/client/src/widgets/LiveList/LiveList.tsx b/apps/client/src/widgets/LiveList/LiveList.tsx deleted file mode 100644 index e3cd189..0000000 --- a/apps/client/src/widgets/LiveList/LiveList.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { axiosInstance } from '@/shared/api'; -import { FieldFilter } from './FieldFilter'; -import { LiveCard } from './LiveCard'; -import { LivePreviewInfo } from '@/pages/Home/model/homeTypes'; -import { Search } from './Search'; -import { Field } from '@/shared/types/sharedTypes'; -import { useIntersect } from '@/pages/Home/model'; - -const LIMIT = 12; - -export function LiveList() { - const [liveList, setLiveList] = useState([]); - const [hasNext, setHasNext] = useState(true); - const [cursor, setCursor] = useState(null); - const [field, setField] = useState(''); - - const getLiveList = useCallback(() => { - axiosInstance.get('/v1/broadcasts', { params: { field, cursor, limit: LIMIT } }).then(response => { - if (response.data.success) { - const { broadcasts, nextCursor } = response.data.data; - setLiveList(prev => [...prev, ...broadcasts]); - setCursor(nextCursor); - if (!nextCursor) setHasNext(false); - } - }); - }, [field, cursor]); - - const ref = useIntersect({ - onIntersect: (entry, observer) => { - observer.unobserve(entry.target); - if (hasNext && cursor) getLiveList(); - }, - options: { threshold: 0.3 }, - }); - - const hanldeFilterField = (selectedField: Field) => { - axiosInstance - .get('/v1/broadcasts', { params: { field: selectedField, cursor: null, limit: LIMIT } }) - .then(response => { - if (response.data.success) { - const { broadcasts, nextCursor } = response.data.data; - setLiveList(broadcasts); - setCursor(nextCursor); - setHasNext(!!nextCursor); - } - }); - }; - - const handleSearch = (keyword: string) => { - setField(''); - setCursor(null); - axiosInstance.get('/v1/broadcasts/search', { params: { keyword: keyword.trim() } }).then(response => { - if (response.data.success) { - setLiveList(response.data.data); - } - }); - }; - - useEffect(() => { - getLiveList(); - }, [getLiveList]); - - return ( -
-
- - -
-
-
- {liveList ? ( - liveList.map(data => { - const { broadcastId, broadcastTitle, camperId, profileImage, thumbnail } = data; - return ( -
- -
- ); - }) - ) : ( -
방송 정보가 없습니다.
- )} -
-
-
-
- ); -} diff --git a/apps/client/src/widgets/LiveList/index.ts b/apps/client/src/widgets/LiveList/index.ts deleted file mode 100644 index e761f10..0000000 --- a/apps/client/src/widgets/LiveList/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { LiveList } from './LiveList'; diff --git a/apps/client/src/widgets/LivePreviewList/LivePreviewList.tsx b/apps/client/src/widgets/LivePreviewList/LivePreviewList.tsx new file mode 100644 index 0000000..2d70d95 --- /dev/null +++ b/apps/client/src/widgets/LivePreviewList/LivePreviewList.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState } from 'react'; +import { FieldFilter, LivePreviewCard, Search, LivePreviewInfo } from '@/features/livePreview'; +import { Field } from '@/shared/types'; +import { useIntersect } from '@/shared/lib'; +import { useLivePreviewList, useSearchLivePreviewList } from '@/features/livePreview/model/queries'; + +export function LivePreviewList() { + const [field, setField] = useState(''); + const [searchKeyword, setSearchKeyword] = useState(''); + const [isSearching, setIsSearching] = useState(false); + const [liveList, setLiveList] = useState([]); + + const { data: infiniteData, fetchNextPage, hasNextPage, isFetching } = useLivePreviewList(field); + + const { data: searchData } = useSearchLivePreviewList(searchKeyword); + + const { ref } = useIntersect({ + onIntersect: (entry, observer) => { + observer.unobserve(entry.target); + if (hasNextPage && !isFetching && !isSearching) { + fetchNextPage(); + } + }, + options: { threshold: 0.3 }, + }); + + useEffect(() => { + if (!isSearching && infiniteData) { + const newList = infiniteData.pages.flatMap(page => page.broadcasts); + setLiveList(newList); + } + }, [infiniteData, isSearching]); + + useEffect(() => { + if (isSearching && searchData) { + setLiveList(searchData); + } + }, [searchData, isSearching]); + + const handleFilterField = (selectedField: Field) => { + setField(selectedField); + setIsSearching(false); + setSearchKeyword(''); + }; + + const handleSearch = (keyword: string) => { + if (keyword.trim() === '') { + setIsSearching(false); + setSearchKeyword(''); + } + setSearchKeyword(keyword); + setField(''); + setIsSearching(true); + }; + + return ( +
+
+ + +
+
+
+ {liveList ? ( + liveList.map(data => { + const { broadcastId, broadcastTitle, camperId, profileImage, thumbnail } = data; + return ( +
+ +
+ ); + }) + ) : ( +
방송 정보가 없습니다.
+ )} +
+
+
+
+ ); +} diff --git a/apps/client/src/widgets/LivePreviewList/index.ts b/apps/client/src/widgets/LivePreviewList/index.ts new file mode 100644 index 0000000..06d02ea --- /dev/null +++ b/apps/client/src/widgets/LivePreviewList/index.ts @@ -0,0 +1 @@ +export { LivePreviewList } from './LivePreviewList'; diff --git a/apps/client/src/widgets/index.ts b/apps/client/src/widgets/index.ts index 635bafa..92b6db0 100644 --- a/apps/client/src/widgets/index.ts +++ b/apps/client/src/widgets/index.ts @@ -1,3 +1,3 @@ export { Banner } from './Banner'; -export { LiveList } from './LiveList'; +export { LivePreviewList } from './LivePreviewList'; export { Header } from './Header'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ad0663..1b66d4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -299,6 +299,12 @@ importers: '@radix-ui/react-toast': specifier: ^1.2.2 version: 1.2.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-query': + specifier: ^5.66.0 + version: 5.66.0(react@18.3.1) + '@tanstack/react-query-devtools': + specifier: ^5.66.0 + version: 5.66.0(@tanstack/react-query@5.66.0(react@18.3.1))(react@18.3.1) axios: specifier: ^1.7.7 version: 1.7.7 @@ -339,6 +345,9 @@ importers: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.14(ts-node@10.9.2(@swc/core@1.8.0)(@types/node@20.17.6)(typescript@5.6.3))) devDependencies: + '@tanstack/eslint-plugin-query': + specifier: ^5.66.1 + version: 5.66.1(eslint@8.57.1)(typescript@5.6.3) '@types/node': specifier: ^20.3.1 version: 20.17.6 @@ -2684,6 +2693,28 @@ packages: '@swc/types@0.1.14': resolution: {integrity: sha512-PbSmTiYCN+GMrvfjrMo9bdY+f2COnwbdnoMw7rqU/PI5jXpKjxOGZ0qqZCImxnT81NkNsKnmEpvu+hRXLBeCJg==} + '@tanstack/eslint-plugin-query@5.66.1': + resolution: {integrity: sha512-pYMVTGgJ7yPk9Rm6UWEmbY6TX0EmMmxJqYkthgeDCwEznToy2m+W928nUODFirtZBZlhBsqHy33LO0kyTlgf0w==} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + + '@tanstack/query-core@5.66.0': + resolution: {integrity: sha512-J+JeBtthiKxrpzUu7rfIPDzhscXF2p5zE/hVdrqkACBP8Yu0M96mwJ5m/8cPPYQE9aRNvXztXHlNwIh4FEeMZw==} + + '@tanstack/query-devtools@5.65.0': + resolution: {integrity: sha512-g5y7zc07U9D3esMdqUfTEVu9kMHoIaVBsD0+M3LPdAdD710RpTcLiNvJY1JkYXqkq9+NV+CQoemVNpQPBXVsJg==} + + '@tanstack/react-query-devtools@5.66.0': + resolution: {integrity: sha512-uB57wA2YZaQ2fPcFW0E9O1zAGDGSbRKRx84uMk/86VyU9jWVxvJ3Uzp+zNm+nZJYsuekCIo2opTdgNuvM3cKgA==} + peerDependencies: + '@tanstack/react-query': ^5.66.0 + react: ^18 || ^19 + + '@tanstack/react-query@5.66.0': + resolution: {integrity: sha512-z3sYixFQJe8hndFnXgWu7C79ctL+pI0KAelYyW+khaNJ1m22lWrhJU2QrsTcRKMuVPtoZvfBYrTStIdKo+x0Xw==} + peerDependencies: + react: ^18 || ^19 + '@tsconfig/node10@1.0.11': resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} @@ -2926,6 +2957,10 @@ packages: resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/scope-manager@8.24.0': + resolution: {integrity: sha512-HZIX0UByphEtdVBKaQBgTDdn9z16l4aTUz8e8zPQnyxwHBtf5vtl1L+OhH+m1FGV9DrRmoDuYKqzVrvWDcDozw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/type-utils@6.21.0': resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} engines: {node: ^16.0.0 || >=18.0.0} @@ -2954,6 +2989,10 @@ packages: resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/types@8.24.0': + resolution: {integrity: sha512-VacJCBTyje7HGAw7xp11q439A+zeGG0p0/p2zsZwpnMzjPB5WteaWqt4g2iysgGFafrqvyLWqq6ZPZAOCoefCw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@6.21.0': resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} engines: {node: ^16.0.0 || >=18.0.0} @@ -2972,6 +3011,12 @@ packages: typescript: optional: true + '@typescript-eslint/typescript-estree@8.24.0': + resolution: {integrity: sha512-ITjYcP0+8kbsvT9bysygfIfb+hBj6koDsu37JZG7xrCiy3fPJyNmfVtaGsgTUSEuTzcvME5YI5uyL5LD1EV5ZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <5.8.0' + '@typescript-eslint/utils@6.21.0': resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} engines: {node: ^16.0.0 || >=18.0.0} @@ -2984,6 +3029,13 @@ packages: peerDependencies: eslint: ^8.56.0 + '@typescript-eslint/utils@8.24.0': + resolution: {integrity: sha512-07rLuUBElvvEb1ICnafYWr4hk8/U7X9RDCOqd9JcAMtjh/9oRmcfN4yGzbPVirgMR0+HLVHehmu19CWeh7fsmQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.8.0' + '@typescript-eslint/visitor-keys@6.21.0': resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} engines: {node: ^16.0.0 || >=18.0.0} @@ -2992,6 +3044,10 @@ packages: resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/visitor-keys@8.24.0': + resolution: {integrity: sha512-kArLq83QxGLbuHrTMoOEWO+l2MwsNS2TGISEdx8xgqpkbytB07XmlQyQdNDrCc1ecSqx0cnmhGvpX+VBwqqSkg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} @@ -4096,6 +4152,10 @@ packages: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + eslint-visitor-keys@4.2.0: + resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint@8.57.1: resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -6428,6 +6488,12 @@ packages: peerDependencies: typescript: '>=4.2.0' + ts-api-utils@2.0.1: + resolution: {integrity: sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -8341,7 +8407,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.12.6 - debug: 4.3.7(supports-color@5.5.0) + debug: 4.3.7(supports-color@9.4.0) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -8409,7 +8475,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.3.7(supports-color@5.5.0) + debug: 4.3.7(supports-color@9.4.0) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -9720,6 +9786,29 @@ snapshots: dependencies: '@swc/counter': 0.1.3 + '@tanstack/eslint-plugin-query@5.66.1(eslint@8.57.1)(typescript@5.6.3)': + dependencies: + '@typescript-eslint/utils': 8.24.0(eslint@8.57.1)(typescript@5.6.3) + eslint: 8.57.1 + transitivePeerDependencies: + - supports-color + - typescript + + '@tanstack/query-core@5.66.0': {} + + '@tanstack/query-devtools@5.65.0': {} + + '@tanstack/react-query-devtools@5.66.0(@tanstack/react-query@5.66.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/query-devtools': 5.65.0 + '@tanstack/react-query': 5.66.0(react@18.3.1) + react: 18.3.1 + + '@tanstack/react-query@5.66.0(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.66.0 + react: 18.3.1 + '@tsconfig/node10@1.0.11': {} '@tsconfig/node12@1.0.11': {} @@ -10017,7 +10106,7 @@ snapshots: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.3) '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.3.7(supports-color@5.5.0) + debug: 4.3.7(supports-color@9.4.0) eslint: 8.57.1 optionalDependencies: typescript: 5.6.3 @@ -10034,6 +10123,11 @@ snapshots: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 + '@typescript-eslint/scope-manager@8.24.0': + dependencies: + '@typescript-eslint/types': 8.24.0 + '@typescript-eslint/visitor-keys': 8.24.0 + '@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@5.3.3)': dependencies: '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) @@ -10050,7 +10144,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.3) '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.6.3) - debug: 4.3.7(supports-color@5.5.0) + debug: 4.3.7(supports-color@9.4.0) eslint: 8.57.1 ts-api-utils: 1.4.0(typescript@5.6.3) optionalDependencies: @@ -10062,6 +10156,8 @@ snapshots: '@typescript-eslint/types@7.18.0': {} + '@typescript-eslint/types@8.24.0': {} + '@typescript-eslint/typescript-estree@6.21.0(typescript@5.3.3)': dependencies: '@typescript-eslint/types': 6.21.0 @@ -10081,7 +10177,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.3.7(supports-color@5.5.0) + debug: 4.3.7(supports-color@9.4.0) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 @@ -10092,6 +10188,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.24.0(typescript@5.6.3)': + dependencies: + '@typescript-eslint/types': 8.24.0 + '@typescript-eslint/visitor-keys': 8.24.0 + debug: 4.3.7(supports-color@9.4.0) + fast-glob: 3.3.2 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.6.3 + ts-api-utils: 2.0.1(typescript@5.6.3) + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@5.3.3)': dependencies: '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) @@ -10117,6 +10227,17 @@ snapshots: - supports-color - typescript + '@typescript-eslint/utils@8.24.0(eslint@8.57.1)(typescript@5.6.3)': + dependencies: + '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) + '@typescript-eslint/scope-manager': 8.24.0 + '@typescript-eslint/types': 8.24.0 + '@typescript-eslint/typescript-estree': 8.24.0(typescript@5.6.3) + eslint: 8.57.1 + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@6.21.0': dependencies: '@typescript-eslint/types': 6.21.0 @@ -10127,6 +10248,11 @@ snapshots: '@typescript-eslint/types': 7.18.0 eslint-visitor-keys: 3.4.3 + '@typescript-eslint/visitor-keys@8.24.0': + dependencies: + '@typescript-eslint/types': 8.24.0 + eslint-visitor-keys: 4.2.0 + '@ungap/structured-clone@1.2.0': {} '@vitejs/plugin-react-swc@3.7.1(vite@5.4.10(@types/node@20.17.6)(terser@5.36.0))': @@ -11118,7 +11244,7 @@ snapshots: engine.io-client@6.6.2(bufferutil@4.0.8)(utf-8-validate@5.0.10): dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.3.7(supports-color@5.5.0) + debug: 4.3.7(supports-color@9.4.0) engine.io-parser: 5.2.3 ws: 8.17.1(bufferutil@4.0.8)(utf-8-validate@5.0.10) xmlhttprequest-ssl: 2.1.2 @@ -11440,6 +11566,8 @@ snapshots: eslint-visitor-keys@3.4.3: {} + eslint-visitor-keys@4.2.0: {} + eslint@8.57.1: dependencies: '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) @@ -11453,7 +11581,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.7(supports-color@5.5.0) + debug: 4.3.7(supports-color@9.4.0) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -13862,7 +13990,7 @@ snapshots: socket.io-client@4.8.1(bufferutil@4.0.8)(utf-8-validate@5.0.10): dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.3.7(supports-color@5.5.0) + debug: 4.3.7(supports-color@9.4.0) engine.io-client: 6.6.2(bufferutil@4.0.8)(utf-8-validate@5.0.10) socket.io-parser: 4.2.4 transitivePeerDependencies: @@ -13873,7 +14001,7 @@ snapshots: socket.io-parser@4.2.4: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.3.7(supports-color@5.5.0) + debug: 4.3.7(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -14251,6 +14379,10 @@ snapshots: dependencies: typescript: 5.6.3 + ts-api-utils@2.0.1(typescript@5.6.3): + dependencies: + typescript: 5.6.3 + ts-interface-checker@0.1.13: {} ts-jest@29.2.5(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@20.17.6)(ts-node@10.9.2(@swc/core@1.8.0)(@types/node@20.17.6)(typescript@5.3.3)))(typescript@5.3.3):