Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/entities/playlist/api/playlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type {
PlaylistDetail,
PlaylistParams,
PlaylistResponse,
CarouselParams,
CarouselCdListResponse,
} from '@/entities/playlist/types/playlist'
import { api } from '@/shared/api/httpClient'

Expand Down Expand Up @@ -72,3 +74,17 @@ export const postPlaylistConfirm = (playlistId: number) => {
export const getPlaylistViewCounts = (playlistId: number) => {
return api.get(`/main/playlist/browse/view-counts/${playlistId}`)
}

// 피드 플레이리스트 캐러셀 조회
export const getCdCarousel = (shareCode: string, params: CarouselParams) => {
return api.get<CarouselCdListResponse>(`/main/playlist/feed/${shareCode}/carousel`, {
params,
})
}

// 피드 좋아요한 플레이리스트 캐러셀 조회
export const getLikedCdCarousel = (shareCode: string, params: CarouselParams) => {
return api.get<CarouselCdListResponse>(`/main/playlist/feed/${shareCode}/likes/carousel`, {
params,
})
}
1 change: 1 addition & 0 deletions src/entities/playlist/model/useMyCd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const useMyCdActions = (cdId: number, options?: { enabled?: boolean }) =>
mutationKey: ['patchMyCdPublic', cdId],
mutationFn: () => patchMyCdPublic(cdId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['playlistDetail', cdId] })
queryClient.invalidateQueries({ queryKey: ['getTracklist', cdId] })
queryClient.invalidateQueries({ queryKey: ['myCdList'] })
queryClient.invalidateQueries({ queryKey: ['feedCdList'] })
Expand Down
40 changes: 40 additions & 0 deletions src/entities/playlist/model/usePlaylists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
} from '@tanstack/react-query'

import {
getCdCarousel,
getCdList,
getLikedCdCarousel,
getLikedCdList,
getMyPlaylistDetail,
getPlaylistDetail,
Expand All @@ -24,6 +26,8 @@ import type {
PlaylistResponse,
CdListParams,
FEED_CD_LIST_TAB_TYPE,
CarouselParams,
CarouselDirection,
} from '@/entities/playlist/types/playlist'
import { useAuthStore, type ShareCode } from '@/features/auth'

Expand Down Expand Up @@ -146,3 +150,39 @@ export const useFeedCdList = ({
enabled: !!shareCode,
})
}

type PageParam = { cursor: number; direction: CarouselDirection } | undefined

export const useCarouselCdList = (
type: FEED_CD_LIST_TAB_TYPE, // cds or likes
shareCode: string,
params: CarouselParams
) => {
return useInfiniteQuery({
queryKey: ['feedCdList', type, shareCode, params.sort, params.anchorId],

Comment on lines +156 to +163
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

anchorId가 cache key에 들어가서 카드 이동마다 infinite query가 리셋됩니다.

FeedCarousel은 중심 카드가 바뀔 때마다 route id를 갱신합니다. 지금 키에 params.anchorId가 포함돼 있어서 스와이프 한 번마다 새 쿼리가 생성되고, 이미 받아온 pages를 버린 채 다시 로딩하게 됩니다. 이 훅의 양방향 프리패칭 이점이 거의 사라집니다.

♻️ 수정 방향 예시
   return useInfiniteQuery({
-    queryKey: ['feedCdList', type, shareCode, params.sort, params.anchorId],
+    queryKey: ['feedCdList', type, shareCode, params.sort, params.limit],

anchorId는 초기 진입 seed로만 사용하고, 실제 재-anchor가 필요할 때만 별도로 reset/refetch 하는 편이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/entities/playlist/model/usePlaylists.ts` around lines 156 - 163, The
query key for useInfiniteQuery in useCarouselCdList currently includes
params.anchorId which causes the infinite query to reset on every carousel
center-card change; remove params.anchorId from the queryKey (keep
['feedCdList', type, shareCode, params.sort]) and instead pass params.anchorId
only into the fetch function as an initial seed; when the anchor truly needs to
trigger a reload, call the queryClient.invalidateQueries or
queryClient.resetQueries / refetch manually from the component (or expose a
reset/refetch function from useCarouselCdList) so anchor changes do not recreate
the whole infinite query state.

queryFn: ({ pageParam }: { pageParam: PageParam }) => {
const fetchFn = type === 'cds' ? getCdCarousel : getLikedCdCarousel

return fetchFn(shareCode, {
...params,
cursor: pageParam?.cursor,
direction: pageParam?.direction,
})
},

initialPageParam: undefined as PageParam,

getNextPageParam: (lastPage): PageParam => {
if (!lastPage.hasNext || lastPage.nextCursor === null) return undefined
return { cursor: lastPage.nextCursor, direction: 'NEXT' }
},

getPreviousPageParam: (firstPage): PageParam => {
if (!firstPage.hasPrev || firstPage.prevCursor === null) return undefined
return { cursor: firstPage.prevCursor, direction: 'PREV' }
},

enabled: !!shareCode,
})
}
21 changes: 21 additions & 0 deletions src/entities/playlist/types/playlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,24 @@ export interface PlaylistParams {
cursorId?: number
size?: number
}

export type CarouselDirection = 'NEXT' | 'PREV'
export type CarouselSort = 'POPULAR' | 'RECENT'

export interface CarouselParams {
anchorId?: number
direction?: CarouselDirection
cursor?: number
sort?: CarouselSort
limit?: number
}

export interface CarouselCdListResponse {
content: (CdBasicInfo & OnlyCdResponse)[]
prevCursor: number | null
nextCursor: number | null
size: number
hasPrev: boolean
hasNext: boolean
totalCount: number
}
13 changes: 11 additions & 2 deletions src/pages/feed/FeedLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { Outlet } from 'react-router-dom'
import { Outlet, useParams } from 'react-router-dom'

import { useOwnerStatus } from '@/features/auth'
import { Loading } from '@/shared/ui'

const FeedLayout = () => {
return <Outlet />
const { shareCode = '' } = useParams()

const { data, isLoading } = useOwnerStatus(shareCode || '')

if (isLoading) return <Loading isLoading />

return <Outlet context={{ isOwner: data?.isOwner }} />
Comment on lines +9 to +13
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

오너 상태 조회 실패를 정상 렌더로 넘기지 마세요.

여기서는 useOwnerStatus가 실패해도 그대로 Outlet을 렌더링해서 isOwner: undefined가 하위 라우트로 내려갑니다. 그러면 실제 소유자여도 편집/삭제 UI가 숨겨지고 헤더 문구도 잘못 나옵니다. 로딩이 끝난 뒤에는 isError || !data를 먼저 처리하고, 성공한 경우에만 data를 context로 넘기는 쪽이 안전합니다.

🐛 수정 예시
-import { Outlet, useParams } from 'react-router-dom'
+import { Navigate, Outlet, useParams } from 'react-router-dom'
...
-  const { data, isLoading } = useOwnerStatus(shareCode || '')
+  const { data, isLoading, isError } = useOwnerStatus(shareCode)
...
-  return <Outlet context={{ isOwner: data?.isOwner }} />
+  if (isError || !data) return <Navigate to="/error" replace />
+
+  return <Outlet context={data} />
As per coding guidelines: 상태 관리 - 에러 처리: Error Boundary와 try-catch 또는 onError 콜백 활용.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { data, isLoading } = useOwnerStatus(shareCode || '')
if (isLoading) return <Loading isLoading />
return <Outlet context={{ isOwner: data?.isOwner }} />
import { Navigate, Outlet, useParams } from 'react-router-dom'
const { data, isLoading, isError } = useOwnerStatus(shareCode)
if (isLoading) return <Loading isLoading />
if (isError || !data) return <Navigate to="/error" replace />
return <Outlet context={data} />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/feed/FeedLayout.tsx` around lines 9 - 13, The current FeedLayout
renders <Outlet context={{ isOwner: data?.isOwner }} /> even when useOwnerStatus
failed, causing undefined isOwner in child routes; update the component to check
useOwnerStatus's isError (and !data) after isLoading and render an
error/fallback (or null) instead of the Outlet, and only pass data.isOwner into
<Outlet context={{ isOwner: ... }}> when the query succeeded; locate the
useOwnerStatus call and the Outlet return in FeedLayout (symbols:
useOwnerStatus, isLoading, isError, data, Outlet) and implement the early error
branch so children never receive undefined isOwner.

}

export default FeedLayout
86 changes: 2 additions & 84 deletions src/pages/feed/cds/index.tsx
Original file line number Diff line number Diff line change
@@ -1,87 +1,5 @@
import { useEffect, useCallback, useState, useMemo } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { FeedCarousel } from '@/pages/feed/ui'

import { useMyCdActions, useMyCdList } from '@/entities/playlist/model/useMyCd'
import { CdViewerLayout } from '@/pages/feed/ui'
import { Loading } from '@/shared/ui'

const Cds = () => {
const navigate = useNavigate()
const { id: routePlaylistId } = useParams<{ id?: string }>()

const myCdPlaylist = useMyCdList('RECENT')

const playlistData = useMemo(() => {
return myCdPlaylist.data ?? []
}, [myCdPlaylist.data])

const isLoading = myCdPlaylist.isLoading
const isError = myCdPlaylist.isError

const [centerItem, setCenterItem] = useState<{
playlistId: number | null
playlistName: string
}>({ playlistId: null, playlistName: '' })

useEffect(() => {
if (isLoading || !playlistData.length) return

const routeId = routePlaylistId ? Number(routePlaylistId) : null
const found = routeId ? playlistData.find((p) => p.playlistId === routeId) : null

if (found) {
setCenterItem({
playlistId: found.playlistId,
playlistName: found.playlistName,
})
} else {
const first = playlistData[0]

setCenterItem({
playlistId: first.playlistId,
playlistName: first.playlistName,
})

navigate(`./${first.playlistId}`, { replace: true })
}
}, [playlistData, isLoading, routePlaylistId, navigate])

const handleCenterChange = useCallback(
(playlist: { playlistId: number; playlistName: string }) => {
setCenterItem(playlist)

const path = `./${playlist.playlistId}`

navigate(path, { replace: true })
},
[navigate]
)

const myCdDetail = useMyCdActions(Number(centerItem.playlistId), {
enabled: !!centerItem.playlistId,
})

const playlistDetail = myCdDetail.tracklist

if (isLoading) return <Loading isLoading />

if (isError) {
navigate('/error')
return null
}

return (
<>
<CdViewerLayout
playlistData={playlistData}
playlistDetail={playlistDetail}
centerItem={centerItem}
onCenterChange={handleCenterChange}
pageType="MY"
isOwner // TODO: 실제 값으로 수정 필요
/>
</>
)
}
const Cds = () => <FeedCarousel type="cds" pageType="MY" />

export default Cds
10 changes: 5 additions & 5 deletions src/pages/feed/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useMemo, useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useNavigate, useOutletContext, useParams, useSearchParams } from 'react-router-dom'

import styled, { css } from 'styled-components'

import { Gear } from '@/assets/icons'
import { FeedBg } from '@/assets/images'
import { type FEED_CD_LIST_TAB_TYPE } from '@/entities/playlist'
import { useUserProfile } from '@/entities/user'
import { useOwnerStatus } from '@/features/auth'
import { type ShareCodeOwnerResponse } from '@/features/auth'
import { FeedCdList, FeedProfile } from '@/pages/feed/ui'
import { FeedbackIcon } from '@/pages/feedback/ui'
import { flexRowCenter } from '@/shared/styles/mixins'
Expand All @@ -26,7 +26,7 @@ const FeedPage = () => {
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()

const { data: ownershipData, isLoading: isOwnershipLoading } = useOwnerStatus(shareCode || '')
const { isOwner: isMyFeed } = useOutletContext<ShareCodeOwnerResponse>()
const {
userProfile,
isLoading: isProfileLoading,
Expand All @@ -49,7 +49,6 @@ const FeedPage = () => {
},
})

const isMyFeed = ownershipData?.isOwner ?? false
const currentTab = (searchParams.get('tab') || 'cds') as FEED_CD_LIST_TAB_TYPE

const TAB_LIST = useMemo(
Expand All @@ -74,8 +73,9 @@ const FeedPage = () => {
setSearchParams({ tab: nextTab }, { replace: true })
}

if ((isOwnershipLoading && !ownershipData) || isProfileLoading) return <Loading isLoading />
if (isProfileLoading) return <Loading isLoading />
if (isProfileError) return <NotFound isFullPage isProfile />

return (
<FeedWrapper>
<TopVisualBackground>
Expand Down
90 changes: 2 additions & 88 deletions src/pages/feed/likes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,91 +1,5 @@
import { useEffect, useCallback, useState, useMemo, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { FeedCarousel } from '@/pages/feed/ui'

import { usePlaylistDetail } from '@/entities/playlist'
import { useMyLikedCdList } from '@/entities/playlist/model/useMyCd'
import { CdViewerLayout } from '@/pages/feed/ui'
import { Loading } from '@/shared/ui'

const Likes = () => {
const navigate = useNavigate()
const { id: routePlaylistId } = useParams<{ id?: string }>()

const likedCdPlaylist = useMyLikedCdList('RECENT')

const playlistData = useMemo(() => {
return likedCdPlaylist.data ?? []
}, [likedCdPlaylist.data])

const isLoading = likedCdPlaylist.isLoading
const isError = likedCdPlaylist.isError

const [centerItem, setCenterItem] = useState<{
playlistId: number | null
playlistName: string
}>({ playlistId: null, playlistName: '' })

const lastIndexRef = useRef<number>(0)
useEffect(() => {
if (likedCdPlaylist.isLoading || !playlistData.length) return

const routeId = routePlaylistId ? Number(routePlaylistId) : null
const currentIndex = playlistData.findIndex((p) => p.playlistId === routeId)

if (currentIndex !== -1) {
lastIndexRef.current = currentIndex
setCenterItem({
playlistId: playlistData[currentIndex].playlistId,
playlistName: playlistData[currentIndex].playlistName,
})
} else {
const nextItem = playlistData[lastIndexRef.current] || playlistData.at(-1)

if (nextItem) {
setCenterItem({
playlistId: nextItem.playlistId,
playlistName: nextItem.playlistName,
})

navigate(`../${nextItem.playlistId}`, { replace: true })
}
}
}, [playlistData, likedCdPlaylist.isLoading, routePlaylistId, navigate])

const handleCenterChange = useCallback(
(playlist: { playlistId: number; playlistName: string }) => {
setCenterItem(playlist)

const path = `../${playlist.playlistId}`

navigate(path, { replace: true })
},
[navigate]
)

const likedCdDetail = usePlaylistDetail(centerItem.playlistId, {
enabled: !!centerItem.playlistId,
})
const playlistDetail = likedCdDetail.data

if (isLoading) return <Loading isLoading />

if (isError) {
navigate('/error')
return null
}

return (
<>
<CdViewerLayout
playlistData={playlistData}
playlistDetail={playlistDetail}
centerItem={centerItem}
onCenterChange={handleCenterChange}
pageType="LIKE"
isOwner // TODO: 실제 값으로 수정 필요
/>
</>
)
}
const Likes = () => <FeedCarousel type="likes" pageType="LIKE" />

export default Likes
Loading
Loading