feat: updates

This commit is contained in:
qier222 2022-03-17 19:30:43 +08:00
parent d96bd2a547
commit e3486ab550
No known key found for this signature in database
GPG key ID: 9C85007ED905F14D
23 changed files with 331 additions and 261 deletions

View file

@ -1,23 +1,41 @@
import Realm from 'realm' import Realm from 'realm'
import type { FetchTracksResponse } from '../renderer/src/api/track' import type { FetchTracksResponse } from '../renderer/src/api/track'
import type { FetchAlbumResponse } from '../renderer/src/api/album'
enum ModelNames { enum ModelNames {
TRACK = 'Track', TRACK = 'Track',
ALBUM = 'Album',
ARTIST = 'Artist',
PLAYLIST = 'Playlist',
}
const universalProperties = {
id: 'int',
json: 'string',
updateAt: 'int',
} }
const TrackSchema = { const TrackSchema = {
name: ModelNames.TRACK, name: ModelNames.TRACK,
properties: { properties: universalProperties,
id: 'int', primaryKey: 'id',
json: 'string', }
updateAt: 'int',
}, const AlbumSchema = {
name: ModelNames.ALBUM,
properties: universalProperties,
primaryKey: 'id',
}
const PlaylistSchema = {
name: ModelNames.PLAYLIST,
properties: universalProperties,
primaryKey: 'id', primaryKey: 'id',
} }
const realm = new Realm({ const realm = new Realm({
path: './dist/db.realm', path: './.tmp/db.realm',
schema: [TrackSchema], schema: [TrackSchema, AlbumSchema, PlaylistSchema],
}) })
export const database = { export const database = {
@ -29,7 +47,7 @@ export const database = {
model, model,
{ {
id: key, id: key,
updateAt: ~~(Date.now() / 1000), updateAt: Date.now(),
json: JSON.stringify(value), json: JSON.stringify(value),
}, },
'modified' 'modified'
@ -40,29 +58,43 @@ export const database = {
}, },
} }
export function setTracks(data: FetchTracksResponse) { export async function setTracks(data: FetchTracksResponse) {
const tracks = data.songs
if (!data.songs) return if (!data.songs) return
const write = async () => const tracks = data.songs
realm.write(() => { realm.write(() => {
tracks.forEach(track => { tracks.forEach(track => {
database.set(ModelNames.TRACK, track.id, track) database.set(ModelNames.TRACK, track.id, track)
})
}) })
write() })
} }
export async function setCache(api: string, data: any) { export async function setCache(api: string, data: any) {
switch (api) { switch (api) {
case 'song_detail': case 'song_detail': {
setTracks(data) setTracks(data)
return
}
case 'album': {
if (!data.album) return
realm.write(() => {
database.set(ModelNames.ALBUM, Number(data.album.id), data)
})
return
}
case 'playlist_detail': {
if (!data.playlist) return
realm.write(() => {
database.set(ModelNames.PLAYLIST, Number(data.playlist.id), data)
})
return
}
} }
} }
export function getCache(api: string, query: any) { export function getCache(api: string, query: any) {
switch (api) { switch (api) {
case 'song_detail': { case 'song_detail': {
const ids: string[] = query.ids.split(',') const ids: string[] = query?.ids.split(',')
const idsQuery = ids.map(id => `id = ${id}`).join(' OR ') const idsQuery = ids.map(id => `id = ${id}`).join(' OR ')
const tracksRaw = realm const tracksRaw = realm
.objects(ModelNames.TRACK) .objects(ModelNames.TRACK)
@ -78,5 +110,23 @@ export function getCache(api: string, query: any) {
privileges: {}, privileges: {},
} }
} }
case 'album': {
if (!query?.id) return
const album = realm.objectForPrimaryKey(
ModelNames.ALBUM,
Number(query?.id)
)?.json
if (album) return JSON.parse(album)
return
}
case 'playlist_detail': {
if (!query?.id) return
const playlist = realm.objectForPrimaryKey(
ModelNames.PLAYLIST,
Number(query?.id)
)?.json
if (playlist) return JSON.parse(playlist)
return
}
} }
} }

View file

