feat: 搜索页面和一堆更新

This commit is contained in:
qier222 2022-03-29 00:11:05 +08:00
parent 4d7bc14827
commit b4590c3c34
No known key found for this signature in database
GPG Key ID: 9C85007ED905F14D
17 changed files with 279 additions and 87 deletions

View File

@ -7,22 +7,22 @@ export enum SearchApiNames {
// 搜索
export enum SearchTypes {
SINGLE = 1,
ALBUM = 10,
ARTIST = 100,
PLAYLIST = 1000,
USER = 1002,
MV = 1004,
LYRICS = 1006,
RADIO = 1009,
VIDEO = 1014,
ALL = 1018,
SINGLE = '1',
ALBUM = '10',
ARTIST = '100',
PLAYLIST = '1000',
USER = '1002',
MV = '1004',
LYRICS = '1006',
RADIO = '1009',
VIDEO = '1014',
ALL = '1018',
}
export interface SearchParams {
keywords: string
limit?: number // 返回数量 , 默认为 30
offset?: number // 偏移数量,用于分页 , 如 : 如 :( 页数 -1)*30, 其中 30 为 limit 的值 , 默认为 0
type?: SearchTypes // type: 搜索类型
type: keyof typeof SearchTypes // type: 搜索类型
}
interface SearchResponse {
code: number
@ -71,7 +71,10 @@ export function search(params: SearchParams): Promise<SearchResponse> {
return request({
url: '/search',
method: 'get',
params: params,
params: {
...params,
type: SearchTypes[params.type ?? SearchTypes.ALL],
},
})
}

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="8" r="3" stroke="currentColor" stroke-width="2"/>
<path d="M6 19V19C6 16.7909 7.79086 15 10 15H14C16.2091 15 18 16.7909 18 19V19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 341 B

View File

@ -1,29 +1,34 @@
import SvgIcon from '@/components/SvgIcon'
const Cover = ({
isRounded,
imageUrl,
onClick,
roundedClass = 'rounded-xl',
showPlayButton = false,
showHover = true,
}: {
imageUrl: string
isRounded?: boolean
onClick?: () => void
roundedClass?: string
showPlayButton?: boolean
showHover?: boolean
}) => {
const [isError, setIsError] = useState(false)
return (
<div onClick={onClick} className='group relative z-0'>
{/* Neon shadow */}
<div
className={classNames(
'absolute top-2 z-[-1] h-full w-full scale-x-[.92] scale-y-[.96] rounded-xl bg-cover opacity-0 blur-lg filter transition duration-300 group-hover:opacity-60',
isRounded && 'rounded-full',
!isRounded && 'rounded-xl'
)}
style={{
backgroundImage: `url("${imageUrl}")`,
}}
></div>
{showHover && (
<div
className={classNames(
'absolute top-2 z-[-1] h-full w-full scale-x-[.92] scale-y-[.96] rounded-xl bg-cover opacity-0 blur-lg filter transition duration-300 group-hover:opacity-60',
roundedClass
)}
style={{
backgroundImage: `url("${imageUrl}")`,
}}
></div>
)}
{/* Cover */}
{isError ? (
@ -34,20 +39,21 @@ const Cover = ({
<img
className={classNames(
'box-content aspect-square h-full w-full border border-black border-opacity-5 dark:border-white dark:border-opacity-[.03]',
isRounded && 'rounded-full',
!isRounded && 'rounded-xl'
roundedClass
)}
src={imageUrl}
onError={() => setIsError(true)}
onError={() => imageUrl && setIsError(true)}
/>
)}
{/* Play button */}
<div className='absolute top-0 hidden h-full w-full place-content-center group-hover:grid'>
<button className='btn-pressed-animation grid h-11 w-11 cursor-default place-content-center rounded-full border border-white border-opacity-[.08] bg-white bg-opacity-[.14] text-white backdrop-blur backdrop-filter transition-all hover:bg-opacity-[.44]'>
<SvgIcon className='ml-0.5 h-6 w-6' name='play-fill' />
</button>
</div>
{showPlayButton && (
<div className='absolute top-0 hidden h-full w-full place-content-center group-hover:grid'>
<button className='btn-pressed-animation grid h-11 w-11 cursor-default place-content-center rounded-full border border-white border-opacity-[.08] bg-white bg-opacity-[.14] text-white backdrop-blur backdrop-filter transition-all hover:bg-opacity-[.44]'>
<SvgIcon className='ml-0.5 h-6 w-6' name='play-fill' />
</button>
</div>
)}
</div>
)
}

View File

@ -145,6 +145,7 @@ const CoverRow = ({
<Cover
onClick={() => goTo(item.id)}
imageUrl={getImageUrl(item)}
showPlayButton={true}
/>
)}

View File

@ -1,4 +1,3 @@
import { Fragment } from 'react'
import ArtistInline from '@/components/ArtistsInline'
import IconButton from '@/components/IconButton'
import Slider from '@/components/Slider'
@ -27,7 +26,7 @@ const PlayingTrack = () => {
}
return (
<Fragment>
<>
{track && (
<div className='flex items-center gap-3'>
{track?.al?.picUrl && (
@ -67,7 +66,7 @@ const PlayingTrack = () => {
</div>
)}
{!track && <div></div>}
</Fragment>
</>
)
}

View File

@ -1,4 +1,3 @@
import { Fragment } from 'react'
import type { RouteObject } from 'react-router-dom'
import { useRoutes } from 'react-router-dom'
import Album from '@/pages/Album'
@ -6,6 +5,7 @@ import Home from '@/pages/Home'
import Login from '@/pages/Login'
import Playlist from '@/pages/Playlist'
import Artist from '@/pages/Artist'
import Search from '@/pages/Search'
const routes: RouteObject[] = [
{
@ -16,6 +16,16 @@ const routes: RouteObject[] = [
path: '/login',
element: <Login />,
},
{
path: '/search/:keywords',
element: <Search />,
children: [
{
path: ':type',
element: <Search />,
},
],
},
{
path: '/playlist/:id',
element: <Playlist />,
@ -32,7 +42,7 @@ const routes: RouteObject[] = [
const Router = () => {
const element = useRoutes(routes)
return <Fragment>{element}</Fragment>
return <>{element}</>
}
export default Router

View File

@ -29,12 +29,13 @@ const NavigationButtons = () => {
}
const SearchBox = () => {
const [keyword, setKeyword] = useState('')
const { type } = useParams()
const [keywords, setKeywords] = useState('')
const navigate = useNavigate()
const toSearch = (e: React.KeyboardEvent) => {
if (!keyword) return
if (!keywords) return
if (e.key === 'Enter') {
navigate(`/search/${keyword}`)
navigate(`/search/${keywords}${type ? `/${type}` : ''}`)
}
}
@ -45,18 +46,18 @@ const SearchBox = () => {
name='search'
/>
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
value={keywords}
onChange={e => setKeywords(e.target.value)}
onKeyDown={toSearch}
type='text'
className='flex-grow bg-transparent placeholder:text-gray-500 dark:text-white dark:placeholder:text-gray-400'
placeholder='搜索'
/>
<div
onClick={() => setKeyword('')}
onClick={() => setKeywords('')}
className={classNames(
'cursor-default rounded-full p-1 transition after:bg-gray-300 hover:bg-white/20 dark:text-white/50',
!keyword && 'hidden'
!keywords && 'hidden'
)}
>
<SvgIcon className='h-4 w-4' name='x' />
@ -85,6 +86,12 @@ const Avatar = () => {
onClick={() => navigate('/login')}
className='app-region-no-drag h-9 w-9 rounded-full bg-gray-100 dark:bg-gray-700'
/>
// <div onClick={() => navigate('/login')}>
// <SvgIcon
// name='user'
// className='h-9 w-9 rounded-full bg-gray-400/10 p-1 text-gray-400'
// />
// </div>
)
}

View File

@ -1,7 +1,6 @@
import ArtistInline from '@/components/ArtistsInline'
import Skeleton from '@/components/Skeleton'
import { resizeImage } from '@/utils/common'
import { Fragment } from 'react'
import SvgIcon from './SvgIcon'
const Track = ({
@ -78,15 +77,27 @@ const TrackGrid = ({
tracks,
isSkeleton = false,
onTrackDoubleClick,
cols = 2,
}: {
tracks: Track[]
isSkeleton?: boolean
onTrackDoubleClick?: (trackID: number) => void
cols?: number
}) => {
return (
<div className='grid grid-cols-2 gap-x-2'>
<div
className='grid gap-x-2'
style={{
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
}}
>
{tracks.map((track, index) => (
<Track key={track.id} track={track} isSkeleton={isSkeleton} />
<Track
key={track.id}
track={track}
isSkeleton={isSkeleton}
isHighlight={false}
/>
))}
</div>
)

View File

@ -1,4 +1,4 @@
import { Fragment, memo } from 'react'
import { memo } from 'react'
import { NavLink } from 'react-router-dom'
import ArtistInline from '@/components/ArtistsInline'
import Skeleton from '@/components/Skeleton'
@ -106,7 +106,7 @@ const Track = memo(
{isSkeleton ? (
<Skeleton>PLACEHOLDER1234567890</Skeleton>
) : (
<Fragment>
<>
<NavLink
to={`/album/${track.al.id}`}
className={classNames(
@ -117,7 +117,7 @@ const Track = memo(
{track.al.name}
</NavLink>
<span className='flex-grow'></span>
</Fragment>
</>
)}
</div>
@ -194,7 +194,7 @@ const TracksList = memo(
)
return (
<Fragment>
<>
{/* Tracks table header */}
<div className='ml-2 mr-4 mt-10 mb-2 grid grid-cols-12 border-b border-gray-100 py-2.5 text-sm text-gray-400 dark:border-gray-800 dark:text-gray-500'>
<div className='col-span-6 grid grid-cols-[4.2rem_auto]'>
@ -228,7 +228,7 @@ const TracksList = memo(
/>
))}
</div>
</Fragment>
</>
)
}
)

View File

@ -137,6 +137,7 @@ declare interface Artist {
publishTime?: number
picId_str?: string
img1v1Id_str?: string
occupation?: string
}
declare interface Album {
alias: unknown[]

View File

@ -1,5 +1,4 @@
import dayjs from 'dayjs'
import { Fragment } from 'react'
import { NavLink } from 'react-router-dom'
import Button, { Color as ButtonColor } from '@/components/Button'
import CoverRow, { Subtitle } from '@/components/CoverRow'
@ -81,11 +80,11 @@ const Header = ({
const [isCoverError, setCoverError] = useState(false)
return (
<Fragment>
<>
{/* Header background */}
<div className='absolute top-0 left-0 z-0 h-[24rem] w-full overflow-hidden'>
{coverUrl && !isCoverError && (
<Fragment>
<>
<img
src={coverUrl}
className='absolute -top-full w-full blur-[100px]'
@ -94,7 +93,7 @@ const Header = ({
src={coverUrl}
className='absolute -top-full w-full blur-[100px]'
/>
</Fragment>
</>
)}
<div className='absolute top-0 h-full w-full bg-gradient-to-b from-white/80 to-white dark:from-black/50 dark:to-[#1d1d1d]'></div>
</div>
@ -208,7 +207,7 @@ const Header = ({
</div>
</div>
</div>
</Fragment>
</>
)
}

View File

@ -8,26 +8,19 @@ import dayjs from 'dayjs'
import TracksGrid from '@/components/TracksGrid'
import CoverRow, { Subtitle } from '@/components/CoverRow'
import Skeleton from '@/components/Skeleton'
import { Fragment } from 'react'
import useTracks from '@/hooks/useTracks'
const Header = ({ artist }: { artist: Artist | undefined }) => {
const coverImage = resizeImage(artist?.img1v1Url || '', 'md')
return (
<Fragment>
<>
<div className='absolute top-0 left-0 z-0 h-[24rem] w-full overflow-hidden'>
{coverImage && (
<Fragment>
<img
src={coverImage}
className='absolute -top-full w-full blur-[100px]'
/>
<img
src={coverImage}
className='absolute -top-full w-full blur-[100px]'
/>
</Fragment>
<>
<img src={coverImage} className='absolute w-full blur-[100px]' />
<img src={coverImage} className='absolute w-full blur-[100px]' />
</>
)}
<div className='absolute top-0 h-full w-full bg-gradient-to-b from-white/80 to-white dark:from-black/50 dark:to-[#1d1d1d]'></div>
</div>
@ -47,7 +40,7 @@ const Header = ({ artist }: { artist: Artist | undefined }) => {
<div className='text-7xl font-bold text-white'>{artist?.name}</div>
</div>
</div>
</Fragment>
</>
)
}
@ -67,7 +60,7 @@ const LatestRelease = ({
{isLoading ? (
<Skeleton className='aspect-square w-full rounded-xl'></Skeleton>
) : (
<Cover imageUrl={album?.picUrl ?? ''} />
<Cover imageUrl={album?.picUrl ?? ''} showPlayButton={true} />
)}
<div className='line-clamp-2 line-clamp-1 mt-2 font-semibold leading-tight decoration-gray-600 decoration-2 hover:underline dark:text-white dark:decoration-gray-200'>
{album?.name}

View File

@ -1,6 +1,5 @@
import md5 from 'md5'
import QRCode from 'qrcode'
import { Fragment } from 'react'
import {
checkLoginQrCodeStatus,
fetchLoginQrCodeKey,
@ -166,7 +165,7 @@ const OtherLoginMethods = ({
},
]
return (
<Fragment>
<>
<div className='mt-8 mb-4 flex w-full items-center'>
<span className='h-px flex-grow bg-gray-300 dark:bg-gray-700'></span>
<span className='mx-2 text-sm text-gray-400 '>or</span>
@ -187,7 +186,7 @@ const OtherLoginMethods = ({
)
)}
</div>
</Fragment>
</>
)
}
@ -244,11 +243,11 @@ const LoginWithEmail = () => {
}
return (
<Fragment>
<>
<EmailInput {...{ email, setEmail }} />
<PasswordInput {...{ password, setPassword }} />
<LoginButton onClick={handleLogin} disabled={doLogin.isLoading} />
</Fragment>
</>
)
}
@ -303,11 +302,11 @@ const LoginWithPhone = () => {
}
return (
<Fragment>
<>
<PhoneInput {...{ countryCode, setCountryCode, phone, setPhone }} />
<PasswordInput {...{ password, setPassword }} />
<LoginButton onClick={handleLogin} disabled={doLogin.isLoading} />
</Fragment>
</>
)
}

View File

@ -1,4 +1,4 @@
import React, { Fragment, memo } from 'react'
import { memo } from 'react'
import Button, { Color as ButtonColor } from '@/components/Button'
import Skeleton from '@/components/Skeleton'
import SvgIcon from '@/components/SvgIcon'
@ -25,7 +25,7 @@ const Header = memo(
const coverUrl = resizeImage(playlist?.coverImgUrl || '', 'lg')
return (
<Fragment>
<>
{/* Header background */}
<div className='absolute top-0 left-0 z-0 h-[24rem] w-full overflow-hidden'>
<img src={coverUrl} className='absolute top-0 w-full blur-[100px]' />
@ -134,7 +134,7 @@ const Header = memo(
</div>
</div>
</div>
</Fragment>
</>
)
}
)
@ -188,7 +188,7 @@ const Tracks = memo(
}, [tracksPages])
return (
<Fragment>
<>
{isLoadingPlaylist ? (
<TracksList tracks={[]} isSkeleton={true} />
) : isLoadingTracks ? (
@ -199,7 +199,7 @@ const Tracks = memo(
) : (
<TracksList tracks={tracks} onTrackDoubleClick={handlePlay} />
)}
</Fragment>
</>
)
}
)

View File

@ -1,5 +1,165 @@
import {
multiMatchSearch,
search,
SearchApiNames,
SearchTypes,
} from '@/api/search'
import Cover from '@/components/Cover'
import TrackGrid from '@/components/TracksGrid'
import { resizeImage } from '@/utils/common'
const Artists = ({ artists }: { artists: Artist[] }) => {
const navigate = useNavigate()
return (
<>
{artists.map(artist => (
<div
onClick={() => navigate(`/artist/${artist.id}`)}
key={artist.id}
className='btn-hover-animation flex items-center p-2.5 after:rounded-xl after:bg-gray-100 dark:after:bg-white/10'
>
<div className='mr-4 h-14 w-14'>
<Cover
imageUrl={resizeImage(artist.img1v1Url, 'xs')}
roundedClass='rounded-full'
showHover={false}
/>
</div>
<div>
<div className='text-lg font-semibold dark:text-white'>
{artist.name}
</div>
<div className='mt-0.5 text-sm font-medium text-gray-500 dark:text-gray-400'>
</div>
</div>
</div>
))}
</>
)
}
const Albums = ({ albums }: { albums: Album[] }) => {
const navigate = useNavigate()
return (
<>
{albums.map(album => (
<div
onClick={() => navigate(`/album/${album.id}`)}
key={album.id}
className='btn-hover-animation flex items-center p-2.5 after:rounded-xl after:bg-gray-100 dark:after:bg-white/10'
>
<div className='mr-4 h-14 w-14'>
<Cover
imageUrl={resizeImage(album.picUrl, 'xs')}
roundedClass='rounded-lg'
showHover={false}
/>
</div>
<div>
<div className='text-lg font-semibold dark:text-white'>
{album.name}
</div>
<div className='mt-0.5 text-sm font-medium text-gray-500 dark:text-gray-400'>
· {album?.artist.name} · 2020
</div>
</div>
</div>
))}
</>
)
}
const Search = () => {
return <div></div>
const { keywords = '', type = 'ALL' } = useParams()
const searchType: keyof typeof SearchTypes =
type.toUpperCase() in SearchTypes
? (type.toUpperCase() as keyof typeof SearchTypes)
: 'ALL'
const { data: bestMatchRaw, isLoading: isLoadingBestMatch } = useQuery(
[SearchApiNames.MULTI_MATCH_SEARCH, keywords],
() => multiMatchSearch({ keywords })
)
const bestMatch = useMemo(() => {
if (!bestMatchRaw?.result) return []
return bestMatchRaw.result.orders
.filter(order => ['album', 'artist'].includes(order)) // 暂时只支持专辑和艺人
.map(order => {
return bestMatchRaw.result[order][0]
})
.slice(0, 2)
}, [bestMatchRaw?.result])
const { data: searchResult, isLoading: isLoadingSearchResult } = useQuery(
[SearchApiNames.SEARCH, keywords, searchType],
() => search({ keywords, type: searchType })
)
return (
<div>
<div className='mt-6 mb-8 text-4xl font-semibold dark:text-white'>
<span className='text-gray-500'></span> &quot;{keywords}&quot;
</div>
{/* Best match */}
{bestMatch.length !== 0 && (
<div className='mb-6'>
<div className='mb-2 text-sm font-medium text-gray-400'></div>
<div className='grid grid-cols-2'>
{bestMatch.map(match => (
<div
key={`${match.id}${match.picUrl}`}
className='btn-hover-animation flex items-center p-3 after:rounded-xl after:bg-gray-100 dark:after:bg-white/10'
>
<div className='mr-6 h-24 w-24'>
<Cover
imageUrl={resizeImage(match.picUrl, 'xs')}
showHover={false}
roundedClass='rounded-full'
/>
</div>
<div>
<div className='text-xl font-semibold dark:text-white'>
{match.name}
</div>
<div className='mt-0.5 font-medium text-gray-500 dark:text-gray-400'>
{(match as Artist).occupation === '歌手' ? '艺人' : '专辑'}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Search result */}
<div className='grid grid-cols-2 gap-6'>
{searchResult?.result.artist.artists && (
<div>
<div className='mb-2 text-sm font-medium text-gray-400'></div>
<Artists artists={searchResult.result.artist.artists} />
</div>
)}
{searchResult?.result.album.albums && (
<div>
<div className='mb-2 text-sm font-medium text-gray-400'></div>
<Albums albums={searchResult.result.album.albums} />
</div>
)}
{searchResult?.result.song.songs && (
<div className='col-span-2'>
<div className='mb-2 text-sm font-medium text-gray-400'></div>
<TrackGrid tracks={searchResult.result.song.songs} cols={3} />
</div>
)}
</div>
</div>
)
}
export default Search

View File

@ -32,7 +32,7 @@
.btn-hover-animation {
@apply relative transform;
&::after {
@apply absolute top-0 left-0 z-[-1] h-full w-full scale-[0.92] rounded-lg opacity-0 transition-all duration-300;
@apply absolute top-0 left-0 z-[-1] h-full w-full scale-[.92] rounded-lg opacity-0 transition-all duration-300;
content: '';
}

View File

@ -179,8 +179,7 @@ export class Player {
this.play()
this.state = State.PLAYING
const id = this.trackID
_howler.once('load', () => this._cacheAudio(id, audio))
this._cacheAudio(this.trackID, audio)
if (!this._progressInterval) {
this._setupProgressInterval()