feat: updates

This commit is contained in:
qier222 2022-10-28 20:29:04 +08:00
parent a1b0bcf4d3
commit 884f3df41a
No known key found for this signature in database
198 changed files with 4572 additions and 5336 deletions

View 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

View 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

View 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

View 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

View 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

View 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