mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-17 13:48:02 +00:00
feat: updates
This commit is contained in:
parent
a1b0bcf4d3
commit
884f3df41a
198 changed files with 4572 additions and 5336 deletions
104
packages/web/components/Topbar/Avatar.tsx
Normal file
104
packages/web/components/Topbar/Avatar.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import Icon from '../Icon'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import useUser, { useMutationLogout } from '@/web/api/hooks/useUser'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import { useRef, useState } from 'react'
|
||||
import BasicContextMenu from '../ContextMenus/BasicContextMenu'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const Avatar = ({ className }: { className?: string }) => {
|
||||
const { data: user } = useUser()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const avatarUrl = user?.profile?.avatarUrl
|
||||
? resizeImage(user?.profile?.avatarUrl ?? '', 'sm')
|
||||
: ''
|
||||
|
||||
const avatarRef = useRef<HTMLImageElement>(null)
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
const logout = useMutationLogout()
|
||||
|
||||
return (
|
||||
<>
|
||||
{avatarUrl ? (
|
||||
<div>
|
||||
<img
|
||||
ref={avatarRef}
|
||||
src={avatarUrl}
|
||||
onClick={event => {
|
||||
if (event.target === avatarRef.current && showMenu) {
|
||||
setShowMenu(false)
|
||||
return
|
||||
}
|
||||
setShowMenu(true)
|
||||
}}
|
||||
className={cx(
|
||||
'app-region-no-drag rounded-full',
|
||||
className || 'h-12 w-12'
|
||||
)}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{avatarRef.current && showMenu && (
|
||||
<BasicContextMenu
|
||||
classNames={css`
|
||||
min-width: 162px;
|
||||
`}
|
||||
onClose={event => {
|
||||
if (event.target === avatarRef.current) return
|
||||
setShowMenu(false)
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Profile',
|
||||
onClick: () => {
|
||||
toast('开发中')
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: t`settings.settings`,
|
||||
onClick: () => {
|
||||
toast('开发中')
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: t`auth.logout`,
|
||||
onClick: () => {
|
||||
logout.mutateAsync()
|
||||
},
|
||||
},
|
||||
]}
|
||||
target={avatarRef.current}
|
||||
cursorPosition={{
|
||||
x: 0,
|
||||
y: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => (uiStates.showLoginPanel = true)}
|
||||
className={cx(
|
||||
'rounded-full bg-day-600 p-2.5 dark:bg-night-600',
|
||||
className || 'h-12 w-12'
|
||||
)}
|
||||
>
|
||||
<Icon name='user' className='h-7 w-7 text-neutral-500' />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Avatar
|
||||
48
packages/web/components/Topbar/NavigationButtons.tsx
Normal file
48
packages/web/components/Topbar/NavigationButtons.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import { motion, useAnimation } from 'framer-motion'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import Icon from '../Icon'
|
||||
|
||||
const buttonClassNames =
|
||||
'app-region-no-drag rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl transition-colors duration-400 hover:bg-white/20 hover:text-white/60'
|
||||
|
||||
const NavigationButtons = () => {
|
||||
const navigate = useNavigate()
|
||||
const controlsBack = useAnimation()
|
||||
const controlsForward = useAnimation()
|
||||
const transition = { duration: 0.18, ease }
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
onMouseDown={async () => {
|
||||
await controlsBack.start({ x: -5 })
|
||||
await controlsBack.start({ x: 0 })
|
||||
}}
|
||||
className={buttonClassNames}
|
||||
>
|
||||
<motion.div animate={controlsBack} transition={transition}>
|
||||
<Icon name='back' className='h-7 w-7' />
|
||||
</motion.div>
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
navigate(1)
|
||||
}}
|
||||
onMouseDown={async () => {
|
||||
await controlsForward.start({ x: 5 })
|
||||
await controlsForward.start({ x: 0 })
|
||||
}}
|
||||
className={cx('ml-2.5', buttonClassNames)}
|
||||
>
|
||||
<motion.div animate={controlsForward} transition={transition}>
|
||||
<Icon name='forward' className='h-7 w-7' />
|
||||
</motion.div>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NavigationButtons
|
||||
173
packages/web/components/Topbar/SearchBox.tsx
Normal file
173
packages/web/components/Topbar/SearchBox.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import Icon from '../Icon'
|
||||
import { breakpoint as bp } from '@/web/utils/const'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useMemo, useState, useEffect, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchSearchSuggestions } from '@/web/api/search'
|
||||
import { SearchApiNames } from '@/shared/api/Search'
|
||||
import { useClickAway, useDebounce } from 'react-use'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const SearchSuggestions = ({
|
||||
searchText,
|
||||
isInputFocused,
|
||||
}: {
|
||||
searchText: string
|
||||
isInputFocused: boolean
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [debouncedSearchText, setDebouncedSearchText] = useState('')
|
||||
useDebounce(() => setDebouncedSearchText(searchText), 500, [searchText])
|
||||
const { data: suggestions } = useQuery(
|
||||
[SearchApiNames.FetchSearchSuggestions, debouncedSearchText],
|
||||
() => fetchSearchSuggestions({ keywords: debouncedSearchText }),
|
||||
{
|
||||
enabled: debouncedSearchText.length > 0,
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
const suggestionsArray = useMemo(() => {
|
||||
if (suggestions?.code !== 200) {
|
||||
return []
|
||||
}
|
||||
const suggestionsArray: {
|
||||
name: string
|
||||
type: 'album' | 'artist' | 'track'
|
||||
id: number
|
||||
}[] = []
|
||||
const rawItems = [
|
||||
...(suggestions.result.artists || []),
|
||||
...(suggestions.result.albums || []),
|
||||
...(suggestions.result.songs || []),
|
||||
]
|
||||
rawItems.forEach(item => {
|
||||
const type = (item as Artist).albumSize
|
||||
? 'artist'
|
||||
: (item as Track).duration
|
||||
? 'track'
|
||||
: 'album'
|
||||
suggestionsArray.push({
|
||||
name: item.name,
|
||||
type,
|
||||
id: item.id,
|
||||
})
|
||||
})
|
||||
return suggestionsArray
|
||||
}, [suggestions])
|
||||
|
||||
const [clickedSearchText, setClickedSearchText] = useState('')
|
||||
useEffect(() => {
|
||||
if (clickedSearchText !== searchText) {
|
||||
setClickedSearchText('')
|
||||
}
|
||||
}, [clickedSearchText, searchText])
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
useClickAway(panelRef, () => setClickedSearchText(searchText))
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isInputFocused &&
|
||||
searchText.length > 0 &&
|
||||
suggestionsArray.length > 0 &&
|
||||
!clickedSearchText &&
|
||||
searchText === debouncedSearchText && (
|
||||
<motion.div
|
||||
ref={panelRef}
|
||||
initial={{ opacity: 0, scaleY: 0.96 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scaleY: 1,
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
},
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scaleY: 0.96,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
className={cx(
|
||||
'absolute mt-2 origin-top rounded-24 border border-white/10 bg-white/10 p-2 backdrop-blur-3xl',
|
||||
css`
|
||||
width: 286px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
{suggestionsArray?.map(suggestion => (
|
||||
<div
|
||||
key={`${suggestion.type}-${suggestion.id}`}
|
||||
className='line-clamp-1 rounded-12 p-2 text-white hover:bg-white/10'
|
||||
onClick={() => {
|
||||
setClickedSearchText(searchText)
|
||||
if (['album', 'artist'].includes(suggestion.type)) {
|
||||
navigate(`${suggestion.type}/${suggestion.id}`)
|
||||
}
|
||||
if (suggestion.type === 'track') {
|
||||
// TODO: play song
|
||||
}
|
||||
}}
|
||||
>
|
||||
{suggestion.type} -{suggestion.name}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
const SearchBox = () => {
|
||||
const navigate = useNavigate()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
{/* Input */}
|
||||
<div
|
||||
className={cx(
|
||||
'app-region-no-drag flex items-center rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl',
|
||||
css`
|
||||
${bp.lg} {
|
||||
min-width: 284px;
|
||||
}
|
||||
`
|
||||
)}
|
||||
>
|
||||
<Icon name='search' className='mr-2.5 h-7 w-7' />
|
||||
<input
|
||||
placeholder={t`search.search`}
|
||||
className={cx(
|
||||
'flex-shrink bg-transparent font-medium placeholder:text-white/40 dark:text-white/80',
|
||||
css`
|
||||
@media (max-width: 420px) {
|
||||
width: 142px;
|
||||
}
|
||||
`
|
||||
)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key !== 'Enter') return
|
||||
e.preventDefault()
|
||||
navigate(`/search/${searchText}`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SearchSuggestions searchText={searchText} isInputFocused={isFocused} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchBox
|
||||
19
packages/web/components/Topbar/SettingsButton.tsx
Normal file
19
packages/web/components/Topbar/SettingsButton.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import Icon from '@/web/components/Icon'
|
||||
import { cx } from '@emotion/css'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const SettingsButton = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<button
|
||||
onClick={() => toast('开发中')}
|
||||
className={cx(
|
||||
'app-region-no-drag flex h-12 w-12 items-center justify-center rounded-full bg-day-600 text-neutral-500 transition duration-400 dark:bg-white/10 dark:hover:bg-white/20 dark:hover:text-neutral-300',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon name='settings' className='h-5 w-5 ' />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsButton
|
||||
77
packages/web/components/Topbar/TopbarDesktop.tsx
Normal file
77
packages/web/components/Topbar/TopbarDesktop.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import Avatar from './Avatar'
|
||||
import SearchBox from './SearchBox'
|
||||
import SettingsButton from './SettingsButton'
|
||||
import NavigationButtons from './NavigationButtons'
|
||||
import topbarBackground from '@/web/assets/images/topbar-background.png'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
const Background = () => {
|
||||
const { hideTopbarBackground } = useSnapshot(uiStates)
|
||||
const location = useLocation()
|
||||
const isPageHaveBlurBG =
|
||||
location.pathname.startsWith('/album/') ||
|
||||
location.pathname.startsWith('/artist/') ||
|
||||
location.pathname.startsWith('/playlist/')
|
||||
const show = !hideTopbarBackground || !isPageHaveBlurBG
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ ease }}
|
||||
className={cx(
|
||||
'absolute inset-0 z-0 bg-contain bg-repeat-x',
|
||||
window.env?.isElectron && 'rounded-t-24',
|
||||
css`
|
||||
background-image: url(${topbarBackground});
|
||||
`
|
||||
)}
|
||||
></motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const TopbarDesktop = () => {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'app-region-drag fixed top-0 left-0 right-0 z-20 flex items-center justify-between bg-contain pt-11 pb-10 pr-6',
|
||||
css`
|
||||
padding-left: 144px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
{/* Background */}
|
||||
<Background />
|
||||
|
||||
{/* Left Part */}
|
||||
<div className='z-10 flex items-center'>
|
||||
<NavigationButtons />
|
||||
|
||||
{/* Dividing line */}
|
||||
<div className='mx-6 h-4 w-px bg-black/20 dark:bg-white/20'></div>
|
||||
|
||||
<SearchBox />
|
||||
</div>
|
||||
|
||||
{/* Right Part */}
|
||||
<div className='z-10 flex'>
|
||||
<SettingsButton />
|
||||
<Avatar className='ml-3 h-12 w-12' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopbarDesktop
|
||||
23
packages/web/components/Topbar/TopbarMobile.tsx
Normal file
23
packages/web/components/Topbar/TopbarMobile.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import Avatar from './Avatar'
|
||||
import SearchBox from './SearchBox'
|
||||
import SettingsButton from './SettingsButton'
|
||||
|
||||
const TopbarMobile = () => {
|
||||
return (
|
||||
<div className='mb-5 mt-10 flex px-2.5'>
|
||||
<div className='flex-grow'>
|
||||
<SearchBox />
|
||||
</div>
|
||||
|
||||
<div className='ml-6 flex flex-shrink-0'>
|
||||
<SettingsButton />
|
||||
<div className='ml-3'>
|
||||
<Avatar />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopbarMobile
|
||||
Loading…
Add table
Add a link
Reference in a new issue