diff --git a/src/entities/playlist/api/playlist.ts b/src/entities/playlist/api/playlist.ts index efa5b149..ac73dd68 100644 --- a/src/entities/playlist/api/playlist.ts +++ b/src/entities/playlist/api/playlist.ts @@ -5,6 +5,8 @@ import type { PlaylistDetail, PlaylistParams, PlaylistResponse, + CarouselParams, + CarouselCdListResponse, } from '@/entities/playlist/types/playlist' import { api } from '@/shared/api/httpClient' @@ -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(`/main/playlist/feed/${shareCode}/carousel`, { + params, + }) +} + +// 피드 좋아요한 플레이리스트 캐러셀 조회 +export const getLikedCdCarousel = (shareCode: string, params: CarouselParams) => { + return api.get(`/main/playlist/feed/${shareCode}/likes/carousel`, { + params, + }) +} diff --git a/src/entities/playlist/model/useMyCd.ts b/src/entities/playlist/model/useMyCd.ts index 75c59408..e66a9610 100644 --- a/src/entities/playlist/model/useMyCd.ts +++ b/src/entities/playlist/model/useMyCd.ts @@ -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'] }) diff --git a/src/entities/playlist/model/usePlaylists.ts b/src/entities/playlist/model/usePlaylists.ts index 43dafdeb..730f4ea0 100644 --- a/src/entities/playlist/model/usePlaylists.ts +++ b/src/entities/playlist/model/usePlaylists.ts @@ -10,7 +10,9 @@ import { } from '@tanstack/react-query' import { + getCdCarousel, getCdList, + getLikedCdCarousel, getLikedCdList, getMyPlaylistDetail, getPlaylistDetail, @@ -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' @@ -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], + + 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, + }) +} diff --git a/src/entities/playlist/types/playlist.ts b/src/entities/playlist/types/playlist.ts index d18187cb..4126c1ea 100644 --- a/src/entities/playlist/types/playlist.ts +++ b/src/entities/playlist/types/playlist.ts @@ -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 +} diff --git a/src/pages/feed/FeedLayout.tsx b/src/pages/feed/FeedLayout.tsx index 765ee3d0..58702e4d 100644 --- a/src/pages/feed/FeedLayout.tsx +++ b/src/pages/feed/FeedLayout.tsx @@ -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 + const { shareCode = '' } = useParams() + + const { data, isLoading } = useOwnerStatus(shareCode || '') + + if (isLoading) return + + return } export default FeedLayout diff --git a/src/pages/feed/cds/index.tsx b/src/pages/feed/cds/index.tsx index 430d1013..47c9f5c6 100644 --- a/src/pages/feed/cds/index.tsx +++ b/src/pages/feed/cds/index.tsx @@ -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 - - if (isError) { - navigate('/error') - return null - } - - return ( - <> - - - ) -} +const Cds = () => export default Cds diff --git a/src/pages/feed/index.tsx b/src/pages/feed/index.tsx index 15f52798..94c4df01 100644 --- a/src/pages/feed/index.tsx +++ b/src/pages/feed/index.tsx @@ -1,5 +1,5 @@ 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' @@ -7,7 +7,7 @@ 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' @@ -26,7 +26,7 @@ const FeedPage = () => { const [searchParams, setSearchParams] = useSearchParams() const navigate = useNavigate() - const { data: ownershipData, isLoading: isOwnershipLoading } = useOwnerStatus(shareCode || '') + const { isOwner: isMyFeed } = useOutletContext() const { userProfile, isLoading: isProfileLoading, @@ -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( @@ -74,8 +73,9 @@ const FeedPage = () => { setSearchParams({ tab: nextTab }, { replace: true }) } - if ((isOwnershipLoading && !ownershipData) || isProfileLoading) return + if (isProfileLoading) return if (isProfileError) return + return ( diff --git a/src/pages/feed/likes/index.tsx b/src/pages/feed/likes/index.tsx index 3878ed2d..3bb9e266 100644 --- a/src/pages/feed/likes/index.tsx +++ b/src/pages/feed/likes/index.tsx @@ -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(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 - - if (isError) { - navigate('/error') - return null - } - - return ( - <> - - - ) -} +const Likes = () => export default Likes diff --git a/src/pages/feed/ui/layout/CdViewerLayout.tsx b/src/pages/feed/ui/CarouselItem.tsx similarity index 69% rename from src/pages/feed/ui/layout/CdViewerLayout.tsx rename to src/pages/feed/ui/CarouselItem.tsx index e95ca134..e762bfbb 100644 --- a/src/pages/feed/ui/layout/CdViewerLayout.tsx +++ b/src/pages/feed/ui/CarouselItem.tsx @@ -1,5 +1,5 @@ import { useEffect, useCallback, useState } from 'react' -import { useLocation, useNavigate, useParams } from 'react-router-dom' +import { useNavigate, useParams } from 'react-router-dom' import { useQueryClient } from '@tanstack/react-query' import styled from 'styled-components' @@ -11,15 +11,16 @@ import { Dots, LeftArrow } from '@/assets/icons' import type { CdMetaResponse, PlaylistDetail } from '@/entities/playlist' import { useMyCdActions } from '@/entities/playlist/model/useMyCd' import { useLike } from '@/features/like' -import { CdCarousel } from '@/pages/feed/ui' +import { SwipeCarousel } from '@/features/swipe' import { useDevice } from '@/shared/lib/useDevice' -import { BottomSheet, Header, LiveInfo, Modal, SvgButton } from '@/shared/ui' +import { cdSpinner, flexRowCenter } from '@/shared/styles/mixins' +import { BottomSheet, Header, LiveInfo, Modal, SvgButton, Cd } from '@/shared/ui' import type { ModalProps } from '@/shared/ui/Modal' import { ActionBar, ControlBar, ProgressBar } from '@/widgets/playlist' type Props = { playlistData: CdMetaResponse - playlistDetail?: PlaylistDetail + playlistDetail: PlaylistDetail centerItem: { playlistId: number | null; playlistName: string } pageType: 'MY' | 'LIKE' isOwner: boolean @@ -40,15 +41,11 @@ const COMMENT_OPTIONS = (isPublic: boolean, selectedTab: 'MY' | 'LIKE'): OptionI return [ { text: 'CD 편집하기', type: 'edit' }, - { - text: isPublic ? '비공개로 전환' : '공개로 전환', - type: 'toggleVisibility', - }, + { text: isPublic ? '비공개로 전환' : '공개로 전환', type: 'toggleVisibility' }, { text: '삭제하기', type: 'delete' }, ] } - -const CdViewerLayout = ({ +const CarouselItem = ({ playlistData, playlistDetail, centerItem, @@ -58,12 +55,12 @@ const CdViewerLayout = ({ }: Props) => { const navigate = useNavigate() const { toast } = useToast() - const { state } = useLocation() - const { shareCode } = useParams<{ shareCode: string }>() - const { id: playlistId } = useParams() const queryClient = useQueryClient() - const { toggleLike } = useLike(Number(playlistId)) + const { shareCode, id: playlistId } = useParams() + const { isMobile } = useDevice() + const isSmall = isMobile && window.innerHeight < 633 + const [activeIndex, setActiveIndex] = useState(0) const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false) const onModalClose = () => setModal((prev) => ({ ...prev, isOpen: false })) const [modal, setModal] = useState({ @@ -78,6 +75,7 @@ const CdViewerLayout = ({ onCancel: onModalClose, }) + const { toggleLike } = useLike(Number(playlistId)) const { setPlaylist, isPlaying, @@ -91,28 +89,41 @@ const CdViewerLayout = ({ unmuteOnce, } = usePlaylist() - const isFromMyCdList = state?.isFromMyCdList === true const { deleteMutation, togglePublicMutation } = useMyCdActions(Number(playlistId), { - enabled: isFromMyCdList, + enabled: false, }) + useEffect(() => { + if (!playlistId || !playlistData) return + const index = playlistData.findIndex((p) => p.playlistId === Number(playlistId)) + if (index >= 0) setActiveIndex(index) + }, [playlistId, playlistData]) + + const handleSelectIndex = useCallback( + (index: number) => { + setActiveIndex(index) + const center = playlistData[index] + if (center && onCenterChange) { + onCenterChange({ + playlistId: center.playlistId, + playlistName: center.playlistName, + }) + } + }, + [playlistData, onCenterChange] + ) + useEffect(() => { if (!playlistDetail) return if (currentPlaylist?.playlistId === playlistDetail.playlistId) return - setPlaylist(playlistDetail, 0, 0, !isMobile) }, [playlistDetail, currentPlaylist, isMobile, setPlaylist]) const handleProgressClick = useCallback( (trackIndex: number, seconds: number) => { if (!currentPlaylist) return - setPlaylist(currentPlaylist, trackIndex, seconds) - - if (seconds !== undefined) { - playerRef.current?.seekTo(seconds, true) - } - + if (seconds !== undefined) playerRef.current?.seekTo(seconds, true) if (!isPlaying) play() }, [currentPlaylist, setPlaylist, playerRef, isPlaying, play] @@ -130,40 +141,27 @@ const CdViewerLayout = ({ } } - const isPublic = playlistDetail?.isPublic ?? false + const isPublic = playlistDetail.isPublic const handleOptionClick = (type: OptionType) => { - if (type === 'edit') { - navigate(`/mypage/customize`, { - state: { playlistId: currentPlaylist?.playlistId }, - }) - } + if (type === 'edit') + navigate(`/mypage/customize`, { state: { playlistId: currentPlaylist?.playlistId } }) if (type === 'delete') { setModal({ isOpen: true, - title: 'CD를 삭제할까요?', - description: '삭제 후에는 되돌릴 수 없어요', + title: '해당 CD를 삭제할까요?', ctaType: 'double', confirmText: '삭제하기', - cancelText: '취소', onConfirm: () => { deleteMutation.mutate(undefined, { onSuccess: async () => { onModalClose() pause() await toast('CD_DELETE') - const currentIndex = playlistData.findIndex( (p) => p.playlistId === currentPlaylist?.playlistId ) - const nextPlaylist = playlistData[currentIndex + 1] ?? playlistData[currentIndex - 1] - - if (nextPlaylist) { - navigate(`../${nextPlaylist.playlistId}`, { replace: true }) - } else { - navigate('../', { replace: true }) - } - + navigate(nextPlaylist ? `../${nextPlaylist.playlistId}` : '../', { replace: true }) queryClient.invalidateQueries({ queryKey: ['myCdList'] }) queryClient.invalidateQueries({ queryKey: ['feedCdList'] }) }, @@ -173,9 +171,7 @@ const CdViewerLayout = ({ onClose: onModalClose, }) } - if (type === 'toggleVisibility') { - if (!currentPlaylist) return - + if (type === 'toggleVisibility' && currentPlaylist) { togglePublicMutation.mutate(undefined, { onSuccess: () => { toast(isPublic ? 'PRIVATE' : 'PUBLIC') @@ -193,10 +189,7 @@ const CdViewerLayout = ({ }, }) } - if (type === 'like_delete') { - toggleLike() - } - + if (type === 'like_delete') toggleLike() setIsBottomSheetOpen(false) } @@ -208,7 +201,15 @@ const CdViewerLayout = ({
navigate(-1)} />} - center={{pageType === 'MY' ? '나의 CD' : '좋아요한 CD'}} + center={ + + {pageType === 'MY' + ? isOwner + ? '나의 CD' + : `${playlistDetail.creatorNickname}의 CD` + : '좋아요한 CD'} + + } right={isOwner && setIsBottomSheetOpen(true)} />} /> @@ -216,20 +217,34 @@ const CdViewerLayout = ({ - + > + {playlistData.map((slide, index) => ( + + + + + + + + ))} + @@ -237,7 +252,7 @@ const CdViewerLayout = ({ {centerItem.playlistName} - {pageType === 'LIKE' && playlistDetail?.creatorNickname && ( + {pageType === 'LIKE' && playlistDetail.creatorNickname && ( {playlistDetail.creatorNickname} )} @@ -247,7 +262,6 @@ const CdViewerLayout = ({ currentIndex={currentTrackIndex} onClick={handleProgressClick} /> - )} - + ) } -export default CdViewerLayout +export default CarouselItem const Container = styled.div` display: flex; @@ -297,14 +301,31 @@ const Container = styled.div` align-items: center; ` +const EmblaSlide = styled.div<{ $isMobile?: boolean }>` + flex: 0 0 50%; + ${flexRowCenter} + padding: ${({ $isMobile }) => ($isMobile ? '6px 0 0 0' : '16px 0')}; +` + +const Slide = styled.div<{ $active?: boolean; $isMobile?: boolean }>` + position: relative; + ${flexRowCenter} + transition: transform 0.8s ease; + margin: ${({ $isMobile }) => ($isMobile ? '0 24px 16px 24px' : '32px 24px 24px 24px')}; + opacity: ${({ $active }) => ($active ? 1 : 0.5)}; +` + +const CdSpinner = styled.div<{ $isPlaying: boolean }>` + position: relative; + ${cdSpinner} +` + const TitleWrapper = styled.div` padding-top: 60px; ` const Title = styled.p<{ $isMobile?: boolean }>` ${({ theme }) => theme.FONT.headline1}; - padding-top: 8px; - @media (min-height: 899px) { padding-top: 56px; } @@ -320,13 +341,13 @@ const BottomWrapper = styled.div` const Creator = styled.p` ${({ theme }) => theme.FONT['body2-normal']}; color: ${({ theme }) => theme.COLOR['gray-300']}; + margin-top: 2px; ` const CenterWrapper = styled.div` display: flex; flex-direction: column; margin-top: 0; - @media (min-height: 899px) { margin-top: 80px; } @@ -338,7 +359,6 @@ const OptionButton = styled.button` margin-bottom: 8px; ${({ theme }) => theme.FONT.headline2}; color: ${({ theme }) => theme.COLOR['gray-100']}; - &:hover { color: ${({ theme }) => theme.COLOR['primary-normal']}; } @@ -350,5 +370,4 @@ const PrivateLabel = styled.span` color: ${({ theme }) => theme.COLOR['gray-300']}; background-color: ${({ theme }) => theme.COLOR['gray-700']}; border-radius: 99px; - max-width: 48px; ` diff --git a/src/pages/feed/ui/CdCarousel.tsx b/src/pages/feed/ui/CdCarousel.tsx deleted file mode 100644 index 3cd90254..00000000 --- a/src/pages/feed/ui/CdCarousel.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { useState, useCallback, useEffect } from 'react' -import { useParams } from 'react-router-dom' - -import styled from 'styled-components' - -import type { CdMetaResponse } from '@/entities/playlist' -import { SwipeCarousel } from '@/features/swipe' -import { useDevice } from '@/shared/lib/useDevice' -import { flexRowCenter } from '@/shared/styles/mixins' -import { Cd } from '@/shared/ui' - -interface CdCarouselProps { - data: CdMetaResponse - onCenterChange?: (playlist: { playlistId: number; playlistName: string }) => void - currentPlaylistId?: number | null - isPlaying?: boolean - basePath: string -} - -const CdCarousel = ({ - data, - onCenterChange, - currentPlaylistId, - isPlaying, - basePath, -}: CdCarouselProps) => { - const { id: playlistId } = useParams() - const [activeIndex, setActiveIndex] = useState(0) - const { isMobile } = useDevice() - const isSmall = isMobile && window.innerHeight < 633 - - // url 기준으로 active index 동기화 - useEffect(() => { - if (!playlistId) return - - const index = data.findIndex((p) => p.playlistId === Number(playlistId)) - - if (index >= 0) setActiveIndex(index) - }, [playlistId, data]) - - const handleSelectIndex = useCallback( - (index: number) => { - setActiveIndex(index) - const center = data[index] - if (center && onCenterChange) { - onCenterChange({ - playlistId: center.playlistId, - playlistName: center.playlistName, - }) - } - }, - [data, onCenterChange] - ) - - return ( - - {data.map((slide, index) => { - const isNowPlaying = slide.playlistId === currentPlaylistId && isPlaying - - return ( - - - - - - - - ) - })} - - ) -} - -export default CdCarousel - -const EmblaSlide = styled.div<{ $isMobile?: boolean }>` - flex: 0 0 50%; - ${flexRowCenter} - padding: ${({ $isMobile }) => ($isMobile ? '6px 0 0 0' : '16px 0')}; -` - -const Slide = styled.div<{ $active?: boolean; $isMobile?: boolean }>` - position: relative; - ${flexRowCenter} - transition: transform 0.8s ease; - margin: ${({ $isMobile }) => ($isMobile ? '0 24px 16px 24px' : '32px 24px 24px 24px')}; - opacity: ${({ $active }) => ($active ? 1 : 0.5)}; -` - -const CdSpinner = styled.div<{ $isNowPlaying: boolean }>` - position: relative; - animation: spin 40s linear infinite; - animation-play-state: ${(props) => (props.$isNowPlaying ? 'running' : 'paused')}; - transform-origin: center; - - @keyframes spin { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } - } -` diff --git a/src/pages/feed/ui/FeedCarousel.tsx b/src/pages/feed/ui/FeedCarousel.tsx new file mode 100644 index 00000000..3705e24c --- /dev/null +++ b/src/pages/feed/ui/FeedCarousel.tsx @@ -0,0 +1,129 @@ +import { useEffect, useCallback, useState, useMemo } from 'react' +import { Navigate, useLocation, useNavigate, useOutletContext, useParams } from 'react-router-dom' + +import { useCarouselCdList, usePlaylistDetail } from '@/entities/playlist' +import type { ShareCodeOwnerResponse } from '@/features/auth' +import { CarouselItem } from '@/pages/feed/ui' +import { Loading } from '@/shared/ui' + +interface FeedCarouselProps { + type: 'cds' | 'likes' + pageType: 'MY' | 'LIKE' +} + +const FeedCarousel = ({ type, pageType }: FeedCarouselProps) => { + const { isOwner } = useOutletContext() + const navigate = useNavigate() + const { state } = useLocation() + const { shareCode = '', id: routePlaylistId } = useParams<{ + id: string + shareCode: string + }>() + + const [currentSort] = useState(() => state?.currentSort ?? 'RECENT') + const anchorId = routePlaylistId ? Number(routePlaylistId) : undefined + + const { + data, + isLoading, + isError, + fetchNextPage, + fetchPreviousPage, + hasNextPage, + hasPreviousPage, + isFetchingNextPage, + isFetchingPreviousPage, + } = useCarouselCdList(type, shareCode, { + anchorId, + sort: currentSort, + limit: anchorId ? 2 : 3, + }) + + const playlistData = useMemo(() => { + return data?.pages.flatMap((page) => page.content) ?? [] + }, [data]) + + const [centerItem, setCenterItem] = useState<{ + playlistId: number | null + playlistName: string + }>({ playlistId: null, playlistName: '' }) + + useEffect(() => { + if (isLoading || !playlistData.length) return + + const currentIndex = playlistData.findIndex((p) => p.playlistId === anchorId) + + if (currentIndex !== -1) { + const currentItem = playlistData[currentIndex] + setCenterItem({ + playlistId: currentItem.playlistId, + playlistName: currentItem.playlistName, + }) + + if (currentIndex === playlistData.length - 1 && hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + if (currentIndex === 0 && hasPreviousPage && !isFetchingPreviousPage) { + fetchPreviousPage() + } + } else if (routePlaylistId === undefined && playlistData.length > 0) { + const firstItem = playlistData[0] + navigate(`../${firstItem.playlistId}`, { + replace: true, + state: { ...state, currentSort }, + }) + } + }, [ + playlistData, + isLoading, + anchorId, + routePlaylistId, + navigate, + fetchNextPage, + fetchPreviousPage, + hasNextPage, + hasPreviousPage, + isFetchingNextPage, + isFetchingPreviousPage, + state, + currentSort, + ]) + + const handleCenterChange = useCallback( + (playlist: { playlistId: number; playlistName: string }) => { + if (playlist.playlistId === anchorId) return + setCenterItem(playlist) + navigate(`../${playlist.playlistId}`, { + replace: true, + state: { ...state, currentSort }, + }) + }, + [navigate, state, currentSort, anchorId] + ) + + const { data: playlistDetail, isLoading: isDetailLoading } = usePlaylistDetail( + centerItem.playlistId, + { enabled: !!centerItem.playlistId } + ) + + if (isLoading || isDetailLoading || !centerItem.playlistId) { + return + } + + if (!data || !playlistDetail || isError) { + return + } + + return ( + + ) +} + +export default FeedCarousel diff --git a/src/pages/feed/ui/FeedCdList.tsx b/src/pages/feed/ui/FeedCdList.tsx index 09e5944e..558f1e55 100644 --- a/src/pages/feed/ui/FeedCdList.tsx +++ b/src/pages/feed/ui/FeedCdList.tsx @@ -66,7 +66,7 @@ const FeedCdList = ({ shareCode, feedView, isMyFeed, setModal }: FeedCdListProps return } navigate(`/${shareCode}/${feedView}/${cdId}`, { - state: { isFromMyCdList: isMyFeed }, + state: { isFromMyCdList: isMyFeed, currentSort }, }) } diff --git a/src/pages/feed/ui/index.ts b/src/pages/feed/ui/index.ts index 3a5c1171..f12b7957 100644 --- a/src/pages/feed/ui/index.ts +++ b/src/pages/feed/ui/index.ts @@ -1,10 +1,10 @@ export { default as FollowTab } from './FollowTab' -export { default as CdCarousel } from './CdCarousel' export { default as FeedCdList } from './FeedCdList' export { default as FeedProfile } from './FeedProfile' export { default as CdPlayerLayout } from './layout/CdPlayerLayout' -export { default as CdViewerLayout } from './layout/CdViewerLayout' +export { default as CarouselItem } from './CarouselItem' export { default as FollowLayout } from './layout/FollowLayout' export { default as FeedCdListLayout } from './layout/FeedCdListLayout' +export { default as FeedCarousel } from './FeedCarousel' export { default as FollowEmpty } from './FollowEmpty' export { default as FollowList } from './FollowList' diff --git a/src/pages/feed/ui/layout/CdPlayerLayout.tsx b/src/pages/feed/ui/layout/CdPlayerLayout.tsx index 82a6a24c..4b5fb856 100644 --- a/src/pages/feed/ui/layout/CdPlayerLayout.tsx +++ b/src/pages/feed/ui/layout/CdPlayerLayout.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Outlet } from 'react-router-dom' +import { Outlet, useOutletContext } from 'react-router-dom' import styled from 'styled-components' @@ -28,6 +28,7 @@ const Content = () => { } = usePlaylist() const [isMuted, setIsMuted] = useState(null) const { isMobile } = useDevice() + const isOwner = useOutletContext() const videoId = currentPlaylist ? getVideoId(currentPlaylist.songs[currentTrackIndex]?.youtubeUrl) @@ -35,7 +36,7 @@ const Content = () => { return ( <> - + {videoId && ( { creatorId={currentPlaylist.creatorId} stickers={playlistDetail?.cdResponse?.cdItems || []} type="MY" - pageType={selectedTab} /> {centerItem.playlistName} {selectedTab === 'LIKE' && playlistDetail?.creatorNickname && ( diff --git a/src/shared/styles/mixins.ts b/src/shared/styles/mixins.ts index 9152d06f..56105c31 100644 --- a/src/shared/styles/mixins.ts +++ b/src/shared/styles/mixins.ts @@ -1,4 +1,4 @@ -import { css } from 'styled-components' +import { css, keyframes } from 'styled-components' export const flexRowCenter = css` display: flex; @@ -31,3 +31,18 @@ export const ellipsisOneLine = css` overflow: hidden; text-overflow: ellipsis; ` + +export const spin = keyframes` + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +` + +export const cdSpinner = css<{ $isPlaying: boolean }>` + animation: ${spin} 40s linear infinite; + animation-play-state: ${({ $isPlaying }) => ($isPlaying ? 'running' : 'paused')}; + transform-origin: center; +` diff --git a/src/widgets/playlist/ActionBar.tsx b/src/widgets/playlist/ActionBar.tsx index 31c64ba8..cf0a47a3 100644 --- a/src/widgets/playlist/ActionBar.tsx +++ b/src/widgets/playlist/ActionBar.tsx @@ -15,16 +15,9 @@ interface ActionBarProps { creatorId: string stickers?: CdCustomData[] type?: 'MY' | 'DISCOVER' - pageType?: 'MY' | 'LIKE' } -const ActionBar = ({ - playlistId, - creatorId, - stickers, - type = 'DISCOVER', - pageType = 'MY', -}: ActionBarProps) => { +const ActionBar = ({ playlistId, creatorId, stickers, type = 'DISCOVER' }: ActionBarProps) => { const navigate = useNavigate() const handleMovePlaylist = () => { @@ -33,7 +26,7 @@ const ActionBar = ({ return ( - {!(type === 'MY' && pageType === 'MY') && } + diff --git a/src/widgets/playlist/PlaylistLayout.tsx b/src/widgets/playlist/PlaylistLayout.tsx index fdc36e7c..3b5f88f9 100644 --- a/src/widgets/playlist/PlaylistLayout.tsx +++ b/src/widgets/playlist/PlaylistLayout.tsx @@ -6,7 +6,7 @@ import styled, { keyframes, css } from 'styled-components' import type { PlaylistDetail } from '@/entities/playlist' import { FollowButton } from '@/features/follow' import { useDevice } from '@/shared/lib/useDevice' -import { flexColCenter } from '@/shared/styles/mixins' +import { cdSpinner, flexColCenter } from '@/shared/styles/mixins' import { Cd, Header, LiveInfo, Profile } from '@/shared/ui' import { ActionBar, PlayButton, ProgressBar } from '@/widgets/playlist' @@ -133,18 +133,7 @@ const Container = styled.div` const CdSpinner = styled.div<{ $isPlaying: boolean }>` position: relative; - animation: spin 40s linear infinite; - animation-play-state: ${(props) => (props.$isPlaying ? 'running' : 'paused')}; - transform-origin: center; - - @keyframes spin { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } - } + ${cdSpinner} ` const TitleContainer = styled.div`