feat: 增加歌手页面

This commit is contained in:
qier222 2022-03-23 01:21:22 +08:00
parent 7d20e6c5de
commit 36603dc3a0
No known key found for this signature in database
GPG Key ID: 9C85007ED905F14D
16 changed files with 247 additions and 28 deletions

View File

@ -44,6 +44,11 @@ export async function setCache(api: string, data: any, query: any) {
db.set(ModelNames.PLAYLIST, Number(data.playlist.id), data)
break
}
case 'artists': {
if (!data.artist) return
db.set(ModelNames.ARTIST, Number(data.artist.id), data)
break
}
case 'artist/album': {
if (!data.hotAlbums) return
db.set(ModelNames.ARTIST_ALBUMS, Number(data.artist.id), data)
@ -117,6 +122,13 @@ export function getCache(
if (playlist?.json) return JSON.parse(playlist.json)
break
}
case 'artists': {
if (!query?.id) return
const artist = db.get(ModelNames.ARTIST, Number(query?.id)) as any
if (checkIsExpired && isCacheExpired(artist?.updateAt, 30)) return
if (artist?.json) return JSON.parse(artist.json)
break
}
case 'artist/album': {
if (!query?.id) return
const artistAlbums = db.get(

View File

@ -30,6 +30,7 @@ const RegularSchemas = [
ModelNames.PLAYLIST,
ModelNames.ALBUM,
ModelNames.TRACK,
ModelNames.ARTIST,
].map(name => ({
primaryKey: 'id',
name,

View File

@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 14.5V5.5M9 5H8.5C7.39543 5 6.5 5.89543 6.5 7V14.0668C6.5 14.3523 6.56113 14.6345 6.67927 14.8944L8.46709 18.8276C8.79163 19.5416 9.50354 20 10.2878 20H10.8815C12.2002 20 13.158 18.746 12.811 17.4738L12.3445 15.7631C12.171 15.127 12.6499 14.5 13.3093 14.5H17V14.5C19.1704 14.5 20.489 12.1076 19.33 10.2726L16.5517 5.87354C16.2083 5.32974 15.61 5 14.9668 5H9Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M4 10V18M9 19V19C7.89543 19 7 18.1046 7 17V9.38516C7 9.13073 7.04855 8.87862 7.14305 8.64238L8.39102 5.52246C8.75882 4.60294 9.64939 4 10.6397 4V4C12.2928 4 13.4602 5.61954 12.9374 7.18783L12.2857 9.14286C12.1452 9.56454 12.459 10 12.9035 10H17V10C19.1372 10 20.412 12.382 19.2265 14.1603L16.5229 18.2156C16.1962 18.7057 15.6462 19 15.0573 19H9Z" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 538 B

After

Width:  |  Height:  |  Size: 523 B

View File

@ -1 +1,4 @@
<svg viewBox="0 0 384 512" xmlns="http://www.w3.org/2000/svg"><g><path d="M256 431.1c0 45-50.1 80.9-112 80.9s-112-35.9-112-80.9c0-45 50.13-80.01 112-80.01 16.38 0 32.5 2.75 48 8V60.33c0-14 9.125-26.38 22.38-30.5L256 17.55V431.1Z"/><path opacity=".4" d="M256 148.1V17.55l55-16.22c9.625-2.88 20.12-1.001 28.12 5C347.3 12.31 352 21.83 352 31.95v64c0 14.25-9.375 26.75-23 30.75l-73 21.39Z"/></g></svg>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 16.5C13 17.8807 11.8807 19 10.5 19C9.11929 19 8 17.8807 8 16.5C8 15.1193 9.11929 14 10.5 14C11.8807 14 13 15.1193 13 16.5Z" stroke="currentColor" stroke-width="2"/>
<path d="M13.2572 5.49711L15.9021 4.43917C16.4828 4.20687 17.1351 4.54037 17.2868 5.14719C17.4097 5.63869 17.1577 6.14671 16.692 6.34628L14.6061 7.24025C14.2384 7.39783 14 7.75937 14 8.1594V16.5L12 15V7.35407C12 6.53626 12.4979 5.80084 13.2572 5.49711Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 398 B

After

Width:  |  Height:  |  Size: 557 B

View File

@ -1,17 +1,29 @@
const ArtistInline = ({
artists,
className,
disableLink,
}: {
artists: Artist[]
className?: string
disableLink?: boolean
}) => {
if (!artists) return <div></div>
const navigate = useNavigate()
const handleClick = () => {
disableLink ? null : navigate(`/artist/${artists[0].id}`)
}
return (
<div className={classNames('line-clamp-1', className)}>
{artists.map((artist, index) => (
<span key={artist.name}>
<span className={classNames({ 'hover:underline': !!artist.id })}>
<span key={`${artist.id}-${artist.name}`}>
<span
onClick={handleClick}
className={classNames({
'hover:underline': !!artist.id && !disableLink,
})}
>
{artist.name}
</span>
{index < artists.length - 1 ? ', ' : ''}&nbsp;

View File

@ -32,7 +32,7 @@ const Button = ({
'bg-brand-100 dark:bg-brand-600': color === Color.Primary,
'text-brand-500 dark:text-white': iconColor === Color.Primary,
'bg-gray-100 dark:bg-gray-700': color === Color.Gray,
'text-gray-900 dark:text-gray-400': iconColor === Color.Gray,
'text-gray-600 dark:text-gray-400': iconColor === Color.Gray,
'animate-pulse bg-gray-100 !text-transparent dark:bg-gray-800':
isSkelton,
}

View File

@ -125,7 +125,7 @@ const CoverRow = ({
<div
className={classNames(
'grid gap-x-[24px] gap-y-7',
'grid gap-x-6 gap-y-7',
className,
!className &&
'grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6'
@ -135,7 +135,7 @@ const CoverRow = ({
<div
key={item.id ?? index}
onMouseOver={() => prefetch(item.id)}
className='grid gap-x-[24px] gap-y-7'
className='grid gap-x-6 gap-y-7'
>
<div>
{/* Cover */}

View File

@ -5,6 +5,7 @@ import Album from '@/pages/Album'
import Home from '@/pages/Home'
import Login from '@/pages/Login'
import Playlist from '@/pages/Playlist'
import Artist from '@/pages/Artist'
const routes: RouteObject[] = [
{
@ -23,11 +24,15 @@ const routes: RouteObject[] = [
path: '/album/:id',
element: <Album />,
},
{
path: '/artist/:id',
element: <Artist />,
},
]
const router = () => {
const Router = () => {
const element = useRoutes(routes)
return <Fragment>{element}</Fragment>
}
export default router
export default Router

View File

@ -7,7 +7,7 @@ import { prefetchPlaylist } from '@/hooks/usePlaylist'
interface Tab {
name: string
icon?: string
icon: string
route: string
}
interface PrimaryTab extends Tab {
@ -41,7 +41,7 @@ const PrimaryTabs = () => {
onClick={() => scrollToTop()}
key={tab.route}
to={tab.route}
className={({ isActive }: { isActive: boolean }) =>
className={({ isActive }) =>
classNames(
'btn-hover-animation mx-3 flex cursor-default items-center rounded-lg px-3 py-2 transition-colors duration-200 after:scale-[0.97] after:bg-black/[.06] dark:after:bg-white/20',
!isActive && 'text-gray-700 dark:text-white',

View File

@ -131,7 +131,7 @@ const Slider = ({
<div
className={classNames(
'absolute h-[2px] group-hover:bg-brand-500',
isDragging ? 'bg-brand-500' : 'bg-gray-300 dark:bg-gray-600'
isDragging ? 'bg-brand-500' : 'bg-gray-300 dark:bg-gray-500'
)}
style={usedTrackStyle}
></div>

View File

@ -2,18 +2,25 @@ import ArtistInline from '@/components/ArtistsInline'
import Skeleton from '@/components/Skeleton'
import { resizeImage } from '@/utils/common'
const TrackListGrid = ({
const Track = ({
track,
isSkeleton = false,
isHighlight = false,
}: {
track: Track
isSkeleton: boolean
isHighlight: boolean
}) => {
return (
<div
className={classNames(
'group grid w-full rounded-xl after:scale-[.98] after:rounded-xl',
'grid-cols-1 py-1.5 px-2'
'group grid w-full rounded-xl after:scale-[.98] after:rounded-xl ',
'grid-cols-1 py-1.5 px-2',
!isSkeleton && {
'btn-hover-animation after:bg-gray-100 dark:after:bg-white/10':
!isHighlight,
'bg-brand-50 dark:bg-gray-800': isHighlight,
}
)}
>
<div className='grid grid-cols-[3rem_auto] items-center'>
@ -35,17 +42,19 @@ const TrackListGrid = ({
{!isSkeleton && (
<div
v-if='!isSkeleton'
className='line-clamp-1 break-all text-base font-semibold'
className='line-clamp-1 break-all text-base font-semibold dark:text-white'
>
{track.name}
</div>
)}
{isSkeleton && (
<Skeleton className='text-base'>PLACEHOLDER12345</Skeleton>
<Skeleton className='text-base '>PLACEHOLDER12345</Skeleton>
)}
<div className='text-xs'>
{!isSkeleton && <ArtistInline artists={track.ar} />}
<div className='text-xs text-gray-500 dark:text-gray-400'>
{!isSkeleton && (
<ArtistInline artists={track.ar} disableLink={true} />
)}
{isSkeleton && (
<Skeleton className='w-2/3 translate-y-px'>PLACE</Skeleton>
)}
@ -56,4 +65,22 @@ const TrackListGrid = ({
)
}
export default TrackListGrid
const TrackGrid = ({
tracks,
isSkeleton = false,
onTrackDoubleClick,
}: {
tracks: Track[]
isSkeleton?: boolean
onTrackDoubleClick?: (trackID: number) => void
}) => {
return (
<div className='grid grid-cols-2 gap-x-2'>
{tracks.map((track, index) => (
<Track key={track.id} track={track} isSkeleton={isSkeleton} />
))}
</div>
)
}
export default TrackGrid

View File

@ -1,14 +1,24 @@
import { fetchArtist } from '@/api/artist'
import { ArtistApiNames } from '@/api/artist'
import type { FetchArtistParams } from '@/api/artist'
import type { FetchArtistParams, FetchArtistResponse } from '@/api/artist'
export default function useArtist(params: FetchArtistParams, noCache: boolean) {
export default function useArtist(
params: FetchArtistParams,
noCache?: boolean
) {
return useQuery(
[ArtistApiNames.FETCH_ARTIST, params],
() => fetchArtist(params, noCache),
() => fetchArtist(params, !!noCache),
{
enabled: !!params.id && params.id > 0 && !isNaN(Number(params.id)),
staleTime: 3600000,
staleTime: 5 * 60 * 1000, // 5 mins
placeholderData: (): FetchArtistResponse =>
window.ipcRenderer.sendSync('getApiCacheSync', {
api: 'artists',
query: {
id: params.id,
},
}),
}
)
}

View File

@ -146,7 +146,7 @@ const Header = ({
<div className='mt-5 text-lg font-medium text-gray-800 dark:text-gray-300'>
Album by{' '}
<NavLink
to={`/artist/${album?.artist.name}`}
to={`/artist/${album?.artist.id}`}
className='cursor-default font-semibold hover:underline'
>
{album?.artist.name}

View File

@ -0,0 +1,148 @@
import Button, { Color as ButtonColor } from '@/components/Button'
import SvgIcon from '@/components/SvgIcon'
import Cover from '@/components/Cover'
import useArtist from '@/hooks/useArtist'
import useArtistAlbums from '@/hooks/useArtistAlbums'
import { resizeImage } from '@/utils/common'
import dayjs from 'dayjs'
import TracksGrid from '@/components/TracksGrid'
import CoverRow, { Subtitle } from '@/components/CoverRow'
import Skeleton from '@/components/Skeleton'
import { Fragment } from 'react'
const Artist = () => {
const params = useParams()
const { data: artist, isLoading } = useArtist({
id: Number(params.id) || 0,
})
const { data: albumsRaw, isLoading: isLoadingAlbum } = useArtistAlbums({
id: Number(params.id) || 0,
limit: 1000,
})
const albums = useMemo(() => {
if (!albumsRaw?.hotAlbums) return []
return albumsRaw.hotAlbums.filter(
album =>
album.type === '专辑' &&
['混音版', '精选集', 'Remix'].includes(album.subType) === false &&
album.size > 1
)
}, [albumsRaw?.hotAlbums])
const singles = useMemo(() => {
if (!albumsRaw?.hotAlbums) return []
return albumsRaw.hotAlbums.filter(
album =>
album.type !== '专辑' ||
['混音版', '精选集', 'Remix'].includes(album.subType) ||
album.size === 1
)
}, [albumsRaw?.hotAlbums])
const latestAlbum = useMemo(() => {
if (!albumsRaw || !albumsRaw.hotAlbums) return
return albumsRaw.hotAlbums[0]
}, [albumsRaw])
const coverImage = resizeImage(artist?.artist?.img1v1Url || '', 'md')
return (
<div>
<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>
)}
<div className='absolute top-0 h-full w-full bg-gradient-to-b from-white/[.84] to-white dark:from-black/[.6] dark:to-[#1d1d1d]'></div>
</div>
{/* Header */}
<div className='relative mt-6 overflow-hidden rounded-2xl'>
<div className='flex h-[26rem] justify-center overflow-hidden'>
<img src={coverImage} className='aspect-square brightness-[.5]' />
<img src={coverImage} className='aspect-square brightness-[.5]' />
<img src={coverImage} />
<img src={coverImage} className='aspect-square brightness-[.5]' />
<img src={coverImage} className='aspect-square brightness-[.5]' />
</div>
<div className='absolute right-0 left-0 top-[18rem] h-32 bg-gradient-to-t from-[#222]/60 to-transparent'></div>
<div className='absolute top-0 right-0 left-0 flex h-[26rem] items-end justify-between p-8 pb-6'>
<div className='text-7xl font-bold text-white'>
{artist?.artist.name}
</div>
</div>
</div>
<div className='mt-12 grid h-[20rem] grid-cols-[14rem,_auto] grid-rows-1 gap-16 px-2'>
{/* Latest release */}
<div>
<div className='mb-6 text-2xl font-semibold dark:text-white'>
</div>
<div className='flex-grow rounded-xl '>
{isLoadingAlbum ? (
<Skeleton className='aspect-square w-full rounded-xl'></Skeleton>
) : (
<Cover imageUrl={latestAlbum?.picUrl ?? ''} />
)}
<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'>
{latestAlbum?.name}
</div>
<div className='text-[12px] text-gray-500 dark:text-gray-400'>
{latestAlbum?.type} ·{' '}
{dayjs(latestAlbum?.publishTime || 0).year()}
</div>
</div>
</div>
{/* Popular tracks */}
<div>
<div className='mb-6 text-2xl font-semibold dark:text-white'>
</div>
<div className='overflow-scroll rounded-xl '>
<TracksGrid
tracks={artist?.hotSongs.slice(0, 10) ?? []}
isSkeleton={isLoading}
/>
</div>
</div>
</div>
{/* Albums */}
<div className='mt-20 px-2'>
<div className='mb-6 text-2xl font-semibold dark:text-white'></div>
<CoverRow
albums={albums.slice(0, 10)}
subtitle={Subtitle.TYPE_RELEASE_YEAR}
/>
</div>
{/* Singles/EP */}
<div className='mt-16 px-2'>
<div className='mb-6 text-2xl font-semibold dark:text-white'>
EP
</div>
<CoverRow
albums={singles.slice(0, 5)}
subtitle={Subtitle.TYPE_RELEASE_YEAR}
/>
</div>
</div>
)
}
export default Artist

View File

@ -22,7 +22,9 @@ const EmailInput = ({
}) => {
return (
<div className='w-full'>
<div className='mb-1 text-sm font-medium text-gray-700 dark:text-gray-300'>Email</div>
<div className='mb-1 text-sm font-medium text-gray-700 dark:text-gray-300'>
Email
</div>
<input
value={email}
onChange={e => setEmail(e.target.value)}
@ -96,7 +98,7 @@ const PasswordInput = ({
<div className='flex items-center justify-center rounded-md rounded-l-none border border-l-0 border-gray-300 pr-1 dark:border-gray-600 dark:bg-gray-700'>
<button
onClick={() => setShowPassword(!showPassword)}
className='dark:hover-text-white cursor-default rounded p-1.5 text-gray-400 transition duration-300 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-600 dark:hover:text-white'
className='dark:hover-text-white cursor-default rounded-md p-1.5 text-gray-400 transition duration-300 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-600 dark:hover:text-white'
>
<SvgIcon
className='h-5 w-5'

View File

@ -194,7 +194,6 @@ export class Player {
if (_howler.playing()) return
_howler.play()
this.state = State.PLAYING
this._progress = _howler.seek()
}
/**