mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-16 21:28:06 +00:00
feat: monorepo
This commit is contained in:
parent
4d54060a4f
commit
42089d4996
200 changed files with 1530 additions and 1521 deletions
361
packages/web/pages/Album.tsx
Normal file
361
packages/web/pages/Album.tsx
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
import dayjs from 'dayjs'
|
||||
import { NavLink, useParams } from 'react-router-dom'
|
||||
import Button, { Color as ButtonColor } from '@/web/components/Button'
|
||||
import CoverRow, { Subtitle } from '@/web/components/CoverRow'
|
||||
import Skeleton from '@/web/components/Skeleton'
|
||||
import SvgIcon from '@/web/components/SvgIcon'
|
||||
import TracksAlbum from '@/web/components/TracksAlbum'
|
||||
import useAlbum from '@/web/hooks/useAlbum'
|
||||
import useArtistAlbums from '@/web/hooks/useArtistAlbums'
|
||||
import { player } from '@/web/store'
|
||||
import {
|
||||
Mode as PlayerMode,
|
||||
State as PlayerState,
|
||||
TrackListSourceType,
|
||||
} from '@/web/utils/player'
|
||||
import {
|
||||
formatDate,
|
||||
formatDuration,
|
||||
resizeImage,
|
||||
scrollToTop,
|
||||
} from '@/web/utils/common'
|
||||
import useTracks from '@/web/hooks/useTracks'
|
||||
import useUserAlbums, {
|
||||
useMutationLikeAAlbum,
|
||||
} from '@/web/hooks/useUserAlbums'
|
||||
import { useMemo, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const PlayButton = ({
|
||||
album,
|
||||
handlePlay,
|
||||
isLoading,
|
||||
}: {
|
||||
album: Album | undefined
|
||||
handlePlay: () => void
|
||||
isLoading: boolean
|
||||
}) => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const isThisAlbumPlaying = useMemo(
|
||||
() =>
|
||||
playerSnapshot.mode === PlayerMode.TrackList &&
|
||||
playerSnapshot.trackListSource?.type === TrackListSourceType.Album &&
|
||||
playerSnapshot.trackListSource?.id === album?.id,
|
||||
[
|
||||
playerSnapshot.mode,
|
||||
playerSnapshot.trackListSource?.type,
|
||||
playerSnapshot.trackListSource?.id,
|
||||
album?.id,
|
||||
]
|
||||
)
|
||||
|
||||
const isPlaying =
|
||||
isThisAlbumPlaying &&
|
||||
[PlayerState.Playing, PlayerState.Loading].includes(playerSnapshot.state)
|
||||
|
||||
const wrappedHandlePlay = () => {
|
||||
if (isThisAlbumPlaying) {
|
||||
player.playOrPause()
|
||||
} else {
|
||||
handlePlay()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={wrappedHandlePlay} isSkelton={isLoading}>
|
||||
<SvgIcon
|
||||
name={isPlaying ? 'pause' : 'play'}
|
||||
className='mr-1 -ml-1 h-6 w-6'
|
||||
/>
|
||||
{isPlaying ? '暂停' : '播放'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const Header = ({
|
||||
album,
|
||||
isLoading,
|
||||
handlePlay,
|
||||
}: {
|
||||
album: Album | undefined
|
||||
isLoading: boolean
|
||||
handlePlay: () => void
|
||||
}) => {
|
||||
const coverUrl = resizeImage(album?.picUrl || '', 'lg')
|
||||
|
||||
const albumDuration = useMemo(() => {
|
||||
const duration = album?.songs?.reduce((acc, cur) => acc + cur.dt, 0) || 0
|
||||
return formatDuration(duration, 'zh-CN', 'hh[hr] mm[min]')
|
||||
}, [album?.songs])
|
||||
|
||||
const [isCoverError, setCoverError] = useState(
|
||||
coverUrl.includes('3132508627578625')
|
||||
)
|
||||
|
||||
const { data: userAlbums } = useUserAlbums()
|
||||
const isThisAlbumLiked = useMemo(() => {
|
||||
if (!album) return false
|
||||
return !!userAlbums?.data?.find(a => a.id === album.id)
|
||||
}, [album, userAlbums?.data])
|
||||
const mutationLikeAAlbum = useMutationLikeAAlbum()
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header background */}
|
||||
<div className='absolute top-0 left-0 z-0 h-[24rem] w-full overflow-hidden'>
|
||||
{coverUrl && !isCoverError && (
|
||||
<>
|
||||
<img
|
||||
src={coverUrl}
|
||||
className='absolute -top-full w-full blur-[100px]'
|
||||
/>
|
||||
<img
|
||||
src={coverUrl}
|
||||
className='absolute -top-full w-full blur-[100px]'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className='absolute top-0 h-full w-full bg-gradient-to-b from-white/[.85] to-white dark:from-black/50 dark:to-[#1d1d1d]'></div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-[17rem_auto] items-center gap-9'>
|
||||
{/* Cover */}
|
||||
<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'
|
||||
style={{
|
||||
backgroundImage: `url("${coverUrl}")`,
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
) : (
|
||||
coverUrl && (
|
||||
<img
|
||||
src={coverUrl}
|
||||
className='w-full 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' />}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className='z-10 flex h-full flex-col justify-between'>
|
||||
{/* Name */}
|
||||
{isLoading ? (
|
||||
<Skeleton className='w-3/4 text-6xl'>PLACEHOLDER</Skeleton>
|
||||
) : (
|
||||
<div className='text-6xl font-bold dark:text-white'>
|
||||
{album?.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Artist */}
|
||||
{isLoading ? (
|
||||
<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'>
|
||||
Album ·{' '}
|
||||
<NavLink
|
||||
to={`/artist/${album?.artist.id}`}
|
||||
className='cursor-default font-semibold hover:underline'
|
||||
>
|
||||
{album?.artist.name}
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Release date & track count & album duration */}
|
||||
{isLoading ? (
|
||||
<Skeleton className='w-72 translate-y-px text-sm'>
|
||||
PLACEHOLDER
|
||||
</Skeleton>
|
||||
) : (
|
||||
<div className='flex items-center text-sm text-gray-500 dark:text-gray-400'>
|
||||
{album?.mark === 1056768 && (
|
||||
<SvgIcon
|
||||
name='explicit'
|
||||
className='mt-px mr-1 h-4 w-4 text-gray-400 dark:text-gray-500'
|
||||
/>
|
||||
)}
|
||||
{dayjs(album?.publishTime || 0).year()} · {album?.size} 首歌 ·{' '}
|
||||
{albumDuration}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{isLoading ? (
|
||||
<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'>
|
||||
{album?.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<div className='mt-5 flex gap-4'>
|
||||
<PlayButton {...{ album, handlePlay, isLoading }} />
|
||||
|
||||
<Button
|
||||
color={ButtonColor.Gray}
|
||||
iconColor={
|
||||
isThisAlbumLiked ? ButtonColor.Primary : ButtonColor.Gray
|
||||
}
|
||||
isSkelton={isLoading}
|
||||
onClick={() => album?.id && mutationLikeAAlbum.mutate(album)}
|
||||
>
|
||||
<SvgIcon
|
||||
name={isThisAlbumLiked ? 'heart' : 'heart-outline'}
|
||||
className='h-6 w-6'
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color={ButtonColor.Gray}
|
||||
iconColor={ButtonColor.Gray}
|
||||
isSkelton={isLoading}
|
||||
onClick={() => toast('施工中...')}
|
||||
>
|
||||
<SvgIcon name='more' className='h-6 w-6' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const MoreAlbum = ({ album }: { album: Album | undefined }) => {
|
||||
// Fetch artist's albums
|
||||
const { data: albums, isLoading } = useArtistAlbums({
|
||||
id: album?.artist.id ?? 0,
|
||||
limit: 1000,
|
||||
})
|
||||
|
||||
const filteredAlbums = useMemo((): Album[] => {
|
||||
if (!albums) return []
|
||||
const allReleases = albums?.hotAlbums || []
|
||||
const filteredAlbums = allReleases.filter(
|
||||
album =>
|
||||
['专辑', 'EP/Single', 'EP'].includes(album.type) && album.size > 1
|
||||
)
|
||||
const singles = allReleases.filter(album => album.type === 'Single')
|
||||
|
||||
const qualifiedAlbums = [...filteredAlbums, ...singles]
|
||||
|
||||
const formatName = (name: string) =>
|
||||
name.toLowerCase().replace(/(\s|deluxe|edition|\(|\))/g, '')
|
||||
|
||||
const uniqueAlbums: Album[] = []
|
||||
qualifiedAlbums.forEach(a => {
|
||||
// 去除当前页面的专辑
|
||||
if (formatName(a.name) === formatName(album?.name ?? '')) return
|
||||
|
||||
// 去除重复的专辑(包含 deluxe edition 的专辑会视为重复)
|
||||
if (
|
||||
uniqueAlbums.findIndex(aa => {
|
||||
return formatName(a.name) === formatName(aa.name)
|
||||
}) !== -1
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// 去除 remix 专辑
|
||||
if (
|
||||
a.name.toLowerCase().includes('remix)') ||
|
||||
a.name.toLowerCase().includes('remixes)')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
uniqueAlbums.push(a)
|
||||
})
|
||||
|
||||
return uniqueAlbums.slice(0, 5)
|
||||
}, [album?.name, albums])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='my-5 h-px w-full bg-gray-100 dark:bg-gray-800'></div>
|
||||
{!isLoading && albums?.hotAlbums?.length && (
|
||||
<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'
|
||||
>
|
||||
{album?.artist.name}
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
<div className='mt-3'>
|
||||
<CoverRow
|
||||
albums={
|
||||
filteredAlbums.length ? filteredAlbums : albums?.hotAlbums || []
|
||||
}
|
||||
subtitle={Subtitle.TypeReleaseYear}
|
||||
isSkeleton={isLoading}
|
||||
rows={1}
|
||||
navigateCallback={scrollToTop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Album = () => {
|
||||
const params = useParams()
|
||||
const { data: album, isLoading } = useAlbum({
|
||||
id: Number(params.id) || 0,
|
||||
})
|
||||
|
||||
const { data: tracks } = useTracks({
|
||||
ids: album?.songs?.map(track => track.id) ?? [],
|
||||
})
|
||||
|
||||
const handlePlay = async (trackID: number | null = null) => {
|
||||
if (!album?.album.id) {
|
||||
toast('无法播放专辑,该专辑不存在')
|
||||
return
|
||||
}
|
||||
await player.playAlbum(album.album.id, trackID)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-10'>
|
||||
<Header
|
||||
album={album?.album}
|
||||
isLoading={isLoading}
|
||||
handlePlay={handlePlay}
|
||||
/>
|
||||
<TracksAlbum
|
||||
tracks={tracks?.songs ?? album?.album.songs ?? []}
|
||||
onTrackDoubleClick={handlePlay}
|
||||
isSkeleton={isLoading}
|
||||
/>
|
||||
{album?.album && (
|
||||
<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>
|
||||
)}
|
||||
{!isLoading && <MoreAlbum album={album?.album} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Album
|
||||
235
packages/web/pages/Artist.tsx
Normal file
235
packages/web/pages/Artist.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import Button, { Color as ButtonColor } from '@/web/components/Button'
|
||||
import SvgIcon from '@/web/components/SvgIcon'
|
||||
import Cover from '@/web/components/Cover'
|
||||
import useArtist from '@/web/hooks/useArtist'
|
||||
import useArtistAlbums from '@/web/hooks/useArtistAlbums'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import dayjs from 'dayjs'
|
||||
import TracksGrid from '@/web/components/TracksGrid'
|
||||
import CoverRow, { Subtitle } from '@/web/components/CoverRow'
|
||||
import Skeleton from '@/web/components/Skeleton'
|
||||
import useTracks from '@/web/hooks/useTracks'
|
||||
import { player } from '@/web/store'
|
||||
import cx from 'classnames'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
const Header = ({ artist }: { artist: Artist | undefined }) => {
|
||||
const coverImage = resizeImage(artist?.img1v1Url || '', 'md')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='absolute top-0 left-0 z-0 h-[24rem] w-full overflow-hidden'>
|
||||
{coverImage && (
|
||||
<>
|
||||
<img src={coverImage} className='absolute w-full blur-[100px]' />
|
||||
<img src={coverImage} className='absolute w-full blur-[100px]' />
|
||||
</>
|
||||
)}
|
||||
<div className='absolute top-0 h-full w-full bg-gradient-to-b from-white/[.85] to-white dark:from-black/50 dark:to-[#1d1d1d]'></div>
|
||||
</div>
|
||||
|
||||
<div className='relative mt-6 overflow-hidden rounded-2xl bg-gray-500/10 dark:bg-gray-800/20'>
|
||||
<div className='flex h-[26rem] justify-center overflow-hidden'>
|
||||
<img src={coverImage} className='aspect-square brightness-[.5]' />
|
||||
<img src={coverImage} className='aspect-square brightness-[.5]' />
|
||||
<img src={coverImage} />
|
||||
<img src={coverImage} className='aspect-square brightness-[.5]' />
|
||||
<img src={coverImage} className='aspect-square brightness-[.5]' />
|
||||
</div>
|
||||
|
||||
<div className='absolute right-0 left-0 top-[18rem] h-32 bg-gradient-to-t from-[#222]/60 to-transparent'></div>
|
||||
|
||||
<div className='absolute top-0 right-0 left-0 flex h-[26rem] items-end justify-between p-8 pb-6'>
|
||||
<div className='text-7xl font-bold text-white'>{artist?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const LatestRelease = ({
|
||||
album,
|
||||
isLoading,
|
||||
}: {
|
||||
album: Album | undefined
|
||||
isLoading: boolean
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const toAlbum = () => navigate(`/album/${album?.id}`)
|
||||
return (
|
||||
<div>
|
||||
<div className='mb-6 text-2xl font-semibold text-gray-800 dark:text-white'>
|
||||
最新发行
|
||||
</div>
|
||||
<div className='flex-grow rounded-xl'>
|
||||
{isLoading ? (
|
||||
<Skeleton className='aspect-square w-full rounded-xl'></Skeleton>
|
||||
) : (
|
||||
<Cover
|
||||
imageUrl={resizeImage(album?.picUrl ?? '', 'md')}
|
||||
showPlayButton={true}
|
||||
onClick={toAlbum}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
onClick={toAlbum}
|
||||
className='line-clamp-2 line-clamp-1 mt-2 font-semibold leading-tight decoration-gray-600 decoration-2 hover:underline dark:text-white dark:decoration-gray-200'
|
||||
>
|
||||
{album?.name}
|
||||
</div>
|
||||
<div className='text-[12px] text-gray-500 dark:text-gray-400'>
|
||||
{album?.type} · {dayjs(album?.publishTime || 0).year()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PopularTracks = ({
|
||||
tracks,
|
||||
isLoadingArtist,
|
||||
}: {
|
||||
tracks: Track[] | undefined
|
||||
isLoadingArtist: boolean
|
||||
}) => {
|
||||
const { data: tracksWithExtraInfo } = useTracks({
|
||||
ids: tracks?.slice(0, 10)?.map(t => t.id) ?? [],
|
||||
})
|
||||
|
||||
const handlePlay = useCallback(
|
||||
(trackID: number | null = null) => {
|
||||
if (!tracks?.length) {
|
||||
toast('无法播放歌单')
|
||||
return
|
||||
}
|
||||
player.playAList(
|
||||
tracks.map(t => t.id),
|
||||
trackID
|
||||
)
|
||||
},
|
||||
[tracks]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='mb-6 text-2xl font-semibold text-gray-800 dark:text-white'>
|
||||
热门歌曲
|
||||
</div>
|
||||
<div className='rounded-xl'>
|
||||
<TracksGrid
|
||||
tracks={tracksWithExtraInfo?.songs ?? tracks?.slice(0, 10) ?? []}
|
||||
isSkeleton={isLoadingArtist}
|
||||
onTrackDoubleClick={handlePlay}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Artist = () => {
|
||||
const params = useParams()
|
||||
|
||||
const { data: artist, isLoading: isLoadingArtist } = useArtist({
|
||||
id: Number(params.id) || 0,
|
||||
})
|
||||
|
||||
const { data: albumsRaw, isLoading: isLoadingAlbums } = useArtistAlbums({
|
||||
id: Number(params.id) || 0,
|
||||
limit: 1000,
|
||||
})
|
||||
|
||||
const albums = useMemo(() => {
|
||||
if (!albumsRaw?.hotAlbums) return []
|
||||
const albums: Album[] = []
|
||||
albumsRaw.hotAlbums.forEach(album => {
|
||||
if (album.type !== '专辑') return false
|
||||
if (['混音版', '精选集', 'Remix'].includes(album.subType)) return false
|
||||
|
||||
// No singles
|
||||
if (album.size <= 1) return false
|
||||
|
||||
// No remixes
|
||||
if (
|
||||
/(\(|\[)(.*)(Remix|remix)(.*)(\)|\])/.test(
|
||||
album.name.toLocaleLowerCase()
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If have same name album only keep the Explicit version
|
||||
const sameNameAlbumIndex = albums.findIndex(a => a.name === album.name)
|
||||
if (sameNameAlbumIndex !== -1) {
|
||||
if (album.mark === 1056768) albums[sameNameAlbumIndex] = album
|
||||
return
|
||||
}
|
||||
|
||||
albums.push(album)
|
||||
})
|
||||
return albums
|
||||
}, [albumsRaw?.hotAlbums])
|
||||
|
||||
const singles = useMemo(() => {
|
||||
if (!albumsRaw?.hotAlbums) return []
|
||||
const albumsIds = albums.map(album => album.id)
|
||||
return albumsRaw.hotAlbums.filter(
|
||||
album => albumsIds.includes(album.id) === false
|
||||
)
|
||||
}, [albums, albumsRaw?.hotAlbums])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header artist={artist?.artist} />
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
'mt-12 px-2',
|
||||
albumsRaw?.hotAlbums?.length !== 0 &&
|
||||
'grid h-[20rem] grid-cols-[14rem,_auto] grid-rows-1 gap-16'
|
||||
)}
|
||||
>
|
||||
{albumsRaw?.hotAlbums?.length !== 0 && (
|
||||
<LatestRelease
|
||||
album={albumsRaw?.hotAlbums[0]}
|
||||
isLoading={isLoadingAlbums}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PopularTracks
|
||||
tracks={artist?.hotSongs}
|
||||
isLoadingArtist={isLoadingArtist}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Albums */}
|
||||
{albums.length !== 0 && (
|
||||
<div className='mt-20 px-2'>
|
||||
<div className='mb-6 text-2xl font-semibold text-gray-800 dark:text-white'>
|
||||
专辑
|
||||
</div>
|
||||
<CoverRow
|
||||
albums={albums.slice(0, 10)}
|
||||
subtitle={Subtitle.TypeReleaseYear}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Singles/EP */}
|
||||
{singles.length !== 0 && (
|
||||
<div className='mt-16 px-2'>
|
||||
<div className='mb-6 text-2xl font-semibold text-gray-800 dark:text-white'>
|
||||
单曲和EP
|
||||
</div>
|
||||
<CoverRow
|
||||
albums={singles.slice(0, 5)}
|
||||
subtitle={Subtitle.TypeReleaseYear}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Artist
|
||||
69
packages/web/pages/Home.tsx
Normal file
69
packages/web/pages/Home.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import {
|
||||
fetchRecommendedPlaylists,
|
||||
fetchDailyRecommendPlaylists,
|
||||
} from '@/web/api/playlist'
|
||||
import CoverRow from '@/web/components/CoverRow'
|
||||
import DailyTracksCard from '@/web/components/DailyTracksCard'
|
||||
import FMCard from '@/web/components/FMCard'
|
||||
import { PlaylistApiNames } from '@/shared/api/Playlists'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { useQuery } from 'react-query'
|
||||
|
||||
export default function Home() {
|
||||
const {
|
||||
data: dailyRecommendPlaylists,
|
||||
isLoading: isLoadingDailyRecommendPlaylists,
|
||||
} = useQuery(
|
||||
PlaylistApiNames.FetchDailyRecommendPlaylists,
|
||||
fetchDailyRecommendPlaylists,
|
||||
{
|
||||
retry: false,
|
||||
placeholderData: () =>
|
||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
api: APIs.RecommendResource,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const {
|
||||
data: recommendedPlaylists,
|
||||
isLoading: isLoadingRecommendedPlaylists,
|
||||
} = useQuery(
|
||||
PlaylistApiNames.FetchRecommendedPlaylists,
|
||||
() => {
|
||||
return fetchRecommendedPlaylists({})
|
||||
},
|
||||
{
|
||||
placeholderData: () =>
|
||||
window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, {
|
||||
api: APIs.Personalized,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const playlists = [
|
||||
...(dailyRecommendPlaylists?.recommend?.slice(1).slice(0, 8) ?? []),
|
||||
...(recommendedPlaylists?.result ?? []),
|
||||
].slice(0, 10)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CoverRow
|
||||
title='推荐歌单'
|
||||
playlists={playlists}
|
||||
isSkeleton={
|
||||
isLoadingRecommendedPlaylists || isLoadingDailyRecommendPlaylists
|
||||
}
|
||||
/>
|
||||
|
||||
<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'>
|
||||
<DailyTracksCard />
|
||||
<FMCard />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
263
packages/web/pages/Library.tsx
Normal file
263
packages/web/pages/Library.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import CoverRow, { Subtitle } from '@/web/components/CoverRow'
|
||||
import SvgIcon, { SvgName } from '@/web/components/SvgIcon'
|
||||
import useUserAlbums from '@/web/hooks/useUserAlbums'
|
||||
import useLyric from '@/web/hooks/useLyric'
|
||||
import usePlaylist from '@/web/hooks/usePlaylist'
|
||||
import useUser from '@/web/hooks/useUser'
|
||||
import useUserPlaylists from '@/web/hooks/useUserPlaylists'
|
||||
import { player } from '@/web/store'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import { sample, chunk } from 'lodash-es'
|
||||
import useUserArtists from '@/web/hooks/useUserArtists'
|
||||
import cx from 'classnames'
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const LikedTracksCard = ({ className }: { className?: string }) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: playlists } = useUserPlaylists()
|
||||
|
||||
const { data: likedSongsPlaylist } = usePlaylist({
|
||||
id: playlists?.playlist?.[0].id ?? 0,
|
||||
})
|
||||
|
||||
// Lyric
|
||||
const [trackID, setTrackID] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (trackID === 0) {
|
||||
setTrackID(
|
||||
sample(likedSongsPlaylist?.playlist.trackIds?.map(t => t.id) ?? []) ?? 0
|
||||
)
|
||||
}
|
||||
}, [likedSongsPlaylist?.playlist.trackIds, trackID])
|
||||
|
||||
const { data: lyric } = useLyric({
|
||||
id: trackID,
|
||||
})
|
||||
|
||||
const lyricLines = useMemo(() => {
|
||||
return (
|
||||
sample(
|
||||
chunk(
|
||||
lyric?.lrc.lyric
|
||||
?.split('\n')
|
||||
?.map(l => l.split(']').pop()?.trim())
|
||||
?.filter(
|
||||
l =>
|
||||
l &&
|
||||
!l.includes('作词') &&
|
||||
!l.includes('作曲') &&
|
||||
!l.includes('纯音乐,请欣赏')
|
||||
),
|
||||
3
|
||||
)
|
||||
) ?? []
|
||||
)
|
||||
}, [lyric])
|
||||
|
||||
const handlePlay = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
if (!likedSongsPlaylist?.playlist.id) {
|
||||
toast('无法播放歌单')
|
||||
return
|
||||
}
|
||||
player.playPlaylist(likedSongsPlaylist.playlist.id)
|
||||
},
|
||||
[likedSongsPlaylist?.playlist.id]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() =>
|
||||
likedSongsPlaylist?.playlist.id &&
|
||||
navigate(`/playlist/${likedSongsPlaylist.playlist.id}`)
|
||||
}
|
||||
className={cx(
|
||||
'relative flex h-full w-full flex-col justify-between rounded-2xl bg-brand-50 py-5 px-6 text-brand-600 dark:bg-brand-600 dark:text-brand-50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='text-sm'>
|
||||
{lyricLines.map((line, index) => (
|
||||
<div key={`${index}-${line}`}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div className='text-2xl font-bold'>我喜欢的音乐</div>
|
||||
<div className='mt-0.5 text-[15px]'>
|
||||
{likedSongsPlaylist?.playlist.trackCount ?? 0} 首歌
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handlePlay}
|
||||
className='btn-pressed-animation absolute bottom-6 right-6 grid h-11 w-11 cursor-default place-content-center rounded-full bg-brand-600 text-brand-50 shadow-lg dark:bg-white dark:text-brand-600'
|
||||
>
|
||||
<SvgIcon name='play-fill' className='ml-0.5 h-6 w-6' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const OtherCard = ({
|
||||
name,
|
||||
icon,
|
||||
className,
|
||||
}: {
|
||||
name: string
|
||||
icon: SvgName
|
||||
className?: string
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'flex h-full w-full flex-col justify-between rounded-2xl bg-gray-100 text-lg font-bold dark:bg-gray-800 dark:text-gray-200',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<SvgIcon name={icon} className='ml-3 mt-3 h-12 w-12' />
|
||||
<span className='m-4'>{name}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Playlists = () => {
|
||||
const { data: playlists } = useUserPlaylists()
|
||||
return (
|
||||
<div>
|
||||
<CoverRow
|
||||
playlists={playlists?.playlist?.slice(1) ?? []}
|
||||
subtitle={Subtitle.Creator}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Albums = () => {
|
||||
const { data: albums } = useUserAlbums({
|
||||
limit: 1000,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CoverRow albums={albums?.data ?? []} subtitle={Subtitle.Artist} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Artists = () => {
|
||||
const { data: artists } = useUserArtists()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CoverRow artists={artists?.data ?? []} subtitle={Subtitle.Artist} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MVs = () => {
|
||||
return <div>施工中</div>
|
||||
}
|
||||
|
||||
const Podcasts = () => {
|
||||
return <div>施工中</div>
|
||||
}
|
||||
interface TabsType {
|
||||
playlist: string
|
||||
album: string
|
||||
artist: string
|
||||
mv: string
|
||||
podcast: string
|
||||
}
|
||||
|
||||
const TabHeader = ({
|
||||
activeTab,
|
||||
tabs,
|
||||
setActiveTab,
|
||||
}: {
|
||||
activeTab: keyof TabsType
|
||||
tabs: TabsType
|
||||
setActiveTab: (tab: keyof TabsType) => void
|
||||
}) => {
|
||||
return (
|
||||
<div className='mt-10 flex text-lg dark:text-white'>
|
||||
{Object.entries(tabs).map(([id, name]) => (
|
||||
<div
|
||||
key={id}
|
||||
onClick={() => setActiveTab(id as keyof TabsType)}
|
||||
className={cx(
|
||||
'btn-pressed-animation mr-3 rounded-lg px-3.5 py-1.5 font-medium',
|
||||
activeTab === id
|
||||
? 'bg-black/[.04] dark:bg-white/10'
|
||||
: 'btn-hover-animation after:bg-black/[.04] dark:after:bg-white/10'
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Tabs = () => {
|
||||
const tabs = {
|
||||
playlist: '全部歌单',
|
||||
album: '专辑',
|
||||
artist: '艺人',
|
||||
mv: 'MV',
|
||||
podcast: '播客',
|
||||
}
|
||||
|
||||
const [activeTab, setActiveTab] = useState<keyof TabsType>('playlist')
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabHeader
|
||||
activeTab={activeTab}
|
||||
tabs={tabs}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
<div className='mt-6'>
|
||||
{activeTab === 'playlist' && <Playlists />}
|
||||
{activeTab === 'album' && <Albums />}
|
||||
{activeTab === 'artist' && <Artists />}
|
||||
{activeTab === 'mv' && <MVs />}
|
||||
{activeTab === 'podcast' && <Podcasts />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Library = () => {
|
||||
const { data: user } = useUser()
|
||||
|
||||
const avatarUrl = useMemo(
|
||||
() => resizeImage(user?.profile?.avatarUrl ?? '', 'sm'),
|
||||
[user?.profile?.avatarUrl]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='mt-8'>
|
||||
<div className='flex items-center text-[2.625rem] font-semibold dark:text-white'>
|
||||
<img src={avatarUrl} className='mr-3 mt-1 h-12 w-12 rounded-full' />
|
||||
{user?.profile?.nickname}的音乐库
|
||||
</div>
|
||||
|
||||
<div className='mt-8 grid grid-cols-[2fr_1fr_1fr] grid-rows-2 gap-4'>
|
||||
<LikedTracksCard className='row-span-2' />
|
||||
<OtherCard name='云盘' icon='fm' className='' />
|
||||
<OtherCard name='本地音乐' icon='music-note' className='' />
|
||||
<OtherCard name='最近播放' icon='playlist' className='' />
|
||||
<OtherCard name='听歌排行' icon='music-library' className='' />
|
||||
</div>
|
||||
|
||||
<Tabs />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Library
|
||||
427
packages/web/pages/Login.tsx
Normal file
427
packages/web/pages/Login.tsx
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
import md5 from 'md5'
|
||||
import QRCode from 'qrcode'
|
||||
import {
|
||||
checkLoginQrCodeStatus,
|
||||
fetchLoginQrCodeKey,
|
||||
loginWithEmail,
|
||||
loginWithPhone,
|
||||
} from '@/web/api/auth'
|
||||
import SvgIcon from '@/web/components/SvgIcon'
|
||||
import { state } from '@/web/store'
|
||||
import { setCookies } from '@/web/utils/cookie'
|
||||
import { useInterval } from 'react-use'
|
||||
import cx from 'classnames'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useMutation, useQuery } from 'react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
enum Method {
|
||||
QrCode = 'qrcode',
|
||||
Email = 'email',
|
||||
Phone = 'phone',
|
||||
}
|
||||
|
||||
const domParser = new DOMParser()
|
||||
|
||||
// Shared components and methods
|
||||
const EmailInput = ({
|
||||
email,
|
||||
setEmail,
|
||||
}: {
|
||||
email: string
|
||||
setEmail: (email: string) => void
|
||||
}) => {
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<div className='mb-1 text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
邮箱
|
||||
</div>
|
||||
<input
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className='w-full rounded-md border border-gray-300 px-2 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
||||
type='email'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PhoneInput = ({
|
||||
countryCode,
|
||||
setCountryCode,
|
||||
phone,
|
||||
setPhone,
|
||||
}: {
|
||||
countryCode: string
|
||||
setCountryCode: (code: string) => void
|
||||
phone: string
|
||||
setPhone: (phone: string) => void
|
||||
}) => {
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<div className='mb-1 text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
手机
|
||||
</div>
|
||||
<div className='flex w-full'>
|
||||
<input
|
||||
className={cx(
|
||||
'rounded-md rounded-r-none border border-r-0 border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
||||
countryCode.length <= 3 && 'w-14',
|
||||
countryCode.length == 4 && 'w-16',
|
||||
countryCode.length >= 5 && 'w-20'
|
||||
)}
|
||||
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 dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
||||
type='text'
|
||||
value={phone}
|
||||
onChange={e => setPhone(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PasswordInput = ({
|
||||
password,
|
||||
setPassword,
|
||||
}: {
|
||||
password: string
|
||||
setPassword: (password: string) => void
|
||||
}) => {
|
||||
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 dark:text-gray-300'>
|
||||
密码
|
||||
</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 dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
||||
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 dark:border-gray-600 dark:bg-gray-700'>
|
||||
<button
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='dark:hover-text-white cursor-default rounded-md p-1.5 text-gray-400 transition duration-300 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-600 dark:hover:text-white'
|
||||
>
|
||||
<SvgIcon
|
||||
className='h-5 w-5'
|
||||
name={showPassword ? 'eye-off' : 'eye'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LoginButton = ({
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
onClick: () => void
|
||||
disabled: boolean
|
||||
}) => {
|
||||
// TODO: Add loading indicator
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cx(
|
||||
'my-2 mt-6 flex w-full cursor-default items-center justify-center rounded-lg py-2 text-lg font-semibold transition duration-200',
|
||||
!disabled &&
|
||||
'bg-brand-100 text-brand-500 dark:bg-brand-600 dark:text-white',
|
||||
disabled &&
|
||||
'bg-brand-100 text-brand-300 dark:bg-brand-700 dark:text-white/50'
|
||||
)}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const OtherLoginMethods = ({
|
||||
method,
|
||||
setMethod,
|
||||
}: {
|
||||
method: Method
|
||||
setMethod: (method: Method) => void
|
||||
}) => {
|
||||
const otherLoginMethods: {
|
||||
id: Method
|
||||
name: string
|
||||
}[] = [
|
||||
{
|
||||
id: Method.QrCode,
|
||||
name: '二维码',
|
||||
},
|
||||
{
|
||||
id: Method.Email,
|
||||
name: '邮箱',
|
||||
},
|
||||
{
|
||||
id: Method.Phone,
|
||||
name: '手机',
|
||||
},
|
||||
]
|
||||
return (
|
||||
<>
|
||||
<div className='mt-8 mb-4 flex w-full items-center'>
|
||||
<span className='h-px flex-grow bg-gray-300 dark:bg-gray-700'></span>
|
||||
<span className='mx-2 text-sm text-gray-400 '>or</span>
|
||||
<span className='h-px flex-grow bg-gray-300 dark:bg-gray-700'></span>
|
||||
</div>
|
||||
<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 text-gray-600 transition duration-300 hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500 dark:hover:text-gray-100'
|
||||
>
|
||||
<SvgIcon className='mr-2 h-5 w-5' name={id} />
|
||||
<span>{name}</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const saveCookie = (cookies: string) => {
|
||||
setCookies(cookies)
|
||||
}
|
||||
|
||||
// Login with Email
|
||||
const LoginWithEmail = () => {
|
||||
const [password, setPassword] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const doLogin = useMutation(
|
||||
() =>
|
||||
loginWithEmail({
|
||||
email: email.trim(),
|
||||
md5_password: md5(password.trim()),
|
||||
}),
|
||||
{
|
||||
onSuccess: result => {
|
||||
if (result?.code !== 200) {
|
||||
toast(`Login failed: ${result.code}`)
|
||||
return
|
||||
}
|
||||
saveCookie(result.cookie)
|
||||
navigate(-1)
|
||||
},
|
||||
onError: error => {
|
||||
toast(`Login failed: ${error}`)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const handleLogin = () => {
|
||||
if (!email) {
|
||||
toast.error('Please enter email')
|
||||
return
|
||||
}
|
||||
if (!password) {
|
||||
toast.error('Please enter password')
|
||||
return
|
||||
}
|
||||
if (
|
||||
email.match(
|
||||
/^[^\s@]+@(126|163|yeah|188|vip\.163|vip\.126)\.(com|net)$/
|
||||
) == null
|
||||
) {
|
||||
toast.error('Please use netease email')
|
||||
return
|
||||
}
|
||||
|
||||
doLogin.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EmailInput {...{ email, setEmail }} />
|
||||
<PasswordInput {...{ password, setPassword }} />
|
||||
<LoginButton onClick={handleLogin} disabled={doLogin.isLoading} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Login with Phone
|
||||
const LoginWithPhone = () => {
|
||||
const [password, setPassword] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const countryCode = useSnapshot(state).uiStates.loginPhoneCountryCode
|
||||
const setCountryCode = (countryCode: string) => {
|
||||
state.uiStates.loginPhoneCountryCode = countryCode
|
||||
}
|
||||
const navigate = useNavigate()
|
||||
|
||||
const doLogin = useMutation(
|
||||
() => {
|
||||
return loginWithPhone({
|
||||
countrycode: Number(countryCode.replace('+', '').trim()) || 86,
|
||||
phone: phone.trim(),
|
||||
md5_password: md5(password.trim()),
|
||||
})
|
||||
},
|
||||
{
|
||||
onSuccess: result => {
|
||||
if (result?.code !== 200) {
|
||||
toast(`Login failed: ${result.code}`)
|
||||
return
|
||||
}
|
||||
saveCookie(result.cookie)
|
||||
navigate(-1)
|
||||
},
|
||||
onError: error => {
|
||||
toast(`Login failed: ${error}`)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const handleLogin = () => {
|
||||
if (!countryCode || !Number(countryCode.replace('+', '').trim())) {
|
||||
toast.error('Please enter country code')
|
||||
return
|
||||
}
|
||||
if (!phone) {
|
||||
toast.error('Please enter phone number')
|
||||
return
|
||||
}
|
||||
if (!password) {
|
||||
toast.error('Please enter password')
|
||||
return
|
||||
}
|
||||
|
||||
doLogin.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PhoneInput {...{ countryCode, setCountryCode, phone, setPhone }} />
|
||||
<PasswordInput {...{ password, setPassword }} />
|
||||
<LoginButton onClick={handleLogin} disabled={doLogin.isLoading} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Login with QRCode
|
||||
const LoginWithQRCode = () => {
|
||||
const [qrCodeMessage, setQrCodeMessage] = useState('打开网易云音乐,扫码登录')
|
||||
const [qrCodeImage, setQrCodeImage] = useState('')
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
const {
|
||||
data: key = { code: 200, data: { code: 200, unikey: 'Not Ready' } },
|
||||
status: keyStatus,
|
||||
refetch: refetchKey,
|
||||
} = useQuery(
|
||||
'qrCodeKey',
|
||||
async () => {
|
||||
const result = await fetchLoginQrCodeKey()
|
||||
if (result.data.code !== 200) {
|
||||
throw Error(`Failed to fetch QR code key: ${result.data.code}`)
|
||||
}
|
||||
return result
|
||||
},
|
||||
{
|
||||
retry: true,
|
||||
retryDelay: 500,
|
||||
}
|
||||
)
|
||||
|
||||
useInterval(async () => {
|
||||
if (keyStatus !== 'success') return
|
||||
const qrCodeStatus = await checkLoginQrCodeStatus({ key: key.data.unikey })
|
||||
switch (qrCodeStatus.code) {
|
||||
case 800:
|
||||
refetchKey()
|
||||
break
|
||||
case 801:
|
||||
setQrCodeMessage('打开网易云音乐,扫码登录')
|
||||
break
|
||||
case 802:
|
||||
setQrCodeMessage('等待确认')
|
||||
break
|
||||
case 803:
|
||||
if (qrCodeStatus.cookie === undefined) {
|
||||
toast('checkLoginQrCodeStatus returned 803 without cookie')
|
||||
break
|
||||
}
|
||||
saveCookie(qrCodeStatus.cookie)
|
||||
navigate(-1)
|
||||
break
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
const qrCodeUrl = useMemo(
|
||||
() => `https://music.163.com/login?codekey=${key.data.unikey}`,
|
||||
[key]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const updateImage = async () => {
|
||||
const svg = await QRCode.toString(qrCodeUrl, {
|
||||
margin: 0,
|
||||
color: {
|
||||
light: '#ffffff00',
|
||||
},
|
||||
type: 'svg',
|
||||
})
|
||||
const path = domParser
|
||||
.parseFromString(svg, 'text/xml')
|
||||
.getElementsByTagName('path')[0]
|
||||
|
||||
setQrCodeImage(path?.getAttribute('d') ?? '')
|
||||
}
|
||||
updateImage()
|
||||
}, [qrCodeUrl])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='rounded-3xl border p-6 text-brand-500 dark:border-gray-700'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='270'
|
||||
height='270'
|
||||
viewBox='0 0 37 37'
|
||||
shapeRendering='crispEdges'
|
||||
>
|
||||
<path stroke='currentColor' d={qrCodeImage} />
|
||||
</svg>
|
||||
</div>
|
||||
<div className='mt-4 text-sm text-gray-500 dark:text-gray-200'>
|
||||
{qrCodeMessage}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const [method, setMethod] = useState<Method>(Method.Phone)
|
||||
|
||||
return (
|
||||
<div className='grid h-full place-content-center'>
|
||||
<div className='w-80'>
|
||||
{method === Method.Email && <LoginWithEmail />}
|
||||
{method === Method.Phone && <LoginWithPhone />}
|
||||
{method === Method.QrCode && <LoginWithQRCode />}
|
||||
<OtherLoginMethods {...{ method, setMethod }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
315
packages/web/pages/Playlist.tsx
Normal file
315
packages/web/pages/Playlist.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import { memo, useCallback, useEffect, useMemo } from 'react'
|
||||
import Button, { Color as ButtonColor } from '@/web/components/Button'
|
||||
import Skeleton from '@/web/components/Skeleton'
|
||||
import SvgIcon from '@/web/components/SvgIcon'
|
||||
import TracksList from '@/web/components/TracksList'
|
||||
import usePlaylist from '@/web/hooks/usePlaylist'
|
||||
import useScroll from '@/web/hooks/useScroll'
|
||||
import useTracksInfinite from '@/web/hooks/useTracksInfinite'
|
||||
import { player } from '@/web/store'
|
||||
import { formatDate, resizeImage } from '@/web/utils/common'
|
||||
import useUserPlaylists, {
|
||||
useMutationLikeAPlaylist,
|
||||
} from '@/web/hooks/useUserPlaylists'
|
||||
import useUser from '@/web/hooks/useUser'
|
||||
import {
|
||||
Mode as PlayerMode,
|
||||
TrackListSourceType,
|
||||
State as PlayerState,
|
||||
} from '@/web/utils/player'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const PlayButton = ({
|
||||
playlist,
|
||||
handlePlay,
|
||||
isLoading,
|
||||
}: {
|
||||
playlist: Playlist | undefined
|
||||
handlePlay: () => void
|
||||
isLoading: boolean
|
||||
}) => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
const isThisPlaylistPlaying = useMemo(
|
||||
() =>
|
||||
playerSnapshot.mode === PlayerMode.TrackList &&
|
||||
playerSnapshot.trackListSource?.type === TrackListSourceType.Playlist &&
|
||||
playerSnapshot.trackListSource?.id === playlist?.id,
|
||||
[
|
||||
playerSnapshot.mode,
|
||||
playerSnapshot.trackListSource?.id,
|
||||
playerSnapshot.trackListSource?.type,
|
||||
playlist?.id,
|
||||
]
|
||||
)
|
||||
|
||||
const wrappedHandlePlay = () => {
|
||||
if (isThisPlaylistPlaying) {
|
||||
player.playOrPause()
|
||||
} else {
|
||||
handlePlay()
|
||||
}
|
||||
}
|
||||
|
||||
const isPlaying =
|
||||
isThisPlaylistPlaying &&
|
||||
[PlayerState.Playing, PlayerState.Loading].includes(playerSnapshot.state)
|
||||
|
||||
return (
|
||||
<Button onClick={wrappedHandlePlay} isSkelton={isLoading}>
|
||||
<SvgIcon
|
||||
name={isPlaying ? 'pause' : 'play'}
|
||||
className='-ml-1 mr-1 h-6 w-6'
|
||||
/>
|
||||
{isPlaying ? '暂停' : '播放'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const Header = memo(
|
||||
({
|
||||
playlist,
|
||||
isLoading,
|
||||
handlePlay,
|
||||
}: {
|
||||
playlist: Playlist | undefined
|
||||
isLoading: boolean
|
||||
handlePlay: () => void
|
||||
}) => {
|
||||
const coverUrl = resizeImage(playlist?.coverImgUrl || '', 'lg')
|
||||
|
||||
const mutationLikeAPlaylist = useMutationLikeAPlaylist()
|
||||
const { data: userPlaylists } = useUserPlaylists()
|
||||
|
||||
const isThisPlaylistLiked = useMemo(() => {
|
||||
if (!playlist) return false
|
||||
return !!userPlaylists?.playlist?.find(p => p.id === playlist.id)
|
||||
}, [playlist, userPlaylists?.playlist])
|
||||
|
||||
const { data: user } = useUser()
|
||||
const isThisPlaylistCreatedByCurrentUser = useMemo(() => {
|
||||
if (!playlist || !user) return false
|
||||
return playlist.creator.userId === user?.profile?.userId
|
||||
}, [playlist, user])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 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/[.85] to-white dark:from-black/50 dark:to-[#1d1d1d]'></div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-[17rem_auto] items-center gap-9'>
|
||||
{/* Cover */}
|
||||
<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'
|
||||
style={{
|
||||
backgroundImage: `url("${coverUrl}")`,
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<img
|
||||
src={coverUrl}
|
||||
className='rounded-2xl border border-black border-opacity-5'
|
||||
/>
|
||||
)}
|
||||
{isLoading && (
|
||||
<Skeleton v-else className='h-full w-full rounded-2xl' />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* <!-- Playlist info --> */}
|
||||
<div className='z-10 flex h-full flex-col justify-between'>
|
||||
{/* <!-- Playlist name --> */}
|
||||
{!isLoading && (
|
||||
<div className='text-4xl font-bold dark:text-white'>
|
||||
{playlist?.name}
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<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'>
|
||||
歌单 · <span>{playlist?.creator?.nickname}</span>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<Skeleton v-else className='mt-5 w-64 text-lg'>
|
||||
PLACEHOLDER
|
||||
</Skeleton>
|
||||
)}
|
||||
|
||||
{/* <!-- Playlist last update time & track count --> */}
|
||||
{!isLoading && (
|
||||
<div className='text-sm text-gray-500 dark:text-gray-400'>
|
||||
更新于 {formatDate(playlist?.updateTime || 0, 'zh-CN')} ·{' '}
|
||||
{playlist?.trackCount} 首歌
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<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'>
|
||||
{playlist?.description}
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<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'>
|
||||
<PlayButton {...{ playlist, handlePlay, isLoading }} />
|
||||
|
||||
{!isThisPlaylistCreatedByCurrentUser && (
|
||||
<Button
|
||||
color={ButtonColor.Gray}
|
||||
iconColor={
|
||||
isThisPlaylistLiked ? ButtonColor.Primary : ButtonColor.Gray
|
||||
}
|
||||
isSkelton={isLoading}
|
||||
onClick={() =>
|
||||
playlist?.id && mutationLikeAPlaylist.mutate(playlist)
|
||||
}
|
||||
>
|
||||
<SvgIcon
|
||||
name={isThisPlaylistLiked ? 'heart' : 'heart-outline'}
|
||||
className='h-6 w-6'
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
color={ButtonColor.Gray}
|
||||
iconColor={ButtonColor.Gray}
|
||||
isSkelton={isLoading}
|
||||
onClick={() => toast('施工中...')}
|
||||
>
|
||||
<SvgIcon name='more' className='h-6 w-6' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
Header.displayName = 'Header'
|
||||
|
||||
const Tracks = memo(
|
||||
({
|
||||
playlist,
|
||||
handlePlay,
|
||||
isLoadingPlaylist,
|
||||
}: {
|
||||
playlist: Playlist | undefined
|
||||
handlePlay: (trackID: number | null) => void
|
||||
isLoadingPlaylist: boolean
|
||||
}) => {
|
||||
const {
|
||||
data: tracksPages,
|
||||
hasNextPage,
|
||||
isLoading: isLoadingTracks,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = useTracksInfinite({
|
||||
ids: playlist?.trackIds?.map(t => t.id) || [],
|
||||
})
|
||||
|
||||
const scroll = useScroll(document.getElementById('mainContainer'), {
|
||||
throttle: 500,
|
||||
offset: {
|
||||
bottom: 256,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!scroll.arrivedState.bottom || !hasNextPage || isFetchingNextPage)
|
||||
return
|
||||
fetchNextPage()
|
||||
}, [
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
scroll.arrivedState.bottom,
|
||||
])
|
||||
|
||||
const tracks = useMemo(() => {
|
||||
if (!tracksPages) return []
|
||||
const allTracks: Track[] = []
|
||||
tracksPages.pages.forEach(page => allTracks.push(...(page?.songs ?? [])))
|
||||
return allTracks
|
||||
}, [tracksPages])
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoadingPlaylist ? (
|
||||
<TracksList tracks={[]} isSkeleton={true} />
|
||||
) : isLoadingTracks ? (
|
||||
<TracksList
|
||||
tracks={playlist?.tracks ?? []}
|
||||
onTrackDoubleClick={handlePlay}
|
||||
/>
|
||||
) : (
|
||||
<TracksList tracks={tracks} onTrackDoubleClick={handlePlay} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
Tracks.displayName = 'Tracks'
|
||||
|
||||
const Playlist = () => {
|
||||
const params = useParams()
|
||||
const { data: playlist, isLoading } = usePlaylist({
|
||||
id: Number(params.id) || 0,
|
||||
})
|
||||
|
||||
const handlePlay = useCallback(
|
||||
(trackID: number | null = null) => {
|
||||
if (!playlist?.playlist?.id) {
|
||||
toast('无法播放歌单')
|
||||
return
|
||||
}
|
||||
player.playPlaylist(playlist.playlist.id, trackID)
|
||||
},
|
||||
[playlist]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='mt-10'>
|
||||
<Header
|
||||
playlist={playlist?.playlist}
|
||||
isLoading={isLoading}
|
||||
handlePlay={handlePlay}
|
||||
/>
|
||||
|
||||
<Tracks
|
||||
playlist={playlist?.playlist}
|
||||
handlePlay={handlePlay}
|
||||
isLoadingPlaylist={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Playlist
|
||||
5
packages/web/pages/Podcast.tsx
Normal file
5
packages/web/pages/Podcast.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const Podcast = () => {
|
||||
return <div>施工中...</div>
|
||||
}
|
||||
|
||||
export default Podcast
|
||||
199
packages/web/pages/Search/Search.tsx
Normal file
199
packages/web/pages/Search/Search.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { multiMatchSearch, search } from '@/web/api/search'
|
||||
import Cover from '@/web/components/Cover'
|
||||
import TrackGrid from '@/web/components/TracksGrid'
|
||||
import { player } from '@/web/store'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import { SearchTypes, SearchApiNames } from '@/shared/api/Search'
|
||||
import dayjs from 'dayjs'
|
||||
import { useMemo, useCallback } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
const Artists = ({ artists }: { artists: Artist[] }) => {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<>
|
||||
{artists.map(artist => (
|
||||
<div
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
key={artist.id}
|
||||
className='btn-hover-animation flex items-center p-2.5 after:rounded-xl after:bg-gray-100 dark:after:bg-white/10'
|
||||
>
|
||||
<div className='mr-4 h-14 w-14'>
|
||||
<Cover
|
||||
imageUrl={resizeImage(artist.img1v1Url, 'xs')}
|
||||
roundedClass='rounded-full'
|
||||
showHover={false}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className='text-lg font-semibold dark:text-white'>
|
||||
{artist.name}
|
||||
</div>
|
||||
<div className='mt-0.5 text-sm font-medium text-gray-500 dark:text-gray-400'>
|
||||
艺人
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Albums = ({ albums }: { albums: Album[] }) => {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<>
|
||||
{albums.map(album => (
|
||||
<div
|
||||
onClick={() => navigate(`/album/${album.id}`)}
|
||||
key={album.id}
|
||||
className='btn-hover-animation flex items-center p-2.5 after:rounded-xl after:bg-gray-100 dark:after:bg-white/10'
|
||||
>
|
||||
<div className='mr-4 h-14 w-14'>
|
||||
<Cover
|
||||
imageUrl={resizeImage(album.picUrl, 'xs')}
|
||||
roundedClass='rounded-lg'
|
||||
showHover={false}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className='text-lg font-semibold dark:text-white'>
|
||||
{album.name}
|
||||
</div>
|
||||
<div className='mt-0.5 text-sm font-medium text-gray-500 dark:text-gray-400'>
|
||||
专辑 · {album?.artist.name} · {dayjs(album.publishTime).year()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Search = () => {
|
||||
const { keywords = '', type = 'all' } = useParams()
|
||||
|
||||
const searchType: keyof typeof SearchTypes =
|
||||
type.toUpperCase() in SearchTypes
|
||||
? (type.toUpperCase() as keyof typeof SearchTypes)
|
||||
: 'All'
|
||||
|
||||
const { data: bestMatchRaw, isLoading: isLoadingBestMatch } = useQuery(
|
||||
[SearchApiNames.MultiMatchSearch, keywords],
|
||||
() => multiMatchSearch({ keywords })
|
||||
)
|
||||
|
||||
const bestMatch = useMemo(() => {
|
||||
if (!bestMatchRaw?.result) return []
|
||||
return bestMatchRaw.result.orders
|
||||
.filter(order => ['album', 'artist'].includes(order)) // 暂时只支持专辑和艺人
|
||||
.map(order => {
|
||||
return bestMatchRaw.result[order][0]
|
||||
})
|
||||
.slice(0, 2)
|
||||
}, [bestMatchRaw?.result])
|
||||
|
||||
const { data: searchResult, isLoading: isLoadingSearchResult } = useQuery(
|
||||
[SearchApiNames.Search, keywords, searchType],
|
||||
() => search({ keywords, type: searchType })
|
||||
)
|
||||
|
||||
const handlePlayTracks = useCallback(
|
||||
(trackID: number | null = null) => {
|
||||
const tracks = searchResult?.result?.song?.songs
|
||||
if (!tracks?.length) {
|
||||
toast('无法播放歌单')
|
||||
return
|
||||
}
|
||||
player.playAList(
|
||||
tracks.map(t => t.id),
|
||||
trackID
|
||||
)
|
||||
},
|
||||
[searchResult?.result?.song?.songs]
|
||||
)
|
||||
|
||||
const navigate = useNavigate()
|
||||
const navigateBestMatch = (match: Artist | Album) => {
|
||||
if ((match as Artist).albumSize !== undefined) {
|
||||
navigate(`/artist/${match.id}`)
|
||||
return
|
||||
}
|
||||
if ((match as Album).artist !== undefined) {
|
||||
navigate(`/album/${match.id}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='mt-6 mb-8 text-4xl font-semibold dark:text-white'>
|
||||
<span className='text-gray-500'>搜索</span> "{keywords}"
|
||||
</div>
|
||||
|
||||
{/* Best match */}
|
||||
{bestMatch.length !== 0 && (
|
||||
<div className='mb-6'>
|
||||
<div className='mb-2 text-sm font-medium text-gray-400'>最佳匹配</div>
|
||||
<div className='grid grid-cols-2'>
|
||||
{bestMatch.map(match => (
|
||||
<div
|
||||
onClick={() => navigateBestMatch(match)}
|
||||
key={`${match.id}${match.picUrl}`}
|
||||
className='btn-hover-animation flex items-center p-3 after:rounded-xl after:bg-gray-100 dark:after:bg-white/10'
|
||||
>
|
||||
<div className='mr-6 h-24 w-24'>
|
||||
<Cover
|
||||
imageUrl={resizeImage(match.picUrl, 'xs')}
|
||||
showHover={false}
|
||||
roundedClass='rounded-full'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className='text-xl font-semibold dark:text-white'>
|
||||
{match.name}
|
||||
</div>
|
||||
<div className='mt-0.5 font-medium text-gray-500 dark:text-gray-400'>
|
||||
{(match as Artist).occupation === '歌手' ? '艺人' : '专辑'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search result */}
|
||||
<div className='grid grid-cols-2 gap-6'>
|
||||
{searchResult?.result?.artist?.artists && (
|
||||
<div>
|
||||
<div className='mb-2 text-sm font-medium text-gray-400'>艺人</div>
|
||||
<Artists artists={searchResult.result.artist.artists} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchResult?.result?.album?.albums && (
|
||||
<div>
|
||||
<div className='mb-2 text-sm font-medium text-gray-400'>专辑</div>
|
||||
<Albums albums={searchResult.result.album.albums} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchResult?.result?.song?.songs && (
|
||||
<div className='col-span-2'>
|
||||
<div className='mb-2 text-sm font-medium text-gray-400'>歌曲</div>
|
||||
<TrackGrid
|
||||
tracks={searchResult.result.song.songs}
|
||||
cols={3}
|
||||
onTrackDoubleClick={handlePlayTracks}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Search
|
||||
3
packages/web/pages/Search/index.ts
Normal file
3
packages/web/pages/Search/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Search from './Search'
|
||||
|
||||
export default Search
|
||||
79
packages/web/pages/Settings/Appearance.tsx
Normal file
79
packages/web/pages/Settings/Appearance.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { state } from '@/web/store'
|
||||
import { changeAccentColor } from '@/web/utils/theme'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import cx from 'classnames'
|
||||
|
||||
const AccentColor = () => {
|
||||
const colors = {
|
||||
red: 'bg-red-500',
|
||||
orange: 'bg-orange-500',
|
||||
amber: 'bg-amber-500',
|
||||
yellow: 'bg-yellow-500',
|
||||
lime: 'bg-lime-500',
|
||||
green: 'bg-green-500',
|
||||
emerald: 'bg-emerald-500',
|
||||
teal: 'bg-teal-500',
|
||||
cyan: 'bg-cyan-500',
|
||||
sky: 'bg-sky-500',
|
||||
blue: 'bg-blue-500',
|
||||
indigo: 'bg-indigo-500',
|
||||
violet: 'bg-violet-500',
|
||||
purple: 'bg-purple-500',
|
||||
fuchsia: 'bg-fuchsia-500',
|
||||
pink: 'bg-pink-500',
|
||||
rose: 'bg-rose-500',
|
||||
}
|
||||
|
||||
const changeColor = (color: string) => {
|
||||
state.settings.accentColor = color
|
||||
changeAccentColor(color)
|
||||
}
|
||||
|
||||
const accentColor = useSnapshot(state).settings.accentColor
|
||||
return (
|
||||
<div className='mt-4'>
|
||||
<div className='mb-2 dark:text-white'>强调色</div>
|
||||
<div className=' flex items-center'>
|
||||
{Object.entries(colors).map(([color, bg]) => (
|
||||
<div
|
||||
key={color}
|
||||
className={cx(
|
||||
bg,
|
||||
'mr-2.5 flex h-5 w-5 items-center justify-center rounded-full'
|
||||
)}
|
||||
onClick={() => changeColor(color)}
|
||||
>
|
||||
{color === accentColor && (
|
||||
<div className='h-1.5 w-1.5 rounded-full bg-white'></div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Theme = () => {
|
||||
return (
|
||||
<div className='mt-4'>
|
||||
<div className='mb-2 dark:text-white'>主题</div>
|
||||
<div></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Appearance = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className='text-xl font-medium text-gray-800 dark:text-white/70'>
|
||||
主题
|
||||
</div>
|
||||
<div className='mt-3 h-px w-full bg-black/5 dark:bg-white/10'></div>
|
||||
|
||||
<AccentColor />
|
||||
<Theme />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Appearance
|
||||
105
packages/web/pages/Settings/Settings.tsx
Normal file
105
packages/web/pages/Settings/Settings.tsx
Normal file
File diff suppressed because one or more lines are too long
44
packages/web/pages/Settings/UnblockNeteaseMusic.tsx
Normal file
44
packages/web/pages/Settings/UnblockNeteaseMusic.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
const UnblockNeteaseMusic = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className='text-xl font-medium text-gray-800 dark:text-white/70'>
|
||||
UnblockNeteaseMusic
|
||||
</div>
|
||||
<div className='mt-3 h-px w-full bg-black/5 dark:bg-white/10'></div>
|
||||
|
||||
<div>
|
||||
音源:
|
||||
<div>
|
||||
<input type='checkbox' id='migu' value='migu' />
|
||||
<label htmlFor='migu'>migu</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type='checkbox' id='youtube' value='youtube' />
|
||||
<label htmlFor='youtube'>youtube</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type='checkbox' id='kugou' value='kugou' />
|
||||
<label htmlFor='kugou'>kugou</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type='checkbox' id='kuwo' value='kuwo' />
|
||||
<label htmlFor='kuwo'>kuwo</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type='checkbox' id='qq' value='qq' />
|
||||
<label htmlFor='qq'>qq</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type='checkbox' id='bilibili' value='bilibili' />
|
||||
<label htmlFor='bilibili'>bilibili</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type='checkbox' id='joox' value='joox' />
|
||||
<label htmlFor='joox'>joox</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UnblockNeteaseMusic
|
||||
3
packages/web/pages/Settings/index.ts
Normal file
3
packages/web/pages/Settings/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Settings from './Settings'
|
||||
|
||||
export default Settings
|
||||
Loading…
Add table
Add a link
Reference in a new issue