mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-16 13:17:46 +00:00
feat: updates
This commit is contained in:
parent
d96bd2a547
commit
e3486ab550
23 changed files with 331 additions and 261 deletions
|
|
@ -1,23 +1,41 @@
|
|||
import Realm from 'realm'
|
||||
import type { FetchTracksResponse } from '../renderer/src/api/track'
|
||||
import type { FetchAlbumResponse } from '../renderer/src/api/album'
|
||||
|
||||
enum ModelNames {
|
||||
TRACK = 'Track',
|
||||
ALBUM = 'Album',
|
||||
ARTIST = 'Artist',
|
||||
PLAYLIST = 'Playlist',
|
||||
}
|
||||
|
||||
const universalProperties = {
|
||||
id: 'int',
|
||||
json: 'string',
|
||||
updateAt: 'int',
|
||||
}
|
||||
|
||||
const TrackSchema = {
|
||||
name: ModelNames.TRACK,
|
||||
properties: {
|
||||
id: 'int',
|
||||
json: 'string',
|
||||
updateAt: 'int',
|
||||
},
|
||||
properties: universalProperties,
|
||||
primaryKey: 'id',
|
||||
}
|
||||
|
||||
const AlbumSchema = {
|
||||
name: ModelNames.ALBUM,
|
||||
properties: universalProperties,
|
||||
primaryKey: 'id',
|
||||
}
|
||||
|
||||
const PlaylistSchema = {
|
||||
name: ModelNames.PLAYLIST,
|
||||
properties: universalProperties,
|
||||
primaryKey: 'id',
|
||||
}
|
||||
|
||||
const realm = new Realm({
|
||||
path: './dist/db.realm',
|
||||
schema: [TrackSchema],
|
||||
path: './.tmp/db.realm',
|
||||
schema: [TrackSchema, AlbumSchema, PlaylistSchema],
|
||||
})
|
||||
|
||||
export const database = {
|
||||
|
|
@ -29,7 +47,7 @@ export const database = {
|
|||
model,
|
||||
{
|
||||
id: key,
|
||||
updateAt: ~~(Date.now() / 1000),
|
||||
updateAt: Date.now(),
|
||||
json: JSON.stringify(value),
|
||||
},
|
||||
'modified'
|
||||
|
|
@ -40,29 +58,43 @@ export const database = {
|
|||
},
|
||||
}
|
||||
|
||||
export function setTracks(data: FetchTracksResponse) {
|
||||
const tracks = data.songs
|
||||
export async function setTracks(data: FetchTracksResponse) {
|
||||
if (!data.songs) return
|
||||
const write = async () =>
|
||||
realm.write(() => {
|
||||
tracks.forEach(track => {
|
||||
database.set(ModelNames.TRACK, track.id, track)
|
||||
})
|
||||
const tracks = data.songs
|
||||
realm.write(() => {
|
||||
tracks.forEach(track => {
|
||||
database.set(ModelNames.TRACK, track.id, track)
|
||||
})
|
||||
write()
|
||||
})
|
||||
}
|
||||
|
||||
export async function setCache(api: string, data: any) {
|
||||
switch (api) {
|
||||
case 'song_detail':
|
||||
case 'song_detail': {
|
||||
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) {
|
||||
switch (api) {
|
||||
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 tracksRaw = realm
|
||||
.objects(ModelNames.TRACK)
|
||||
|
|
@ -78,5 +110,23 @@ export function getCache(api: string, query: any) {
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@ import Main from './components/Main'
|
|||
const App = () => {
|
||||
return (
|
||||
<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 />
|
||||
<Main />
|
||||
<Player />
|
||||
</div>
|
||||
|
||||
<Toaster position="bottom-center" containerStyle={{ bottom: '5rem' }} />
|
||||
<Toaster position='bottom-center' containerStyle={{ bottom: '5rem' }} />
|
||||
|
||||
{/* Devtool */}
|
||||
<ReactQueryDevtools
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const ArtistInline = ({
|
|||
<div className={classNames('flex truncate', className)}>
|
||||
{artists.map((artist, index) => (
|
||||
<span key={artist.id}>
|
||||
<span className="hover:underline">{artist.name}</span>
|
||||
<span className='hover:underline'>{artist.name}</span>
|
||||
{index < artists.length - 1 ? ', ' : ''}
|
||||
</span>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const Cover = ({
|
|||
const [isError, setIsError] = useState(false)
|
||||
|
||||
return (
|
||||
<div onClick={onClick} className="group relative z-0">
|
||||
<div onClick={onClick} className='group relative z-0'>
|
||||
{/* Neon shadow */}
|
||||
<div
|
||||
className={classNames(
|
||||
|
|
@ -27,8 +27,8 @@ const Cover = ({
|
|||
|
||||
{/* Cover */}
|
||||
{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">
|
||||
<SvgIcon name="music-note" className="h-1/2 w-1/2" />
|
||||
<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' />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
|
|
@ -43,9 +43,9 @@ const Cover = ({
|
|||
)}
|
||||
|
||||
{/* 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-1 h-4 w-4" name="play" />
|
||||
<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-1 h-4 w-4' name='play' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -20,12 +20,12 @@ const Title = ({
|
|||
seeMoreLink: string
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="my-4 text-[28px] font-bold text-black dark:text-white">
|
||||
<div className='flex items-baseline justify-between'>
|
||||
<div className='my-4 text-[28px] font-bold text-black dark:text-white'>
|
||||
{title}
|
||||
</div>
|
||||
{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
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -83,6 +83,8 @@ const CoverRow = ({
|
|||
seeMoreLink,
|
||||
isSkeleton,
|
||||
className,
|
||||
rows = 2,
|
||||
navigateCallback, // Callback function when click on the cover/title
|
||||
}: {
|
||||
title?: string
|
||||
albums?: Album[]
|
||||
|
|
@ -92,13 +94,15 @@ const CoverRow = ({
|
|||
seeMoreLink?: string
|
||||
isSkeleton?: boolean
|
||||
className?: string
|
||||
rows?: number
|
||||
navigateCallback?: () => void
|
||||
}) => {
|
||||
const renderItems = useMemo(() => {
|
||||
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 ?? []
|
||||
}, [albums, artists, isSkeleton, playlists])
|
||||
}, [albums, artists, isSkeleton, playlists, rows])
|
||||
|
||||
const navigate = useNavigate()
|
||||
const goTo = (id: number) => {
|
||||
|
|
@ -106,6 +110,7 @@ const CoverRow = ({
|
|||
if (albums) navigate(`/album/${id}`)
|
||||
if (playlists) navigate(`/playlist/${id}`)
|
||||
if (artists) navigate(`/artist/${id}`)
|
||||
if (navigateCallback) navigateCallback()
|
||||
}
|
||||
|
||||
const prefetch = (id: number) => {
|
||||
|
|
@ -129,12 +134,12 @@ const CoverRow = ({
|
|||
<div
|
||||
key={item.id ?? index}
|
||||
onMouseOver={() => prefetch(item.id)}
|
||||
className="grid gap-x-[24px] gap-y-7"
|
||||
className='grid gap-x-[24px] gap-y-7'
|
||||
>
|
||||
<div>
|
||||
{/* Cover */}
|
||||
{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
|
||||
onClick={() => goTo(item.id)}
|
||||
|
|
@ -143,30 +148,30 @@ const CoverRow = ({
|
|||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="mt-2">
|
||||
<div className="font-semibold">
|
||||
<div className='mt-2'>
|
||||
<div className='font-semibold'>
|
||||
{/* Name */}
|
||||
{isSkeleton ? (
|
||||
<div className="flex w-full -translate-y-px flex-col">
|
||||
<Skeleton className="w-full leading-tight">
|
||||
<div className='flex w-full -translate-y-px flex-col'>
|
||||
<Skeleton className='w-full leading-tight'>
|
||||
PLACEHOLDER
|
||||
</Skeleton>
|
||||
<Skeleton className="w-1/3 translate-y-px leading-tight">
|
||||
<Skeleton className='w-1/3 translate-y-px leading-tight'>
|
||||
PLACEHOLDER
|
||||
</Skeleton>
|
||||
</div>
|
||||
) : (
|
||||
<span className="line-clamp-2 leading-tight ">
|
||||
<span className='line-clamp-2 leading-tight '>
|
||||
{/* Playlist private icon */}
|
||||
{(item as Playlist).privacy && (
|
||||
<SvgIcon
|
||||
name="lock"
|
||||
className="mr-1 mb-1 inline-block h-3 w-3 text-gray-300"
|
||||
name='lock'
|
||||
className='mr-1 mb-1 inline-block h-3 w-3 text-gray-300'
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
|
|
@ -176,11 +181,11 @@ const CoverRow = ({
|
|||
|
||||
{/* Subtitle */}
|
||||
{isSkeleton ? (
|
||||
<Skeleton className="w-3/5 translate-y-px text-[12px]">
|
||||
<Skeleton className='w-3/5 translate-y-px text-[12px]'>
|
||||
PLACEHOLDER
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,19 +3,19 @@ import style from './DailyTracksCard.module.scss'
|
|||
|
||||
const DailyTracksCard = () => {
|
||||
return (
|
||||
<div className="relative h-[198px] cursor-pointer overflow-hidden rounded-2xl">
|
||||
<div className='relative h-[198px] cursor-pointer overflow-hidden rounded-2xl'>
|
||||
{/* Cover */}
|
||||
<img
|
||||
className={classNames(
|
||||
'absolute top-0 left-0 w-full will-change-transform',
|
||||
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="grid grid-cols-2 grid-rows-2 gap-2 text-[64px] font-semibold leading-[64px] text-white opacity-[96]">
|
||||
<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]'>
|
||||
{Array.from('每日推荐').map(word => (
|
||||
<div key={word}>{word}</div>
|
||||
))}
|
||||
|
|
@ -23,8 +23,8 @@ const DailyTracksCard = () => {
|
|||
</div>
|
||||
|
||||
{/* 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]">
|
||||
<SvgIcon name="play" className="ml-1 h-4 w-4" />
|
||||
<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' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,36 +25,36 @@ const FMCard = () => {
|
|||
|
||||
return (
|
||||
<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 }}
|
||||
>
|
||||
<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 */}
|
||||
<div>
|
||||
<div className="text-xl font-semibold">How Can I Make It OK?</div>
|
||||
<div className="opacity-75">Wolf Alice</div>
|
||||
<div className='text-xl font-semibold'>How Can I Make It OK?</div>
|
||||
<div className='opacity-75'>Wolf Alice</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className='flex items-center justify-between'>
|
||||
{/* Actions */}
|
||||
|
||||
<div>
|
||||
{Object.values(ACTION).map(action => (
|
||||
<button
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* FM logo */}
|
||||
<div className="right-4 bottom-5 flex text-white opacity-20">
|
||||
<SvgIcon name="fm" className="mr-2 h-5 w-5" />
|
||||
<span className="font-semibold">私人FM</span>
|
||||
<div className='right-4 bottom-5 flex text-white opacity-20'>
|
||||
<SvgIcon name='fm' className='mr-2 h-5 w-5' />
|
||||
<span className='font-semibold'>私人FM</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import Topbar from '@/components/Topbar'
|
|||
const Main = () => {
|
||||
return (
|
||||
<div
|
||||
id="mainContainer"
|
||||
className="relative flex h-screen max-h-screen flex-grow flex-col overflow-y-auto bg-white dark:bg-[#1d1d1d]"
|
||||
id='mainContainer'
|
||||
className='relative flex h-screen max-h-screen flex-grow flex-col overflow-y-auto bg-white dark:bg-[#1d1d1d]'
|
||||
>
|
||||
<Topbar />
|
||||
<main id="main" className="mb-24 flex-grow px-8">
|
||||
<main id='main' className='mb-24 flex-grow px-8'>
|
||||
<Router />
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -29,39 +29,39 @@ const PlayingTrack = () => {
|
|||
return (
|
||||
<Fragment>
|
||||
{track && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className='flex items-center gap-3'>
|
||||
{track?.al?.picUrl && (
|
||||
<img
|
||||
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')}
|
||||
/>
|
||||
)}
|
||||
{!track?.al?.picUrl && (
|
||||
<div
|
||||
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 className="flex flex-col justify-center leading-tight">
|
||||
<div className='flex flex-col justify-center leading-tight'>
|
||||
<div
|
||||
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}
|
||||
</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 ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IconButton onClick={() => toast('Work in progress')}>
|
||||
<SvgIcon
|
||||
className="h-4 w-4 text-black dark:text-white"
|
||||
name="heart-outline"
|
||||
className='h-4 w-4 text-black dark:text-white'
|
||||
name='heart-outline'
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
|
|
@ -76,22 +76,22 @@ const MediaControls = () => {
|
|||
const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state])
|
||||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||
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}>
|
||||
<SvgIcon className="h-4 w-4" name="previous" />
|
||||
<SvgIcon className='h-4 w-4' name='previous' />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => track && player.playOrPause()}
|
||||
disabled={!track}
|
||||
className="rounded-2xl"
|
||||
className='rounded-2xl'
|
||||
>
|
||||
<SvgIcon
|
||||
className="h-[1.5rem] w-[1.5rem] "
|
||||
className='h-[1.5rem] w-[1.5rem] '
|
||||
name={state === PlayerState.PLAYING ? 'pause' : 'play'}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton onClick={() => track && player.nextTrack()} disabled={!track}>
|
||||
<SvgIcon className="h-4 w-4" name="next" />
|
||||
<SvgIcon className='h-4 w-4' name='next' />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -99,21 +99,21 @@ const MediaControls = () => {
|
|||
|
||||
const Others = () => {
|
||||
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')}>
|
||||
<SvgIcon className="h-4 w-4" name="playlist" />
|
||||
<SvgIcon className='h-4 w-4' name='playlist' />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => toast('Work in progress')}>
|
||||
<SvgIcon className="h-4 w-4" name="repeat" />
|
||||
<SvgIcon className='h-4 w-4' name='repeat' />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => toast('Work in progress')}>
|
||||
<SvgIcon className="h-4 w-4" name="shuffle" />
|
||||
<SvgIcon className='h-4 w-4' name='shuffle' />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => toast('Work in progress')}>
|
||||
<SvgIcon className="h-4 w-4" name="volume" />
|
||||
<SvgIcon className='h-4 w-4' name='volume' />
|
||||
</IconButton>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -128,7 +128,7 @@ const Progress = () => {
|
|||
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||
|
||||
return (
|
||||
<div className="absolute w-screen">
|
||||
<div className='absolute w-screen'>
|
||||
{track && (
|
||||
<Slider
|
||||
min={0}
|
||||
|
|
@ -141,7 +141,7 @@ const Progress = () => {
|
|||
/>
|
||||
)}
|
||||
{!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>
|
||||
)
|
||||
|
|
@ -149,7 +149,7 @@ const Progress = () => {
|
|||
|
||||
const Player = () => {
|
||||
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 />
|
||||
|
||||
<PlayingTrack />
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import SvgIcon from '@/components/SvgIcon'
|
|||
import { prefetchPlaylist } from '@/hooks/usePlaylist'
|
||||
import useUser from '@/hooks/useUser'
|
||||
import useUserPlaylists from '@/hooks/useUserPlaylists'
|
||||
import { scrollToTop } from '@/utils/common'
|
||||
|
||||
interface Tab {
|
||||
name: string
|
||||
|
|
@ -34,9 +35,10 @@ const primaryTabs: PrimaryTab[] = [
|
|||
const PrimaryTabs = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="app-region-drag h-14"></div>
|
||||
<div className='app-region-drag h-14'></div>
|
||||
{primaryTabs.map(tab => (
|
||||
<NavLink
|
||||
onClick={() => scrollToTop()}
|
||||
key={tab.route}
|
||||
to={tab.route}
|
||||
className={({ isActive }: { isActive: boolean }) =>
|
||||
|
|
@ -47,12 +49,12 @@ const PrimaryTabs = () => {
|
|||
)
|
||||
}
|
||||
>
|
||||
<SvgIcon className="mr-3 h-6 w-6" name={tab.icon} />
|
||||
<span className="font-semibold">{tab.name}</span>
|
||||
<SvgIcon className='mr-3 h-6 w-6' name={tab.icon} />
|
||||
<span className='font-semibold'>{tab.name}</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -65,10 +67,11 @@ const Playlists = () => {
|
|||
})
|
||||
|
||||
return (
|
||||
<div className="mb-16 overflow-auto pb-2">
|
||||
<div className='mb-16 overflow-auto pb-2'>
|
||||
{playlists?.playlist?.map(playlist => (
|
||||
<NavLink
|
||||
key={playlist.id}
|
||||
onClick={() => scrollToTop()}
|
||||
to={`/playlist/${playlist.id}`}
|
||||
onMouseOver={() => prefetchPlaylist({ id: playlist.id })}
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -88,8 +91,8 @@ const Playlists = () => {
|
|||
const Sidebar = () => {
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
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'
|
||||
>
|
||||
<PrimaryTabs />
|
||||
<Playlists />
|
||||
|
|
|
|||
|
|
@ -122,18 +122,18 @@ const Slider = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="group flex h-2 -translate-y-[3px] items-center"
|
||||
className='group flex h-2 -translate-y-[3px] items-center'
|
||||
ref={sliderRef}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 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 */}
|
||||
<div
|
||||
className={classNames(
|
||||
'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}
|
||||
></div>
|
||||
|
|
@ -148,7 +148,7 @@ const Slider = ({
|
|||
onClick={e => e.stopPropagation()}
|
||||
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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ const Slider = () => {
|
|||
onMouseEnter={() => setIsHover(true)}
|
||||
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
|
||||
className={classNames(
|
||||
'absolute h-[2px]',
|
||||
|
|
@ -43,16 +43,16 @@ const Slider = () => {
|
|||
)}
|
||||
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>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
type='range'
|
||||
min='0'
|
||||
max='100'
|
||||
value={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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
const SvgIcon = ({ name, className }: { name: string; className?: string }) => {
|
||||
const symbolId = `#icon-${name}`
|
||||
return (
|
||||
<svg aria-hidden="true" className={className}>
|
||||
<use href={symbolId} fill="currentColor" />
|
||||
<svg aria-hidden='true' className={className}>
|
||||
<use href={symbolId} fill='currentColor' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,14 +14,14 @@ const NavigationButtons = () => {
|
|||
if (action === ACTION.FORWARD) navigate(1)
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<div className='flex gap-1'>
|
||||
{[ACTION.BACK, ACTION.FORWARD].map(action => (
|
||||
<div
|
||||
onClick={() => handleNavigate(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>
|
||||
|
|
@ -30,15 +30,15 @@ const NavigationButtons = () => {
|
|||
|
||||
const SearchBox = () => {
|
||||
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
|
||||
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"
|
||||
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'
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-transparent placeholder:text-gray-500 dark:text-white dark:placeholder:text-gray-400"
|
||||
placeholder="Search"
|
||||
type='text'
|
||||
className='w-full bg-transparent placeholder:text-gray-500 dark:text-white dark:placeholder:text-gray-400'
|
||||
placeholder='Search'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -46,8 +46,8 @@ const SearchBox = () => {
|
|||
|
||||
const Settings = () => {
|
||||
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">
|
||||
<SvgIcon className="h-5 w-5" name="settings" />
|
||||
<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' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -59,7 +59,7 @@ const Avatar = () => {
|
|||
<img
|
||||
src={user?.profile?.avatarUrl}
|
||||
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]'
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<div className='flex gap-2'>
|
||||
<NavigationButtons />
|
||||
<SearchBox />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className='flex items-center gap-3'>
|
||||
<Settings />
|
||||
<Avatar />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ const PlayOrPauseButtonInTrack = memo(
|
|||
)}
|
||||
>
|
||||
<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'}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -74,10 +74,10 @@ const Track = memo(
|
|||
)}
|
||||
>
|
||||
{/* 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 */}
|
||||
{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 && (
|
||||
<div
|
||||
|
|
@ -101,9 +101,9 @@ const Track = memo(
|
|||
)}
|
||||
|
||||
{/* Track name */}
|
||||
<div className="flex">
|
||||
<div className='flex'>
|
||||
{isSkeleton ? (
|
||||
<Skeleton className="text-lg">
|
||||
<Skeleton className='text-lg'>
|
||||
PLACEHOLDER123456789012345
|
||||
</Skeleton>
|
||||
) : (
|
||||
|
|
@ -120,7 +120,7 @@ const Track = memo(
|
|||
</div>
|
||||
|
||||
{/* Artists */}
|
||||
<div className="col-span-4 flex items-center">
|
||||
<div className='col-span-4 flex items-center'>
|
||||
{isSkeleton ? (
|
||||
<Skeleton>PLACEHOLDER1234</Skeleton>
|
||||
) : (
|
||||
|
|
@ -136,7 +136,7 @@ const Track = memo(
|
|||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
{!isSkeleton && (
|
||||
<button
|
||||
|
|
@ -150,7 +150,7 @@ const Track = memo(
|
|||
>
|
||||
<SvgIcon
|
||||
name={isLiked ? 'heart' : 'heart-outline'}
|
||||
className="h-4 w-4 "
|
||||
className='h-4 w-4 '
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -209,15 +209,15 @@ const TracksAlbum = ({
|
|||
)
|
||||
|
||||
return (
|
||||
<div className="grid w-full">
|
||||
<div className='grid w-full'>
|
||||
{/* 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="col-span-6 grid grid-cols-[2rem_auto]">
|
||||
<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>#</div>
|
||||
<div>TITLE</div>
|
||||
</div>
|
||||
<div className="col-span-4">ARTIST</div>
|
||||
<div className="col-span-2 justify-self-end">TIME</div>
|
||||
<div className='col-span-4'>ARTIST</div>
|
||||
<div className='col-span-2 justify-self-end'>TIME</div>
|
||||
</div>
|
||||
|
||||
{/* Tracks */}
|
||||
|
|
|
|||
|
|
@ -16,38 +16,38 @@ const TrackListGrid = ({
|
|||
'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 */}
|
||||
<div>
|
||||
{!isSkeleton && (
|
||||
<img
|
||||
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 && (
|
||||
<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>
|
||||
|
||||
{/* Track name & Artists */}
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className='flex flex-col justify-center'>
|
||||
{!isSkeleton && (
|
||||
<div
|
||||
v-if="!isSkeleton"
|
||||
className="line-clamp-1 break-all text-base font-semibold"
|
||||
v-if='!isSkeleton'
|
||||
className='line-clamp-1 break-all text-base font-semibold'
|
||||
>
|
||||
{track.name}
|
||||
</div>
|
||||
)}
|
||||
{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 && (
|
||||
<Skeleton className="w-2/3 translate-y-px">PLACE</Skeleton>
|
||||
<Skeleton className='w-2/3 translate-y-px'>PLACE</Skeleton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -33,32 +33,32 @@ const Track = memo(
|
|||
)}
|
||||
>
|
||||
{/* 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 */}
|
||||
<div>
|
||||
{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
|
||||
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>
|
||||
|
||||
{/* Track name & Artists */}
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className='flex flex-col justify-center'>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
{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} />
|
||||
)}
|
||||
|
|
@ -67,7 +67,7 @@ const Track = memo(
|
|||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<Skeleton>PLACEHOLDER1234567890</Skeleton>
|
||||
) : (
|
||||
|
|
@ -75,17 +75,17 @@ const Track = memo(
|
|||
<NavLink
|
||||
to={`/album/${track.al.id}`}
|
||||
onMouseOver={() => prefetchAlbum({ id: track.al.id })}
|
||||
className="hover:underline"
|
||||
className='hover:underline'
|
||||
>
|
||||
{track.al.name}
|
||||
</NavLink>
|
||||
<span className="flex-grow"></span>
|
||||
<span className='flex-grow'></span>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
{!isSkeleton && (
|
||||
<button
|
||||
|
|
@ -98,7 +98,7 @@ const Track = memo(
|
|||
>
|
||||
<SvgIcon
|
||||
name={isLiked ? 'heart' : 'heart-outline'}
|
||||
className="h-4 w-4 "
|
||||
className='h-4 w-4 '
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -107,7 +107,7 @@ const Track = memo(
|
|||
{isSkeleton ? (
|
||||
<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')}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -146,16 +146,16 @@ 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]">
|
||||
<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></div>
|
||||
<div>TITLE</div>
|
||||
</div>
|
||||
<div className="col-span-4">ALBUM</div>
|
||||
<div className="col-span-2 justify-self-end">TIME</div>
|
||||
<div className='col-span-4'>ALBUM</div>
|
||||
<div className='col-span-2 justify-self-end'>TIME</div>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full gap-1">
|
||||
<div className='grid w-full gap-1'>
|
||||
{/* Tracks */}
|
||||
{!isSkeleton &&
|
||||
tracks.map(track => (
|
||||
|
|
|
|||
|
|
@ -10,7 +10,12 @@ import useAlbum from '@/hooks/useAlbum'
|
|||
import useArtistAlbums from '@/hooks/useArtistAlbums'
|
||||
import { player } from '@/store'
|
||||
import { State as PlayerState } from '@/utils/player'
|
||||
import { formatDate, formatDuration, resizeImage } from '@/utils/common'
|
||||
import {
|
||||
formatDate,
|
||||
formatDuration,
|
||||
resizeImage,
|
||||
scrollToTop,
|
||||
} from '@/utils/common'
|
||||
|
||||
const PlayButton = ({
|
||||
album,
|
||||
|
|
@ -49,7 +54,7 @@ const PlayButton = ({
|
|||
<Button onClick={wrappedHandlePlay} isSkelton={isLoading}>
|
||||
<SvgIcon
|
||||
name={isPlaying && isThisAlbumPlaying ? 'pause' : 'play'}
|
||||
className="mr-2 h-4 w-4"
|
||||
className='mr-2 h-4 w-4'
|
||||
/>
|
||||
{isPlaying && isThisAlbumPlaying ? '暂停' : '播放'}
|
||||
</Button>
|
||||
|
|
@ -77,29 +82,29 @@ const Header = ({
|
|||
return (
|
||||
<Fragment>
|
||||
{/* 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 && (
|
||||
<Fragment>
|
||||
<img
|
||||
src={coverUrl}
|
||||
className="absolute -top-full w-full blur-[100px]"
|
||||
className='absolute -top-full w-full blur-[100px]'
|
||||
/>
|
||||
<img
|
||||
src={coverUrl}
|
||||
className="absolute -top-full w-full blur-[100px]"
|
||||
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/[.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 className="grid grid-cols-[17rem_auto] items-center gap-9">
|
||||
<div className='grid grid-cols-[17rem_auto] items-center gap-9'>
|
||||
{/* Cover */}
|
||||
<div className="relative z-0 aspect-square self-start">
|
||||
<div className='relative z-0 aspect-square self-start'>
|
||||
{/* Neon shadow */}
|
||||
{!isLoading && coverUrl && !isCoverError && (
|
||||
<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={{
|
||||
backgroundImage: `url("${coverUrl}")`,
|
||||
}}
|
||||
|
|
@ -108,41 +113,41 @@ const Header = ({
|
|||
|
||||
{!isLoading && isCoverError ? (
|
||||
// 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">
|
||||
<SvgIcon name="music-note" className="h-1/2 w-1/2" />
|
||||
<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' />
|
||||
</div>
|
||||
) : (
|
||||
coverUrl && (
|
||||
<img
|
||||
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)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{isLoading && <Skeleton className="h-full w-full rounded-2xl" />}
|
||||
{isLoading && <Skeleton className='h-full w-full rounded-2xl' />}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="z-10 flex h-full flex-col justify-between">
|
||||
<div className='z-10 flex h-full flex-col justify-between'>
|
||||
{/* Name */}
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Artist */}
|
||||
{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{' '}
|
||||
<NavLink
|
||||
to={`/artist/${album?.artist.name}`}
|
||||
className="cursor-default font-semibold hover:underline"
|
||||
className='cursor-default font-semibold hover:underline'
|
||||
>
|
||||
{album?.artist.name}
|
||||
</NavLink>
|
||||
|
|
@ -151,11 +156,11 @@ const Header = ({
|
|||
|
||||
{/* Release date & track count & album duration */}
|
||||
{isLoading ? (
|
||||
<Skeleton className="w-72 translate-y-px text-sm">
|
||||
<Skeleton className='w-72 translate-y-px text-sm'>
|
||||
PLACEHOLDER
|
||||
</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,{' '}
|
||||
{albumDuration}
|
||||
</div>
|
||||
|
|
@ -163,17 +168,17 @@ const Header = ({
|
|||
|
||||
{/* Description */}
|
||||
{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
|
||||
</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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="mt-5 flex gap-4">
|
||||
<div className='mt-5 flex gap-4'>
|
||||
<PlayButton {...{ album, handlePlay, isLoading }} />
|
||||
|
||||
<Button
|
||||
|
|
@ -181,7 +186,7 @@ const Header = ({
|
|||
isSkelton={isLoading}
|
||||
onClick={() => toast('Work in progress')}
|
||||
>
|
||||
<SvgIcon name="heart" className="h-4 w-4" />
|
||||
<SvgIcon name='heart' className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
|
|
@ -190,7 +195,7 @@ const Header = ({
|
|||
isSkelton={isLoading}
|
||||
onClick={() => toast('Work in progress')}
|
||||
>
|
||||
<SvgIcon name="more" className="h-4 w-4" />
|
||||
<SvgIcon name='more' className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -208,7 +213,6 @@ const MoreAlbum = ({ album }: { album: Album | undefined }) => {
|
|||
|
||||
const filteredAlbums = useMemo((): Album[] => {
|
||||
if (!albums) return []
|
||||
const albumID = album?.id
|
||||
const allReleases = albums?.hotAlbums || []
|
||||
const filteredAlbums = allReleases.filter(
|
||||
album =>
|
||||
|
|
@ -218,19 +222,18 @@ const MoreAlbum = ({ album }: { album: Album | undefined }) => {
|
|||
|
||||
const qualifiedAlbums = [...filteredAlbums, ...singles]
|
||||
|
||||
const formatName = (name: string) =>
|
||||
name.toLowerCase().replace(/(\s|deluxe|edition|\(|\))/g, '')
|
||||
|
||||
const uniqueAlbums: Album[] = []
|
||||
qualifiedAlbums.forEach(a => {
|
||||
// 去除当前页面的专辑
|
||||
if (Number(a.id) === Number(albumID) || album?.name === a.name) return
|
||||
if (formatName(a.name) === formatName(album?.name ?? '')) return
|
||||
|
||||
// 去除重复的专辑(包含 deluxe edition 的专辑会视为重复)
|
||||
if (
|
||||
uniqueAlbums.findIndex(aa => {
|
||||
return (
|
||||
a.name === aa.name ||
|
||||
a.name.toLowerCase().replace(/(\s|deluxe|edition|\(|\))/g, '') ===
|
||||
aa.name.toLowerCase().replace(/(\s|deluxe|edition|\(|\))/g, '')
|
||||
)
|
||||
return formatName(a.name) === formatName(aa.name)
|
||||
}) !== -1
|
||||
) {
|
||||
return
|
||||
|
|
@ -248,25 +251,27 @@ const MoreAlbum = ({ album }: { album: Album | undefined }) => {
|
|||
})
|
||||
|
||||
return uniqueAlbums.slice(0, 5)
|
||||
}, [album?.id, album?.name, albums])
|
||||
}, [album?.name, albums])
|
||||
|
||||
return (
|
||||
<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='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'>
|
||||
More by{' '}
|
||||
<NavLink
|
||||
to={`/artist/${album?.artist?.id}`}
|
||||
className="cursor-default hover:underline"
|
||||
className='cursor-default hover:underline'
|
||||
>
|
||||
{album?.artist.name}
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className='mt-3'>
|
||||
<CoverRow
|
||||
albums={filteredAlbums}
|
||||
subtitle={Subtitle.TYPE_RELEASE_YEAR}
|
||||
isSkeleton={isLoading}
|
||||
rows={1}
|
||||
navigateCallback={scrollToTop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -289,7 +294,7 @@ const Album = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<div className='mt-10'>
|
||||
<Header
|
||||
album={album?.album}
|
||||
isLoading={isLoading}
|
||||
|
|
@ -301,10 +306,10 @@ const Album = () => {
|
|||
isSkeleton={isLoading}
|
||||
/>
|
||||
{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>
|
||||
{album.album.company && (
|
||||
<div className="mt-[2px]">© {album.album.company} </div>
|
||||
<div className='mt-[2px]'>© {album.album.company} </div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -14,15 +14,15 @@ export default function Home() {
|
|||
return (
|
||||
<div>
|
||||
<CoverRow
|
||||
title="Good Morning"
|
||||
title='Good Morning'
|
||||
playlists={recommendedPlaylists?.result.slice(0, 10) ?? []}
|
||||
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
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className='grid grid-cols-2 gap-6'>
|
||||
<DailyTracksCard />
|
||||
<FMCard />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -21,13 +21,13 @@ const EmailInput = ({
|
|||
setEmail: (email: string) => void
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-1 text-sm font-medium text-gray-700">Email</div>
|
||||
<div className='w-full'>
|
||||
<div className='mb-1 text-sm font-medium text-gray-700'>Email</div>
|
||||
<input
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-2 py-2"
|
||||
type="email"
|
||||
className='w-full rounded-md border border-gray-300 px-2 py-2'
|
||||
type='email'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -45,9 +45,9 @@ const PhoneInput = ({
|
|||
setPhone: (phone: string) => void
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-1 text-sm font-medium text-gray-700">Phone</div>
|
||||
<div className="flex w-full">
|
||||
<div className='w-full'>
|
||||
<div className='mb-1 text-sm font-medium text-gray-700'>Phone</div>
|
||||
<div className='flex w-full'>
|
||||
<input
|
||||
className={classNames(
|
||||
'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 >= 5 && 'w-20'
|
||||
)}
|
||||
type="text"
|
||||
placeholder="+86"
|
||||
type='text'
|
||||
placeholder='+86'
|
||||
value={countryCode}
|
||||
onChange={e => setCountryCode(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="flex-grow rounded-md rounded-l-none border border-gray-300 px-3 py-2"
|
||||
type="text"
|
||||
className='flex-grow rounded-md rounded-l-none border border-gray-300 px-3 py-2'
|
||||
type='text'
|
||||
value={phone}
|
||||
onChange={e => setPhone(e.target.value)}
|
||||
/>
|
||||
|
|
@ -80,22 +80,22 @@ const PasswordInput = ({
|
|||
}) => {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
return (
|
||||
<div className="mt-3 flex w-full flex-col">
|
||||
<div className="mb-1 text-sm font-medium text-gray-700">Password</div>
|
||||
<div className="flex w-full">
|
||||
<div className='mt-3 flex w-full flex-col'>
|
||||
<div className='mb-1 text-sm font-medium text-gray-700'>Password</div>
|
||||
<div className='flex w-full'>
|
||||
<input
|
||||
value={password}
|
||||
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'}
|
||||
/>
|
||||
<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
|
||||
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
|
||||
className="h-5 w-5"
|
||||
className='h-5 w-5'
|
||||
name={showPassword ? 'eye-off' : 'eye'}
|
||||
/>
|
||||
</button>
|
||||
|
|
@ -153,21 +153,21 @@ const OtherLoginMethods = ({
|
|||
]
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="mt-8 mb-4 flex w-full items-center">
|
||||
<span className="h-px flex-grow bg-gray-300"></span>
|
||||
<span className="mx-2 text-sm text-gray-400">or</span>
|
||||
<span className="h-px flex-grow bg-gray-300"></span>
|
||||
<div className='mt-8 mb-4 flex w-full items-center'>
|
||||
<span className='h-px flex-grow bg-gray-300'></span>
|
||||
<span className='mx-2 text-sm text-gray-400'>or</span>
|
||||
<span className='h-px flex-grow bg-gray-300'></span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className='flex gap-3'>
|
||||
{otherLoginMethods.map(
|
||||
({ id, name }) =>
|
||||
method !== id && (
|
||||
<button
|
||||
key={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>
|
||||
</button>
|
||||
)
|
||||
|
|
@ -274,11 +274,11 @@ const LoginWithQRCode = () => {
|
|||
}, [qrCodeUrl])
|
||||
const qrCodeMessage = 'test'
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="rounded-3xl border p-6">
|
||||
<img src={qrCodeImage} alt="QR Code" className="no-drag" />
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='rounded-3xl border p-6'>
|
||||
<img src={qrCodeImage} alt='QR Code' className='no-drag' />
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-gray-500">{qrCodeMessage}</div>
|
||||
<div className='mt-4 text-sm text-gray-500'>{qrCodeMessage}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -287,8 +287,8 @@ export default function Login() {
|
|||
const [method, setMethod] = useState<Method>(Method.PHONE)
|
||||
|
||||
return (
|
||||
<div className="grid h-full place-content-center">
|
||||
<div className="w-80">
|
||||
<div className='grid h-full place-content-center'>
|
||||
<div className='w-80'>
|
||||
{method === Method.EMAIL && <LoginWithEmail />}
|
||||
{method === Method.PHONE && <LoginWithPhone />}
|
||||
{method === Method.QRCODE && <LoginWithQRCode />}
|
||||
|
|
|
|||
|
|
@ -27,18 +27,18 @@ const Header = memo(
|
|||
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]" />
|
||||
<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 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]' />
|
||||
<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 className="grid grid-cols-[16rem_auto] items-center gap-9">
|
||||
<div className='grid grid-cols-[16rem_auto] items-center gap-9'>
|
||||
{/* Cover */}
|
||||
<div className="relative z-0 aspect-square self-start">
|
||||
<div className='relative z-0 aspect-square self-start'>
|
||||
{!isLoading && (
|
||||
<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={{
|
||||
backgroundImage: `url("${coverUrl}")`,
|
||||
}}
|
||||
|
|
@ -48,70 +48,70 @@ const Header = memo(
|
|||
{!isLoading && (
|
||||
<img
|
||||
src={coverUrl}
|
||||
className="rounded-2xl border border-black border-opacity-5"
|
||||
className='rounded-2xl border border-black border-opacity-5'
|
||||
/>
|
||||
)}
|
||||
{isLoading && (
|
||||
<Skeleton v-else className="h-full w-full rounded-2xl" />
|
||||
<Skeleton v-else className='h-full w-full rounded-2xl' />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* <!-- 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 --> */}
|
||||
{!isLoading && (
|
||||
<div className="text-4xl font-bold dark:text-white">
|
||||
<div className='text-4xl font-bold dark:text-white'>
|
||||
{playlist?.name}
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<Skeleton v-else className="w-3/4 text-4xl">
|
||||
<Skeleton v-else className='w-3/4 text-4xl'>
|
||||
PLACEHOLDER
|
||||
</Skeleton>
|
||||
)}
|
||||
|
||||
{/* <!-- Playlist creator --> */}
|
||||
{!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>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<Skeleton v-else className="mt-5 w-64 text-lg">
|
||||
<Skeleton v-else className='mt-5 w-64 text-lg'>
|
||||
PLACEHOLDER
|
||||
</Skeleton>
|
||||
)}
|
||||
|
||||
{/* <!-- Playlist last update time & track count --> */}
|
||||
{!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
|
||||
{formatDate(playlist?.updateTime || 0, 'en')} ·
|
||||
{playlist?.trackCount} Songs
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<Skeleton v-else className="w-72 translate-y-px text-sm">
|
||||
<Skeleton v-else className='w-72 translate-y-px text-sm'>
|
||||
PLACEHOLDER
|
||||
</Skeleton>
|
||||
)}
|
||||
|
||||
{/* <!-- Playlist description --> */}
|
||||
{!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}
|
||||
</div>
|
||||
)}
|
||||
{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
|
||||
</Skeleton>
|
||||
)}
|
||||
|
||||
{/* <!-- Buttons --> */}
|
||||
<div className="mt-5 flex gap-4">
|
||||
<div className='mt-5 flex gap-4'>
|
||||
<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
|
||||
</Button>
|
||||
|
||||
|
|
@ -120,7 +120,7 @@ const Header = memo(
|
|||
isSkelton={isLoading}
|
||||
onClick={() => toast('Work in progress')}
|
||||
>
|
||||
<SvgIcon name="heart" className="h-4 w-4" />
|
||||
<SvgIcon name='heart' className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
|
|
@ -129,7 +129,7 @@ const Header = memo(
|
|||
isSkelton={isLoading}
|
||||
onClick={() => toast('Work in progress')}
|
||||
>
|
||||
<SvgIcon name="more" className="h-4 w-4" />
|
||||
<SvgIcon name='more' className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -225,7 +225,7 @@ const Playlist = () => {
|
|||
)
|
||||
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<div className='mt-10'>
|
||||
<Header
|
||||
playlist={playlist?.playlist}
|
||||
isLoading={isLoading}
|
||||
|
|
|
|||
|
|
@ -102,3 +102,9 @@ export function formatDuration(
|
|||
export function sleep(time: number) {
|
||||
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' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ module.exports = {
|
|||
bracketSpacing: true,
|
||||
htmlWhitespaceSensitivity: 'strict',
|
||||
singleQuote: true,
|
||||
jsxSingleQuote: true,
|
||||
|
||||
// Tailwind CSS
|
||||
plugins: [require('prettier-plugin-tailwindcss')],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue