feat: updates

This commit is contained in:
qier222 2022-06-25 13:47:07 +08:00
parent f340a90117
commit cec4c5909d
No known key found for this signature in database
GPG key ID: 9C85007ED905F14D
50 changed files with 1304 additions and 207 deletions

View file

@ -1,13 +1,19 @@
import { resizeImage } from '@/web/utils/common'
import { css, cx } from '@emotion/css'
import { memo } from 'react'
import { useNavigate } from 'react-router-dom'
import Image from './Image'
const Artist = ({ artist }: { artist: Artist }) => {
const navigate = useNavigate()
const to = () => {
navigate(`/artist/${artist.id}`)
}
return (
<div className='text-center'>
<Image
alt={artist.name}
onClick={to}
src={resizeImage(artist.img1v1Url, 'md')}
className={cx(
'aspect-square rounded-full',
@ -17,7 +23,10 @@ const Artist = ({ artist }: { artist: Artist }) => {
`
)}
/>
<div className='line-clamp-1 mt-2.5 text-12 font-medium text-neutral-700 dark:text-neutral-600 lg:text-14 lg:font-bold'>
<div
onClick={to}
className='line-clamp-1 mt-2.5 text-12 font-medium text-neutral-700 dark:text-neutral-600 lg:text-14 lg:font-bold'
>
{artist.name}
</div>
</div>

View file

@ -0,0 +1,51 @@
import { useNavigate } from 'react-router-dom'
import { cx } from '@emotion/css'
const ArtistInline = ({
artists,
className,
disableLink,
onClick,
hoverClassName,
}: {
artists: Artist[]
className?: string
hoverClassName?: string
disableLink?: boolean
onClick?: (artistId: number) => void
}) => {
const navigate = useNavigate()
const handleClick = (id: number) => {
if (id === 0 || disableLink) return
if (!onClick) {
navigate(`/artist/${id}`)
} else {
onClick(id)
}
}
if (!artists) return <div></div>
return (
<div
className={cx(
!className?.includes('line-clamp') && 'line-clamp-1',
className
)}
>
{artists.map((artist, index) => (
<span key={`${artist.id}-${artist.name}`}>
<span
onClick={() => handleClick(artist.id)}
className={cx(!!artist.id && !disableLink && hoverClassName)}
>
{artist.name}
</span>
{index < artists.length - 1 ? ', ' : ''}&nbsp;
</span>
))}
</div>
)
}
export default ArtistInline

View file

@ -9,7 +9,6 @@ import { memo, useCallback } from 'react'
const Album = ({ album }: { album: Album }) => {
const navigate = useNavigate()
const goTo = () => {
console.log('dsada')
navigate(`/album/${album.id}`)
}
const prefetch = () => {

View file

@ -5,23 +5,26 @@ import { player } from '@/web/store'
import { useSnapshot } from 'valtio'
import Router from '@/web/components/New/Router'
import MenuBar from './MenuBar'
import TopbarMobile from './Topbar/TopbarMobile'
import Topbar from './Topbar/TopbarMobile'
import { isIOS, isPWA, isSafari } from '@/web/utils/common'
import Login from './Login'
import { useLocation } from 'react-router-dom'
import PlayingNext from './PlayingNextMobile'
const LayoutMobile = () => {
const playerSnapshot = useSnapshot(player)
const showPlayer = !!playerSnapshot.track
const location = useLocation()
return (
<div id='layout' className='select-none bg-white pb-28 pt-3 dark:bg-black'>
<div id='layout' className='select-none bg-white pb-28 dark:bg-black'>
<main className='min-h-screen overflow-y-auto overflow-x-hidden pb-16'>
<TopbarMobile />
{location.pathname === '/' && <Topbar />}
<Router />
</main>
<div
className={cx(
'fixed bottom-0 left-0 right-0 pt-3 dark:bg-black',
'fixed bottom-0 left-0 right-0 z-20 pt-3 dark:bg-black',
css`
padding-bottom: calc(
${isIOS && isSafari && isPWA
@ -34,7 +37,7 @@ const LayoutMobile = () => {
{showPlayer && (
<div
className={cx(
'absolute left-7 right-7',
'absolute left-7 right-7 z-20',
css`
top: calc(
-100% - 6px + ${isIOS && isSafari && isPWA ? '24px' : 'env(safe-area-inset-bottom)'}
@ -47,6 +50,7 @@ const LayoutMobile = () => {
)}
<MenuBar />
{/* <PlayingNext /> */}
</div>
<Login />

View file

@ -1,79 +1,150 @@
import { cx, css } from '@emotion/css'
import { useEffect, useRef, useState, useMemo } from 'react'
import qrCode from 'qrcode'
import { useEffect, useState } from 'react'
import { state } from '@/web/store'
import { useSnapshot } from 'valtio'
const QRCode = ({ className, text }: { className?: string; text: string }) => {
const [image, setImage] = useState<string>('')
useEffect(() => {
if (text) {
qrCode
.toString(text, {
margin: 0,
color: { light: '#ffffff00' },
type: 'svg',
})
.then(image => {
setImage(image)
console.log(image)
})
}
}, [text])
const encodedImage = useMemo(() => encodeURIComponent(image), [image])
import { AnimatePresence, motion, useAnimation } from 'framer-motion'
import { ease } from '@/web/utils/const'
import Icon from '@/web/components/Icon'
import LoginWithPhoneOrEmail from './LoginWithPhoneOrEmail'
import LoginWithQRCode from './LoginWithQRCode'
const OR = ({
children,
onClick,
}: {
children: React.ReactNode
onClick: () => void
}) => {
return (
<img
className={cx('', className)}
src={`data:image/svg+xml;utf8,${encodedImage}`}
></img>
<>
<div className='mt-4 flex items-center'>
<div className='h-px flex-grow bg-white/20'></div>
<div className='mx-2 text-16 font-medium text-white'>or</div>
<div className='h-px flex-grow bg-white/20'></div>
</div>
<div className='mt-4 flex justify-center'>
<button
className='text-16 font-medium text-night-50 transition-colors duration-300 hover:text-white'
onClick={onClick}
>
{children}
</button>
</div>
</>
)
}
const Login = () => {
return <div></div>
const { uiStates, persistedUiStates } = useSnapshot(state)
const [cardType, setCardType] = useState<'qrCode' | 'phone/email'>(
persistedUiStates.loginType === 'qrCode' ? 'qrCode' : 'phone/email'
)
const animateCard = useAnimation()
const handleSwitchCard = async () => {
const transition = { duration: 0.36, ease }
await animateCard.start({
rotateY: 90,
opacity: 0,
transition,
})
setCardType(cardType === 'qrCode' ? 'phone/email' : 'qrCode')
state.persistedUiStates.loginType =
cardType === 'qrCode' ? 'phone' : 'qrCode'
await animateCard.start({
rotateY: 0,
opacity: 1,
transition,
})
}
return (
<div className='fixed inset-0 z-30 flex justify-center rounded-24 bg-black/80 pt-56 backdrop-blur-3xl'>
<div className='flex flex-col items-center'>
<div
className={cx(
'rounded-48 bg-white/10 p-9',
css`
width: 392px;
height: fit-content;
`
)}
>
<div className='text-center text-18 font-medium text-night-600'>
Log in with NetEase QR
</div>
<>
{/* Blur bg */}
<AnimatePresence>
{uiStates.showLoginPanel && (
<motion.div
className='fixed inset-0 z-30 bg-black/80 backdrop-blur-3xl lg:rounded-24'
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease }}
></motion.div>
)}
</AnimatePresence>
<div className='mt-4 rounded-24 bg-white p-2.5'>
<QRCode
text='tetesoahfoahdodaoshdoaish'
className={css`
border-radius: 17px;
`}
/>
</div>
{/* Content */}
<AnimatePresence>
{uiStates.showLoginPanel && (
<div className='fixed inset-0 z-30 flex justify-center rounded-24 pt-56'>
<motion.div
className='flex flex-col items-center'
variants={{
show: {
opacity: 1,
y: 0,
transition: {
duration: 0.3,
ease,
delay: 0.2,
},
},
hide: {
opacity: 0,
y: 100,
transition: {
duration: 0.3,
ease,
},
},
}}
initial='hide'
animate='show'
exit='hide'
>
{/* Login card */}
<AnimatePresence>
<motion.div
animate={animateCard}
className={cx(
'relative rounded-48 bg-white/10 p-9',
css`
width: 392px;
height: fit-content;
`
)}
>
{cardType === 'qrCode' && <LoginWithQRCode />}
{cardType === 'phone/email' && <LoginWithPhoneOrEmail />}
<div className='mt-4 flex items-center'>
<div className='h-px flex-grow bg-white/20'></div>
<div className='mx-2 text-16 font-medium text-white'>or</div>
<div className='h-px flex-grow bg-white/20'></div>
</div>
<OR onClick={handleSwitchCard}>
{cardType === 'qrCode'
? 'Use Phone or Email'
: 'Scan QR Code'}
</OR>
</motion.div>
</AnimatePresence>
<div className='mt-4 flex justify-center'>
<button className='text-16 font-medium text-night-50'>
Use Phone or Email
</button>
{/* Close button */}
<AnimatePresence>
<motion.div
layout='position'
transition={{ ease }}
onClick={() => (state.uiStates.showLoginPanel = false)}
className='mt-10 flex h-14 w-14 items-center justify-center rounded-full bg-white/10'
>
<Icon name='x' className='h-7 w-7 text-white/50' />
</motion.div>
</AnimatePresence>
</motion.div>
</div>
</div>
{/* Close */}
<div className='mt-10 h-14 w-14 rounded-full bg-white/10'></div>
</div>
</div>
)}
</AnimatePresence>
</>
)
}

View file

@ -0,0 +1,238 @@
import { cx, css } from '@emotion/css'
import { useState } from 'react'
import { state } from '@/web/store'
import { useMutation } from 'react-query'
import { loginWithEmail, loginWithPhone } from '@/web/api/auth'
import md5 from 'md5'
import toast from 'react-hot-toast'
import { setCookies } from '@/web/utils/cookie'
import { AnimatePresence, motion } from 'framer-motion'
import { ease } from '@/web/utils/const'
import { useSnapshot } from 'valtio'
const LoginWithPhoneOrEmail = () => {
const { persistedUiStates } = useSnapshot(state)
const [email, setEmail] = useState<string>('')
const [countryCode, setCountryCode] = useState<string>(
persistedUiStates.loginPhoneCountryCode || '+86'
)
const [phone, setPhone] = useState<string>('')
const [password, setPassword] = useState<string>('')
const [loginType, setLoginType] = useState<'phone' | 'email'>(
persistedUiStates.loginType === 'email' ? 'email' : 'phone'
)
const doEmailLogin = useMutation(
() =>
loginWithEmail({
email: email.trim(),
md5_password: md5(password.trim()),
}),
{
onSuccess: result => {
if (result?.code !== 200) {
toast(`Login failed: ${result.code}`)
return
}
setCookies(result.cookie)
state.uiStates.showLoginPanel = false
},
onError: error => {
toast(`Login failed: ${error}`)
},
}
)
const handleEmailLogin = () => {
if (!email) {
toast.error('Please enter email')
return
}
if (!password) {
toast.error('Please enter password')
return
}
if (
email.match(
/^[^\s@]+@(126|163|yeah|188|vip\.163|vip\.126)\.(com|net)$/
) == null
) {
toast.error('Please use netease email')
return
}
doEmailLogin.mutate()
}
const doPhoneLogin = useMutation(
() => {
return loginWithPhone({
countrycode: Number(countryCode.replace('+', '').trim()) || 86,
phone: phone.trim(),
md5_password: md5(password.trim()),
})
},
{
onSuccess: result => {
if (result?.code !== 200) {
toast(`Login failed: ${result.code}`)
return
}
setCookies(result.cookie)
state.uiStates.showLoginPanel = false
},
onError: error => {
toast(`Login failed: ${error}`)
},
}
)
const handlePhoneLogin = () => {
if (!countryCode || !Number(countryCode.replace('+', '').trim())) {
toast.error('Please enter country code')
return
}
if (!phone) {
toast.error('Please enter phone number')
return
}
if (!password) {
toast.error('Please enter password')
return
}
doPhoneLogin.mutate()
}
const transition = {
duration: 0.5,
ease,
}
const variants = {
hidden: {
opacity: 0,
transition,
},
show: {
opacity: 1,
transition,
},
}
return (
<>
<div className='text-center text-18 font-medium text-night-600'>
Log in with{' '}
<span
className={cx(
'transition-colors duration-300',
loginType === 'phone' ? 'text-brand-600' : 'hover:text-night-50'
)}
onClick={() => {
const type = loginType === 'phone' ? 'email' : 'phone'
setLoginType(type)
state.persistedUiStates.loginType = type
}}
>
Phone
</span>{' '}
/{' '}
<span
className={cx(
'transition-colors duration-300',
loginType === 'email' ? 'text-brand-600' : 'hover:text-night-50'
)}
onClick={() => {
if (loginType !== 'email') setLoginType('email')
}}
>
Email
</span>
</div>
{/* Phone input */}
{loginType === 'phone' && (
<div className='mt-4 rounded-12 bg-black/50 px-3 text-16 font-medium text-night-50'>
<AnimatePresence>
<motion.div
variants={variants}
initial='hidden'
animate='show'
exit='hidden'
className='flex items-center'
>
<input
onChange={e => {
setCountryCode(e.target.value)
state.persistedUiStates.loginPhoneCountryCode = e.target.value
}}
className={cx(
'my-3.5 flex-shrink-0 bg-transparent',
css`
width: 28px;
`
)}
placeholder='+86'
value={countryCode}
/>
<div className='mx-2 h-5 w-px flex-shrink-0 bg-white/20'></div>
<input
onChange={e => setPhone(e.target.value)}
className='my-3.5 flex-grow appearance-none bg-transparent'
placeholder='Phone'
type='number'
value={phone}
/>
</motion.div>
</AnimatePresence>
</div>
)}
{/* Email input */}
{loginType === 'email' && (
<div className='mt-4 flex items-center rounded-12 bg-black/50 px-3 py-3.5 text-16 font-medium text-night-50'>
<AnimatePresence>
<motion.div
variants={variants}
initial='hidden'
animate='show'
exit='hidden'
>
<input
onChange={e => setEmail(e.target.value)}
className='flex-grow appearance-none bg-transparent'
placeholder='Email'
type='email'
value={email}
/>
</motion.div>
</AnimatePresence>
</div>
)}
{/* Password input */}
<div className='mt-4 flex items-center rounded-12 bg-black/50 p-3 text-16 font-medium text-night-50'>
<input
onChange={e => setPassword(e.target.value)}
className='bg-transparent'
placeholder='Password'
type='password'
value={password}
/>
</div>
{/* Login button */}
<div
onClick={() =>
loginType === 'phone' ? handlePhoneLogin() : handleEmailLogin()
}
className='mt-4 rounded-full bg-brand-700 p-4 text-center text-16 font-medium text-white'
>
LOG IN
</div>
</>
)
}
export default LoginWithPhoneOrEmail

View file

@ -0,0 +1,129 @@
import { cx, css } from '@emotion/css'
import { useEffect, useState, useMemo } from 'react'
import qrCode from 'qrcode'
import { state } from '@/web/store'
import { useSnapshot } from 'valtio'
import { useMutation, useQuery } from 'react-query'
import {
checkLoginQrCodeStatus,
fetchLoginQrCodeKey,
loginWithEmail,
loginWithPhone,
} from '@/web/api/auth'
import md5 from 'md5'
import toast from 'react-hot-toast'
import { setCookies } from '@/web/utils/cookie'
import { AnimatePresence, motion } from 'framer-motion'
import { ease } from '@/web/utils/const'
import Icon from '@/web/components/Icon'
const QRCode = ({ className, text }: { className?: string; text: string }) => {
const [image, setImage] = useState<string>('')
useEffect(() => {
if (text) {
qrCode
.toString(text, {
margin: 0,
color: { light: '#ffffff00' },
type: 'svg',
})
.then(image => {
setImage(image)
})
}
}, [text])
const encodedImage = useMemo(() => encodeURIComponent(image), [image])
return (
<img
className={cx('aspect-square', className)}
src={text ? `data:image/svg+xml;utf8,${encodedImage}` : undefined}
></img>
)
}
const LoginWithQRCode = () => {
const {
data: key,
status: keyStatus,
refetch: refetchKey,
} = useQuery(
'qrCodeKey',
async () => {
const result = await fetchLoginQrCodeKey()
if (result.data.code !== 200) {
throw Error(`Failed to fetch QR code key: ${result.data.code}`)
}
return result
},
{
cacheTime: 0,
retry: true,
retryDelay: 500,
refetchOnWindowFocus: false,
refetchInterval: 1000 * 60 * 5, // 5 min
}
)
const { data: status } = useQuery(
'qrCodeStatus',
async () => checkLoginQrCodeStatus({ key: key?.data?.unikey || '' }),
{
refetchInterval: 1000,
enabled: !!key?.data?.unikey,
onSuccess: status => {
switch (status.code) {
case 800:
refetchKey()
break
case 801:
// setQrCodeMessage('打开网易云音乐,扫码登录')
break
case 802:
// setQrCodeMessage('等待确认')
break
case 803:
if (!status.cookie) {
toast('checkLoginQrCodeStatus returned 803 without cookie')
break
}
setCookies(status.cookie)
state.uiStates.showLoginPanel = false
break
}
},
}
)
const text = useMemo(
() =>
key?.data?.unikey
? `https://music.163.com/login?codekey=${key.data.unikey}`
: '',
[key?.data?.unikey]
)
return (
<>
<div className='text-center text-18 font-medium text-night-600'>
Log in with NetEase QR
</div>
<div className='mt-4 rounded-24 bg-white p-2.5'>
<QRCode
text={text}
className={cx(
'w-full',
css`
border-radius: 17px;
`
)}
/>
</div>
</>
)
}
export default LoginWithQRCode

View file

@ -4,7 +4,6 @@ import Router from './Router'
const Main = () => {
return (
<main
id='main'
className={cx(
'no-scrollbar overflow-y-auto pb-16 pr-6 pl-10',
css`

View file

@ -114,14 +114,16 @@ const Tabs = () => {
scale: { scale: 0.8 },
reset: { scale: 1 },
}}
className={cx(
active === tab.path
? 'text-brand-600 dark:text-brand-700'
: 'lg:hover:text-black lg:dark:hover:text-white'
)}
>
<Icon
name={tab.icon}
className={cx(
'app-region-no-drag h-10 w-10 transition-colors duration-500',
active === tab.path
? 'text-brand-600 dark:text-brand-700'
: 'lg:hover:text-black lg:dark:hover:text-white'
'app-region-no-drag h-10 w-10 transition-colors duration-500'
)}
/>
</motion.div>
@ -130,12 +132,13 @@ const Tabs = () => {
)
}
const MenuBar = () => {
const MenuBar = ({ className }: { className?: string }) => {
const isMobile = useIsMobile()
return (
<div
className={cx(
'app-region-drag relative flex h-full w-full flex-col justify-center',
className,
css`
grid-area: menubar;
`

View file

@ -8,6 +8,10 @@ import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player'
import Slider from './Slider'
import { animate, motion, useAnimation } from 'framer-motion'
import { ease } from '@/web/utils/const'
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/api/hooks/useUserLikedTracksIDs'
import ArtistInline from './ArtistsInLine'
const Progress = () => {
const { track, progress } = useSnapshot(player)
@ -79,6 +83,24 @@ const Cover = () => {
)
}
const HeartButton = () => {
const { track } = useSnapshot(player)
const { data: likedIDs } = useUserLikedTracksIDs()
const isLiked = !!likedIDs?.ids?.find(id => id === track?.id)
const likeATrack = useMutationLikeATrack()
return (
<button onClick={() => track?.id && likeATrack.mutateAsync(track.id)}>
<Icon
name={isLiked ? 'heart' : 'heart-outline'}
className='h-7 w-7 text-black/90 dark:text-white/40'
/>
</button>
)
}
const NowPlaying = () => {
const { state, track } = useSnapshot(player)
@ -100,9 +122,11 @@ const NowPlaying = () => {
<div className='line-clamp-1 text-lg text-black dark:text-white'>
{track?.name}
</div>
<div className='line-clamp-1 text-base text-black/30 dark:text-white/30'>
{track?.ar.map(a => a.name).join(', ')}
</div>
<ArtistInline
artists={track?.ar || []}
className='text-black/30 dark:text-white/30'
hoverClassName='hover:text-black/50 dark:hover:text-white/50 transition-colors duration-500'
/>
{/* Dividing line */}
<div className='mt-2 h-px w-2/3 bg-black/10 dark:bg-white/10'></div>
@ -149,12 +173,7 @@ const NowPlaying = () => {
</button>
</div>
<button>
<Icon
name='heart'
className='h-7 w-7 text-black/90 dark:text-white/40'
/>
</button>
<HeartButton />
</div>
</div>
</div>

View file

@ -1,6 +1,8 @@
import { motion } from 'framer-motion'
import { ease } from '@/web/utils/const'
import useIsMobile from '@/web/hooks/useIsMobile'
import scrollPositions from '@/web/store/scrollPositions'
import { useLayoutEffect } from 'react'
const PageTransition = ({
children,
@ -10,6 +12,15 @@ const PageTransition = ({
disableEnterAnimation?: boolean
}) => {
const isMobile = useIsMobile()
// To restore scroll position
useLayoutEffect(() => {
const main = document.querySelector('main')
if (main) {
main.scrollTop = scrollPositions.get(window.location.pathname) ?? 0
}
}, [])
if (isMobile) {
return <>{children}</>
}

View file

@ -22,39 +22,55 @@ const PlayerMobile = () => {
info: PanInfo
) => {
console.log(JSON.stringify(info))
const x = info.offset.x
const { x, y } = info.offset
const offset = 100
if (x > offset) player.prevTrack()
if (x < -offset) player.nextTrack()
if (y > -100) {
if (x > offset) player.prevTrack()
if (x < -offset) player.nextTrack()
}
setLocked(false)
}
const y = useMotionValue(0)
return (
<div
className={cx(
'relative flex h-16 w-full items-center rounded-20 px-3',
'relative z-20 flex h-16 w-full items-center rounded-20 px-3',
css`
background-color: ${bgColor.to};
`
)}
>
<div
{/* Indictor */}
<motion.div
drag='y'
dragConstraints={{ top: 0, bottom: 0 }}
style={{ y: y.get() * 2 }}
className={cx(
'absolute -top-2.5 h-1.5 w-10 rounded-full bg-brand-700',
'absolute flex items-center justify-center',
css`
left: calc((100% - 40px) / 2);
--width: 60px;
--height: 26px;
left: calc((100% - var(--width)) / 2);
top: calc(var(--height) * -1);
height: var(--height);
width: var(--width);
`
)}
></div>
>
<div className='h-1.5 w-10 rounded-full bg-brand-700'></div>
</motion.div>
{/* Cover */}
<div className='h-full py-2.5'>
<Image
src={resizeImage(track?.al.picUrl || '', 'sm')}
alt='Cover'
className='z-10 aspect-square h-full rounded-lg'
/>
</div>
{/* Track info */}
<div className='relative flex h-full flex-grow items-center overflow-hidden px-3'>
<motion.div
drag='x'
@ -93,10 +109,12 @@ const PlayerMobile = () => {
></div>
</div>
{/* Like */}
<button>
<Icon name='heart' className='h-7 w-7 text-white/10' />
</button>
{/* Play or pause */}
<button
onClick={() => player.playOrPause()}
className='ml-2.5 flex items-center justify-center rounded-full bg-white/20 p-2.5'

View file

@ -163,7 +163,7 @@ const TrackList = ({ className }: { className?: string }) => {
{/* 底部渐变遮罩 */}
<div
className='pointer-events-none absolute right-0 left-0 z-20 h-14 bg-gradient-to-t from-black to-transparent'
className='pointer-events-none absolute right-0 left-0 z-20 hidden h-14 bg-gradient-to-t from-black to-transparent lg:block'
style={{ top: `${listHeight - 56}px` }}
></div>
</>

View file

@ -0,0 +1,16 @@
import { useLockBodyScroll } from 'react-use'
import PlayingNext from './PlayingNext'
const PlayingNextMobile = () => {
useLockBodyScroll(true)
return (
<div className='fixed inset-0 z-10 bg-black/80 backdrop-blur-3xl'>
<div className='px-7'>
<PlayingNext />
</div>
</div>
)
}
export default PlayingNextMobile

View file

@ -1,8 +1,5 @@
import { Route, RouteObject, Routes, useLocation } from 'react-router-dom'
import Login from '@/web/pages/Login'
import Artist from '@/web/pages/Artist'
import { Route, Routes, useLocation } from 'react-router-dom'
import Search from '@/web/pages/Search'
import Library from '@/web/pages/Library'
import Settings from '@/web/pages/Settings'
import { AnimatePresence } from 'framer-motion'
import React, { ReactNode, Suspense } from 'react'
@ -12,54 +9,10 @@ const Discover = React.lazy(() => import('@/web/pages/New/Discover'))
const Browse = React.lazy(() => import('@/web/pages/New/Browse'))
const Album = React.lazy(() => import('@/web/pages/New/Album'))
const Playlist = React.lazy(() => import('@/web/pages/New/Playlist'))
const Artist = React.lazy(() => import('@/web/pages/New/Artist'))
const routes: RouteObject[] = [
{
path: '/',
element: <My />,
},
{
path: '/discover',
element: <Discover />,
},
{
path: '/library',
element: <Library />,
},
{
path: '/settings',
element: <Settings />,
},
{
path: '/login',
element: <Login />,
},
{
path: '/search/:keywords',
element: <Search />,
children: [
{
path: ':type',
element: <Search />,
},
],
},
{
path: '/playlist/:id',
element: <Playlist />,
},
{
path: '/album/:id',
element: <Album />,
},
{
path: '/artist/:id',
element: <Artist />,
},
]
const lazy = (components: ReactNode) => {
return <Suspense>{components}</Suspense>
const lazy = (component: ReactNode) => {
return <Suspense>{component}</Suspense>
}
const Router = () => {
@ -71,9 +24,13 @@ const Router = () => {
<Route path='/' element={lazy(<My />)} />
<Route path='/discover' element={lazy(<Discover />)} />
<Route path='/browse' element={lazy(<Browse />)} />
<Route path='/login' element={lazy(<Login />)} />
<Route path='/album/:id' element={lazy(<Album />)} />
<Route path='/playlist/:id' element={lazy(<Playlist />)} />
<Route path='/artist/:id' element={lazy(<Artist />)} />
<Route path='/settings' element={lazy(<Settings />)} />
<Route path='/search/:keywords' element={lazy(<Search />)}>
<Route path=':type' element={lazy(<Search />)} />
</Route>
</Routes>
</AnimatePresence>
)

View file

@ -0,0 +1,20 @@
import { useLayoutEffect } from 'react'
import scrollPositions from '@/web/store/scrollPositions'
import { throttle } from 'lodash-es'
const ScrollRestoration = () => {
useLayoutEffect(() => {
const main = document.querySelector('main')
const handleScroll = throttle(() => {
scrollPositions.set(window.location.pathname, main?.scrollTop ?? 0)
}, 100)
main?.addEventListener('scroll', handleScroll)
return () => {
main?.removeEventListener('scroll', handleScroll)
}
}, [])
return <></>
}
export default ScrollRestoration

View file

@ -1,11 +1,10 @@
import { css, cx } from '@emotion/css'
import { useNavigate } from 'react-router-dom'
import Icon from '../../Icon'
import { resizeImage } from '@/web/utils/common'
import useUser from '@/web/api/hooks/useUser'
import { state } from '@/web/store'
const Avatar = ({ className }: { className?: string }) => {
const navigate = useNavigate()
const { data: user } = useUser()
const avatarUrl = user?.profile?.avatarUrl
@ -17,7 +16,7 @@ const Avatar = ({ className }: { className?: string }) => {
{avatarUrl ? (
<img
src={avatarUrl}
onClick={() => navigate('/login')}
onClick={() => (state.uiStates.showLoginPanel = true)}
className={cx(
'app-region-no-drag rounded-full',
className || 'h-12 w-12'
@ -25,7 +24,7 @@ const Avatar = ({ className }: { className?: string }) => {
/>
) : (
<div
onClick={() => navigate('/login')}
onClick={() => (state.uiStates.showLoginPanel = true)}
className={cx(
'rounded-full bg-day-600 p-2.5 dark:bg-night-600',
className || 'h-12 w-12'

View file

@ -1,8 +1,13 @@
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 { useState } from 'react'
const SearchBox = () => {
const navigate = useNavigate()
const [searchText, setSearchText] = useState('')
return (
<div
className={cx(
@ -25,6 +30,13 @@ const SearchBox = () => {
}
`
)}
value={searchText}
onChange={e => setSearchText(e.target.value)}
onKeyDown={e => {
if (e.key !== 'Enter') return
e.preventDefault()
navigate(`/search/${searchText}`)
}}
/>
</div>
)

View file

@ -19,6 +19,7 @@ const TopbarDesktop = () => {
!location.pathname.startsWith('/album/') &&
!location.pathname.startsWith('/playlist/') &&
!location.pathname.startsWith('/browse') &&
!location.pathname.startsWith('/artist') &&
css`
background-image: url(${topbarBackground});
`

View file

@ -1,11 +1,11 @@
import { css } from '@emotion/css'
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-7 flex px-2.5'>
<div className='mb-5 mt-10 flex px-2.5'>
<div className='flex-grow'>
<SearchBox />
</div>

View file

@ -16,6 +16,7 @@ import useVideoCover from '@/web/hooks/useVideoCover'
import { motion } from 'framer-motion'
import { ease } from '@/web/utils/const'
import { injectGlobal } from '@emotion/css'
import { useNavigate } from 'react-router-dom'
injectGlobal`
.plyr__video-wrapper,
@ -115,6 +116,7 @@ const TrackListHeader = ({
playlist?: Playlist
onPlay: () => void
}) => {
const navigate = useNavigate()
const isMobile = useIsMobile()
const albumDuration = useMemo(() => {
const duration = album?.songs?.reduce((acc, cur) => acc + cur.dt, 0) || 0
@ -142,8 +144,15 @@ const TrackListHeader = ({
</div>
{/* Creator */}
<div className='mt-2.5 text-24 font-medium dark:text-night-400 lg:mt-6'>
{album?.artist.name || playlist?.creator.nickname}
<div className='mt-2.5 lg:mt-6'>
<span
onClick={() => {
if (album?.artist?.id) navigate(`/artist/${album?.artist?.id}`)
}}
className='text-24 font-medium transition-colors duration-300 dark:text-night-400 hover:dark:text-neutral-100 '
>
{album?.artist.name || playlist?.creator.nickname}
</span>
</div>
{/* Extra info */}

View file

@ -1,4 +1,18 @@
import { useState } from 'react'
import { IpcChannels } from '@/shared/IpcChannels'
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
const TrafficLight = () => {
const [isMaximized, setIsMaximized] = useState(false)
useIpcRenderer(IpcChannels.IsMaximized, (e, value) => {
setIsMaximized(value)
})
if (isMaximized) {
return <></>
}
const className =
'mr-2 h-3 w-3 rounded-full last-of-type:mr-0 dark:bg-white/20'
return (