mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-16 13:17:46 +00:00
feat: 支持收藏歌单和专辑
This commit is contained in:
parent
db5730dfdd
commit
49bb849982
12 changed files with 242 additions and 46 deletions
|
|
@ -39,7 +39,7 @@ Object.entries(neteaseApi).forEach(([name, handler]) => {
|
||||||
try {
|
try {
|
||||||
const result = await handler({
|
const result = await handler({
|
||||||
...req.query,
|
...req.query,
|
||||||
cookie: `MUSIC_U=${req.cookies['MUSIC_U']}`,
|
cookie: req.cookies,
|
||||||
})
|
})
|
||||||
|
|
||||||
setCache(name, result.body, req.query)
|
setCache(name, result.body, req.query)
|
||||||
|
|
|
||||||
|
|
@ -27,3 +27,23 @@ export function fetchAlbum(
|
||||||
params: { ...params, ...otherParams },
|
params: { ...params, ...otherParams },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LikeAAlbumParams {
|
||||||
|
t: 1 | 2
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
export interface LikeAAlbumResponse {
|
||||||
|
code: number
|
||||||
|
}
|
||||||
|
export function likeAAlbum(
|
||||||
|
params: LikeAAlbumParams
|
||||||
|
): Promise<LikeAAlbumResponse> {
|
||||||
|
return request({
|
||||||
|
url: '/album/sub',
|
||||||
|
method: 'post',
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ export enum PlaylistApiNames {
|
||||||
FETCH_PLAYLIST = 'fetchPlaylist',
|
FETCH_PLAYLIST = 'fetchPlaylist',
|
||||||
FETCH_RECOMMENDED_PLAYLISTS = 'fetchRecommendedPlaylists',
|
FETCH_RECOMMENDED_PLAYLISTS = 'fetchRecommendedPlaylists',
|
||||||
FETCH_DAILY_RECOMMEND_PLAYLISTS = 'fetchDailyRecommendPlaylists',
|
FETCH_DAILY_RECOMMEND_PLAYLISTS = 'fetchDailyRecommendPlaylists',
|
||||||
|
LIKE_A_PLAYLIST = 'likeAPlaylist',
|
||||||
}
|
}
|
||||||
|
|
||||||
// 歌单详情
|
// 歌单详情
|
||||||
|
|
@ -70,3 +71,23 @@ export function fetchDailyRecommendPlaylists(): Promise<FetchDailyRecommendPlayl
|
||||||
method: 'get',
|
method: 'get',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LikeAPlaylistParams {
|
||||||
|
t: 1 | 2
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
export interface LikeAPlaylistResponse {
|
||||||
|
code: number
|
||||||
|
}
|
||||||
|
export function likeAPlaylist(
|
||||||
|
params: LikeAPlaylistParams
|
||||||
|
): Promise<LikeAPlaylistResponse> {
|
||||||
|
return request({
|
||||||
|
url: '/playlist/subscribe',
|
||||||
|
method: 'post',
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,9 @@ export interface LikeATrackResponse {
|
||||||
playlistId: number
|
playlistId: number
|
||||||
songs: Track[]
|
songs: Track[]
|
||||||
}
|
}
|
||||||
export function likeATrack(params: LikeATrackParams) {
|
export function likeATrack(
|
||||||
|
params: LikeATrackParams
|
||||||
|
): Promise<LikeATrackResponse> {
|
||||||
return request({
|
return request({
|
||||||
url: '/like',
|
url: '/like',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,9 @@ export interface FetchUserAlbumsResponse {
|
||||||
count: number
|
count: number
|
||||||
data: Album[]
|
data: Album[]
|
||||||
}
|
}
|
||||||
export function fetchUserAlbums(params: FetchUserAlbumsParams) {
|
export function fetchUserAlbums(
|
||||||
|
params: FetchUserAlbumsParams
|
||||||
|
): Promise<FetchUserAlbumsResponse> {
|
||||||
return request({
|
return request({
|
||||||
url: '/album/sublist',
|
url: '/album/sublist',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
|
|
||||||
|
|
@ -60,11 +60,7 @@ const PrimaryTabs = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const Playlists = () => {
|
const Playlists = () => {
|
||||||
const { data: user } = useUser()
|
const { data: playlists } = useUserPlaylists()
|
||||||
const { data: playlists } = useUserPlaylists({
|
|
||||||
uid: user?.account?.id ?? 0,
|
|
||||||
offset: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mb-16 overflow-auto pb-2'>
|
<div className='mb-16 overflow-auto pb-2'>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
|
import { likeAAlbum } from '@/api/album'
|
||||||
import type { FetchUserAlbumsParams, FetchUserAlbumsResponse } from '@/api/user'
|
import type { FetchUserAlbumsParams, FetchUserAlbumsResponse } from '@/api/user'
|
||||||
import { UserApiNames, fetchUserAlbums } from '@/api/user'
|
import { UserApiNames, fetchUserAlbums } from '@/api/user'
|
||||||
|
import { useQueryClient } from 'react-query'
|
||||||
|
import useUser from './useUser'
|
||||||
|
|
||||||
export default function useUserAlbums(params: FetchUserAlbumsParams) {
|
export default function useUserAlbums(params: FetchUserAlbumsParams = {}) {
|
||||||
|
const { data: user } = useUser()
|
||||||
return useQuery(
|
return useQuery(
|
||||||
[UserApiNames.FETCH_USER_ALBUMS, params],
|
[UserApiNames.FETCH_USER_ALBUMS, user?.profile?.userId ?? 0],
|
||||||
() => fetchUserAlbums(params),
|
() => fetchUserAlbums(params),
|
||||||
{
|
{
|
||||||
placeholderData: (): FetchUserAlbumsResponse =>
|
placeholderData: (): FetchUserAlbumsResponse | undefined =>
|
||||||
window.ipcRenderer?.sendSync('getApiCacheSync', {
|
window.ipcRenderer?.sendSync('getApiCacheSync', {
|
||||||
api: 'album/sublist',
|
api: 'album/sublist',
|
||||||
query: params,
|
query: params,
|
||||||
|
|
@ -14,3 +18,56 @@ export default function useUserAlbums(params: FetchUserAlbumsParams) {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useMutationLikeAAlbum = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { data: user } = useUser()
|
||||||
|
const { data: userAlbums } = useUserAlbums({ limit: 2000 })
|
||||||
|
const uid = user?.account?.id ?? 0
|
||||||
|
const key = [UserApiNames.FETCH_USER_ALBUMS, uid]
|
||||||
|
|
||||||
|
return useMutation(
|
||||||
|
async (album: Album) => {
|
||||||
|
if (!album.id || userAlbums?.data === undefined) {
|
||||||
|
throw new Error('album id is required or userAlbums is undefined')
|
||||||
|
}
|
||||||
|
const response = await likeAAlbum({
|
||||||
|
id: album.id,
|
||||||
|
t: userAlbums?.data.findIndex(a => a.id === album.id) > -1 ? 2 : 1,
|
||||||
|
})
|
||||||
|
if (response.code !== 200) throw new Error((response as any).msg)
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onMutate: async album => {
|
||||||
|
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
|
||||||
|
await queryClient.cancelQueries(key)
|
||||||
|
|
||||||
|
// Snapshot the previous value
|
||||||
|
const previousData = queryClient.getQueryData(key)
|
||||||
|
|
||||||
|
// Optimistically update to the new value
|
||||||
|
queryClient.setQueryData(key, old => {
|
||||||
|
const userAlbums = old as FetchUserAlbumsResponse
|
||||||
|
const albums = userAlbums.data
|
||||||
|
const newAlbums =
|
||||||
|
albums.findIndex(a => a.id === album.id) > -1
|
||||||
|
? albums.filter(a => a.id !== album.id)
|
||||||
|
: [...albums, album]
|
||||||
|
return {
|
||||||
|
...userAlbums,
|
||||||
|
data: newAlbums,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return a context object with the snapshotted value
|
||||||
|
return { previousData }
|
||||||
|
},
|
||||||
|
// If the mutation fails, use the context returned from onMutate to roll back
|
||||||
|
onError: (err, trackID, context) => {
|
||||||
|
queryClient.setQueryData(key, (context as any).previousData)
|
||||||
|
toast((err as any).toString())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,16 @@ export const useMutationLikeATrack = () => {
|
||||||
const key = [UserApiNames.FETCH_USER_LIKED_TRACKS_IDS, uid]
|
const key = [UserApiNames.FETCH_USER_LIKED_TRACKS_IDS, uid]
|
||||||
|
|
||||||
return useMutation(
|
return useMutation(
|
||||||
(trackID: number) => {
|
async (trackID: number) => {
|
||||||
if (!trackID || userLikedSongs?.ids === undefined) {
|
if (!trackID || userLikedSongs?.ids === undefined) {
|
||||||
throw new Error('trackID is required or userLikedSongs is undefined')
|
throw new Error('trackID is required or userLikedSongs is undefined')
|
||||||
}
|
}
|
||||||
return likeATrack({
|
const response = await likeATrack({
|
||||||
id: trackID,
|
id: trackID,
|
||||||
like: !userLikedSongs.ids.includes(trackID),
|
like: !userLikedSongs.ids.includes(trackID),
|
||||||
})
|
})
|
||||||
|
if (response.code !== 200) throw new Error((response as any).msg)
|
||||||
|
return response
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onMutate: async trackID => {
|
onMutate: async trackID => {
|
||||||
|
|
@ -57,7 +59,6 @@ export const useMutationLikeATrack = () => {
|
||||||
const newIds = ids.includes(trackID)
|
const newIds = ids.includes(trackID)
|
||||||
? ids.filter(id => id !== trackID)
|
? ids.filter(id => id !== trackID)
|
||||||
: [...ids, trackID]
|
: [...ids, trackID]
|
||||||
console.log(trackID, ids.includes(trackID), ids, newIds)
|
|
||||||
return {
|
return {
|
||||||
...likedSongs,
|
...likedSongs,
|
||||||
ids: newIds,
|
ids: newIds,
|
||||||
|
|
@ -70,10 +71,7 @@ export const useMutationLikeATrack = () => {
|
||||||
// If the mutation fails, use the context returned from onMutate to roll back
|
// If the mutation fails, use the context returned from onMutate to roll back
|
||||||
onError: (err, trackID, context) => {
|
onError: (err, trackID, context) => {
|
||||||
queryClient.setQueryData(key, (context as any).previousData)
|
queryClient.setQueryData(key, (context as any).previousData)
|
||||||
},
|
toast((err as any).toString())
|
||||||
// Always refetch after error or success:
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries(key)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,25 @@
|
||||||
import type {
|
import { likeAPlaylist } from '@/api/playlist'
|
||||||
FetchUserPlaylistsParams,
|
import type { FetchUserPlaylistsResponse } from '@/api/user'
|
||||||
FetchUserPlaylistsResponse,
|
|
||||||
} from '@/api/user'
|
|
||||||
import { UserApiNames, fetchUserPlaylists } from '@/api/user'
|
import { UserApiNames, fetchUserPlaylists } from '@/api/user'
|
||||||
|
import { useQueryClient } from 'react-query'
|
||||||
|
import useUser from './useUser'
|
||||||
|
|
||||||
|
export default function useUserPlaylists() {
|
||||||
|
const { data: user } = useUser()
|
||||||
|
const uid = user?.profile?.userId ?? 0
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
uid: uid,
|
||||||
|
offset: 0,
|
||||||
|
limit: 2000,
|
||||||
|
}
|
||||||
|
|
||||||
export default function useUserPlaylists(params: FetchUserPlaylistsParams) {
|
|
||||||
return useQuery(
|
return useQuery(
|
||||||
[UserApiNames.FETCH_USER_PLAYLISTS, params],
|
[UserApiNames.FETCH_USER_PLAYLISTS, uid],
|
||||||
async () => {
|
async () => {
|
||||||
|
if (!params.uid) {
|
||||||
|
throw new Error('请登录后再请求用户收藏的歌单')
|
||||||
|
}
|
||||||
const data = await fetchUserPlaylists(params)
|
const data = await fetchUserPlaylists(params)
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
@ -27,3 +39,59 @@ export default function useUserPlaylists(params: FetchUserPlaylistsParams) {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useMutationLikeAPlaylist = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { data: user } = useUser()
|
||||||
|
const { data: userPlaylists } = useUserPlaylists()
|
||||||
|
const uid = user?.account?.id ?? 0
|
||||||
|
const key = [UserApiNames.FETCH_USER_PLAYLISTS, uid]
|
||||||
|
|
||||||
|
return useMutation(
|
||||||
|
async (playlist: Playlist) => {
|
||||||
|
if (!playlist.id || userPlaylists?.playlist === undefined) {
|
||||||
|
throw new Error('playlist id is required or userPlaylists is undefined')
|
||||||
|
}
|
||||||
|
const response = await likeAPlaylist({
|
||||||
|
id: playlist.id,
|
||||||
|
t:
|
||||||
|
userPlaylists.playlist.findIndex(p => p.id === playlist.id) > -1
|
||||||
|
? 2
|
||||||
|
: 1,
|
||||||
|
})
|
||||||
|
if (response.code !== 200) throw new Error((response as any).msg)
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onMutate: async playlist => {
|
||||||
|
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
|
||||||
|
await queryClient.cancelQueries(key)
|
||||||
|
|
||||||
|
// Snapshot the previous value
|
||||||
|
const previousData = queryClient.getQueryData(key)
|
||||||
|
|
||||||
|
// Optimistically update to the new value
|
||||||
|
queryClient.setQueryData(key, old => {
|
||||||
|
const userPlaylists = old as FetchUserPlaylistsResponse
|
||||||
|
const playlists = userPlaylists.playlist
|
||||||
|
const newPlaylists =
|
||||||
|
playlists.findIndex(p => p.id === playlist.id) > -1
|
||||||
|
? playlists.filter(p => p.id !== playlist.id)
|
||||||
|
: [...playlists, playlist]
|
||||||
|
return {
|
||||||
|
...userPlaylists,
|
||||||
|
playlist: newPlaylists,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return a context object with the snapshotted value
|
||||||
|
return { previousData }
|
||||||
|
},
|
||||||
|
// If the mutation fails, use the context returned from onMutate to roll back
|
||||||
|
onError: (err, trackID, context) => {
|
||||||
|
queryClient.setQueryData(key, (context as any).previousData)
|
||||||
|
toast((err as any).toString())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import {
|
||||||
scrollToTop,
|
scrollToTop,
|
||||||
} from '@/utils/common'
|
} from '@/utils/common'
|
||||||
import useTracks from '@/hooks/useTracks'
|
import useTracks from '@/hooks/useTracks'
|
||||||
|
import useUserAlbums, { useMutationLikeAAlbum } from '@/hooks/useUserAlbums'
|
||||||
|
import useUser from '@/hooks/useUser'
|
||||||
|
|
||||||
const PlayButton = ({
|
const PlayButton = ({
|
||||||
album,
|
album,
|
||||||
|
|
@ -79,6 +81,13 @@ const Header = ({
|
||||||
|
|
||||||
const [isCoverError, setCoverError] = useState(false)
|
const [isCoverError, setCoverError] = useState(false)
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Header background */}
|
{/* Header background */}
|
||||||
|
|
@ -189,11 +198,16 @@ const Header = ({
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color={ButtonColor.Gray}
|
color={ButtonColor.Gray}
|
||||||
iconColor={ButtonColor.Gray}
|
iconColor={
|
||||||
|
isThisAlbumLiked ? ButtonColor.Primary : ButtonColor.Gray
|
||||||
|
}
|
||||||
isSkelton={isLoading}
|
isSkelton={isLoading}
|
||||||
onClick={() => toast('施工中...')}
|
onClick={() => album?.id && mutationLikeAAlbum.mutate(album)}
|
||||||
>
|
>
|
||||||
<SvgIcon name='heart-outline' className='h-6 w-6' />
|
<SvgIcon
|
||||||
|
name={isThisAlbumLiked ? 'heart' : 'heart-outline'}
|
||||||
|
className='h-6 w-6'
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,8 @@ import useUserArtists from '@/hooks/useUserArtists'
|
||||||
|
|
||||||
const LikedTracksCard = ({ className }: { className?: string }) => {
|
const LikedTracksCard = ({ className }: { className?: string }) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data: user } = useUser()
|
|
||||||
|
|
||||||
const { data: playlists } = useUserPlaylists({
|
const { data: playlists } = useUserPlaylists()
|
||||||
uid: user?.account?.id ?? 0,
|
|
||||||
offset: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: likedSongsPlaylist } = usePlaylist({
|
const { data: likedSongsPlaylist } = usePlaylist({
|
||||||
id: playlists?.playlist?.[0].id ?? 0,
|
id: playlists?.playlist?.[0].id ?? 0,
|
||||||
|
|
@ -126,12 +122,7 @@ const OtherCard = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const Playlists = () => {
|
const Playlists = () => {
|
||||||
const { data: user } = useUser()
|
const { data: playlists } = useUserPlaylists()
|
||||||
|
|
||||||
const { data: playlists } = useUserPlaylists({
|
|
||||||
uid: user?.account?.id ?? 0,
|
|
||||||
offset: 0,
|
|
||||||
})
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<CoverRow
|
<CoverRow
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ import useScroll from '@/hooks/useScroll'
|
||||||
import useTracksInfinite from '@/hooks/useTracksInfinite'
|
import useTracksInfinite from '@/hooks/useTracksInfinite'
|
||||||
import { player } from '@/store'
|
import { player } from '@/store'
|
||||||
import { formatDate, resizeImage } from '@/utils/common'
|
import { formatDate, resizeImage } from '@/utils/common'
|
||||||
|
import useUserPlaylists, {
|
||||||
|
useMutationLikeAPlaylist,
|
||||||
|
} from '@/hooks/useUserPlaylists'
|
||||||
|
import useUser from '@/hooks/useUser'
|
||||||
|
|
||||||
const enableRenderLog = true
|
const enableRenderLog = true
|
||||||
|
|
||||||
|
|
@ -24,6 +28,20 @@ const Header = memo(
|
||||||
if (enableRenderLog) console.debug('Rendering Playlist.tsx Header')
|
if (enableRenderLog) console.debug('Rendering Playlist.tsx Header')
|
||||||
const coverUrl = resizeImage(playlist?.coverImgUrl || '', 'lg')
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Header background */}
|
{/* Header background */}
|
||||||
|
|
@ -114,14 +132,23 @@ const Header = memo(
|
||||||
播放
|
播放
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{!isThisPlaylistCreatedByCurrentUser && (
|
||||||
<Button
|
<Button
|
||||||
color={ButtonColor.Gray}
|
color={ButtonColor.Gray}
|
||||||
iconColor={ButtonColor.Gray}
|
iconColor={
|
||||||
|
isThisPlaylistLiked ? ButtonColor.Primary : ButtonColor.Gray
|
||||||
|
}
|
||||||
isSkelton={isLoading}
|
isSkelton={isLoading}
|
||||||
onClick={() => toast('施工中...')}
|
onClick={() =>
|
||||||
|
playlist?.id && mutationLikeAPlaylist.mutate(playlist)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SvgIcon name='heart-outline' className='h-6 w-6' />
|
<SvgIcon
|
||||||
|
name={isThisPlaylistLiked ? 'heart' : 'heart-outline'}
|
||||||
|
className='h-6 w-6'
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color={ButtonColor.Gray}
|
color={ButtonColor.Gray}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue