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
3 changes: 3 additions & 0 deletions src/assets/icons/icn_cta_arrow.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/icons/icn_like_stroke.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/assets/icons/icn_next.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/assets/icons/icn_prev.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions src/assets/icons/icn_search.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/assets/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,5 @@ export { default as BgCd } from './icn_bg_cd.svg?react'
export { default as BgHeadphone } from './icn_bg_headphone.svg?react'
export { default as BgMusic } from './icn_bg_music.svg?react'
export { default as Volume } from './icn_volume.svg?react'
export { default as CtaArrow } from './icn_cta_arrow.svg?react'
export { default as LikeStroke } from './icn_like_stroke.svg?react'
Binary file added src/assets/images/img_character_home.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/images/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export { default as LogoHologram } from './img_logo_hologram.png'
export { default as OpacityCharacterBlue } from './img_opacity_character_blue.png'
export { default as OpacityCharacterPink } from './img_opacity_character_pink.png'
export { default as OpacityMusic } from './img_opacity_music.png'
export { default as HomeCharacter } from './img_character_home.png'
5 changes: 5 additions & 0 deletions src/entities/playlist/api/playlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,8 @@ export const getPlaylistViewCounts = (playlistId: number) => {
export const getMyRepresentativePlaylist = () => {
return api.get<MyRepresentResponse>('/main/playlist/mypage/me/representative')
}

// 좋아요한 플레이리스트 조회
export const getMyLikedCdList = (sort: string) => {
return api.get<MyCdListResponse>(`/main/playlist/mypage/me/likes?sort=${sort}`)
}
9 changes: 9 additions & 0 deletions src/entities/playlist/model/useMyPlaylist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
deleteMyPagePlaylist,
setPrimaryPlaylist,
getMyRepresentativePlaylist,
getMyLikedCdList,
} from '@/entities/playlist/api/playlist'

export const useMyCdList = (sort: string) => {
Expand Down Expand Up @@ -75,3 +76,11 @@ export const useMyRepresentativePlaylist = () => {
queryFn: () => getMyRepresentativePlaylist(),
})
}

export const useMyLikedCdList = (sort: string) => {
return useQuery({
queryKey: ['myLikedCdList', sort],
queryFn: () => getMyLikedCdList(sort),
refetchOnMount: 'always',
})
}
5 changes: 3 additions & 2 deletions src/entities/playlist/model/usePlaylists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,11 @@ export const useShufflePlaylists = (size: number = 5) => {
})
}

export const usePlaylistDetail = (playlistId: number) => {
export const usePlaylistDetail = (playlistId: number | null) => {
return useQuery({
queryKey: ['playlistDetail', playlistId],
queryFn: () => getPlaylistDetail(playlistId),
queryFn: () => getPlaylistDetail(playlistId as number),
enabled: !!playlistId,
})
}

Expand Down
2 changes: 1 addition & 1 deletion src/entities/playlist/types/playlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { MusicGenreId } from '@/shared/config/musicGenres'
export interface MyCdInfo {
playlistId: number
playlistName: string
isRepresentative: boolean
isPublic: boolean
}

export type MyCdListResponse = (MyCdInfo & OnlyCdResponse)[]
Expand Down
13 changes: 13 additions & 0 deletions src/features/like/api/like.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { api } from '@/shared/api/httpClient'

export const postLike = (playlistId: number) => {
return api.post(`/main/likes/${playlistId}`)
}

export const deleteLike = (playlistId: number) => {
return api.delete(`/main/likes/${playlistId}`)
}

export const getLikeStatus = (playlistId: number) => {
return api.get(`/main/likes/${playlistId}`)
}
3 changes: 3 additions & 0 deletions src/features/like/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as useLike } from './model/useLike'
export * from './api/like'
export { default as LikeButton } from './ui/LikeButton'
58 changes: 58 additions & 0 deletions src/features/like/model/useLike.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'

import { useAuthStore } from '@/features/auth/store/authStore'
import { postLike, deleteLike, getLikeStatus } from '@/features/like/api/like'

const useLike = (playlistId: number, initialIsLiked: boolean) => {
const queryClient = useQueryClient()
const [isLiked, setIsLiked] = useState(initialIsLiked)
const navigate = useNavigate()
const { isLogin } = useAuthStore()

const likeMutation = useMutation({
mutationFn: (playlistId: number) => postLike(playlistId),
onSuccess: () => {
setIsLiked(true)
queryClient.invalidateQueries({ queryKey: ['playlistDetail', playlistId] })
},
Comment on lines +17 to +20

Choose a reason for hiding this comment

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

high

좋아요 액션이 성공했을 때, playlistDetail 쿼리만 무효화하고 있습니다. '좋아요한 CD' 목록의 데이터 일관성을 위해 myLikedCdList 쿼리도 함께 무효화하는 것이 좋습니다. useMyLikedCdList 훅의 queryKey['myLikedCdList', sort]이므로, queryClient.invalidateQueries({ queryKey: ['myLikedCdList'] })를 추가하면 관련 목록이 모두 업데이트될 것입니다.1

    onSuccess: () => {
      setIsLiked(true)
      queryClient.invalidateQueries({ queryKey: ['playlistDetail', playlistId] })
      queryClient.invalidateQueries({ queryKey: ['myLikedCdList'] })
    },

Style Guide References

Footnotes

  1. Tanstack Query를 사용하여 서버 상태를 관리할 때, 데이터 변경(mutation) 후 관련된 쿼리를 무효화하여 데이터 동기화를 유지하는 것이 좋습니다. '좋아요' 상태가 변경되면 '좋아요한 CD 목록'도 업데이트되어야 합니다.

})

const unlikeMutation = useMutation({
mutationFn: (playlistId: number) => deleteLike(playlistId),
onSuccess: () => {
setIsLiked(false)
queryClient.invalidateQueries({ queryKey: ['playlistDetail', playlistId] })
},
Comment on lines +25 to +28

Choose a reason for hiding this comment

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

high

좋아요 취소 액션이 성공했을 때, playlistDetail 쿼리만 무효화하고 있습니다. '좋아요한 CD' 목록의 데이터 일관성을 위해 myLikedCdList 쿼리도 함께 무효화하는 것이 좋습니다. useMyLikedCdList 훅의 queryKey['myLikedCdList', sort]이므로, queryClient.invalidateQueries({ queryKey: ['myLikedCdList'] })를 추가하면 관련 목록이 모두 업데이트될 것입니다.1

    onSuccess: () => {
      setIsLiked(false)
      queryClient.invalidateQueries({ queryKey: ['playlistDetail', playlistId] })
      queryClient.invalidateQueries({ queryKey: ['myLikedCdList'] })
    },

Style Guide References

Footnotes

  1. Tanstack Query를 사용하여 서버 상태를 관리할 때, 데이터 변경(mutation) 후 관련된 쿼리를 무효화하여 데이터 동기화를 유지하는 것이 좋습니다. '좋아요' 상태가 변경되면 '좋아요한 CD 목록'도 업데이트되어야 합니다.

Comment on lines +17 to +28
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

좋아요 상태 쿼리도 함께 무효화해야 합니다.

useLikeStatus 훅이 ['likeStatus', playlistId] 키에 의존하는데, 토글 후 이 쿼리를 무효화하지 않아 좋아요 상태가 갱신되지 않습니다. 동일 화면에서 상태 표시가 어긋나므로, 성공 콜백에서 likeStatus 쿼리도 무효화해 주세요.

아래와 같이 수정할 수 있습니다:

     onSuccess: () => {
       setIsLiked(true)
       queryClient.invalidateQueries({ queryKey: ['playlistDetail', playlistId] })
+      queryClient.invalidateQueries({ queryKey: ['likeStatus', playlistId] })
     },
...
     onSuccess: () => {
       setIsLiked(false)
       queryClient.invalidateQueries({ queryKey: ['playlistDetail', playlistId] })
+      queryClient.invalidateQueries({ queryKey: ['likeStatus', playlistId] })
     },
🤖 Prompt for AI Agents
In src/features/like/model/useLike.ts around lines 17 to 28, the onSuccess
handlers for the like and unlike mutations invalidate only ['playlistDetail',
playlistId] but not the like status query, so the UI's like state (which depends
on ['likeStatus', playlistId]) doesn't update; update both onSuccess callbacks
to also call queryClient.invalidateQueries({ queryKey: ['likeStatus',
playlistId] }) (in addition to the existing playlistDetail invalidation) so the
likeStatus hook is refreshed after a toggle.

})

const toggleLike = () => {
if (!isLogin) {
navigate('/login')
return
}

if (likeMutation.isPending || unlikeMutation.isPending) return

if (isLiked) {
unlikeMutation.mutate(playlistId)
} else {
likeMutation.mutate(playlistId)
}
}
Comment on lines +1 to +44
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

플레이리스트 전환 시 좋아요 상태가 갱신되도록 보완이 필요합니다.

현재 로컬 상태를 initialIsLiked로 한 번만 초기화해서, 같은 컴포넌트 인스턴스에서 다른 플레이리스트로 이동하면 이전 곡의 좋아요 상태가 그대로 남습니다. 특히 라우트 파라미터만 바뀌고 컴포넌트가 유지되는 케이스에서 바로 노출됩니다. 플레이리스트 ID나 초기값이 바뀔 때 상태를 다시 맞춰 주세요.

적용 예시는 아래와 같습니다:

-import { useState } from 'react'
+import { useEffect, useState } from 'react'
...
   const [isLiked, setIsLiked] = useState(initialIsLiked)