@ -9,13 +9,13 @@ import Main from './components/Main'
const App = () => { const App = () => {
return ( return (
<QueryClientProvider client={reactQueryClient}> <QueryClientProvider client={reactQueryClient}>
<div id="layout" className="grid select-none grid-cols-[16rem_auto]"> <div id='layout' className='grid select-none grid-cols-[16rem_auto]'>
<Sidebar /> <Sidebar />
<Main /> <Main />
<Player /> <Player />
</div> </div>
<Toaster position="bottom-center" containerStyle={{ bottom: '5rem' }} /> <Toaster position='bottom-center' containerStyle={{ bottom: '5rem' }} />
{/* Devtool */} {/* Devtool */}
<ReactQueryDevtools <ReactQueryDevtools

View file

@ -11,7 +11,7 @@ const ArtistInline = ({
<div className={classNames('flex truncate', className)}> <div className={classNames('flex truncate', className)}>
{artists.map((artist, index) => ( {artists.map((artist, index) => (
<span key={artist.id}> <span key={artist.id}>
<span className="hover:underline">{artist.name}</span> <span className='hover:underline'>{artist.name}</span>
{index < artists.length - 1 ? ', ' : ''}&nbsp; {index < artists.length - 1 ? ', ' : ''}&nbsp;
</span> </span>
))} ))}

View file

@ -12,7 +12,7 @@ const Cover = ({
const [isError, setIsError] = useState(false) const [isError, setIsError] = useState(false)
return ( return (
<div onClick={onClick} className="group relative z-0"> <div onClick={onClick} className='group relative z-0'>
{/* Neon shadow */} {/* Neon shadow */}
<div <div
className={classNames( className={classNames(
@ -27,8 +27,8 @@ const Cover = ({
{/* Cover */} {/* Cover */}
{isError ? ( {isError ? (
<div className="box-content flex aspect-square h-full w-full items-center justify-center rounded-xl border border-black border-opacity-5 bg-gray-800 text-gray-300"> <div className='box-content flex aspect-square h-full w-full items-center justify-center rounded-xl border border-black border-opacity-5 bg-gray-800 text-gray-300'>
<SvgIcon name="music-note" className="h-1/2 w-1/2" /> <SvgIcon name='music-note' className='h-1/2 w-1/2' />
</div> </div>
) : ( ) : (
<img <img
@ -43,9 +43,9 @@ const Cover = ({
)} )}
{/* Play button */} {/* Play button */}
<div className="absolute top-0 hidden h-full w-full place-content-center group-hover:grid"> <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]"> <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-1 h-4 w-4" name="play" /> <SvgIcon className='ml-1 h-4 w-4' name='play' />
</button> </button>
</div> </div>
</div> </div>

View file

@ -20,12 +20,12 @@ const Title = ({
seeMoreLink: string seeMoreLink: string
}) => { }) => {
return ( return (
<div className="flex items-baseline justify-between"> <div className='flex items-baseline justify-between'>
<div className="my-4 text-[28px] font-bold text-black dark:text-white"> <div className='my-4 text-[28px] font-bold text-black dark:text-white'>
{title} {title}
</div> </div>
{seeMoreLink && ( {seeMoreLink && (
<div className="text-13px font-semibold text-gray-600 hover:underline"> <div className='text-13px font-semibold text-gray-600 hover:underline'>
See More See More
</div> </div>
)} )}
@ -83,6 +83,8 @@ const CoverRow = ({
seeMoreLink, seeMoreLink,
isSkeleton, isSkeleton,
className, className,
rows = 2,
navigateCallback, // Callback function when click on the cover/title
}: { }: {
title?: string title?: string
albums?: Album[] albums?: Album[]
@ -92,13 +94,15 @@ const CoverRow = ({
seeMoreLink?: string seeMoreLink?: string
isSkeleton?: boolean isSkeleton?: boolean
className?: string className?: string
rows?: number
navigateCallback?: () => void
}) => { }) => {
const renderItems = useMemo(() => { const renderItems = useMemo(() => {
if (isSkeleton) { if (isSkeleton) {
return new Array(10).fill({}) as Array<Album | Playlist | Artist> return new Array(rows * 5).fill({}) as Array<Album | Playlist | Artist>
} }
return albums ?? playlists ?? artists ?? [] return albums ?? playlists ?? artists ?? []
}, [albums, artists, isSkeleton, playlists]) }, [albums, artists, isSkeleton, playlists, rows])
const navigate = useNavigate() const navigate = useNavigate()
const goTo = (id: number) => { const goTo = (id: number) => {
@ -106,6 +110,7 @@ const CoverRow = ({
if (albums) navigate(`/album/${id}`) if (albums) navigate(`/album/${id}`)
if (playlists) navigate(`/playlist/${id}`) if (playlists) navigate(`/playlist/${id}`)
if (artists) navigate(`/artist/${id}`) if (artists) navigate(`/artist/${id}`)
if (navigateCallback) navigateCallback()
} }
const prefetch = (id: number) => { const prefetch = (id: number) => {
@ -129,12 +134,12 @@ const CoverRow = ({
<div <div
key={item.id ?? index} key={item.id ?? index}
onMouseOver={() => prefetch(item.id)} onMouseOver={() => prefetch(item.id)}
className="grid gap-x-[24px] gap-y-7" className='grid gap-x-[24px] gap-y-7'
> >
<div> <div>
{/* Cover */} {/* Cover */}
{isSkeleton ? ( {isSkeleton ? (
<Skeleton className="box-content aspect-square w-full rounded-xl border border-black border-opacity-0" /> <Skeleton className='box-content aspect-square w-full rounded-xl border border-black border-opacity-0' />
) : ( ) : (
<Cover <Cover
onClick={() => goTo(item.id)} onClick={() => goTo(item.id)}
@ -143,30 +148,30 @@ const CoverRow = ({
)} )}
{/* Info */} {/* Info */}
<div className="mt-2"> <div className='mt-2'>
<div className="font-semibold"> <div className='font-semibold'>
{/* Name */} {/* Name */}
{isSkeleton ? ( {isSkeleton ? (
<div className="flex w-full -translate-y-px flex-col"> <div className='flex w-full -translate-y-px flex-col'>
<Skeleton className="w-full leading-tight"> <Skeleton className='w-full leading-tight'>
PLACEHOLDER PLACEHOLDER
</Skeleton> </Skeleton>
<Skeleton className="w-1/3 translate-y-px leading-tight"> <Skeleton className='w-1/3 translate-y-px leading-tight'>
PLACEHOLDER PLACEHOLDER
</Skeleton> </Skeleton>
</div> </div>
) : ( ) : (
<span className="line-clamp-2 leading-tight "> <span className='line-clamp-2 leading-tight '>
{/* Playlist private icon */} {/* Playlist private icon */}
{(item as Playlist).privacy && ( {(item as Playlist).privacy && (
<SvgIcon <SvgIcon
name="lock" name='lock'
className="mr-1 mb-1 inline-block h-3 w-3 text-gray-300" className='mr-1 mb-1 inline-block h-3 w-3 text-gray-300'
/> />
)} )}
<span <span
onClick={() => goTo(item.id)} onClick={() => goTo(item.id)}
className="decoration-gray-600 decoration-2 hover:underline dark:text-white dark:decoration-gray-200" className='decoration-gray-600 decoration-2 hover:underline dark:text-white dark:decoration-gray-200'
> >
{item.name} {item.name}
</span> </span>
@ -176,11 +181,11 @@ const CoverRow = ({
{/* Subtitle */} {/* Subtitle */}
{isSkeleton ? ( {isSkeleton ? (
<Skeleton className="w-3/5 translate-y-px text-[12px]"> <Skeleton className='w-3/5 translate-y-px text-[12px]'>
PLACEHOLDER PLACEHOLDER
</Skeleton> </Skeleton>
) : ( ) : (
<div className="flex text-[12px] text-gray-500 dark:text-gray-400"> <div className='flex text-[12px] text-gray-500 dark:text-gray-400'>
<span>{getSubtitleText(item, subtitle)}</span> <span>{getSubtitleText(item, subtitle)}</span>
</div> </div>
)} )}

View file

@ -3,19 +3,19 @@ import style from './DailyTracksCard.module.scss'
const DailyTracksCard = () => { const DailyTracksCard = () => {
return ( return (
<div className="relative h-[198px] cursor-pointer overflow-hidden rounded-2xl"> <div className='relative h-[198px] cursor-pointer overflow-hidden rounded-2xl'>
{/* Cover */} {/* Cover */}
<img <img
className={classNames( className={classNames(
'absolute top-0 left-0 w-full will-change-transform', 'absolute top-0 left-0 w-full will-change-transform',
style.animation style.animation
)} )}
src="https://p2.music.126.net/QxJA2mr4hhb9DZyucIOIQw==/109951165422200291.jpg?param=1024y1024" src='https://p2.music.126.net/QxJA2mr4hhb9DZyucIOIQw==/109951165422200291.jpg?param=1024y1024'
/> />
{/* 每日推荐 */} {/* 每日推荐 */}
<div className="absolute flex h-full w-1/2 items-center bg-gradient-to-r from-[#0000004d] to-transparent pl-8"> <div className='absolute flex h-full w-1/2 items-center bg-gradient-to-r from-[#0000004d] to-transparent pl-8'>
<div className="grid grid-cols-2 grid-rows-2 gap-2 text-[64px] font-semibold leading-[64px] text-white opacity-[96]"> <div className='grid grid-cols-2 grid-rows-2 gap-2 text-[64px] font-semibold leading-[64px] text-white opacity-[96]'>
{Array.from('每日推荐').map(word => ( {Array.from('每日推荐').map(word => (
<div key={word}>{word}</div> <div key={word}>{word}</div>
))} ))}
@ -23,8 +23,8 @@ const DailyTracksCard = () => {
</div> </div>
{/* Play button */} {/* Play button */}
<button className="btn-pressed-animation absolute right-6 bottom-6 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]"> <button className='btn-pressed-animation absolute right-6 bottom-6 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 name="play" className="ml-1 h-4 w-4" /> <SvgIcon name='play' className='ml-1 h-4 w-4' />
</button> </button>
</div> </div>
) )

View file

@ -25,36 +25,36 @@ const FMCard = () => {
return ( return (
<div <div
className="relative flex h-[198px] overflow-hidden rounded-2xl p-4" className='relative flex h-[198px] overflow-hidden rounded-2xl p-4'
style={{ background }} style={{ background }}
> >
<img className="rounded-lg shadow-2xl" src={coverUrl} /> <img className='rounded-lg shadow-2xl' src={coverUrl} />
<div className="ml-5 flex w-full flex-col justify-between text-white"> <div className='ml-5 flex w-full flex-col justify-between text-white'>
{/* Track info */} {/* Track info */}
<div> <div>
<div className="text-xl font-semibold">How Can I Make It OK?</div> <div className='text-xl font-semibold'>How Can I Make It OK?</div>
<div className="opacity-75">Wolf Alice</div> <div className='opacity-75'>Wolf Alice</div>
</div> </div>
<div className="flex items-center justify-between"> <div className='flex items-center justify-between'>
{/* Actions */} {/* Actions */}
<div> <div>
{Object.values(ACTION).map(action => ( {Object.values(ACTION).map(action => (
<button <button
key={action} key={action}
className="btn-pressed-animation btn-hover-animation mr-1 cursor-default rounded-lg p-2 transition duration-200 after:bg-white/10" className='btn-pressed-animation btn-hover-animation mr-1 cursor-default rounded-lg p-2 transition duration-200 after:bg-white/10'
> >
<SvgIcon name={action} className="h-5 w-5" /> <SvgIcon name={action} className='h-5 w-5' />
</button> </button>
))} ))}
</div> </div>
{/* FM logo */} {/* FM logo */}
<div className="right-4 bottom-5 flex text-white opacity-20"> <div className='right-4 bottom-5 flex text-white opacity-20'>
<SvgIcon name="fm" className="mr-2 h-5 w-5" /> <SvgIcon name='fm' className='mr-2 h-5 w-5' />
<span className="font-semibold">FM</span> <span className='font-semibold'>FM</span>
</div> </div>
</div> </div>
</div> </div>

View file

@ -4,11 +4,11 @@ import Topbar from '@/components/Topbar'
const Main = () => { const Main = () => {
return ( return (
<div <div
id="mainContainer" id='mainContainer'
className="relative flex h-screen max-h-screen flex-grow flex-col overflow-y-auto bg-white dark:bg-[#1d1d1d]" className='relative flex h-screen max-h-screen flex-grow flex-col overflow-y-auto bg-white dark:bg-[#1d1d1d]'
> >
<Topbar /> <Topbar />
<main id="main" className="mb-24 flex-grow px-8"> <main id='main' className='mb-24 flex-grow px-8'>
<Router /> <Router />
</main> </main>
</div> </div>

View file

@ -29,39 +29,39 @@ const PlayingTrack = () => {
return ( return (
<Fragment> <Fragment>
{track && ( {track && (
<div className="flex items-center gap-3"> <div className='flex items-center gap-3'>
{track?.al?.picUrl && ( {track?.al?.picUrl && (
<img <img
onClick={toAlbum} onClick={toAlbum}
className="aspect-square h-full rounded-md shadow-md" className='aspect-square h-full rounded-md shadow-md'
src={resizeImage(track?.al?.picUrl ?? '', 'sm')} src={resizeImage(track?.al?.picUrl ?? '', 'sm')}
/> />
)} )}
{!track?.al?.picUrl && ( {!track?.al?.picUrl && (
<div <div
onClick={toAlbum} onClick={toAlbum}
className="flex aspect-square h-full items-center justify-center rounded-md bg-black/[.04] shadow-sm" className='flex aspect-square h-full items-center justify-center rounded-md bg-black/[.04] shadow-sm'
> >
<SvgIcon className="h-6 w-6 text-gray-300" name="music-note" /> <SvgIcon className='h-6 w-6 text-gray-300' name='music-note' />
</div> </div>
)} )}
<div className="flex flex-col justify-center leading-tight"> <div className='flex flex-col justify-center leading-tight'>
<div <div
onClick={toTrackListSource} onClick={toTrackListSource}
className="line-clamp-1 font-semibold text-black decoration-gray-600 decoration-2 hover:underline dark:text-white dark:decoration-gray-300" className='line-clamp-1 font-semibold text-black decoration-gray-600 decoration-2 hover:underline dark:text-white dark:decoration-gray-300'
> >
{track?.name} {track?.name}
</div> </div>
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400"> <div className='mt-0.5 text-xs text-gray-500 dark:text-gray-400'>
<ArtistInline artists={track?.ar ?? []} /> <ArtistInline artists={track?.ar ?? []} />
</div> </div>
</div> </div>
<IconButton onClick={() => toast('Work in progress')}> <IconButton onClick={() => toast('Work in progress')}>
<SvgIcon <SvgIcon
className="h-4 w-4 text-black dark:text-white" className='h-4 w-4 text-black dark:text-white'
name="heart-outline" name='heart-outline'
/> />
</IconButton> </IconButton>
</div> </div>
@ -76,22 +76,22 @@ const MediaControls = () => {
const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state]) const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state])
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track]) const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
return ( return (
<div className="flex items-center justify-center gap-2 text-black dark:text-white"> <div className='flex items-center justify-center gap-2 text-black dark:text-white'>
<IconButton onClick={() => track && player.prevTrack()} disabled={!track}> <IconButton onClick={() => track && player.prevTrack()} disabled={!track}>
<SvgIcon className="h-4 w-4" name="previous" /> <SvgIcon className='h-4 w-4' name='previous' />
</IconButton> </IconButton>
<IconButton <IconButton
onClick={() => track && player.playOrPause()} onClick={() => track && player.playOrPause()}
disabled={!track} disabled={!track}
className="rounded-2xl" className='rounded-2xl'
> >
<SvgIcon <SvgIcon
className="h-[1.5rem] w-[1.5rem] " className='h-[1.5rem] w-[1.5rem] '
name={state === PlayerState.PLAYING ? 'pause' : 'play'} name={state === PlayerState.PLAYING ? 'pause' : 'play'}
/> />
</IconButton> </IconButton>
<IconButton onClick={() => track && player.nextTrack()} disabled={!track}> <IconButton onClick={() => track && player.nextTrack()} disabled={!track}>
<SvgIcon className="h-4 w-4" name="next" /> <SvgIcon className='h-4 w-4' name='next' />
</IconButton> </IconButton>
</div> </div>
) )
@ -99,21 +99,21 @@ const MediaControls = () => {
const Others = () => { const Others = () => {
return ( return (
<div className="flex items-center justify-end gap-2 pr-2 text-black dark:text-white"> <div className='flex items-center justify-end gap-2 pr-2 text-black dark:text-white'>
<IconButton onClick={() => toast('Work in progress')}> <IconButton onClick={() => toast('Work in progress')}>
<SvgIcon className="h-4 w-4" name="playlist" /> <SvgIcon className='h-4 w-4' name='playlist' />
</IconButton> </IconButton>
<IconButton onClick={() => toast('Work in progress')}> <IconButton onClick={() => toast('Work in progress')}>
<SvgIcon className="h-4 w-4" name="repeat" /> <SvgIcon className='h-4 w-4' name='repeat' />
</IconButton> </IconButton>
<IconButton onClick={() => toast('Work in progress')}> <IconButton onClick={() => toast('Work in progress')}>
<SvgIcon className="h-4 w-4" name="shuffle" /> <SvgIcon className='h-4 w-4' name='shuffle' />
</IconButton> </IconButton>
<IconButton onClick={() => toast('Work in progress')}> <IconButton onClick={() => toast('Work in progress')}>
<SvgIcon className="h-4 w-4" name="volume" /> <SvgIcon className='h-4 w-4' name='volume' />
</IconButton> </IconButton>
<IconButton onClick={() => toast('Work in progress')}> <IconButton onClick={() => toast('Work in progress')}>
<SvgIcon className="h-4 w-4" name="chevron-up" /> <SvgIcon className='h-4 w-4' name='chevron-up' />
</IconButton> </IconButton>
</div> </div>
) )
@ -128,7 +128,7 @@ const Progress = () => {
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track]) const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
return ( return (
<div className="absolute w-screen"> <div className='absolute w-screen'>
{track && ( {track && (
<Slider <Slider
min={0} min={0}
@ -141,7 +141,7 @@ const Progress = () => {
/> />
)} )}
{!track && ( {!track && (
<div className="absolute h-[2px] w-full bg-gray-500 bg-opacity-10"></div> <div className='absolute h-[2px] w-full bg-gray-500 bg-opacity-10'></div>
)} )}
</div> </div>
) )
@ -149,7 +149,7 @@ const Progress = () => {
const Player = () => { const Player = () => {
return ( return (
<div className="fixed bottom-0 left-0 right-0 grid h-16 grid-cols-3 grid-rows-1 bg-white bg-opacity-[.86] py-2.5 px-5 backdrop-blur-xl backdrop-saturate-[1.8] dark:bg-[#222] dark:bg-opacity-[.86]"> <div className='fixed bottom-0 left-0 right-0 grid h-16 grid-cols-3 grid-rows-1 bg-white bg-opacity-[.86] py-2.5 px-5 backdrop-blur-xl backdrop-saturate-[1.8] dark:bg-[#222] dark:bg-opacity-[.86]'>
<Progress /> <Progress />
<PlayingTrack /> <PlayingTrack />

View file

@ -3,6 +3,7 @@ import SvgIcon from '@/components/SvgIcon'
import { prefetchPlaylist } from '@/hooks/usePlaylist' import { prefetchPlaylist } from '@/hooks/usePlaylist'
import useUser from '@/hooks/useUser' import useUser from '@/hooks/useUser'
import useUserPlaylists from '@/hooks/useUserPlaylists' import useUserPlaylists from '@/hooks/useUserPlaylists'
import { scrollToTop } from '@/utils/common'
interface Tab { interface Tab {
name: string name: string
@ -34,9 +35,10 @@ const primaryTabs: PrimaryTab[] = [
const PrimaryTabs = () => { const PrimaryTabs = () => {
return ( return (
<div> <div>
<div className="app-region-drag h-14"></div> <div className='app-region-drag h-14'></div>
{primaryTabs.map(tab => ( {primaryTabs.map(tab => (
<NavLink <NavLink
onClick={() => scrollToTop()}
key={tab.route} key={tab.route}
to={tab.route} to={tab.route}
className={({ isActive }: { isActive: boolean }) => className={({ isActive }: { isActive: boolean }) =>
@ -47,12 +49,12 @@ const PrimaryTabs = () => {
) )
} }
> >
<SvgIcon className="mr-3 h-6 w-6" name={tab.icon} /> <SvgIcon className='mr-3 h-6 w-6' name={tab.icon} />
<span className="font-semibold">{tab.name}</span> <span className='font-semibold'>{tab.name}</span>
</NavLink> </NavLink>
))} ))}
<div className="mx-5 my-2 h-px bg-black opacity-5 dark:bg-white dark:opacity-10"></div> <div className='mx-5 my-2 h-px bg-black opacity-5 dark:bg-white dark:opacity-10'></div>
</div> </div>
) )
} }
@ -65,10 +67,11 @@ const Playlists = () => {
}) })
return ( return (
<div className="mb-16 overflow-auto pb-2"> <div className='mb-16 overflow-auto pb-2'>
{playlists?.playlist?.map(playlist => ( {playlists?.playlist?.map(playlist => (
<NavLink <NavLink
key={playlist.id} key={playlist.id}
onClick={() => scrollToTop()}
to={`/playlist/${playlist.id}`} to={`/playlist/${playlist.id}`}
onMouseOver={() => prefetchPlaylist({ id: playlist.id })} onMouseOver={() => prefetchPlaylist({ id: playlist.id })}
className={({ isActive }: { isActive: boolean }) => className={({ isActive }: { isActive: boolean }) =>
@ -78,7 +81,7 @@ const Playlists = () => {
) )
} }
> >
<span className="line-clamp-1">{playlist.name}</span> <span className='line-clamp-1'>{playlist.name}</span>
</NavLink> </NavLink>
))} ))}
</div> </div>
@ -88,8 +91,8 @@ const Playlists = () => {
const Sidebar = () => { const Sidebar = () => {
return ( return (
<div <div
id="sidebar" id='sidebar'
className="grid h-screen max-w-sm grid-rows-[12rem_auto] border-r border-gray-300/10 bg-gray-50 bg-opacity-[.85] dark:border-gray-500/10 dark:bg-gray-900 dark:bg-opacity-80" className='grid h-screen max-w-sm grid-rows-[12rem_auto] border-r border-gray-300/10 bg-gray-50 bg-opacity-[.85] dark:border-gray-500/10 dark:bg-gray-900 dark:bg-opacity-80'
> >
<PrimaryTabs /> <PrimaryTabs />
<Playlists /> <Playlists />

View file

@ -122,18 +122,18 @@ const Slider = ({
return ( return (
<div <div
className="group flex h-2 -translate-y-[3px] items-center" className='group flex h-2 -translate-y-[3px] items-center'
ref={sliderRef} ref={sliderRef}
onClick={handleClick} onClick={handleClick}
> >
{/* Track */} {/* Track */}
<div className="absolute h-[2px] w-full bg-gray-500 bg-opacity-10"></div> <div className='absolute h-[2px] w-full bg-gray-500 bg-opacity-10'></div>
{/* Passed track */} {/* Passed track */}
<div <div
className={classNames( className={classNames(
'absolute h-[2px] group-hover:bg-brand-500', 'absolute h-[2px] group-hover:bg-brand-500',
isDragging ? 'bg-brand-500' : 'bg-gray-500 dark:bg-gray-400' isDragging ? 'bg-brand-500' : 'bg-gray-300 dark:bg-gray-400'
)} )}
style={usedTrackStyle} style={usedTrackStyle}
></div> ></div>
@ -148,7 +148,7 @@ const Slider = ({
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
> >
<div className="absolute h-2 w-2 rounded-full bg-brand-500"></div> <div className='absolute h-2 w-2 rounded-full bg-brand-500'></div>
</div> </div>
</div> </div>
) )

View file

@ -27,7 +27,7 @@ const Slider = () => {
onMouseEnter={() => setIsHover(true)} onMouseEnter={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)} onMouseLeave={() => setIsHover(false)}
> >
<div className="absolute h-[2px] w-full bg-gray-500 bg-opacity-10"></div> <div className='absolute h-[2px] w-full bg-gray-500 bg-opacity-10'></div>
<div <div
className={classNames( className={classNames(
'absolute h-[2px]', 'absolute h-[2px]',
@ -43,16 +43,16 @@ const Slider = () => {
)} )}
style={thumbStyle} style={thumbStyle}
> >
<div className="absolute h-2 w-2 rounded-full bg-brand-500"></div> <div className='absolute h-2 w-2 rounded-full bg-brand-500'></div>
</div> </div>
<input <input
type="range" type='range'
min="0" min='0'
max="100" max='100'
value={value} value={value}
onChange={e => setValue(Number(e.target.value))} onChange={e => setValue(Number(e.target.value))}
className="absolute h-[2px] w-full appearance-none opacity-0" className='absolute h-[2px] w-full appearance-none opacity-0'
/> />
</div> </div>
) )

View file

@ -1,8 +1,8 @@
const SvgIcon = ({ name, className }: { name: string; className?: string }) => { const SvgIcon = ({ name, className }: { name: string; className?: string }) => {
const symbolId = `#icon-${name}` const symbolId = `#icon-${name}`
return ( return (
<svg aria-hidden="true" className={className}> <svg aria-hidden='true' className={className}>
<use href={symbolId} fill="currentColor" /> <use href={symbolId} fill='currentColor' />
</svg> </svg>
) )
} }

View file

@ -14,14 +14,14 @@ const NavigationButtons = () => {
if (action === ACTION.FORWARD) navigate(1) if (action === ACTION.FORWARD) navigate(1)
} }
return ( return (
<div className="flex gap-1"> <div className='flex gap-1'>
{[ACTION.BACK, ACTION.FORWARD].map(action => ( {[ACTION.BACK, ACTION.FORWARD].map(action => (
<div <div
onClick={() => handleNavigate(action)} onClick={() => handleNavigate(action)}
key={action} key={action}
className="app-region-no-drag btn-hover-animation rounded-lg p-3 text-gray-500 transition duration-300 after:rounded-full after:bg-black/[.06] hover:text-gray-900 dark:text-gray-300 dark:after:bg-white/10 dark:hover:text-gray-200" className='app-region-no-drag btn-hover-animation rounded-lg p-3 text-gray-500 transition duration-300 after:rounded-full after:bg-black/[.06] hover:text-gray-900 dark:text-gray-300 dark:after:bg-white/10 dark:hover:text-gray-200'
> >
<SvgIcon className="h-4 w-4" name={action} /> <SvgIcon className='h-4 w-4' name={action} />
</div> </div>
))} ))}
</div> </div>
@ -30,15 +30,15 @@ const NavigationButtons = () => {
const SearchBox = () => { const SearchBox = () => {
return ( return (
<div className="app-region-no-drag group flex w-[16rem] cursor-text items-center rounded-full bg-gray-500 bg-opacity-5 px-3 transition duration-300 hover:bg-opacity-10 dark:bg-gray-300 dark:bg-opacity-5"> <div className='app-region-no-drag group flex w-[16rem] cursor-text items-center rounded-full bg-gray-500 bg-opacity-5 px-3 transition duration-300 hover:bg-opacity-10 dark:bg-gray-300 dark:bg-opacity-5'>
<SvgIcon <SvgIcon
className="mr-2 h-4 w-4 text-gray-500 transition duration-300 group-hover:text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-200" className='mr-2 h-4 w-4 text-gray-500 transition duration-300 group-hover:text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-200'
name="search" name='search'
/> />
<input <input
type="text" type='text'
className="w-full bg-transparent placeholder:text-gray-500 dark:text-white dark:placeholder:text-gray-400" className='w-full bg-transparent placeholder:text-gray-500 dark:text-white dark:placeholder:text-gray-400'
placeholder="Search" placeholder='Search'
/> />
</div> </div>
) )
@ -46,8 +46,8 @@ const SearchBox = () => {
const Settings = () => { const Settings = () => {
return ( return (
<div className="app-region-no-drag btn-hover-animation rounded-lg p-2.5 text-gray-500 transition duration-300 after:rounded-full after:bg-black/[.06] hover:text-gray-900 dark:text-gray-300 dark:after:bg-white/10 dark:hover:text-gray-200"> <div className='app-region-no-drag btn-hover-animation rounded-lg p-2.5 text-gray-500 transition duration-300 after:rounded-full after:bg-black/[.06] hover:text-gray-900 dark:text-gray-300 dark:after:bg-white/10 dark:hover:text-gray-200'>
<SvgIcon className="h-5 w-5" name="settings" /> <SvgIcon className='h-5 w-5' name='settings' />
</div> </div>
) )
} }
@ -59,7 +59,7 @@ const Avatar = () => {
<img <img
src={user?.profile?.avatarUrl} src={user?.profile?.avatarUrl}
onClick={() => navigate('/login')} onClick={() => navigate('/login')}
className="app-region-no-drag h-9 w-9 rounded-full bg-gray-100 dark:bg-gray-700" className='app-region-no-drag h-9 w-9 rounded-full bg-gray-100 dark:bg-gray-700'
/> />
) )
} }
@ -83,12 +83,12 @@ const Topbar = () => {
'bg-white bg-opacity-[.86] backdrop-blur-xl backdrop-saturate-[1.8] dark:bg-[#222] dark:bg-opacity-[.86]' 'bg-white bg-opacity-[.86] backdrop-blur-xl backdrop-saturate-[1.8] dark:bg-[#222] dark:bg-opacity-[.86]'
)} )}
> >
<div className="flex gap-2"> <div className='flex gap-2'>
<NavigationButtons /> <NavigationButtons />
<SearchBox /> <SearchBox />
</div> </div>
<div className="flex items-center gap-3"> <div className='flex items-center gap-3'>
<Settings /> <Settings />
<Avatar /> <Avatar />
</div> </div>

View file

@ -34,7 +34,7 @@ const PlayOrPauseButtonInTrack = memo(
)} )}
> >
<SvgIcon <SvgIcon
className="h-3.5 w-3.5 text-brand-500" className='h-3.5 w-3.5 text-brand-500'
name={isPlaying && isHighlight ? 'pause' : 'play'} name={isPlaying && isHighlight ? 'pause' : 'play'}
/> />
</div> </div>
@ -74,10 +74,10 @@ const Track = memo(
)} )}
> >
{/* Track name and number */} {/* Track name and number */}
<div className="col-span-6 grid grid-cols-[2rem_auto] pr-8"> <div className='col-span-6 grid grid-cols-[2rem_auto] pr-8'>
{/* Track number */} {/* Track number */}
{isSkeleton ? ( {isSkeleton ? (
<Skeleton className="h-6.5 w-6.5 -translate-x-1"></Skeleton> <Skeleton className='h-6.5 w-6.5 -translate-x-1'></Skeleton>
) : ( ) : (
!isHighlight && ( !isHighlight && (
<div <div
@ -101,9 +101,9 @@ const Track = memo(
)} )}
{/* Track name */} {/* Track name */}
<div className="flex"> <div className='flex'>
{isSkeleton ? ( {isSkeleton ? (
<Skeleton className="text-lg"> <Skeleton className='text-lg'>
PLACEHOLDER123456789012345 PLACEHOLDER123456789012345
</Skeleton> </Skeleton>
) : ( ) : (
@ -120,7 +120,7 @@ const Track = memo(
</div> </div>
{/* Artists */} {/* Artists */}
<div className="col-span-4 flex items-center"> <div className='col-span-4 flex items-center'>
{isSkeleton ? ( {isSkeleton ? (
<Skeleton>PLACEHOLDER1234</Skeleton> <Skeleton>PLACEHOLDER1234</Skeleton>
) : ( ) : (
@ -136,7 +136,7 @@ const Track = memo(
</div> </div>
{/* Actions & Track duration */} {/* Actions & Track duration */}
<div className="col-span-2 flex items-center justify-end"> <div className='col-span-2 flex items-center justify-end'>
{/* Like button */} {/* Like button */}
{!isSkeleton && ( {!isSkeleton && (
<button <button
@ -150,7 +150,7 @@ const Track = memo(
> >
<SvgIcon <SvgIcon
name={isLiked ? 'heart' : 'heart-outline'} name={isLiked ? 'heart' : 'heart-outline'}
className="h-4 w-4 " className='h-4 w-4 '
/> />
</button> </button>
)} )}
@ -209,15 +209,15 @@ const TracksAlbum = ({
) )
return ( return (
<div className="grid w-full"> <div className='grid w-full'>
{/* Tracks table header */} {/* Tracks table header */}
<div className="mx-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='mx-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-[2rem_auto]"> <div className='col-span-6 grid grid-cols-[2rem_auto]'>
<div>#</div> <div>#</div>
<div>TITLE</div> <div>TITLE</div>
</div> </div>
<div className="col-span-4">ARTIST</div> <div className='col-span-4'>ARTIST</div>
<div className="col-span-2 justify-self-end">TIME</div> <div className='col-span-2 justify-self-end'>TIME</div>
</div> </div>
{/* Tracks */} {/* Tracks */}

View file

@ -16,38 +16,38 @@ const TrackListGrid = ({
'grid-cols-1 py-1.5 px-2' 'grid-cols-1 py-1.5 px-2'
)} )}
> >
<div className="grid grid-cols-[3rem_auto] items-center"> <div className='grid grid-cols-[3rem_auto] items-center'>
{/* Cover */} {/* Cover */}
<div> <div>
{!isSkeleton && ( {!isSkeleton && (
<img <img
src={resizeImage(track.al.picUrl, 'xs')} src={resizeImage(track.al.picUrl, 'xs')}
className="box-content h-9 w-9 rounded-md border border-black border-opacity-[.03]" className='box-content h-9 w-9 rounded-md border border-black border-opacity-[.03]'
/> />
)} )}
{isSkeleton && ( {isSkeleton && (
<Skeleton className="mr-4 h-9 w-9 rounded-md border border-gray-100" /> <Skeleton className='mr-4 h-9 w-9 rounded-md border border-gray-100' />
)} )}
</div> </div>
{/* Track name & Artists */} {/* Track name & Artists */}
<div className="flex flex-col justify-center"> <div className='flex flex-col justify-center'>
{!isSkeleton && ( {!isSkeleton && (
<div <div
v-if="!isSkeleton" v-if='!isSkeleton'
className="line-clamp-1 break-all text-base font-semibold" className='line-clamp-1 break-all text-base font-semibold'
> >
{track.name} {track.name}
</div> </div>
)} )}
{isSkeleton && ( {isSkeleton && (
<Skeleton className="text-base">PLACEHOLDER12345</Skeleton> <Skeleton className='text-base'>PLACEHOLDER12345</Skeleton>
)} )}
<div className="text-xs"> <div className='text-xs'>
{!isSkeleton && <ArtistInline artists={track.ar} />} {!isSkeleton && <ArtistInline artists={track.ar} />}
{isSkeleton && ( {isSkeleton && (
<Skeleton className="w-2/3 translate-y-px">PLACE</Skeleton> <Skeleton className='w-2/3 translate-y-px'>PLACE</Skeleton>
)} )}
</div> </div>
</div> </div>

View file

@ -33,32 +33,32 @@ const Track = memo(
)} )}
> >
{/* Track info */} {/* Track info */}
<div className="col-span-6 grid grid-cols-[4.2rem_auto] pr-8"> <div className='col-span-6 grid grid-cols-[4.2rem_auto] pr-8'>
{/* Cover */} {/* Cover */}
<div> <div>
{isSkeleton ? ( {isSkeleton ? (
<Skeleton className="mr-4 h-12 w-12 rounded-md border border-gray-100 dark:border-gray-800" /> <Skeleton className='mr-4 h-12 w-12 rounded-md border border-gray-100 dark:border-gray-800' />
) : ( ) : (
<img <img
src={resizeImage(track.al.picUrl, 'xs')} src={resizeImage(track.al.picUrl, 'xs')}
className="box-content h-12 w-12 rounded-md border border-black border-opacity-[.03]" className='box-content h-12 w-12 rounded-md border border-black border-opacity-[.03]'
/> />
)} )}
</div> </div>
{/* Track name & Artists */} {/* Track name & Artists */}
<div className="flex flex-col justify-center"> <div className='flex flex-col justify-center'>
{isSkeleton ? ( {isSkeleton ? (
<Skeleton className="text-lg">PLACEHOLDER12345</Skeleton> <Skeleton className='text-lg'>PLACEHOLDER12345</Skeleton>
) : ( ) : (
<div className="line-clamp-1 break-all text-lg font-semibold dark:text-white"> <div className='line-clamp-1 break-all text-lg font-semibold dark:text-white'>
{track.name} {track.name}
</div> </div>
)} )}
<div className="text-sm text-gray-600 dark:text-gray-400"> <div className='text-sm text-gray-600 dark:text-gray-400'>
{isSkeleton ? ( {isSkeleton ? (
<Skeleton className="w-2/3 translate-y-px">PLACE</Skeleton> <Skeleton className='w-2/3 translate-y-px'>PLACE</Skeleton>
) : ( ) : (
<ArtistInline artists={track.ar} /> <ArtistInline artists={track.ar} />
)} )}
@ -67,7 +67,7 @@ const Track = memo(
</div> </div>
{/* Album name */} {/* Album name */}
<div className="col-span-4 flex items-center text-gray-600 dark:text-gray-400"> <div className='col-span-4 flex items-center text-gray-600 dark:text-gray-400'>
{isSkeleton ? ( {isSkeleton ? (
<Skeleton>PLACEHOLDER1234567890</Skeleton> <Skeleton>PLACEHOLDER1234567890</Skeleton>
) : ( ) : (
@ -75,17 +75,17 @@ const Track = memo(
<NavLink <NavLink
to={`/album/${track.al.id}`} to={`/album/${track.al.id}`}
onMouseOver={() => prefetchAlbum({ id: track.al.id })} onMouseOver={() => prefetchAlbum({ id: track.al.id })}
className="hover:underline" className='hover:underline'
> >
{track.al.name} {track.al.name}
</NavLink> </NavLink>
<span className="flex-grow"></span> <span className='flex-grow'></span>
</Fragment> </Fragment>
)} )}
</div> </div>
{/* Actions & Track duration */} {/* Actions & Track duration */}
<div className="col-span-2 flex items-center justify-end"> <div className='col-span-2 flex items-center justify-end'>
{/* Like button */} {/* Like button */}
{!isSkeleton && ( {!isSkeleton && (
<button <button
@ -98,7 +98,7 @@ const Track = memo(
> >
<SvgIcon <SvgIcon
name={isLiked ? 'heart' : 'heart-outline'} name={isLiked ? 'heart' : 'heart-outline'}
className="h-4 w-4 " className='h-4 w-4 '
/> />
</button> </button>
)} )}
@ -107,7 +107,7 @@ const Track = memo(
{isSkeleton ? ( {isSkeleton ? (
<Skeleton>0:00</Skeleton> <Skeleton>0:00</Skeleton>
) : ( ) : (
<div className="min-w-[2.5rem] text-right text-gray-600 dark:text-gray-400"> <div className='min-w-[2.5rem] text-right text-gray-600 dark:text-gray-400'>
{formatDuration(track.dt, 'en', 'hh:mm:ss')} {formatDuration(track.dt, 'en', 'hh:mm:ss')}
</div> </div>
)} )}
@ -146,16 +146,16 @@ const TracksList = memo(
return ( return (
<Fragment> <Fragment>
{/* Tracks table header */} {/* 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='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]"> <div className='col-span-6 grid grid-cols-[4.2rem_auto]'>
<div></div> <div></div>
<div>TITLE</div> <div>TITLE</div>
</div> </div>
<div className="col-span-4">ALBUM</div> <div className='col-span-4'>ALBUM</div>
<div className="col-span-2 justify-self-end">TIME</div> <div className='col-span-2 justify-self-end'>TIME</div>
</div> </div>
<div className="grid w-full gap-1"> <div className='grid w-full gap-1'>
{/* Tracks */} {/* Tracks */}
{!isSkeleton && {!isSkeleton &&
tracks.map(track => ( tracks.map(track => (

View file

@ -10,7 +10,12 @@ import useAlbum from '@/hooks/useAlbum'
import useArtistAlbums from '@/hooks/useArtistAlbums' import useArtistAlbums from '@/hooks/useArtistAlbums'
import { player } from '@/store' import { player } from '@/store'
import { State as PlayerState } from '@/utils/player' import { State as PlayerState } from '@/utils/player'
import { formatDate, formatDuration, resizeImage } from '@/utils/common' import {
formatDate,
formatDuration,
resizeImage,
scrollToTop,
} from '@/utils/common'
const PlayButton = ({ const PlayButton = ({
album, album,
@ -49,7 +54,7 @@ const PlayButton = ({
<Button onClick={wrappedHandlePlay} isSkelton={isLoading}> <Button onClick={wrappedHandlePlay} isSkelton={isLoading}>
<SvgIcon <SvgIcon
name={isPlaying && isThisAlbumPlaying ? 'pause' : 'play'} name={isPlaying && isThisAlbumPlaying ? 'pause' : 'play'}
className="mr-2 h-4 w-4" className='mr-2 h-4 w-4'
/> />
{isPlaying && isThisAlbumPlaying ? '暂停' : '播放'} {isPlaying && isThisAlbumPlaying ? '暂停' : '播放'}
</Button> </Button>
@ -77,29 +82,29 @@ const Header = ({
return ( return (
<Fragment> <Fragment>
{/* Header background */} {/* Header background */}
<div className="absolute top-0 left-0 z-0 h-[24rem] w-full overflow-hidden"> <div className='absolute top-0 left-0 z-0 h-[24rem] w-full overflow-hidden'>
{coverUrl && !isCoverError && ( {coverUrl && !isCoverError && (
<Fragment> <Fragment>
<img <img
src={coverUrl} src={coverUrl}
className="absolute -top-full w-full blur-[100px]" className='absolute -top-full w-full blur-[100px]'
/> />
<img <img
src={coverUrl} src={coverUrl}
className="absolute -top-full w-full blur-[100px]" className='absolute -top-full w-full blur-[100px]'
/> />
</Fragment> </Fragment>
)} )}
<div className="absolute top-0 h-full w-full bg-gradient-to-b from-white/[.84] to-white dark:from-black/[.5] dark:to-[#1d1d1d]"></div> <div className='absolute top-0 h-full w-full bg-gradient-to-b from-white/[.84] to-white dark:from-black/[.5] dark:to-[#1d1d1d]'></div>
</div> </div>
<div className="grid grid-cols-[17rem_auto] items-center gap-9"> <div className='grid grid-cols-[17rem_auto] items-center gap-9'>
{/* Cover */} {/* Cover */}
<div className="relative z-0 aspect-square self-start"> <div className='relative z-0 aspect-square self-start'>
{/* Neon shadow */} {/* Neon shadow */}
{!isLoading && coverUrl && !isCoverError && ( {!isLoading && coverUrl && !isCoverError && (
<div <div
className="absolute top-3.5 z-[-1] h-full w-full scale-x-[.92] scale-y-[.96] rounded-2xl bg-cover opacity-40 blur-lg filter" className='absolute top-3.5 z-[-1] h-full w-full scale-x-[.92] scale-y-[.96] rounded-2xl bg-cover opacity-40 blur-lg filter'
style={{ style={{
backgroundImage: `url("${coverUrl}")`, backgroundImage: `url("${coverUrl}")`,
}} }}
@ -108,41 +113,41 @@ const Header = ({
{!isLoading && isCoverError ? ( {!isLoading && isCoverError ? (
// Fallback cover // Fallback cover
<div className="flex h-full w-full items-center justify-center rounded-2xl border border-black border-opacity-5 bg-gray-100 text-gray-300"> <div className='flex h-full w-full items-center justify-center rounded-2xl border border-black border-opacity-5 bg-gray-100 text-gray-300'>
<SvgIcon name="music-note" className="h-1/2 w-1/2" /> <SvgIcon name='music-note' className='h-1/2 w-1/2' />
</div> </div>
) : ( ) : (
coverUrl && ( coverUrl && (
<img <img
src={coverUrl} src={coverUrl}
className="rounded-2xl border border-b-0 border-black border-opacity-5 dark:border-white dark:border-opacity-5" className='rounded-2xl border border-b-0 border-black border-opacity-5 dark:border-white dark:border-opacity-5'
onError={() => setCoverError(true)} onError={() => setCoverError(true)}
/> />
) )
)} )}
{isLoading && <Skeleton className="h-full w-full rounded-2xl" />} {isLoading && <Skeleton className='h-full w-full rounded-2xl' />}
</div> </div>
{/* Info */} {/* Info */}
<div className="z-10 flex h-full flex-col justify-between"> <div className='z-10 flex h-full flex-col justify-between'>
{/* Name */} {/* Name */}
{isLoading ? ( {isLoading ? (
<Skeleton className="w-3/4 text-6xl">PLACEHOLDER</Skeleton> <Skeleton className='w-3/4 text-6xl'>PLACEHOLDER</Skeleton>
) : ( ) : (
<div className="text-6xl font-bold dark:text-white"> <div className='text-6xl font-bold dark:text-white'>
{album?.name} {album?.name}
</div> </div>
)} )}
{/* Artist */} {/* Artist */}
{isLoading ? ( {isLoading ? (
<Skeleton className="mt-5 w-64 text-lg">PLACEHOLDER</Skeleton> <Skeleton className='mt-5 w-64 text-lg'>PLACEHOLDER</Skeleton>
) : ( ) : (
<div className="mt-5 text-lg font-medium text-gray-800 dark:text-gray-300"> <div className='mt-5 text-lg font-medium text-gray-800 dark:text-gray-300'>
Album by{' '} Album by{' '}
<NavLink <NavLink
to={`/artist/${album?.artist.name}`} to={`/artist/${album?.artist.name}`}
className="cursor-default font-semibold hover:underline" className='cursor-default font-semibold hover:underline'
> >
{album?.artist.name} {album?.artist.name}
</NavLink> </NavLink>
@ -151,11 +156,11 @@ const Header = ({
{/* Release date & track count & album duration */} {/* Release date & track count & album duration */}
{isLoading ? ( {isLoading ? (
<Skeleton className="w-72 translate-y-px text-sm"> <Skeleton className='w-72 translate-y-px text-sm'>
PLACEHOLDER PLACEHOLDER
</Skeleton> </Skeleton>
) : ( ) : (
<div className="text-sm font-thin text-gray-500 dark:text-gray-400"> <div className='text-sm font-thin text-gray-500 dark:text-gray-400'>
{dayjs(album?.publishTime || 0).year()} · {album?.size} Songs,{' '} {dayjs(album?.publishTime || 0).year()} · {album?.size} Songs,{' '}
{albumDuration} {albumDuration}
</div> </div>
@ -163,17 +168,17 @@ const Header = ({
{/* Description */} {/* Description */}
{isLoading ? ( {isLoading ? (
<Skeleton className="mt-5 min-h-[2.5rem] w-1/2 text-sm"> <Skeleton className='mt-5 min-h-[2.5rem] w-1/2 text-sm'>
PLACEHOLDER PLACEHOLDER
</Skeleton> </Skeleton>
) : ( ) : (
<div className="line-clamp-2 mt-5 min-h-[2.5rem] text-sm text-gray-500 dark:text-gray-400"> <div className='line-clamp-2 mt-5 min-h-[2.5rem] text-sm text-gray-500 dark:text-gray-400'>
{album?.description} {album?.description}
</div> </div>
)} )}
{/* Buttons */} {/* Buttons */}
<div className="mt-5 flex gap-4"> <div className='mt-5 flex gap-4'>
<PlayButton {...{ album, handlePlay, isLoading }} /> <PlayButton {...{ album, handlePlay, isLoading }} />
<Button <Button
@ -181,7 +186,7 @@ const Header = ({
isSkelton={isLoading} isSkelton={isLoading}
onClick={() => toast('Work in progress')} onClick={() => toast('Work in progress')}
> >
<SvgIcon name="heart" className="h-4 w-4" /> <SvgIcon name='heart' className='h-4 w-4' />
</Button> </Button>
<Button <Button
@ -190,7 +195,7 @@ const Header = ({
isSkelton={isLoading} isSkelton={isLoading}
onClick={() => toast('Work in progress')} onClick={() => toast('Work in progress')}
> >
<SvgIcon name="more" className="h-4 w-4" /> <SvgIcon name='more' className='h-4 w-4' />
</Button> </Button>
</div> </div>
</div> </div>
@ -208,7 +213,6 @@ const MoreAlbum = ({ album }: { album: Album | undefined }) => {
const filteredAlbums = useMemo((): Album[] => { const filteredAlbums = useMemo((): Album[] => {
if (!albums) return [] if (!albums) return []
const albumID = album?.id
const allReleases = albums?.hotAlbums || [] const allReleases = albums?.hotAlbums || []
const filteredAlbums = allReleases.filter( const filteredAlbums = allReleases.filter(
album => album =>
@ -218,19 +222,18 @@ const MoreAlbum = ({ album }: { album: Album | undefined }) => {
const qualifiedAlbums = [...filteredAlbums, ...singles] const qualifiedAlbums = [...filteredAlbums, ...singles]
const formatName = (name: string) =>
name.toLowerCase().replace(/(\s|deluxe|edition|\(|\))/g, '')
const uniqueAlbums: Album[] = [] const uniqueAlbums: Album[] = []
qualifiedAlbums.forEach(a => { qualifiedAlbums.forEach(a => {
// 去除当前页面的专辑 // 去除当前页面的专辑
if (Number(a.id) === Number(albumID) || album?.name === a.name) return if (formatName(a.name) === formatName(album?.name ?? '')) return
// 去除重复的专辑(包含 deluxe edition 的专辑会视为重复) // 去除重复的专辑(包含 deluxe edition 的专辑会视为重复)
if ( if (
uniqueAlbums.findIndex(aa => { uniqueAlbums.findIndex(aa => {
return ( return formatName(a.name) === formatName(aa.name)
a.name === aa.name ||
a.name.toLowerCase().replace(/(\s|deluxe|edition|\(|\))/g, '') ===
aa.name.toLowerCase().replace(/(\s|deluxe|edition|\(|\))/g, '')
)
}) !== -1 }) !== -1
) { ) {
return return
@ -248,25 +251,27 @@ const MoreAlbum = ({ album }: { album: Album | undefined }) => {
}) })
return uniqueAlbums.slice(0, 5) return uniqueAlbums.slice(0, 5)
}, [album?.id, album?.name, albums]) }, [album?.name, albums])
return ( return (
<div> <div>
<div className="my-5 h-px w-full bg-gray-100 dark:bg-gray-800"></div> <div className='my-5 h-px w-full bg-gray-100 dark:bg-gray-800'></div>
<div className="pl-px text-[1.375rem] font-semibold text-gray-800 dark:text-gray-100"> <div className='pl-px text-[1.375rem] font-semibold text-gray-800 dark:text-gray-100'>
More by{' '} More by{' '}
<NavLink <NavLink
to={`/artist/${album?.artist?.id}`} to={`/artist/${album?.artist?.id}`}
className="cursor-default hover:underline" className='cursor-default hover:underline'
> >
{album?.artist.name} {album?.artist.name}
</NavLink> </NavLink>
</div> </div>
<div className="mt-3"> <div className='mt-3'>
<CoverRow <CoverRow
albums={filteredAlbums} albums={filteredAlbums}
subtitle={Subtitle.TYPE_RELEASE_YEAR} subtitle={Subtitle.TYPE_RELEASE_YEAR}
isSkeleton={isLoading} isSkeleton={isLoading}
rows={1}
navigateCallback={scrollToTop}
/> />
</div> </div>
</div> </div>
@ -289,7 +294,7 @@ const Album = () => {
} }
return ( return (
<div className="mt-10"> <div className='mt-10'>
<Header <Header
album={album?.album} album={album?.album}
isLoading={isLoading} isLoading={isLoading}
@ -301,10 +306,10 @@ const Album = () => {
isSkeleton={isLoading} isSkeleton={isLoading}
/> />
{album?.album && ( {album?.album && (
<div className="mt-5 text-xs text-gray-400"> <div className='mt-5 text-xs text-gray-400'>
<div> Released {formatDate(album.album.publishTime || 0, 'en')} </div> <div> Released {formatDate(album.album.publishTime || 0, 'en')} </div>
{album.album.company && ( {album.album.company && (
<div className="mt-[2px]">© {album.album.company} </div> <div className='mt-[2px]'>© {album.album.company} </div>
)} )}
</div> </div>
)} )}

View file

@ -14,15 +14,15 @@ export default function Home() {
return ( return (
<div> <div>
<CoverRow <CoverRow
title="Good Morning" title='Good Morning'
playlists={recommendedPlaylists?.result.slice(0, 10) ?? []} playlists={recommendedPlaylists?.result.slice(0, 10) ?? []}
isSkeleton={isLoadingRecommendedPlaylists} isSkeleton={isLoadingRecommendedPlaylists}
/> />
<div className="mt-10 mb-4 text-[28px] font-bold text-black dark:text-white"> <div className='mt-10 mb-4 text-[28px] font-bold text-black dark:text-white'>
For You For You
</div> </div>
<div className="grid grid-cols-2 gap-6"> <div className='grid grid-cols-2 gap-6'>
<DailyTracksCard /> <DailyTracksCard />
<FMCard /> <FMCard />
</div> </div>

View file

@ -21,13 +21,13 @@ const EmailInput = ({
setEmail: (email: string) => void setEmail: (email: string) => void
}) => { }) => {
return ( return (
<div className="w-full"> <div className='w-full'>
<div className="mb-1 text-sm font-medium text-gray-700">Email</div> <div className='mb-1 text-sm font-medium text-gray-700'>Email</div>
<input <input
value={email} value={email}
onChange={e => setEmail(e.target.value)} onChange={e => setEmail(e.target.value)}
className="w-full rounded-md border border-gray-300 px-2 py-2" className='w-full rounded-md border border-gray-300 px-2 py-2'
type="email" type='email'
/> />
</div> </div>
) )
@ -45,9 +45,9 @@ const PhoneInput = ({
setPhone: (phone: string) => void setPhone: (phone: string) => void
}) => { }) => {
return ( return (
<div className="w-full"> <div className='w-full'>
<div className="mb-1 text-sm font-medium text-gray-700">Phone</div> <div className='mb-1 text-sm font-medium text-gray-700'>Phone</div>
<div className="flex w-full"> <div className='flex w-full'>
<input <input
className={classNames( className={classNames(
'rounded-md rounded-r-none border border-r-0 border-gray-300 px-3 py-2', 'rounded-md rounded-r-none border border-r-0 border-gray-300 px-3 py-2',
@ -55,14 +55,14 @@ const PhoneInput = ({
countryCode.length == 4 && 'w-16', countryCode.length == 4 && 'w-16',
countryCode.length >= 5 && 'w-20' countryCode.length >= 5 && 'w-20'
)} )}
type="text" type='text'
placeholder="+86" placeholder='+86'
value={countryCode} value={countryCode}
onChange={e => setCountryCode(e.target.value)} onChange={e => setCountryCode(e.target.value)}
/> />
<input <input
className="flex-grow rounded-md rounded-l-none border border-gray-300 px-3 py-2" className='flex-grow rounded-md rounded-l-none border border-gray-300 px-3 py-2'
type="text" type='text'
value={phone} value={phone}
onChange={e => setPhone(e.target.value)} onChange={e => setPhone(e.target.value)}
/> />
@ -80,22 +80,22 @@ const PasswordInput = ({
}) => { }) => {
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
return ( return (
<div className="mt-3 flex w-full flex-col"> <div className='mt-3 flex w-full flex-col'>
<div className="mb-1 text-sm font-medium text-gray-700">Password</div> <div className='mb-1 text-sm font-medium text-gray-700'>Password</div>
<div className="flex w-full"> <div className='flex w-full'>
<input <input
value={password} value={password}
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
className="w-full rounded-md rounded-r-none border border-r-0 border-gray-300 px-2 py-2" className='w-full rounded-md rounded-r-none border border-r-0 border-gray-300 px-2 py-2'
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
/> />
<div className="flex items-center justify-center rounded-md rounded-l-none border border-l-0 border-gray-300 pr-1"> <div className='flex items-center justify-center rounded-md rounded-l-none border border-l-0 border-gray-300 pr-1'>
<button <button
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
className="cursor-default rounded p-1.5 text-gray-400 transition duration-300 hover:bg-gray-100 hover:text-gray-600" className='cursor-default rounded p-1.5 text-gray-400 transition duration-300 hover:bg-gray-100 hover:text-gray-600'
> >
<SvgIcon <SvgIcon
className="h-5 w-5" className='h-5 w-5'
name={showPassword ? 'eye-off' : 'eye'} name={showPassword ? 'eye-off' : 'eye'}
/> />
</button> </button>
@ -153,21 +153,21 @@ const OtherLoginMethods = ({
] ]
return ( return (
<Fragment> <Fragment>
<div className="mt-8 mb-4 flex w-full items-center"> <div className='mt-8 mb-4 flex w-full items-center'>
<span className="h-px flex-grow bg-gray-300"></span> <span className='h-px flex-grow bg-gray-300'></span>
<span className="mx-2 text-sm text-gray-400">or</span> <span className='mx-2 text-sm text-gray-400'>or</span>
<span className="h-px flex-grow bg-gray-300"></span> <span className='h-px flex-grow bg-gray-300'></span>
</div> </div>
<div className="flex gap-3"> <div className='flex gap-3'>
{otherLoginMethods.map( {otherLoginMethods.map(
({ id, name }) => ({ id, name }) =>
method !== id && ( method !== id && (
<button <button
key={id} key={id}
onClick={() => setMethod(id)} onClick={() => setMethod(id)}
className="flex w-full cursor-default items-center justify-center rounded-lg bg-gray-100 py-2 font-medium text-gray-600 transition duration-300 hover:bg-gray-200 hover:text-gray-800" className='flex w-full cursor-default items-center justify-center rounded-lg bg-gray-100 py-2 font-medium text-gray-600 transition duration-300 hover:bg-gray-200 hover:text-gray-800'
> >
<SvgIcon className="mr-2 h-5 w-5" name={id} /> <SvgIcon className='mr-2 h-5 w-5' name={id} />
<span>{name}</span> <span>{name}</span>
</button> </button>
) )
@ -274,11 +274,11 @@ const LoginWithQRCode = () => {
}, [qrCodeUrl]) }, [qrCodeUrl])
const qrCodeMessage = 'test' const qrCodeMessage = 'test'
return ( return (
<div className="flex flex-col items-center justify-center"> <div className='flex flex-col items-center justify-center'>
<div className="rounded-3xl border p-6"> <div className='rounded-3xl border p-6'>
<img src={qrCodeImage} alt="QR Code" className="no-drag" /> <img src={qrCodeImage} alt='QR Code' className='no-drag' />
</div> </div>
<div className="mt-4 text-sm text-gray-500">{qrCodeMessage}</div> <div className='mt-4 text-sm text-gray-500'>{qrCodeMessage}</div>
</div> </div>
) )
} }
@ -287,8 +287,8 @@ export default function Login() {
const [method, setMethod] = useState<Method>(Method.PHONE) const [method, setMethod] = useState<Method>(Method.PHONE)
return ( return (
<div className="grid h-full place-content-center"> <div className='grid h-full place-content-center'>
<div className="w-80"> <div className='w-80'>
{method === Method.EMAIL && <LoginWithEmail />} {method === Method.EMAIL && <LoginWithEmail />}
{method === Method.PHONE && <LoginWithPhone />} {method === Method.PHONE && <LoginWithPhone />}
{method === Method.QRCODE && <LoginWithQRCode />} {method === Method.QRCODE && <LoginWithQRCode />}

View file

@ -27,18 +27,18 @@ const Header = memo(
return ( return (
<Fragment> <Fragment>
{/* Header background */} {/* Header background */}
<div className="absolute top-0 left-0 z-0 h-[24rem] w-full overflow-hidden"> <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]" /> <img src={coverUrl} className='absolute top-0 w-full blur-[100px]' />
<img src={coverUrl} className="absolute top-0 w-full blur-[100px]" /> <img src={coverUrl} className='absolute top-0 w-full blur-[100px]' />
<div className="absolute top-0 h-full w-full bg-gradient-to-b from-white/[.84] to-white dark:from-black/[.5] dark:to-[#1d1d1d]"></div> <div className='absolute top-0 h-full w-full bg-gradient-to-b from-white/[.84] to-white dark:from-black/[.5] dark:to-[#1d1d1d]'></div>
</div> </div>
<div className="grid grid-cols-[16rem_auto] items-center gap-9"> <div className='grid grid-cols-[16rem_auto] items-center gap-9'>
{/* Cover */} {/* Cover */}
<div className="relative z-0 aspect-square self-start"> <div className='relative z-0 aspect-square self-start'>
{!isLoading && ( {!isLoading && (
<div <div
className="absolute top-3.5 z-[-1] h-full w-full scale-x-[.92] scale-y-[.96] rounded-2xl bg-cover opacity-40 blur-lg filter" className='absolute top-3.5 z-[-1] h-full w-full scale-x-[.92] scale-y-[.96] rounded-2xl bg-cover opacity-40 blur-lg filter'
style={{ style={{
backgroundImage: `url("${coverUrl}")`, backgroundImage: `url("${coverUrl}")`,
}} }}
@ -48,70 +48,70 @@ const Header = memo(
{!isLoading && ( {!isLoading && (
<img <img
src={coverUrl} src={coverUrl}
className="rounded-2xl border border-black border-opacity-5" className='rounded-2xl border border-black border-opacity-5'
/> />
)} )}
{isLoading && ( {isLoading && (
<Skeleton v-else className="h-full w-full rounded-2xl" /> <Skeleton v-else className='h-full w-full rounded-2xl' />
)} )}
</div> </div>
{/* <!-- Playlist info --> */} {/* <!-- Playlist info --> */}
<div className="z-10 flex h-full flex-col justify-between"> <div className='z-10 flex h-full flex-col justify-between'>
{/* <!-- Playlist name --> */} {/* <!-- Playlist name --> */}
{!isLoading && ( {!isLoading && (
<div className="text-4xl font-bold dark:text-white"> <div className='text-4xl font-bold dark:text-white'>
{playlist?.name} {playlist?.name}
</div> </div>
)} )}
{isLoading && ( {isLoading && (
<Skeleton v-else className="w-3/4 text-4xl"> <Skeleton v-else className='w-3/4 text-4xl'>
PLACEHOLDER PLACEHOLDER
</Skeleton> </Skeleton>
)} )}
{/* <!-- Playlist creator --> */} {/* <!-- Playlist creator --> */}
{!isLoading && ( {!isLoading && (
<div className="mt-5 text-lg font-medium text-gray-800 dark:text-gray-300"> <div className='mt-5 text-lg font-medium text-gray-800 dark:text-gray-300'>
Playlist by <span>{playlist?.creator?.nickname}</span> Playlist by <span>{playlist?.creator?.nickname}</span>
</div> </div>
)} )}
{isLoading && ( {isLoading && (
<Skeleton v-else className="mt-5 w-64 text-lg"> <Skeleton v-else className='mt-5 w-64 text-lg'>
PLACEHOLDER PLACEHOLDER
</Skeleton> </Skeleton>
)} )}
{/* <!-- Playlist last update time & track count --> */} {/* <!-- Playlist last update time & track count --> */}
{!isLoading && ( {!isLoading && (
<div className="text-sm font-thin text-gray-500 dark:text-gray-400"> <div className='text-sm font-thin text-gray-500 dark:text-gray-400'>
Updated at Updated at
{formatDate(playlist?.updateTime || 0, 'en')} · {formatDate(playlist?.updateTime || 0, 'en')} ·
{playlist?.trackCount} Songs {playlist?.trackCount} Songs
</div> </div>
)} )}
{isLoading && ( {isLoading && (
<Skeleton v-else className="w-72 translate-y-px text-sm"> <Skeleton v-else className='w-72 translate-y-px text-sm'>
PLACEHOLDER PLACEHOLDER
</Skeleton> </Skeleton>
)} )}
{/* <!-- Playlist description --> */} {/* <!-- Playlist description --> */}
{!isLoading && ( {!isLoading && (
<div className="line-clamp-2 mt-5 min-h-[2.5rem] text-sm text-gray-500 dark:text-gray-400"> <div className='line-clamp-2 mt-5 min-h-[2.5rem] text-sm text-gray-500 dark:text-gray-400'>
{playlist?.description} {playlist?.description}
</div> </div>
)} )}
{isLoading && ( {isLoading && (
<Skeleton v-else className="mt-5 min-h-[2.5rem] w-1/2 text-sm"> <Skeleton v-else className='mt-5 min-h-[2.5rem] w-1/2 text-sm'>
PLACEHOLDER PLACEHOLDER
</Skeleton> </Skeleton>
)} )}
{/* <!-- Buttons --> */} {/* <!-- Buttons --> */}
<div className="mt-5 flex gap-4"> <div className='mt-5 flex gap-4'>
<Button onClick={() => handlePlay()} isSkelton={isLoading}> <Button onClick={() => handlePlay()} isSkelton={isLoading}>
<SvgIcon name="play" className="mr-2 h-4 w-4" /> <SvgIcon name='play' className='mr-2 h-4 w-4' />
PLAY PLAY
</Button> </Button>
@ -120,7 +120,7 @@ const Header = memo(
isSkelton={isLoading} isSkelton={isLoading}
onClick={() => toast('Work in progress')} onClick={() => toast('Work in progress')}
> >
<SvgIcon name="heart" className="h-4 w-4" /> <SvgIcon name='heart' className='h-4 w-4' />
</Button> </Button>
<Button <Button
@ -129,7 +129,7 @@ const Header = memo(
isSkelton={isLoading} isSkelton={isLoading}
onClick={() => toast('Work in progress')} onClick={() => toast('Work in progress')}
> >
<SvgIcon name="more" className="h-4 w-4" /> <SvgIcon name='more' className='h-4 w-4' />
</Button> </Button>
</div> </div>
</div> </div>
@ -225,7 +225,7 @@ const Playlist = () => {
) )
return ( return (
<div className="mt-10"> <div className='mt-10'>
<Header <Header
playlist={playlist?.playlist} playlist={playlist?.playlist}
isLoading={isLoading} isLoading={isLoading}

View file

@ -102,3 +102,9 @@ export function formatDuration(
export function sleep(time: number) { export function sleep(time: number) {
return new Promise(resolve => setTimeout(resolve, time)) return new Promise(resolve => setTimeout(resolve, time))
} }
export function scrollToTop(smooth = false) {
const main = document.getElementById('mainContainer')
if (!main) return
main.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' })
}

View file

@ -9,6 +9,7 @@ module.exports = {
bracketSpacing: true, bracketSpacing: true,
htmlWhitespaceSensitivity: 'strict', htmlWhitespaceSensitivity: 'strict',
singleQuote: true, singleQuote: true,
jsxSingleQuote: true,
// Tailwind CSS // Tailwind CSS
plugins: [require('prettier-plugin-tailwindcss')], plugins: [require('prettier-plugin-tailwindcss')],