...
+  useEffect(() => {
+    setIsLiked(initialIsLiked)
+  }, [initialIsLiked, playlistId])
🤖 Prompt for AI Agents
In src/features/like/model/useLike.ts around lines 1-44, the hook only
initializes isLiked from initialIsLiked once so when the component stays mounted
and playlistId or initialIsLiked change the local state is stale; add an effect
that watches playlistId and initialIsLiked and calls setIsLiked(initialIsLiked)
(i.e. useEffect(() => setIsLiked(initialIsLiked), [playlistId, initialIsLiked]))
so the local like state is resynchronized whenever the playlist or its initial
status changes.


return { liked: isLiked, toggleLike, likeMutation, unlikeMutation }
}

export default useLike

export const useLikeStatus = (playlistId: number, options?: { enabled?: boolean }) => {
return useQuery({
queryKey: ['likeStatus', playlistId],
queryFn: () => getLikeStatus(playlistId),
staleTime: 0,
enabled: playlistId !== undefined && (options?.enabled ?? true),
})
}
63 changes: 63 additions & 0 deletions src/features/like/ui/LikeButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React from 'react'

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

import { Like, LikeStroke } from '@/assets/icons'
import { useLike } from '@/features/like'
import { flexRowCenter, myCdButton } from '@/shared/styles/mixins'
import SvgButton from '@/shared/ui/SvgButton'

interface LikeButtonProps {
playlistId: number
isLiked: boolean
type?: 'HOME' | 'DISCOVER' | 'MY'
}

const ICON_STYLE = {
HOME: { size: 20, Icon: Like },
DISCOVER: { size: 24, Icon: LikeStroke },
MY: { size: 16, Icon: LikeStroke },
} as const

const LikeButton = ({ playlistId, isLiked, type = 'HOME' }: LikeButtonProps) => {
const theme = useTheme()
const { liked, toggleLike } = useLike(playlistId, isLiked)

const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
toggleLike()
}

const { size, Icon } = ICON_STYLE[type] ?? ICON_STYLE.HOME
const opacity = type === 'HOME' ? (liked ? 1 : 0.2) : 1

return (
<Wrapper $opacity={opacity} $isMy={type === 'MY'}>
<SvgButton
icon={Icon}
onClick={handleClick}
width={size}
height={size}
fill={
type === 'HOME'
? liked
? theme.COLOR['primary-normal']
: theme.COLOR['gray-200']
: liked
? theme.COLOR['primary-normal']
: 'none'
}
stroke={liked ? theme.COLOR['primary-normal'] : theme.COLOR['gray-200']}
/>
{type === 'MY' && <p>좋아요</p>}
</Wrapper>
)
}

export default LikeButton

const Wrapper = styled.div<{ $opacity?: number; $isMy: boolean }>`
opacity: ${({ $opacity }) => $opacity};
${flexRowCenter};
${({ $isMy }) => $isMy && myCdButton};
`
15 changes: 12 additions & 3 deletions src/features/share/ui/ShareButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,16 @@ import GuestCharacter from '@/assets/images/img_character_guest.png'
import MemberCharacter from '@/assets/images/img_character_member.png'
import type { CdCustomData } from '@/entities/playlist'
import ShareImage from '@/features/share/ui/ShareImage'
import { flexRowCenter } from '@/shared/styles/mixins'
import { flexRowCenter, myCdButton } from '@/shared/styles/mixins'
import { BottomSheet, Button, Cd, ScrollCarousel, SvgButton } from '@/shared/ui'

interface ShareButtonProps {
playlistId: number
stickers?: CdCustomData[]
type?: 'MY' | 'DISCOVER'
}

const ShareButton = ({ playlistId, stickers }: ShareButtonProps) => {
const ShareButton = ({ playlistId, stickers, type = 'DISCOVER' }: ShareButtonProps) => {
const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const { toast } = useToast()
Expand Down Expand Up @@ -83,7 +84,10 @@ const ShareButton = ({ playlistId, stickers }: ShareButtonProps) => {

return (
<>
<SvgButton icon={Share} width={24} height={24} onClick={handleShare} />
<ButtonWrapper $isMy={type === 'MY'} onClick={handleShare}>
<SvgButton icon={Share} width={type === 'MY' ? 16 : 24} height={type === 'MY' ? 16 : 24} />
{type === 'MY' && <p>공유</p>}
</ButtonWrapper>

<BottomSheet
isOpen={isBottomSheetOpen}
Expand Down Expand Up @@ -140,3 +144,8 @@ const ButtonBar = styled.div`
flex: 1;
}
`

const ButtonWrapper = styled.div<{ $isMy: boolean }>`
${flexRowCenter};
${({ $isMy }) => $isMy && myCdButton};
`
2 changes: 1 addition & 1 deletion src/pages/home/config/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ export const BUTTON_TEXT = {
} as const

export const TITLE_TEXT = {
MEMBER: (name: string) => `${name}님! 오늘의 첫 곡,\n여기서 시작하세요`,
MEMBER: (name: string) => `${name}님!\n 오늘의 첫 곡, 여기서 시작하세요`,
GUEST: `오늘의 무드에 어울리는 \n 플레이리스트를 들어 보세요`,
} as const
Loading
Loading