mirror of
https://github.com/qier222/YesPlayMusic.git
synced 2025-02-16 23:32:46 +08:00
feat: updates
This commit is contained in:
parent
ffcc60b793
commit
dd5361b8c4
1
.npmrc
1
.npmrc
|
@ -1,3 +1,4 @@
|
||||||
node-linker=hoisted
|
node-linker=hoisted
|
||||||
public-hoist-pattern=*
|
public-hoist-pattern=*
|
||||||
shamefully-hoist=true
|
shamefully-hoist=true
|
||||||
|
strict-peer-dependencies=false
|
||||||
|
|
14
package.json
14
package.json
|
@ -14,19 +14,19 @@
|
||||||
"packageManager": "pnpm@7.0.0",
|
"packageManager": "pnpm@7.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"install": "turbo run post-install --parallel --no-cache",
|
"install": "turbo run post-install --parallel --no-cache",
|
||||||
"build": "turbo run build",
|
"build": "ross-env-shell IS_ELECTRON=yes turbo run build",
|
||||||
"build:web": "turbo run build:web",
|
"build:web": "turbo run build:web",
|
||||||
"dev": "turbo run dev --parallel",
|
"dev": "cross-env-shell IS_ELECTRON=yes turbo run dev --parallel",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,md}\"",
|
||||||
"storybook": "pnpm -F web storybook",
|
"storybook": "pnpm -F web storybook",
|
||||||
"storybook:build": "pnpm -F web storybook:build"
|
"storybook:build": "pnpm -F web storybook:build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.15.0",
|
"cross-env": "^7.0.3",
|
||||||
|
"eslint": "^8.16.0",
|
||||||
"prettier": "^2.6.2",
|
"prettier": "^2.6.2",
|
||||||
"turbo": "^1.2.9",
|
"turbo": "^1.2.14",
|
||||||
"typescript": "^4.6.4"
|
"typescript": "^4.7.2"
|
||||||
},
|
}
|
||||||
"dependencies": {}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -117,6 +117,7 @@ class Cache {
|
||||||
case APIs.UserAccount:
|
case APIs.UserAccount:
|
||||||
case APIs.Personalized:
|
case APIs.Personalized:
|
||||||
case APIs.RecommendResource:
|
case APIs.RecommendResource:
|
||||||
|
case APIs.UserArtists:
|
||||||
case APIs.Likelist: {
|
case APIs.Likelist: {
|
||||||
const data = db.find(Tables.AccountData, api)
|
const data = db.find(Tables.AccountData, api)
|
||||||
if (data?.json) return JSON.parse(data.json)
|
if (data?.json) return JSON.parse(data.json)
|
||||||
|
|
|
@ -37,7 +37,7 @@ class Main {
|
||||||
defaults: {
|
defaults: {
|
||||||
window: {
|
window: {
|
||||||
width: 1440,
|
width: 1440,
|
||||||
height: 960,
|
height: 1024,
|
||||||
},
|
},
|
||||||
settings: initialState.settings,
|
settings: initialState.settings,
|
||||||
},
|
},
|
||||||
|
@ -105,11 +105,14 @@ class Main {
|
||||||
},
|
},
|
||||||
width: this.store.get('window.width'),
|
width: this.store.get('window.width'),
|
||||||
height: this.store.get('window.height'),
|
height: this.store.get('window.height'),
|
||||||
minWidth: 1080,
|
minWidth: 1240,
|
||||||
minHeight: 720,
|
minHeight: 848,
|
||||||
vibrancy: 'fullscreen-ui',
|
// vibrancy: 'fullscreen-ui',
|
||||||
titleBarStyle: 'hiddenInset',
|
titleBarStyle: 'customButtonsOnHover',
|
||||||
frame: !(isWindows || isLinux), // TODO: 适用于linux下独立的启用开关
|
trafficLightPosition: { x: 24, y: 24 },
|
||||||
|
// frame: !(isWindows || isLinux), // TODO: 适用于linux下独立的启用开关
|
||||||
|
frame: false,
|
||||||
|
transparent: true,
|
||||||
}
|
}
|
||||||
if (this.store.get('window')) {
|
if (this.store.get('window')) {
|
||||||
options.x = this.store.get('window.x')
|
options.x = this.store.get('window.x')
|
||||||
|
@ -132,24 +135,21 @@ class Main {
|
||||||
|
|
||||||
disableCORS() {
|
disableCORS() {
|
||||||
if (!this.win) return
|
if (!this.win) return
|
||||||
const upsertKeyValue = (
|
|
||||||
object: Record<string, string | string[]>,
|
const addCORSHeaders = (headers: Record<string, string | string[]>) => {
|
||||||
keyToChange: string,
|
if (
|
||||||
value: string[]
|
headers['Access-Control-Allow-Origin']?.[0] !== '*' &&
|
||||||
) => {
|
headers['access-control-allow-origin']?.[0] !== '*'
|
||||||
if (!object) return
|
) {
|
||||||
for (const key of Object.keys(object)) {
|
headers['Access-Control-Allow-Origin'] = ['*']
|
||||||
if (key.toLowerCase() === keyToChange.toLowerCase()) {
|
|
||||||
object[key] = value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
object[keyToChange] = value
|
return headers
|
||||||
}
|
}
|
||||||
|
|
||||||
this.win.webContents.session.webRequest.onBeforeSendHeaders(
|
this.win.webContents.session.webRequest.onBeforeSendHeaders(
|
||||||
(details, callback) => {
|
(details, callback) => {
|
||||||
const { requestHeaders, url } = details
|
const { requestHeaders, url } = details
|
||||||
upsertKeyValue(requestHeaders, 'access-control-allow-origin', ['*'])
|
addCORSHeaders(requestHeaders)
|
||||||
|
|
||||||
// 不加这几个 header 的话,使用 axios 加载 YouTube 音频会很慢
|
// 不加这几个 header 的话,使用 axios 加载 YouTube 音频会很慢
|
||||||
if (url.includes('googlevideo.com')) {
|
if (url.includes('googlevideo.com')) {
|
||||||
|
@ -164,14 +164,15 @@ class Main {
|
||||||
|
|
||||||
this.win.webContents.session.webRequest.onHeadersReceived(
|
this.win.webContents.session.webRequest.onHeadersReceived(
|
||||||
(details, callback) => {
|
(details, callback) => {
|
||||||
const { responseHeaders } = details
|
const { responseHeaders, url } = details
|
||||||
if (responseHeaders) {
|
if (url.includes('sentry.io')) {
|
||||||
upsertKeyValue(responseHeaders, 'access-control-allow-origin', ['*'])
|
callback({ responseHeaders })
|
||||||
upsertKeyValue(responseHeaders, 'access-control-allow-headers', ['*'])
|
return
|
||||||
}
|
}
|
||||||
callback({
|
if (responseHeaders) {
|
||||||
responseHeaders,
|
addCORSHeaders(responseHeaders)
|
||||||
})
|
}
|
||||||
|
callback({ responseHeaders })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,6 +50,11 @@ function initWindowIpcMain(win: BrowserWindow | null) {
|
||||||
on(IpcChannels.Close, () => {
|
on(IpcChannels.Close, () => {
|
||||||
app.exit()
|
app.exit()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
on(IpcChannels.ResetWindowSize, () => {
|
||||||
|
if (!win) return
|
||||||
|
win?.setSize(1440, 1024, true)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -188,6 +188,9 @@ class Server {
|
||||||
const source =
|
const source =
|
||||||
retrievedSong.source === 'ytdl' ? 'youtube' : retrievedSong.source
|
retrievedSong.source === 'ytdl' ? 'youtube' : retrievedSong.source
|
||||||
if (retrievedSong.url) {
|
if (retrievedSong.url) {
|
||||||
|
log.debug(
|
||||||
|
`[server] UMN match: ${matchedAudio.song?.name} (https://youtube.com/v/${matchedAudio.song?.id})`
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
|
|
|
@ -17,8 +17,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/node": "^6.19.7",
|
"@sentry/node": "^6.19.7",
|
||||||
"@sentry/tracing": "^6.19.7",
|
"@sentry/tracing": "^6.19.7",
|
||||||
"@unblockneteasemusic/rust-napi": "^0.3.0-pre.1",
|
"@unblockneteasemusic/rust-napi": "^0.3.0",
|
||||||
"NeteaseCloudMusicApi": "^4.5.12",
|
"NeteaseCloudMusicApi": "^4.6.0",
|
||||||
"better-sqlite3": "7.5.1",
|
"better-sqlite3": "7.5.1",
|
||||||
"change-case": "^4.1.2",
|
"change-case": "^4.1.2",
|
||||||
"compare-versions": "^4.1.3",
|
"compare-versions": "^4.1.3",
|
||||||
|
@ -35,27 +35,26 @@
|
||||||
"@types/cookie-parser": "^1.4.3",
|
"@types/cookie-parser": "^1.4.3",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.13",
|
||||||
"@types/express-fileupload": "^1.2.2",
|
"@types/express-fileupload": "^1.2.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.21.0",
|
"@typescript-eslint/eslint-plugin": "^5.26.0",
|
||||||
"@typescript-eslint/parser": "^5.21.0",
|
"@typescript-eslint/parser": "^5.26.0",
|
||||||
"@vitejs/plugin-react": "^1.3.1",
|
"@vitejs/plugin-react": "^1.3.1",
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"electron": "^18.2.1",
|
"electron": "^19.0.1",
|
||||||
"electron-builder": "^23.0.3",
|
"electron-builder": "^23.0.3",
|
||||||
"electron-devtools-installer": "^3.2.0",
|
"electron-devtools-installer": "^3.2.0",
|
||||||
"electron-rebuild": "^3.2.7",
|
"electron-rebuild": "^3.2.7",
|
||||||
"electron-releases": "^3.1009.0",
|
"electron-releases": "^3.1021.0",
|
||||||
"esbuild": "^0.14.39",
|
"esbuild": "^0.14.41",
|
||||||
"eslint": "*",
|
"eslint": "*",
|
||||||
"express-fileupload": "^1.3.1",
|
"express-fileupload": "^1.4.0",
|
||||||
"minimist": "^1.2.6",
|
"minimist": "^1.2.6",
|
||||||
"music-metadata": "^7.12.3",
|
"music-metadata": "^7.12.3",
|
||||||
"open-cli": "^7.0.1",
|
"open-cli": "^7.0.1",
|
||||||
"ora": "^6.1.0",
|
"ora": "^6.1.0",
|
||||||
"picocolors": "^1.0.0",
|
"picocolors": "^1.0.0",
|
||||||
"prettier": "*",
|
"prettier": "*",
|
||||||
"prettier-plugin-tailwindcss": "^0.1.10",
|
|
||||||
"typescript": "*",
|
"typescript": "*",
|
||||||
"wait-on": "^6.0.1"
|
"wait-on": "^6.0.1"
|
||||||
},
|
},
|
||||||
|
|
|
@ -22,6 +22,7 @@ export const enum IpcChannels {
|
||||||
Repeat = 'Repeat',
|
Repeat = 'Repeat',
|
||||||
SyncSettings = 'SyncSettings',
|
SyncSettings = 'SyncSettings',
|
||||||
GetAudioCacheSize = 'GetAudioCacheSize',
|
GetAudioCacheSize = 'GetAudioCacheSize',
|
||||||
|
ResetWindowSize = 'ResetWindowSize',
|
||||||
}
|
}
|
||||||
|
|
||||||
// ipcMain.on params
|
// ipcMain.on params
|
||||||
|
@ -56,6 +57,7 @@ export interface IpcChannelsParams {
|
||||||
}
|
}
|
||||||
[IpcChannels.SyncSettings]: Store['settings']
|
[IpcChannels.SyncSettings]: Store['settings']
|
||||||
[IpcChannels.GetAudioCacheSize]: void
|
[IpcChannels.GetAudioCacheSize]: void
|
||||||
|
[IpcChannels.ResetWindowSize]: void
|
||||||
}
|
}
|
||||||
|
|
||||||
// ipcRenderer.on params
|
// ipcRenderer.on params
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
const { mergeConfig } = require('vite')
|
const { mergeConfig } = require('vite')
|
||||||
const { join } = require('path')
|
const { join } = require('path')
|
||||||
const { createSvgIconsPlugin } = require('vite-plugin-svg-icons')
|
const { createSvgIconsPlugin } = require('vite-plugin-svg-icons')
|
||||||
console.log(join(__dirname, '../assets/icons'))
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
stories: [
|
stories: [
|
||||||
|
@ -13,7 +12,6 @@ module.exports = {
|
||||||
'@storybook/addon-essentials',
|
'@storybook/addon-essentials',
|
||||||
'@storybook/addon-interactions',
|
'@storybook/addon-interactions',
|
||||||
'@storybook/addon-postcss',
|
'@storybook/addon-postcss',
|
||||||
'@storybook/addon-viewport',
|
|
||||||
'storybook-tailwind-dark-mode',
|
'storybook-tailwind-dark-mode',
|
||||||
],
|
],
|
||||||
framework: '@storybook/react',
|
framework: '@storybook/react',
|
||||||
|
@ -31,6 +29,11 @@ module.exports = {
|
||||||
symbolId: 'icon-[name]',
|
symbolId: 'icon-[name]',
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': join(__dirname, '../../'),
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
4532
packages/web/.storybook/mock/tracks.ts
Normal file
4532
packages/web/.storybook/mock/tracks.ts
Normal file
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
||||||
import 'virtual:svg-icons-register'
|
import 'virtual:svg-icons-register'
|
||||||
import '../styles/global.scss'
|
import '../styles/global.css'
|
||||||
import '../styles/accentColor.scss'
|
import '../styles/accentColor.css'
|
||||||
import viewports from './viewports'
|
import viewports from './viewports'
|
||||||
|
|
||||||
export const parameters = {
|
export const parameters = {
|
||||||
|
|
19
packages/web/AppNew.tsx
Normal file
19
packages/web/AppNew.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { Toaster } from 'react-hot-toast'
|
||||||
|
import TitleBar from '@/web/components/TitleBar'
|
||||||
|
import IpcRendererReact from '@/web/IpcRendererReact'
|
||||||
|
import Layout from '@/web/components/New/Layout'
|
||||||
|
import Devtool from '@/web/components/New/Devtool'
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
return (
|
||||||
|
<div className='dark '>
|
||||||
|
{window.env?.isEnableTitlebar && <TitleBar />}
|
||||||
|
<Layout />
|
||||||
|
<Toaster position='bottom-center' containerStyle={{ bottom: '5rem' }} />
|
||||||
|
<IpcRendererReact />
|
||||||
|
<Devtool />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
|
@ -1,7 +1,7 @@
|
||||||
import { IpcChannels } from '@/shared/IpcChannels'
|
import { IpcChannels } from '@/shared/IpcChannels'
|
||||||
import useUserLikedTracksIDs, {
|
import useUserLikedTracksIDs, {
|
||||||
useMutationLikeATrack,
|
useMutationLikeATrack,
|
||||||
} from '@/web/hooks/useUserLikedTracksIDs'
|
} from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||||
import { player } from '@/web/store'
|
import { player } from '@/web/store'
|
||||||
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
|
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
|
||||||
import { State as PlayerState } from '@/web/utils/player'
|
import { State as PlayerState } from '@/web/utils/player'
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { FetchTracksParams, TrackApiNames } from '@/shared/api/Track'
|
import { FetchTracksParams, TrackApiNames } from '@/shared/api/Track'
|
||||||
import { useInfiniteQuery } from 'react-query'
|
import { useInfiniteQuery } from 'react-query'
|
||||||
import { fetchTracks } from '../api/track'
|
import { fetchTracks } from '../track'
|
||||||
|
|
||||||
// 100 tracks each page
|
// 100 tracks each page
|
||||||
const offset = 100
|
const offset = 100
|
|
@ -8,7 +8,7 @@ import {
|
||||||
UserApiNames,
|
UserApiNames,
|
||||||
FetchUserAlbumsResponse,
|
FetchUserAlbumsResponse,
|
||||||
} from '@/shared/api/User'
|
} from '@/shared/api/User'
|
||||||
import { fetchUserAlbums } from '../api/user'
|
import { fetchUserAlbums } from '../user'
|
||||||
|
|
||||||
export default function useUserAlbums(params: FetchUserAlbumsParams = {}) {
|
export default function useUserAlbums(params: FetchUserAlbumsParams = {}) {
|
||||||
const { data: user } = useUser()
|
const { data: user } = useUser()
|
|
@ -3,7 +3,7 @@ import useUser from './useUser'
|
||||||
import { useMutation, useQueryClient } from 'react-query'
|
import { useMutation, useQueryClient } from 'react-query'
|
||||||
import { IpcChannels } from '@/shared/IpcChannels'
|
import { IpcChannels } from '@/shared/IpcChannels'
|
||||||
import { APIs } from '@/shared/CacheAPIs'
|
import { APIs } from '@/shared/CacheAPIs'
|
||||||
import { fetchUserLikedTracksIDs } from '../api/user'
|
import { fetchUserLikedTracksIDs } from '../user'
|
||||||
import {
|
import {
|
||||||
FetchUserLikedTracksIDsResponse,
|
FetchUserLikedTracksIDsResponse,
|
||||||
UserApiNames,
|
UserApiNames,
|
|
@ -1,5 +1,5 @@
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import cx from 'classnames'
|
import {cx} from '@emotion/css'
|
||||||
|
|
||||||
const ArtistInline = ({
|
const ArtistInline = ({
|
||||||
artists,
|
artists,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { resizeImage } from '../utils/common'
|
import { resizeImage } from '../utils/common'
|
||||||
import useUser from '../hooks/useUser'
|
import useUser from '@/web/api/hooks/useUser'
|
||||||
import SvgIcon from './SvgIcon'
|
import Icon from './Icon'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
const Avatar = ({ size }: { size?: string }) => {
|
const Avatar = ({ size }: { size?: string }) => {
|
||||||
|
@ -25,7 +25,7 @@ const Avatar = ({ size }: { size?: string }) => {
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div onClick={() => navigate('/login')}>
|
<div onClick={() => navigate('/login')}>
|
||||||
<SvgIcon
|
<Icon
|
||||||
name='user'
|
name='user'
|
||||||
className={cx(
|
className={cx(
|
||||||
'rounded-full bg-black/[.06] p-1 text-gray-500 dark:bg-white/5',
|
'rounded-full bg-black/[.06] p-1 text-gray-500 dark:bg-white/5',
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
|
|
||||||
export enum Color {
|
export enum Color {
|
||||||
Primary = 'primary',
|
Primary = 'primary',
|
||||||
|
@ -28,7 +28,7 @@ const Button = ({
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cx(
|
className={cx(
|
||||||
'btn-pressed-animation flex cursor-default items-center rounded-lg px-4 py-1.5 text-lg font-medium',
|
'btn-pressed-animation flex cursor-default items-center rounded-20 px-4 py-1.5 text-lg font-medium',
|
||||||
{
|
{
|
||||||
'bg-brand-100 dark:bg-brand-600': color === Color.Primary,
|
'bg-brand-100 dark:bg-brand-600': color === Color.Primary,
|
||||||
'text-brand-500 dark:text-white': iconColor === Color.Primary,
|
'text-brand-500 dark:text-white': iconColor === Color.Primary,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import SvgIcon from '@/web/components/SvgIcon'
|
import Icon from '@/web/components/Icon'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
const Cover = ({
|
const Cover = ({
|
||||||
|
@ -38,7 +38,7 @@ const Cover = ({
|
||||||
{/* Cover */}
|
{/* Cover */}
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<div className='box-content flex aspect-square h-full w-full items-center justify-center rounded-xl border border-black border-opacity-5 bg-gray-800 text-gray-300 '>
|
<div className='box-content flex aspect-square h-full w-full items-center justify-center rounded-xl border border-black border-opacity-5 bg-gray-800 text-gray-300 '>
|
||||||
<SvgIcon name='music-note' className='h-1/2 w-1/2' />
|
<Icon name='music-note' className='h-1/2 w-1/2' />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
|
@ -55,7 +55,7 @@ const Cover = ({
|
||||||
{showPlayButton && (
|
{showPlayButton && (
|
||||||
<div className='absolute top-0 hidden h-full w-full place-content-center group-hover:grid'>
|
<div className='absolute top-0 hidden h-full w-full place-content-center group-hover:grid'>
|
||||||
<button className='btn-pressed-animation grid h-11 w-11 cursor-default place-content-center rounded-full border border-white border-opacity-[.08] bg-white bg-opacity-[.14] text-white backdrop-blur backdrop-filter transition-all hover:bg-opacity-[.44]'>
|
<button className='btn-pressed-animation grid h-11 w-11 cursor-default place-content-center rounded-full border border-white border-opacity-[.08] bg-white bg-opacity-[.14] text-white backdrop-blur backdrop-filter transition-all hover:bg-opacity-[.44]'>
|
||||||
<SvgIcon className='ml-0.5 h-6 w-6' name='play-fill' />
|
<Icon className='ml-0.5 h-6 w-6' name='play-fill' />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import Cover from '@/web/components/Cover'
|
import Cover from '@/web/components/Cover'
|
||||||
import Skeleton from '@/web/components/Skeleton'
|
import Skeleton from '@/web/components/Skeleton'
|
||||||
import SvgIcon from '@/web/components/SvgIcon'
|
import Icon from '@/web/components/Icon'
|
||||||
import { prefetchAlbum } from '@/web/hooks/useAlbum'
|
import { prefetchAlbum } from '@/web/api/hooks/useAlbum'
|
||||||
import { prefetchPlaylist } from '@/web/hooks/usePlaylist'
|
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
|
||||||
import { formatDate, resizeImage, scrollToTop } from '@/web/utils/common'
|
import { formatDate, resizeImage, scrollToTop } from '@/web/utils/common'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
@ -180,7 +180,7 @@ const CoverRow = ({
|
||||||
>
|
>
|
||||||
{/* Playlist private icon */}
|
{/* Playlist private icon */}
|
||||||
{(item as Playlist).privacy === 10 && (
|
{(item as Playlist).privacy === 10 && (
|
||||||
<SvgIcon
|
<Icon
|
||||||
name='lock'
|
name='lock'
|
||||||
className='mr-1 mb-1 inline-block h-3 w-3 text-gray-300'
|
className='mr-1 mb-1 inline-block h-3 w-3 text-gray-300'
|
||||||
/>
|
/>
|
||||||
|
@ -188,7 +188,7 @@ const CoverRow = ({
|
||||||
|
|
||||||
{/* Explicit icon */}
|
{/* Explicit icon */}
|
||||||
{(item as Album)?.mark === 1056768 && (
|
{(item as Album)?.mark === 1056768 && (
|
||||||
<SvgIcon
|
<Icon
|
||||||
name='explicit'
|
name='explicit'
|
||||||
className='float-right mt-[2px] h-4 w-4 text-gray-300'
|
className='float-right mt-[2px] h-4 w-4 text-gray-300'
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,51 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
|
||||||
import CoverWall from './CoverWall'
|
|
||||||
import { shuffle } from 'lodash-es'
|
|
||||||
|
|
||||||
const covers = [
|
|
||||||
'https://p1.music.126.net/MbjHjs0EebOFomva9oh6aQ==/109951164683206719.jpg?param=1024y1024',
|
|
||||||
'https://p1.music.126.net/T7qkRJsFDat6GxWDXP2cTA==/109951164486305073.jpg?param=1024y1024',
|
|
||||||
'https://p2.music.126.net/2jls9nqjYYlQEybpHPaccw==/109951164706184612.jpg?param=1024y1024',
|
|
||||||
'https://p1.music.126.net/lEzPSOjusKaRXKXT3987lQ==/109951166035876388.jpg?param=1024y1024',
|
|
||||||
'https://p2.music.126.net/2qW-OYZod7SgrzxTwtyBqA==/109951165911363831.jpg?param=1024y1024',
|
|
||||||
'https://p2.music.126.net/W-mYCTf6nPLUSaLxFlXDUA==/109951165806001138.jpg?param=1024y1024',
|
|
||||||
'https://p2.music.126.net/6CB6Jsmb7k7qiJqfMY5Row==/109951164260234943.jpg?param=1024y1024',
|
|
||||||
'https://p2.music.126.net/IeRnZyxClyoTwqZ76Qcyhw==/109951166161936990.jpg?param=1024y1024',
|
|
||||||
'https://p2.music.126.net/oYxxIkeXY5Qap7pW1aSzqQ==/109951165389077755.jpg?param=1024y1024',
|
|
||||||
'https://p2.music.126.net/AhYP9TET8l-VSGOpWAKZXw==/109951165134386387.jpg?param=1024y1024',
|
|
||||||
'https://p1.music.126.net/QxJA2mr4hhb9DZyucIOIQw==/109951165422200291.jpg?param=1024y1024',
|
|
||||||
'https://p2.music.126.net/vCTNT88k1rnflXtDdmWT9g==/109951165359041202.jpg?param=1024y1024',
|
|
||||||
'https://p2.music.126.net/iBxAZvHMTKfO3Vf8tdRa7Q==/109951165985707287.jpg?param=1024y1024',
|
|
||||||
'https://p1.music.126.net/b36xosI5j0cpdN1y7ytZPg==/109951166021477556.jpg?param=1024y1024',
|
|
||||||
'https://p1.music.126.net/bYwl8c5jErgbfGhv1tLJJA==/109951165276142037.jpg?param=1024y1024',
|
|
||||||
'https://p2.music.126.net/ZR1nD3lHsAoDUatf3gl1nQ==/109951165061667554.jpg?param=1024y1024',
|
|
||||||
'https://p1.music.126.net/XCMOOyclkmstP7KYHnNwcA==/109951164764312194.jpg?param=1024y1024',
|
|
||||||
'https://p1.music.126.net/jE6ebqtlzw7S0nnO6Heq2A==/109951166270713524.jpg?param=1024y1024',
|
|
||||||
'https://p1.music.126.net/6EoK9Mk27y3Cww5d9FA6ng==/109951165862426529.jpg?param=1024y1024',
|
|
||||||
'https://p1.music.126.net/XPQs_6fT2Ioy5a9eFDPpQw==/109951165255101112.jpg?param=1024y1024',
|
|
||||||
'https://p1.music.126.net/ocpMw2ku61bwhi7V7DJo9g==/109951167225594912.jpg?param=1024y1024',
|
|
||||||
'https://p2.music.126.net/LFmG3XD07JH4OYMafO0txw==/109951167410278760.jpg?param=1024y1024',
|
|
||||||
'https://p1.music.126.net/iZRipUtb21xr2E9Hz8sjYw==/109951167409480781.jpg?param=1024y1024',
|
|
||||||
'https://p2.music.126.net/rvUDvsxa0LZu9o_Oww-0Iw==/109951167344103348.jpg?param=1024y1024',
|
|
||||||
'https://p1.music.126.net/VGN68yovUJZtC47A_pYISg==/109951166515892030.jpg?param=1024y1024',
|
|
||||||
'https://p2.music.126.net/xqluTLLrxqGWr8qiMZNlfw==/109951166327062990.jpg?param=1024y1024',
|
|
||||||
'https://p2.music.126.net/I-gC5w8ECkgwPojf4YybeQ==/109951166074865960.jpg?param=1024y1024',
|
|
||||||
'https://p1.music.126.net/MHIvytC5RXh5Lp2J_3tpaQ==/19017153114022258.jpg?param=1024y1024',
|
|
||||||
'https://p1.music.126.net/3JcFV7xICf5gLwfaNK6wQQ==/109951163618704084.jpg?param=1024y1024',
|
|
||||||
'https://p2.music.126.net/dUHTsm1kr_CdhmcQ3WVhVg==/109951163663181135.jpg?param=1024y1024',
|
|
||||||
'https://p1.music.126.net/d7MyyfAt_YE0e85oK7eFMg==/7697680906568884.jpg?param=1024y1024',
|
|
||||||
]
|
|
||||||
|
|
||||||
export default {
|
|
||||||
title: 'CoverWall',
|
|
||||||
component: CoverWall,
|
|
||||||
} as ComponentMeta<typeof CoverWall>
|
|
||||||
|
|
||||||
const Template: ComponentStory<typeof CoverWall> = args => (
|
|
||||||
<div className='rounded-3xl bg-[#F8F8F8] p-10 dark:bg-black'>
|
|
||||||
<CoverWall covers={shuffle(covers)} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
export const Primary = Template.bind({})
|
|
|
@ -1,22 +0,0 @@
|
||||||
import cx from 'classnames'
|
|
||||||
|
|
||||||
const bigCover = [0, 2, 3, 8, 9, 11, 16, 18, 24, 26, 27]
|
|
||||||
|
|
||||||
const CoverWall = ({ covers }: { covers: string[] }) => {
|
|
||||||
return (
|
|
||||||
<div className='grid auto-rows-auto grid-cols-8 gap-[13px] '>
|
|
||||||
{covers.map((cover, index) => (
|
|
||||||
<img
|
|
||||||
src={cover}
|
|
||||||
key={cover}
|
|
||||||
className={cx(
|
|
||||||
'rounded-3xl',
|
|
||||||
bigCover.includes(index) && 'col-span-2 row-span-2'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CoverWall
|
|
|
@ -1,13 +0,0 @@
|
||||||
@keyframes move {
|
|
||||||
0% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animation {
|
|
||||||
animation: move 38s infinite;
|
|
||||||
animation-direction: alternate;
|
|
||||||
}
|
|
|
@ -1,6 +1,14 @@
|
||||||
import SvgIcon from './SvgIcon'
|
import Icon from './Icon'
|
||||||
import style from './DailyTracksCard.module.scss'
|
import { cx, css, keyframes } from '@emotion/css'
|
||||||
import cx from 'classnames'
|
|
||||||
|
const move = keyframes`
|
||||||
|
0% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
const DailyTracksCard = () => {
|
const DailyTracksCard = () => {
|
||||||
return (
|
return (
|
||||||
|
@ -9,7 +17,10 @@ const DailyTracksCard = () => {
|
||||||
<img
|
<img
|
||||||
className={cx(
|
className={cx(
|
||||||
'absolute top-0 left-0 w-full will-change-transform',
|
'absolute top-0 left-0 w-full will-change-transform',
|
||||||
style.animation
|
css`
|
||||||
|
animation: ${move} 38s infinite;
|
||||||
|
animation-direction: alternate;
|
||||||
|
`
|
||||||
)}
|
)}
|
||||||
src='https://p2.music.126.net/QxJA2mr4hhb9DZyucIOIQw==/109951165422200291.jpg?param=1024y1024'
|
src='https://p2.music.126.net/QxJA2mr4hhb9DZyucIOIQw==/109951165422200291.jpg?param=1024y1024'
|
||||||
/>
|
/>
|
||||||
|
@ -25,7 +36,7 @@ const DailyTracksCard = () => {
|
||||||
|
|
||||||
{/* Play button */}
|
{/* Play button */}
|
||||||
<button className='btn-pressed-animation absolute right-6 bottom-6 grid h-11 w-11 cursor-default place-content-center rounded-full border border-white border-opacity-[.08] bg-white bg-opacity-[.14] text-white backdrop-blur backdrop-filter transition-all hover:bg-opacity-[.44]'>
|
<button className='btn-pressed-animation absolute right-6 bottom-6 grid h-11 w-11 cursor-default place-content-center rounded-full border border-white border-opacity-[.08] bg-white bg-opacity-[.14] text-white backdrop-blur backdrop-filter transition-all hover:bg-opacity-[.44]'>
|
||||||
<SvgIcon name='play-fill' className='ml-0.5 h-6 w-6' />
|
<Icon name='play-fill' className='ml-0.5 h-6 w-6' />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,13 +1,10 @@
|
||||||
import { player } from '@/web/store'
|
import { player } from '@/web/store'
|
||||||
import { resizeImage } from '@/web/utils/common'
|
import { resizeImage } from '@/web/utils/common'
|
||||||
import SvgIcon from './SvgIcon'
|
import Icon from './Icon'
|
||||||
import ArtistInline from './ArtistsInline'
|
import ArtistInline from './ArtistsInline'
|
||||||
import {
|
import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player'
|
||||||
State as PlayerState,
|
|
||||||
Mode as PlayerMode,
|
|
||||||
} from '@/web/utils/player'
|
|
||||||
import useCoverColor from '../hooks/useCoverColor'
|
import useCoverColor from '../hooks/useCoverColor'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
@ -34,11 +31,11 @@ const MediaControls = () => {
|
||||||
className={classes}
|
className={classes}
|
||||||
onClick={() => player.fmTrash()}
|
onClick={() => player.fmTrash()}
|
||||||
>
|
>
|
||||||
<SvgIcon name='dislike' className='h-6 w-6' />
|
<Icon name='dislike' className='h-6 w-6' />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button key='play' className={classes} onClick={playOrPause}>
|
<button key='play' className={classes} onClick={playOrPause}>
|
||||||
<SvgIcon
|
<Icon
|
||||||
className='h-6 w-6'
|
className='h-6 w-6'
|
||||||
name={
|
name={
|
||||||
playerSnapshot.mode === PlayerMode.FM &&
|
playerSnapshot.mode === PlayerMode.FM &&
|
||||||
|
@ -54,7 +51,7 @@ const MediaControls = () => {
|
||||||
className={classes}
|
className={classes}
|
||||||
onClick={() => player.nextTrack(true)}
|
onClick={() => player.nextTrack(true)}
|
||||||
>
|
>
|
||||||
<SvgIcon name='next' className='h-6 w-6' />
|
<Icon name='next' className='h-6 w-6' />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -127,7 +124,7 @@ const FMCard = () => {
|
||||||
track ? 'text-white ' : 'text-gray-700 dark:text-white'
|
track ? 'text-white ' : 'text-gray-700 dark:text-white'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SvgIcon name='fm' className='mr-1 h-6 w-6' />
|
<Icon name='fm' className='mr-1 h-6 w-6' />
|
||||||
<span className='font-semibold'>私人FM</span>
|
<span className='font-semibold'>私人FM</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -40,13 +40,7 @@ export type SvgName =
|
||||||
| 'windows-un-maximize'
|
| 'windows-un-maximize'
|
||||||
| 'x'
|
| 'x'
|
||||||
|
|
||||||
const SvgIcon = ({
|
const Icon = ({ name, className }: { name: SvgName; className?: string }) => {
|
||||||
name,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
name: SvgName
|
|
||||||
className?: string
|
|
||||||
}) => {
|
|
||||||
const symbolId = `#icon-${name}`
|
const symbolId = `#icon-${name}`
|
||||||
return (
|
return (
|
||||||
<svg aria-hidden='true' className={className}>
|
<svg aria-hidden='true' className={className}>
|
||||||
|
@ -55,4 +49,4 @@ const SvgIcon = ({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SvgIcon
|
export default Icon
|
|
@ -1,5 +1,5 @@
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
|
|
||||||
const IconButton = ({
|
const IconButton = ({
|
||||||
children,
|
children,
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import useLyric from '@/web/hooks/useLyric'
|
import useLyric from '@/web/api/hooks/useLyric'
|
||||||
import { player } from '@/web/store'
|
import { player } from '@/web/store'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { lyricParser } from '@/web/utils/lyric'
|
import { lyricParser } from '@/web/utils/lyric'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
|
|
||||||
const Lyric = ({ className }: { className?: string }) => {
|
const Lyric = ({ className }: { className?: string }) => {
|
||||||
// const ease = [0.5, 0.2, 0.2, 0.8]
|
// const ease = [0.5, 0.2, 0.2, 0.8]
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import useLyric from '@/web/hooks/useLyric'
|
import useLyric from '@/web/api/hooks/useLyric'
|
||||||
import { player } from '@/web/store'
|
import { player } from '@/web/store'
|
||||||
import { motion, useMotionValue } from 'framer-motion'
|
import { motion, useMotionValue } from 'framer-motion'
|
||||||
import { lyricParser } from '@/web/utils/lyric'
|
import { lyricParser } from '@/web/utils/lyric'
|
||||||
import { useWindowSize } from 'react-use'
|
import { useWindowSize } from 'react-use'
|
||||||
import { useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
import { useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
|
|
||||||
const Lyric = ({ className }: { className?: string }) => {
|
const Lyric = ({ className }: { className?: string }) => {
|
||||||
// const ease = [0.5, 0.2, 0.2, 0.8]
|
// const ease = [0.5, 0.2, 0.2, 0.8]
|
||||||
|
|
|
@ -3,12 +3,12 @@ import { player, state } from '@/web/store'
|
||||||
import { getCoverColor } from '@/web/utils/common'
|
import { getCoverColor } from '@/web/utils/common'
|
||||||
import { colord } from 'colord'
|
import { colord } from 'colord'
|
||||||
import IconButton from '../IconButton'
|
import IconButton from '../IconButton'
|
||||||
import SvgIcon from '../SvgIcon'
|
import Icon from '../Icon'
|
||||||
import Lyric from './Lyric'
|
import Lyric from './Lyric'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import Lyric2 from './Lyric2'
|
import Lyric2 from './Lyric2'
|
||||||
import useCoverColor from '@/web/hooks/useCoverColor'
|
import useCoverColor from '@/web/hooks/useCoverColor'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
|
|
||||||
|
@ -56,7 +56,7 @@ const LyricPanel = () => {
|
||||||
|
|
||||||
<div className='absolute bottom-3.5 right-7 text-white'>
|
<div className='absolute bottom-3.5 right-7 text-white'>
|
||||||
<IconButton onClick={() => (state.uiStates.showLyricPanel = false)}>
|
<IconButton onClick={() => (state.uiStates.showLyricPanel = false)}>
|
||||||
<SvgIcon className='h-6 w-6' name='lyrics' />
|
<Icon className='h-6 w-6' name='lyrics' />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
@ -1,21 +1,18 @@
|
||||||
import useUserLikedTracksIDs, {
|
import useUserLikedTracksIDs, {
|
||||||
useMutationLikeATrack,
|
useMutationLikeATrack,
|
||||||
} from '@/web/hooks/useUserLikedTracksIDs'
|
} from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||||
import { player, state } from '@/web/store'
|
import { player, state } from '@/web/store'
|
||||||
import { resizeImage } from '@/web/utils/common'
|
import { resizeImage } from '@/web/utils/common'
|
||||||
|
|
||||||
import ArtistInline from '../ArtistsInline'
|
import ArtistInline from '../ArtistsInline'
|
||||||
import Cover from '../Cover'
|
import Cover from '../Cover'
|
||||||
import IconButton from '../IconButton'
|
import IconButton from '../IconButton'
|
||||||
import SvgIcon from '../SvgIcon'
|
import Icon from '../Icon'
|
||||||
import {
|
import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player'
|
||||||
State as PlayerState,
|
|
||||||
Mode as PlayerMode,
|
|
||||||
} from '@/web/utils/player'
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
|
|
||||||
const PlayingTrack = () => {
|
const PlayingTrack = () => {
|
||||||
const playerSnapshot = useSnapshot(player)
|
const playerSnapshot = useSnapshot(player)
|
||||||
|
@ -85,7 +82,7 @@ const LikeButton = ({ track }: { track: Track | undefined | null }) => {
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => track?.id && mutationLikeATrack.mutate(track.id)}
|
onClick={() => track?.id && mutationLikeATrack.mutate(track.id)}
|
||||||
>
|
>
|
||||||
<SvgIcon
|
<Icon
|
||||||
className='h-6 w-6 text-white'
|
className='h-6 w-6 text-white'
|
||||||
name={
|
name={
|
||||||
track?.id && userLikedSongs?.ids?.includes(track.id)
|
track?.id && userLikedSongs?.ids?.includes(track.id)
|
||||||
|
@ -111,12 +108,12 @@ const Controls = () => {
|
||||||
onClick={() => track && player.prevTrack()}
|
onClick={() => track && player.prevTrack()}
|
||||||
disabled={!track}
|
disabled={!track}
|
||||||
>
|
>
|
||||||
<SvgIcon className='h-6 w-6' name='previous' />
|
<Icon className='h-6 w-6' name='previous' />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
{mode === PlayerMode.FM && (
|
{mode === PlayerMode.FM && (
|
||||||
<IconButton onClick={() => player.fmTrash()}>
|
<IconButton onClick={() => player.fmTrash()}>
|
||||||
<SvgIcon className='h-6 w-6' name='dislike' />
|
<Icon className='h-6 w-6' name='dislike' />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
|
@ -124,7 +121,7 @@ const Controls = () => {
|
||||||
disabled={!track}
|
disabled={!track}
|
||||||
className='after:rounded-xl'
|
className='after:rounded-xl'
|
||||||
>
|
>
|
||||||
<SvgIcon
|
<Icon
|
||||||
className='h-7 w-7'
|
className='h-7 w-7'
|
||||||
name={
|
name={
|
||||||
[PlayerState.Playing, PlayerState.Loading].includes(state)
|
[PlayerState.Playing, PlayerState.Loading].includes(state)
|
||||||
|
@ -134,7 +131,7 @@ const Controls = () => {
|
||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton onClick={() => track && player.nextTrack()} disabled={!track}>
|
<IconButton onClick={() => track && player.nextTrack()} disabled={!track}>
|
||||||
<SvgIcon className='h-6 w-6' name='next' />
|
<Icon className='h-6 w-6' name='next' />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import Router from './Router'
|
import Router from './Router'
|
||||||
import Topbar from './Topbar'
|
import Topbar from './Topbar'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
|
|
||||||
const Main = () => {
|
const Main = () => {
|
||||||
return (
|
return (
|
||||||
|
|
42
packages/web/components/New/ArtistRow.tsx
Normal file
42
packages/web/components/New/ArtistRow.tsx
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import { resizeImage } from '@/web/utils/common'
|
||||||
|
import { css, cx } from '@emotion/css'
|
||||||
|
import Image from './Image'
|
||||||
|
|
||||||
|
const ArtistRow = ({
|
||||||
|
artists,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
artists: Artist[] | undefined
|
||||||
|
title?: string
|
||||||
|
className?: string
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{/* Title */}
|
||||||
|
{title && (
|
||||||
|
<h4 className='mb-6 text-14 font-bold uppercase dark:text-neutral-300'>
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Artists */}
|
||||||
|
<div className='grid grid-cols-5 gap-10'>
|
||||||
|
{artists?.map(artist => (
|
||||||
|
<div key={artist.id} className='text-center'>
|
||||||
|
<Image
|
||||||
|
alt={artist.name}
|
||||||
|
src={resizeImage(artist.img1v1Url, 'md')}
|
||||||
|
className='aspect-square rounded-full'
|
||||||
|
/>
|
||||||
|
<div className='line-clamp-1 mt-2.5 text-14 font-bold text-neutral-700 dark:text-neutral-600'>
|
||||||
|
{artist.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArtistRow
|
58
packages/web/components/New/CoverRow.tsx
Normal file
58
packages/web/components/New/CoverRow.tsx
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import { resizeImage } from '@/web/utils/common'
|
||||||
|
import { cx } from '@emotion/css'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import Image from './Image'
|
||||||
|
|
||||||
|
const CoverRow = ({
|
||||||
|
albums,
|
||||||
|
playlists,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
title?: string
|
||||||
|
className?: string
|
||||||
|
albums?: Album[]
|
||||||
|
playlists?: Playlist[]
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const goTo = (id: number) => {
|
||||||
|
if (albums) navigate(`/album/${id}`)
|
||||||
|
if (playlists) navigate(`/playlist/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{/* Title */}
|
||||||
|
{title && (
|
||||||
|
<h4 className='mb-6 text-14 font-bold uppercase dark:text-neutral-300'>
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div className='grid grid-cols-3 gap-10 xl:grid-cols-4 2xl:grid-cols-5'>
|
||||||
|
{albums?.map(album => (
|
||||||
|
<Image
|
||||||
|
onClick={() => goTo(album.id)}
|
||||||
|
key={album.id}
|
||||||
|
alt={album.name}
|
||||||
|
src={resizeImage(album?.picUrl || '', 'md')}
|
||||||
|
className='aspect-square rounded-24'
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{playlists?.map(playlist => (
|
||||||
|
<Image
|
||||||
|
onClick={() => goTo(playlist.id)}
|
||||||
|
key={playlist.id}
|
||||||
|
alt={playlist.name}
|
||||||
|
src={resizeImage(playlist?.picUrl || '', 'md')}
|
||||||
|
className='aspect-square rounded-24'
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CoverRow
|
21
packages/web/components/New/CoverWall.stories.tsx
Normal file
21
packages/web/components/New/CoverWall.stories.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||||
|
import CoverWall from './CoverWall'
|
||||||
|
import { shuffle } from 'lodash-es'
|
||||||
|
import { covers } from '../../.storybook/mock/tracks'
|
||||||
|
import { resizeImage } from '@/web/utils/common'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Components/CoverWall',
|
||||||
|
component: CoverWall,
|
||||||
|
} as ComponentMeta<typeof CoverWall>
|
||||||
|
|
||||||
|
const Template: ComponentStory<typeof CoverWall> = args => (
|
||||||
|
<div className='rounded-3xl bg-[#F8F8F8] p-10 dark:bg-black'>
|
||||||
|
<CoverWall
|
||||||
|
covers={shuffle(covers.map(c => resizeImage(c, 'lg'))).slice(0, 31)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Default = Template.bind({})
|
63
packages/web/components/New/CoverWall.tsx
Normal file
63
packages/web/components/New/CoverWall.tsx
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import { css, cx } from '@emotion/css'
|
||||||
|
import { sampleSize, shuffle } from 'lodash-es'
|
||||||
|
import Image from './Image'
|
||||||
|
import { covers } from '@/web/.storybook/mock/tracks'
|
||||||
|
import { resizeImage } from '@/web/utils/common'
|
||||||
|
import useBreakpoint from '@/web/hooks/useBreakpoint'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
const CoverWall = () => {
|
||||||
|
const bigCover = useMemo(
|
||||||
|
() =>
|
||||||
|
shuffle(
|
||||||
|
sampleSize([...Array(covers.length).keys()], ~~(covers.length / 3))
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
const breakpoint = useBreakpoint()
|
||||||
|
const sizes = {
|
||||||
|
small: {
|
||||||
|
sm: 'xs',
|
||||||
|
md: 'xs',
|
||||||
|
lg: 'sm',
|
||||||
|
xl: 'sm',
|
||||||
|
'2xl': 'md',
|
||||||
|
},
|
||||||
|
big: {
|
||||||
|
sm: 'xs',
|
||||||
|
md: 'sm',
|
||||||
|
lg: 'md',
|
||||||
|
xl: 'md',
|
||||||
|
'2xl': 'lg',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'grid w-full grid-flow-row-dense grid-cols-8',
|
||||||
|
css`
|
||||||
|
gap: 13px;
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{covers.map((cover, index) => (
|
||||||
|
<Image
|
||||||
|
src={resizeImage(
|
||||||
|
cover,
|
||||||
|
sizes[bigCover.includes(index) ? 'big' : 'small'][breakpoint]
|
||||||
|
)}
|
||||||
|
key={cover}
|
||||||
|
alt='Album Cover'
|
||||||
|
placeholder={null}
|
||||||
|
className={cx(
|
||||||
|
'aspect-square h-full w-full rounded-24',
|
||||||
|
bigCover.includes(index) && 'col-span-2 row-span-2'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CoverWall
|
19
packages/web/components/New/Devtool.tsx
Normal file
19
packages/web/components/New/Devtool.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { ReactQueryDevtools } from 'react-query/devtools'
|
||||||
|
|
||||||
|
const Devtool = () => {
|
||||||
|
return (
|
||||||
|
<ReactQueryDevtools
|
||||||
|
initialIsOpen={false}
|
||||||
|
toggleButtonProps={{
|
||||||
|
style: {
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
left: 'auto',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Devtool
|
78
packages/web/components/New/Image.tsx
Normal file
78
packages/web/components/New/Image.tsx
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import { css, cx } from '@emotion/css'
|
||||||
|
import { AnimatePresence, motion, useAnimation } from 'framer-motion'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { ease } from '@/web/utils/const'
|
||||||
|
|
||||||
|
const Image = ({
|
||||||
|
src,
|
||||||
|
srcSet,
|
||||||
|
className,
|
||||||
|
alt,
|
||||||
|
lazyLoad = true,
|
||||||
|
sizes,
|
||||||
|
placeholder = 'blank',
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
src?: string
|
||||||
|
srcSet?: string
|
||||||
|
sizes?: string
|
||||||
|
className?: string
|
||||||
|
alt: string
|
||||||
|
lazyLoad?: boolean
|
||||||
|
placeholder?: 'artist' | 'album' | 'playlist' | 'podcast' | 'blank' | null
|
||||||
|
onClick?: (e: React.MouseEvent<HTMLImageElement>) => void
|
||||||
|
}) => {
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const animate = useAnimation()
|
||||||
|
const placeholderAnimate = useAnimation()
|
||||||
|
const transition = { duration: 0.6, ease }
|
||||||
|
|
||||||
|
useEffect(() => setError(false), [src])
|
||||||
|
|
||||||
|
const onload = async () => {
|
||||||
|
setLoaded(true)
|
||||||
|
animate.start({ opacity: 1 })
|
||||||
|
}
|
||||||
|
const onError = () => {
|
||||||
|
setError(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hidden = error || !loaded
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx('relative overflow-hidden', className)}>
|
||||||
|
{/* Image */}
|
||||||
|
<motion.img
|
||||||
|
alt={alt}
|
||||||
|
className={cx('absolute inset-0 h-full w-full')}
|
||||||
|
src={src}
|
||||||
|
srcSet={srcSet}
|
||||||
|
sizes={sizes}
|
||||||
|
decoding='async'
|
||||||
|
loading={lazyLoad ? 'lazy' : undefined}
|
||||||
|
onLoad={onload}
|
||||||
|
onError={onError}
|
||||||
|
animate={animate}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
transition={transition}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Placeholder / Error fallback */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{hidden && placeholder && (
|
||||||
|
<motion.div
|
||||||
|
animate={placeholderAnimate}
|
||||||
|
initial={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={transition}
|
||||||
|
className='absolute inset-0 h-full w-full bg-white dark:bg-neutral-800'
|
||||||
|
></motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Image
|
44
packages/web/components/New/Layout.tsx
Normal file
44
packages/web/components/New/Layout.tsx
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import Main from '@/web/components/New/Main'
|
||||||
|
import Player from '@/web/components/New/Player'
|
||||||
|
import Sidebar from '@/web/components/New/Sidebar'
|
||||||
|
import Topbar from '@/web/components/New/Topbar'
|
||||||
|
import { css, cx } from '@emotion/css'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { player } from '@/web/store'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
|
||||||
|
const Layout = () => {
|
||||||
|
const playerSnapshot = useSnapshot(player)
|
||||||
|
const track = useMemo(() => playerSnapshot.track, [playerSnapshot])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id='layout'
|
||||||
|
className={cx(
|
||||||
|
'grid h-screen select-none overflow-hidden rounded-24 bg-white dark:bg-black',
|
||||||
|
css`
|
||||||
|
grid-template-columns: 6.5rem auto 358px;
|
||||||
|
grid-template-rows: 132px auto;
|
||||||
|
`,
|
||||||
|
track
|
||||||
|
? css`
|
||||||
|
grid-template-areas:
|
||||||
|
'sidebar main -'
|
||||||
|
'sidebar main player';
|
||||||
|
`
|
||||||
|
: css`
|
||||||
|
grid-template-areas:
|
||||||
|
'sidebar main main'
|
||||||
|
'sidebar main main';
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Sidebar />
|
||||||
|
<Topbar />
|
||||||
|
<Main />
|
||||||
|
{track && <Player />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Layout
|
23
packages/web/components/New/Main.tsx
Normal file
23
packages/web/components/New/Main.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { css, cx } from '@emotion/css'
|
||||||
|
import Router from './Router'
|
||||||
|
|
||||||
|
const Main = () => {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className={cx(
|
||||||
|
'overflow-y-auto pb-16 pr-6 pl-10',
|
||||||
|
css`
|
||||||
|
padding-top: 132px;
|
||||||
|
grid-area: main;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Router />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Main
|
23
packages/web/components/New/NowPlaying.stories.tsx
Normal file
23
packages/web/components/New/NowPlaying.stories.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||||
|
import NowPlaying from './NowPlaying'
|
||||||
|
import tracks from '../../.storybook/mock/tracks'
|
||||||
|
import { sample } from 'lodash-es'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Components/NowPlaying',
|
||||||
|
component: NowPlaying,
|
||||||
|
parameters: {
|
||||||
|
viewport: {
|
||||||
|
defaultViewport: 'iphone8p',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ComponentMeta<typeof NowPlaying>
|
||||||
|
|
||||||
|
const Template: ComponentStory<typeof NowPlaying> = args => (
|
||||||
|
<div className='fixed inset-0 bg-[#F8F8F8] p-4 dark:bg-black'>
|
||||||
|
<NowPlaying track={sample(tracks)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Default = Template.bind({})
|
169
packages/web/components/New/NowPlaying.tsx
Normal file
169
packages/web/components/New/NowPlaying.tsx
Normal file
|
@ -0,0 +1,169 @@
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { css, cx } from '@emotion/css'
|
||||||
|
import Icon from '../Icon'
|
||||||
|
import { formatDuration, resizeImage } from '@/web/utils/common'
|
||||||
|
import { player } from '@/web/store'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player'
|
||||||
|
import Slider from './Slider'
|
||||||
|
import { animate, motion, useAnimation } from 'framer-motion'
|
||||||
|
import { ease } from '@/web/utils/const'
|
||||||
|
|
||||||
|
const Progress = () => {
|
||||||
|
const playerSnapshot = useSnapshot(player)
|
||||||
|
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||||
|
const progress = useMemo(
|
||||||
|
() => playerSnapshot.progress,
|
||||||
|
[playerSnapshot.progress]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='mt-10 flex w-full flex-col'>
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={(track?.dt ?? 100000) / 1000}
|
||||||
|
value={progress}
|
||||||
|
onChange={value => {
|
||||||
|
player.progress = value
|
||||||
|
}}
|
||||||
|
onlyCallOnChangeAfterDragEnded={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='mt-1 flex justify-between text-14 font-bold text-black/20 dark:text-white/20'>
|
||||||
|
<span>{formatDuration(progress * 1000, 'en', 'hh:mm:ss')}</span>
|
||||||
|
<span>{formatDuration(track?.dt || 0, 'en', 'hh:mm:ss')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Cover = () => {
|
||||||
|
const playerSnapshot = useSnapshot(player)
|
||||||
|
const [cover, setCover] = useState('')
|
||||||
|
const animationStartTime = useRef(0)
|
||||||
|
const controls = useAnimation()
|
||||||
|
const duration = 150 // ms
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cover = resizeImage(playerSnapshot.track?.al.picUrl || '', 'lg')
|
||||||
|
const animate = async () => {
|
||||||
|
animationStartTime.current = Date.now()
|
||||||
|
await controls.start({ opacity: 0 })
|
||||||
|
setCover(cover)
|
||||||
|
}
|
||||||
|
animate()
|
||||||
|
}, [controls, playerSnapshot.track?.al.picUrl])
|
||||||
|
|
||||||
|
// 防止狂点下一首或上一首造成封面与歌曲不匹配的问题
|
||||||
|
useEffect(() => {
|
||||||
|
const realCover = playerSnapshot.track?.al.picUrl ?? ''
|
||||||
|
if (cover !== realCover) setCover(realCover)
|
||||||
|
}, [cover, playerSnapshot.track?.al.picUrl])
|
||||||
|
|
||||||
|
const onLoad = () => {
|
||||||
|
const passedTime = Date.now() - animationStartTime.current
|
||||||
|
controls.start({
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
delay: passedTime > duration ? 0 : (duration - passedTime) / 1000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.img
|
||||||
|
animate={controls}
|
||||||
|
transition={{ duration: duration / 1000, ease }}
|
||||||
|
className={cx('absolute inset-0 w-full')}
|
||||||
|
src={cover}
|
||||||
|
onLoad={onLoad}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NowPlaying = () => {
|
||||||
|
const playerSnapshot = useSnapshot(player)
|
||||||
|
|
||||||
|
const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state])
|
||||||
|
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'relative flex aspect-square h-full w-full flex-col justify-end overflow-hidden rounded-24 border',
|
||||||
|
css`
|
||||||
|
border-color: hsl(0, 100%, 100%, 0.08);
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Cover */}
|
||||||
|
<Cover />
|
||||||
|
|
||||||
|
{/* Info & Controls */}
|
||||||
|
<div className='m-3 flex flex-col items-center rounded-20 bg-white/60 p-5 font-medium backdrop-blur-3xl dark:bg-black/70'>
|
||||||
|
{/* Track Info */}
|
||||||
|
<div className='line-clamp-1 text-lg text-black dark:text-white'>
|
||||||
|
{track?.name}
|
||||||
|
</div>
|
||||||
|
<div className='line-clamp-1 text-base text-black/30 dark:text-white/30'>
|
||||||
|
{track?.ar.map(a => a.name).join(', ')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dividing line */}
|
||||||
|
<div className='mt-2 h-px w-2/3 bg-black/10 dark:bg-white/10'></div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<Progress />
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className='mt-4 flex w-full items-center justify-between'>
|
||||||
|
<button>
|
||||||
|
<Icon
|
||||||
|
name='shuffle'
|
||||||
|
className='h-7 w-7 text-black/90 dark:text-white/40'
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className='text-black/95 dark:text-white/80'>
|
||||||
|
<button
|
||||||
|
onClick={() => track && player.prevTrack()}
|
||||||
|
disabled={!track}
|
||||||
|
className='rounded-full bg-black/10 p-2.5 dark:bg-white/10'
|
||||||
|
>
|
||||||
|
<Icon name='previous' className='h-6 w-6 ' />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => track && player.playOrPause()}
|
||||||
|
className='mx-2 rounded-full bg-black/10 p-2.5 dark:bg-white/10'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name={
|
||||||
|
[PlayerState.Playing, PlayerState.Loading].includes(state)
|
||||||
|
? 'pause'
|
||||||
|
: 'play'
|
||||||
|
}
|
||||||
|
className='h-6 w-6 '
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => track && player.nextTrack()}
|
||||||
|
disabled={!track}
|
||||||
|
className='rounded-full bg-black/10 p-2.5 dark:bg-white/10'
|
||||||
|
>
|
||||||
|
<Icon name='next' className='h-6 w-6 ' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button>
|
||||||
|
<Icon
|
||||||
|
name='repeat-1'
|
||||||
|
className='h-7 w-7 text-black/90 dark:text-white/40'
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NowPlaying
|
23
packages/web/components/New/PageTransition.tsx
Normal file
23
packages/web/components/New/PageTransition.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { ease } from '@/web/utils/const'
|
||||||
|
|
||||||
|
const PageTransition = ({
|
||||||
|
children,
|
||||||
|
disableEnterAnimation,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
disableEnterAnimation?: boolean
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: disableEnterAnimation ? 1 : 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18, ease }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageTransition
|
93
packages/web/components/New/PlayLikedSongsCard.tsx
Normal file
93
packages/web/components/New/PlayLikedSongsCard.tsx
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import useLyric from '@/web/api/hooks/useLyric'
|
||||||
|
import usePlaylist from '@/web/api/hooks/usePlaylist'
|
||||||
|
import useUserPlaylists from '@/web/api/hooks/useUserPlaylists'
|
||||||
|
import { player } from '@/web/store'
|
||||||
|
import { sample, chunk } from 'lodash-es'
|
||||||
|
import { css, cx } from '@emotion/css'
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
const PlayLikedSongsCard = () => {
|
||||||
|
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('纯音乐,请欣赏')
|
||||||
|
),
|
||||||
|
4
|
||||||
|
)
|
||||||
|
) ?? []
|
||||||
|
)
|
||||||
|
}, [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
|
||||||
|
className={cx(
|
||||||
|
'flex flex-col justify-between rounded-24 p-8 dark:bg-night-600',
|
||||||
|
css`
|
||||||
|
height: 322px;
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className='text-21 font-medium text-white/20'>
|
||||||
|
{lyricLines.map((line, index) => (
|
||||||
|
<div key={`${index}-${line}`}>{line}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={handlePlay}
|
||||||
|
className='rounded-full bg-brand-700 py-5 px-6 text-16 font-medium text-white'
|
||||||
|
>
|
||||||
|
Play Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PlayLikedSongsCard
|
23
packages/web/components/New/Player.tsx
Normal file
23
packages/web/components/New/Player.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { css, cx } from '@emotion/css'
|
||||||
|
import NowPlaying from './NowPlaying'
|
||||||
|
import PlayingNext from './PlayingNext'
|
||||||
|
|
||||||
|
const Player = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'relative flex w-full flex-col justify-between overflow-hidden pr-6 pl-4',
|
||||||
|
css`
|
||||||
|
grid-area: player;
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PlayingNext className='mb-3 h-full' />
|
||||||
|
<div className='pb-20'>
|
||||||
|
<NowPlaying />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Player
|
21
packages/web/components/New/PlayingNext.stories.tsx
Normal file
21
packages/web/components/New/PlayingNext.stories.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||||
|
import PlayingNext from './PlayingNext'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Components/PlayingNext',
|
||||||
|
component: PlayingNext,
|
||||||
|
parameters: {
|
||||||
|
viewport: {
|
||||||
|
defaultViewport: 'iphone6',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ComponentMeta<typeof PlayingNext>
|
||||||
|
|
||||||
|
const Template: ComponentStory<typeof PlayingNext> = args => (
|
||||||
|
<div className='fixed inset-0 bg-[#F8F8F8] p-4 dark:bg-black'>
|
||||||
|
<PlayingNext />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Default = Template.bind({})
|
88
packages/web/components/New/PlayingNext.tsx
Normal file
88
packages/web/components/New/PlayingNext.tsx
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
import { resizeImage } from '@/web/utils/common'
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { player } from '@/web/store'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import useTracks from '@/web/api/hooks/useTracks'
|
||||||
|
import { css, cx } from '@emotion/css'
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
|
import Image from './Image'
|
||||||
|
|
||||||
|
const PlayingNext = ({ className }: { className?: string }) => {
|
||||||
|
const playerSnapshot = useSnapshot(player)
|
||||||
|
const list = useMemo(
|
||||||
|
() => playerSnapshot.trackList.slice(playerSnapshot.trackIndex + 1, 100),
|
||||||
|
[playerSnapshot.trackList, playerSnapshot.trackIndex]
|
||||||
|
)
|
||||||
|
const { data: tracks } = useTracks({ ids: list })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'absolute top-0 z-10 pb-6 text-14 font-bold text-neutral-700 dark:text-neutral-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
PLAYING NEXT
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'relative z-10 overflow-scroll',
|
||||||
|
className,
|
||||||
|
css`
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
css`
|
||||||
|
padding-top: 42px;
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0,
|
||||||
|
black 42px
|
||||||
|
);
|
||||||
|
mask-image: linear-gradient(to bottom, transparent 0, black 42px);
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<motion.div className='grid gap-4'>
|
||||||
|
<AnimatePresence>
|
||||||
|
{tracks?.songs?.map((track, index) => (
|
||||||
|
<motion.div
|
||||||
|
className='flex items-center justify-between'
|
||||||
|
key={track.id}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ x: '100%', opacity: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.24,
|
||||||
|
}}
|
||||||
|
layout
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
alt='Cover'
|
||||||
|
className='mr-4 aspect-square h-14 w-14 flex-shrink-0 rounded-12'
|
||||||
|
src={resizeImage(track.al.picUrl, 'sm')}
|
||||||
|
/>
|
||||||
|
<div className='flex-grow'>
|
||||||
|
<div className='line-clamp-1 text-18 font-medium text-neutral-700 dark:text-neutral-200'>
|
||||||
|
{track.name}
|
||||||
|
</div>
|
||||||
|
<div className='line-clamp-1 mt-1 text-16 font-bold text-neutral-200 dark:text-neutral-700'>
|
||||||
|
{track.ar.map(a => a.name).join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='text-18 font-medium text-neutral-700 dark:text-neutral-200'>
|
||||||
|
{String(index + 1).padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className='pointer-events-none sticky bottom-0 h-8 w-full bg-gradient-to-t from-black'></div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PlayingNext
|
79
packages/web/components/New/Router.tsx
Normal file
79
packages/web/components/New/Router.tsx
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import { Route, RouteObject, Routes, useLocation } from 'react-router-dom'
|
||||||
|
import Login from '@/web/pages/Login'
|
||||||
|
import Playlist from '@/web/pages/Playlist'
|
||||||
|
import Artist from '@/web/pages/Artist'
|
||||||
|
import Search from '@/web/pages/Search'
|
||||||
|
import Library from '@/web/pages/Library'
|
||||||
|
import Settings from '@/web/pages/Settings'
|
||||||
|
import { AnimatePresence } from 'framer-motion'
|
||||||
|
import React, { ReactNode, Suspense } from 'react'
|
||||||
|
|
||||||
|
const My = React.lazy(() => import('@/web/pages/New/My'))
|
||||||
|
const Discover = React.lazy(() => import('@/web/pages/New/Discover'))
|
||||||
|
const Album = React.lazy(() => import('@/web/pages/New/Album'))
|
||||||
|
|
||||||
|
const routes: RouteObject[] = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: <My />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/discover',
|
||||||
|
element: <Discover />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/library',
|
||||||
|
element: <Library />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
element: <Settings />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
element: <Login />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/search/:keywords',
|
||||||
|
element: <Search />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: ':type',
|
||||||
|
element: <Search />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/playlist/:id',
|
||||||
|
element: <Playlist />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/album/:id',
|
||||||
|
element: <Album />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/artist/:id',
|
||||||
|
element: <Artist />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const lazy = (components: ReactNode) => {
|
||||||
|
return <Suspense>{components}</Suspense>
|
||||||
|
}
|
||||||
|
|
||||||
|
const Router = () => {
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence exitBeforeEnter>
|
||||||
|
<Routes location={location} key={location.pathname}>
|
||||||
|
<Route path='/' element={lazy(<My />)} />
|
||||||
|
<Route path='/discover' element={lazy(<Discover />)} />
|
||||||
|
<Route path='/login' element={lazy(<Login />)} />
|
||||||
|
<Route path='/album/:id' element={lazy(<Album />)} />
|
||||||
|
</Routes>
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Router
|
|
@ -3,7 +3,7 @@ import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||||
import Sidebar from './Sidebar'
|
import Sidebar from './Sidebar'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Sidebar',
|
title: 'Components/Sidebar',
|
||||||
component: Sidebar,
|
component: Sidebar,
|
||||||
} as ComponentMeta<typeof Sidebar>
|
} as ComponentMeta<typeof Sidebar>
|
||||||
|
|
||||||
|
@ -13,4 +13,4 @@ const Template: ComponentStory<typeof Sidebar> = args => (
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
export const Primary = Template.bind({})
|
export const Default = Template.bind({})
|
110
packages/web/components/New/Sidebar.tsx
Normal file
110
packages/web/components/New/Sidebar.tsx
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { css, cx } from '@emotion/css'
|
||||||
|
import Icon from '../Icon'
|
||||||
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
|
import { useAnimation, motion } from 'framer-motion'
|
||||||
|
import { ease } from '@/web/utils/const'
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
name: 'MY MUSIC',
|
||||||
|
path: '/',
|
||||||
|
icon: 'my',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'DISCOVER',
|
||||||
|
path: '/discover',
|
||||||
|
icon: 'explore',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'BROWSE',
|
||||||
|
path: '/browse',
|
||||||
|
icon: 'discovery',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'LYRICS',
|
||||||
|
path: '/lyrics',
|
||||||
|
icon: 'lyrics',
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const getNameByPath = (path: string): string => {
|
||||||
|
return tabs.find(tab => tab.path === path)?.name || ''
|
||||||
|
}
|
||||||
|
const TabName = () => {
|
||||||
|
const location = useLocation()
|
||||||
|
const [name, setName] = useState(getNameByPath(location.pathname))
|
||||||
|
const controls = useAnimation()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newName = getNameByPath(location.pathname)
|
||||||
|
const animate = async () => {
|
||||||
|
await controls.start('out')
|
||||||
|
setName(newName)
|
||||||
|
await controls.start('in')
|
||||||
|
}
|
||||||
|
if (newName !== name) animate()
|
||||||
|
}, [controls, location.pathname, name])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'absolute bottom-8 right-0 left-0 z-10 flex rotate-180 select-none items-center font-bold text-brand-600 dark:text-brand-700',
|
||||||
|
css`
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-orientation: mixed;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
initial='in'
|
||||||
|
animate={controls}
|
||||||
|
variants={{
|
||||||
|
in: { opacity: 1 },
|
||||||
|
out: { opacity: 0 },
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 0.18,
|
||||||
|
ease,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</motion.span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Sidebar = () => {
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'app-region-drag relative flex h-full w-full flex-col justify-center',
|
||||||
|
css`
|
||||||
|
grid-area: sidebar;
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className='grid grid-cols-1 justify-items-center gap-12 text-black/10 dark:text-white/20'>
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<NavLink key={tab.name} to={tab.path}>
|
||||||
|
<Icon
|
||||||
|
name={tab.icon}
|
||||||
|
className={cx(
|
||||||
|
'app-region-no-drag h-10 w-10 transition duration-500 active:scale-75',
|
||||||
|
location.pathname === tab.path
|
||||||
|
? 'text-brand-600 dark:text-brand-700'
|
||||||
|
: 'hover:text-black dark:hover:text-white'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<TabName />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Sidebar
|
176
packages/web/components/New/Slider.tsx
Normal file
176
packages/web/components/New/Slider.tsx
Normal file
|
@ -0,0 +1,176 @@
|
||||||
|
import { useRef, useState, useMemo, useCallback, useEffect } from 'react'
|
||||||
|
import { cx } from '@emotion/css'
|
||||||
|
|
||||||
|
const Slider = ({
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
onChange,
|
||||||
|
onlyCallOnChangeAfterDragEnded = false,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
alwaysShowThumb = false,
|
||||||
|
}: {
|
||||||
|
value: number
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
onChange: (value: number) => void
|
||||||
|
onlyCallOnChangeAfterDragEnded?: boolean
|
||||||
|
orientation?: 'horizontal' | 'vertical'
|
||||||
|
alwaysShowTrack?: boolean
|
||||||
|
alwaysShowThumb?: boolean
|
||||||
|
}) => {
|
||||||
|
const sliderRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [draggingValue, setDraggingValue] = useState(value)
|
||||||
|
const memoedValue = useMemo(
|
||||||
|
() =>
|
||||||
|
isDragging && onlyCallOnChangeAfterDragEnded ? draggingValue : value,
|
||||||
|
[isDragging, draggingValue, value, onlyCallOnChangeAfterDragEnded]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the value of the slider based on the position of the pointer
|
||||||
|
*/
|
||||||
|
const getNewValue = useCallback(
|
||||||
|
(pointer: { x: number; y: number }) => {
|
||||||
|
if (!sliderRef?.current) return 0
|
||||||
|
const slider = sliderRef.current.getBoundingClientRect()
|
||||||
|
const newValue =
|
||||||
|
orientation === 'horizontal'
|
||||||
|
? ((pointer.x - slider.x) / slider.width) * max
|
||||||
|
: ((slider.height - (pointer.y - slider.y)) / slider.height) * max
|
||||||
|
if (newValue < min) return min
|
||||||
|
if (newValue > max) return max
|
||||||
|
return newValue
|
||||||
|
},
|
||||||
|
[sliderRef, max, min, orientation]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle slider click event
|
||||||
|
*/
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) =>
|
||||||
|
onChange(getNewValue({ x: e.clientX, y: e.clientY })),
|
||||||
|
[getNewValue, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle pointer down event
|
||||||
|
*/
|
||||||
|
const handlePointerDown = () => {
|
||||||
|
setIsDragging(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle pointer move events
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePointerMove = (e: { clientX: number; clientY: number }) => {
|
||||||
|
if (!isDragging) return
|
||||||
|
const newValue = getNewValue({ x: e.clientX, y: e.clientY })
|
||||||
|
onlyCallOnChangeAfterDragEnded
|
||||||
|
? setDraggingValue(newValue)
|
||||||
|
: onChange(newValue)
|
||||||
|
}
|
||||||
|
document.addEventListener('pointermove', handlePointerMove)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointermove', handlePointerMove)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isDragging,
|
||||||
|
onChange,
|
||||||
|
setDraggingValue,
|
||||||
|
onlyCallOnChangeAfterDragEnded,
|
||||||
|
getNewValue,
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle pointer up events
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePointerUp = () => {
|
||||||
|
if (!isDragging) return
|
||||||
|
setIsDragging(false)
|
||||||
|
if (onlyCallOnChangeAfterDragEnded) {
|
||||||
|
onChange(draggingValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('pointerup', handlePointerUp)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerup', handlePointerUp)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isDragging,
|
||||||
|
setIsDragging,
|
||||||
|
onlyCallOnChangeAfterDragEnded,
|
||||||
|
draggingValue,
|
||||||
|
onChange,
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track and thumb styles
|
||||||
|
*/
|
||||||
|
const usedTrackStyle = useMemo(() => {
|
||||||
|
const percentage = `${(memoedValue / max) * 100}%`
|
||||||
|
return orientation === 'horizontal'
|
||||||
|
? { width: percentage }
|
||||||
|
: { height: percentage }
|
||||||
|
}, [max, memoedValue, orientation])
|
||||||
|
const thumbStyle = useMemo(() => {
|
||||||
|
const percentage = `${(memoedValue / max) * 100}%`
|
||||||
|
return orientation === 'horizontal'
|
||||||
|
? { left: percentage }
|
||||||
|
: { bottom: percentage }
|
||||||
|
}, [max, memoedValue, orientation])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'group relative flex items-center',
|
||||||
|
orientation === 'horizontal' && 'h-2',
|
||||||
|
orientation === 'vertical' && 'h-full w-2 flex-col'
|
||||||
|
)}
|
||||||
|
ref={sliderRef}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{/* Track */}
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'absolute overflow-hidden rounded-full bg-black/10 bg-opacity-10 dark:bg-white/10',
|
||||||
|
orientation === 'horizontal' && 'h-[3px] w-full',
|
||||||
|
orientation === 'vertical' && 'h-full w-[3px]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Passed track */}
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'bg-black dark:bg-white',
|
||||||
|
orientation === 'horizontal' && 'h-full rounded-r-full',
|
||||||
|
orientation === 'vertical' && 'bottom-0 w-full rounded-t-full'
|
||||||
|
)}
|
||||||
|
style={usedTrackStyle}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumb */}
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'absolute flex h-2 w-2 items-center justify-center rounded-full bg-black bg-opacity-20 transition-opacity dark:bg-white',
|
||||||
|
isDragging || alwaysShowThumb
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-0 group-hover:opacity-100',
|
||||||
|
orientation === 'horizontal' && '-translate-x-1',
|
||||||
|
orientation === 'vertical' && 'translate-y-1'
|
||||||
|
)}
|
||||||
|
style={thumbStyle}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Slider
|
34
packages/web/components/New/Tabs.tsx
Normal file
34
packages/web/components/New/Tabs.tsx
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import { cx } from '@emotion/css'
|
||||||
|
|
||||||
|
const Tabs = ({
|
||||||
|
tabs,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
tabs: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}[]
|
||||||
|
value: string
|
||||||
|
onChange: (id: string) => void
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='flex'>
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
className={cx(
|
||||||
|
'mr-2.5 rounded-12 py-3 px-6 text-16 font-medium ',
|
||||||
|
value === tab.id
|
||||||
|
? 'bg-brand-700 text-white'
|
||||||
|
: 'dark:bg-white/10 dark:text-white/20'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Tabs
|
|
@ -3,14 +3,14 @@ import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||||
import Topbar from './Topbar'
|
import Topbar from './Topbar'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Topbar',
|
title: 'Components/Topbar',
|
||||||
component: Topbar,
|
component: Topbar,
|
||||||
} as ComponentMeta<typeof Topbar>
|
} as ComponentMeta<typeof Topbar>
|
||||||
|
|
||||||
const Template: ComponentStory<typeof Topbar> = args => (
|
const Template: ComponentStory<typeof Topbar> = args => (
|
||||||
<div className='w-[calc(100vw_-_32px)] rounded-3xl bg-[#F8F8F8] px-11 dark:bg-black'>
|
<div className='w-[calc(100vw_-_32px)] rounded-24 bg-[#F8F8F8] px-11 dark:bg-black'>
|
||||||
<Topbar />
|
<Topbar />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
export const Primary = Template.bind({})
|
export const Default = Template.bind({})
|
123
packages/web/components/New/Topbar.tsx
Normal file
123
packages/web/components/New/Topbar.tsx
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
import { css, cx } from '@emotion/css'
|
||||||
|
import { motion, useAnimation } from 'framer-motion'
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { ease } from '@/web/utils/const'
|
||||||
|
import Icon from '../Icon'
|
||||||
|
import { resizeImage } from '@/web/utils/common'
|
||||||
|
import useUser from '@/web/api/hooks/useUser'
|
||||||
|
|
||||||
|
const NavigationButtons = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const controlsBack = useAnimation()
|
||||||
|
const controlsForward = useAnimation()
|
||||||
|
const transition = { duration: 0.2, ease }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
navigate(-1)
|
||||||
|
await controlsBack.start({ x: -5 })
|
||||||
|
await controlsBack.start({ x: 0 })
|
||||||
|
}}
|
||||||
|
className='app-region-no-drag rounded-full bg-day-600 p-2.5 dark:bg-night-600'
|
||||||
|
>
|
||||||
|
<motion.div animate={controlsBack} transition={transition}>
|
||||||
|
<Icon name='back' className='h-7 w-7 text-neutral-500' />
|
||||||
|
</motion.div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
navigate(1)
|
||||||
|
await controlsForward.start({ x: 5 })
|
||||||
|
await controlsForward.start({ x: 0 })
|
||||||
|
}}
|
||||||
|
className='app-region-no-drag ml-2.5 rounded-full bg-day-600 p-2.5 dark:bg-night-600'
|
||||||
|
>
|
||||||
|
<motion.div animate={controlsForward} transition={transition}>
|
||||||
|
<Icon name='forward' className='h-7 w-7 text-neutral-500' />
|
||||||
|
</motion.div>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Avatar = ({ className }: { className?: string }) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data: user } = useUser()
|
||||||
|
|
||||||
|
const avatarUrl = user?.profile?.avatarUrl
|
||||||
|
? resizeImage(user?.profile?.avatarUrl ?? '', 'sm')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
onClick={() => navigate('/login')}
|
||||||
|
className={cx(
|
||||||
|
'app-region-no-drag rounded-full',
|
||||||
|
className || 'h-12 w-12'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={() => navigate('/login')}
|
||||||
|
className={cx(
|
||||||
|
'rounded-full bg-day-600 p-2.5 dark:bg-night-600',
|
||||||
|
className || 'h-12 w-12'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon name='user' className='h-7 w-7 text-neutral-500' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Topbar = () => {
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'app-region-drag fixed top-0 right-0 z-10 flex items-center justify-between pt-11 pb-10 pr-6 pl-10 ',
|
||||||
|
css`
|
||||||
|
left: 104px;
|
||||||
|
`,
|
||||||
|
!location.pathname.startsWith('/album/') &&
|
||||||
|
!location.pathname.startsWith('/playlist/') &&
|
||||||
|
'bg-gradient-to-b from-white dark:from-black'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Left Part */}
|
||||||
|
<div className='flex items-center'>
|
||||||
|
<NavigationButtons />
|
||||||
|
|
||||||
|
{/* Dividing line */}
|
||||||
|
<div className='mx-6 h-4 w-px bg-black/20 dark:bg-white/20'></div>
|
||||||
|
|
||||||
|
{/* Search Box */}
|
||||||
|
<div className='app-region-no-drag flex min-w-[284px] items-center rounded-full bg-day-600 p-2.5 text-neutral-500 dark:bg-night-600'>
|
||||||
|
<Icon name='search' className='mr-2.5 h-7 w-7' />
|
||||||
|
<input
|
||||||
|
placeholder='Artist, songs and more'
|
||||||
|
className='bg-transparent font-medium placeholder:text-neutral-500 dark:text-neutral-200'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Part */}
|
||||||
|
<div className='flex'>
|
||||||
|
<button className='app-region-no-drag rounded-full bg-day-600 p-2.5 dark:bg-night-600'>
|
||||||
|
<Icon name='placeholder' className='h-7 w-7 text-neutral-500' />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Avatar className='ml-3 h-12 w-12' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Topbar
|
51
packages/web/components/New/TrackList.tsx
Normal file
51
packages/web/components/New/TrackList.tsx
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import { formatDuration } from '@/web/utils/common'
|
||||||
|
import { css, cx } from '@emotion/css'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { player } from '@/web/store'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
|
||||||
|
const TrackList = ({
|
||||||
|
tracks,
|
||||||
|
onPlay,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
tracks?: Track[]
|
||||||
|
onPlay: (id: number) => void
|
||||||
|
className?: string
|
||||||
|
}) => {
|
||||||
|
const playerSnapshot = useSnapshot(player)
|
||||||
|
const playingTrack = useMemo(
|
||||||
|
() => playerSnapshot.track,
|
||||||
|
[playerSnapshot.track]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => {
|
||||||
|
if (e.detail === 2) onPlay?.(trackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{tracks?.map(track => (
|
||||||
|
<div
|
||||||
|
key={track.id}
|
||||||
|
onClick={e => handleClick(e, track.id)}
|
||||||
|
className={cx(
|
||||||
|
'flex py-2 text-18 font-medium transition duration-300 ease-in-out',
|
||||||
|
playingTrack?.id === track.id
|
||||||
|
? 'text-brand-700'
|
||||||
|
: 'text-night-50 dark:hover:text-neutral-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className='mr-6'>{String(track.no).padStart(2, '0')}</div>
|
||||||
|
<div className='flex-grow'>{track.name}</div>
|
||||||
|
<div className='h-10 w-10'></div>
|
||||||
|
<div className='text-right'>
|
||||||
|
{formatDuration(track.dt, 'en', 'hh:mm:ss')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrackList
|
16
packages/web/components/New/TrackListHeader.stories.tsx
Normal file
16
packages/web/components/New/TrackListHeader.stories.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||||
|
import TrackListHeader from './TrackListHeader'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Components/TrackListHeader',
|
||||||
|
component: TrackListHeader,
|
||||||
|
} as ComponentMeta<typeof TrackListHeader>
|
||||||
|
|
||||||
|
const Template: ComponentStory<typeof TrackListHeader> = args => (
|
||||||
|
<div className='w-[calc(100vw_-_32px)] rounded-24 bg-[#F8F8F8] p-10 dark:bg-black'>
|
||||||
|
<TrackListHeader />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Default = Template.bind({})
|
83
packages/web/components/New/TrackListHeader.tsx
Normal file
83
packages/web/components/New/TrackListHeader.tsx
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import { formatDuration, resizeImage } from '@/web/utils/common'
|
||||||
|
import { css, cx } from '@emotion/css'
|
||||||
|
import Icon from '@/web/components/Icon'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import Image from './Image'
|
||||||
|
|
||||||
|
const TrackListHeader = ({
|
||||||
|
album,
|
||||||
|
onPlay,
|
||||||
|
}: {
|
||||||
|
album?: Album
|
||||||
|
onPlay: () => void
|
||||||
|
}) => {
|
||||||
|
const albumDuration = useMemo(() => {
|
||||||
|
const duration = album?.songs?.reduce((acc, cur) => acc + cur.dt, 0) || 0
|
||||||
|
return formatDuration(duration, 'en', 'hh[hr] mm[min]')
|
||||||
|
}, [album?.songs])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'grid grid-rows-1 gap-10',
|
||||||
|
css`
|
||||||
|
grid-template-columns: 318px auto;
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
className='z-10 aspect-square w-full rounded-24'
|
||||||
|
src={resizeImage(album?.picUrl || '', 'lg')}
|
||||||
|
alt='Cover'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<img
|
||||||
|
className={cx(
|
||||||
|
'fixed z-0 object-cover opacity-70',
|
||||||
|
css`
|
||||||
|
top: -400px;
|
||||||
|
left: -370px;
|
||||||
|
width: 1572px;
|
||||||
|
height: 528px;
|
||||||
|
filter: blur(256px) saturate(1.2);
|
||||||
|
/* transform: scale(0.5); */
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
src={resizeImage(album?.picUrl || '', 'lg')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className=' flex flex-col justify-between'>
|
||||||
|
<div>
|
||||||
|
<div className='text-36 font-medium dark:text-neutral-100'>
|
||||||
|
{album?.name}
|
||||||
|
</div>
|
||||||
|
<div className='mt-6 text-24 font-medium dark:text-neutral-600'>
|
||||||
|
{album?.artist.name}
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 flex items-center text-14 font-bold dark:text-neutral-600'>
|
||||||
|
{album?.mark === 1056768 && (
|
||||||
|
<Icon name='explicit' className='mb-px mr-1 h-3.5 w-3.5 ' />
|
||||||
|
)}{' '}
|
||||||
|
{dayjs(album?.publishTime || 0).year()} · {album?.songs.length}{' '}
|
||||||
|
Songs, {albumDuration}
|
||||||
|
</div>
|
||||||
|
<div className='line-clamp-3 mt-6 text-14 font-bold dark:text-neutral-600'>
|
||||||
|
{album?.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='z-10 flex'>
|
||||||
|
<button
|
||||||
|
onClick={onPlay}
|
||||||
|
className='h-[72px] w-[170px] rounded-full dark:bg-brand-700'
|
||||||
|
></button>
|
||||||
|
<button className='ml-6 h-[72px] w-[72px] rounded-full dark:bg-night-50'></button>
|
||||||
|
<button className='ml-6 h-[72px] w-[72px] rounded-full dark:bg-night-50'></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrackListHeader
|
|
@ -1,17 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
|
||||||
import NowPlaying from './NowPlaying'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
title: 'NowPlaying',
|
|
||||||
component: NowPlaying,
|
|
||||||
parameters: {
|
|
||||||
viewport: {
|
|
||||||
defaultViewport: 'iphone8p',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as ComponentMeta<typeof NowPlaying>
|
|
||||||
|
|
||||||
const Template: ComponentStory<typeof NowPlaying> = args => <NowPlaying />
|
|
||||||
|
|
||||||
export const Primary = Template.bind({})
|
|
|
@ -1,72 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import cx from 'classnames'
|
|
||||||
import SvgIcon from './SvgIcon'
|
|
||||||
|
|
||||||
const NowPlaying = () => {
|
|
||||||
return (
|
|
||||||
<div className='relative flex aspect-square w-full flex-col justify-end overflow-hidden rounded-3xl'>
|
|
||||||
{/* Cover */}
|
|
||||||
<img
|
|
||||||
className='insert-0 absolute w-full'
|
|
||||||
src='https://p2.music.126.net/8g2DIiWDpgZ2nSCoILc9kg==/109951165124745870.jpg?param=1024y1024'
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Info & Controls */}
|
|
||||||
<div className='m-3 flex flex-col items-center rounded-[20px] bg-white/60 p-5 backdrop-blur-3xl dark:bg-black/70'>
|
|
||||||
{/* Track Info */}
|
|
||||||
<div className='text-lg text-black dark:text-white'>
|
|
||||||
Life In Technicolor II
|
|
||||||
</div>
|
|
||||||
<div className='text-base text-black/30 dark:text-white/30'>
|
|
||||||
Coldplay
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dividing line */}
|
|
||||||
<div className='mt-2 h-px w-2/3 bg-black/10 dark:bg-white/10'></div>
|
|
||||||
|
|
||||||
{/* Progress */}
|
|
||||||
<div className='mt-10 flex w-full flex-col'>
|
|
||||||
{/* Slider */}
|
|
||||||
<div className='relative h-[3px] rounded-full bg-black/10 dark:bg-white/10'>
|
|
||||||
<div className='absolute left-0 top-0 bottom-0 w-2/3 rounded-full bg-black dark:bg-white'></div>
|
|
||||||
</div>
|
|
||||||
<div className='mt-1 flex justify-between text-[14px] font-semibold text-black/20 dark:text-white/20'>
|
|
||||||
<span>00:54</span>
|
|
||||||
<span>02:53</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls */}
|
|
||||||
<div className='mt-4 flex w-full items-center justify-between'>
|
|
||||||
<button>
|
|
||||||
<SvgIcon
|
|
||||||
name='shuffle'
|
|
||||||
className='h-7 w-7 text-black/90 dark:text-white/40'
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className='text-black/95 dark:text-white/80'>
|
|
||||||
<button className='rounded-full bg-black/10 p-[10px] dark:bg-white/10'>
|
|
||||||
<SvgIcon name='previous' className='h-6 w-6 ' />
|
|
||||||
</button>
|
|
||||||
<button className='mx-2 rounded-full bg-black/10 p-[10px] dark:bg-white/10'>
|
|
||||||
<SvgIcon name='play' className='h-6 w-6 ' />
|
|
||||||
</button>
|
|
||||||
<button className='rounded-full bg-black/10 p-[10px] dark:bg-white/10'>
|
|
||||||
<SvgIcon name='next' className='h-6 w-6 ' />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button>
|
|
||||||
<SvgIcon
|
|
||||||
name='repeat-1'
|
|
||||||
className='h-7 w-7 text-black/90 dark:text-white/40'
|
|
||||||
/>{' '}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NowPlaying
|
|
|
@ -1,18 +1,15 @@
|
||||||
import ArtistInline from './ArtistsInline'
|
import ArtistInline from './ArtistsInline'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import Slider from './Slider'
|
import Slider from './Slider'
|
||||||
import SvgIcon from './SvgIcon'
|
import Icon from './Icon'
|
||||||
import useUserLikedTracksIDs, {
|
import useUserLikedTracksIDs, {
|
||||||
useMutationLikeATrack,
|
useMutationLikeATrack,
|
||||||
} from '@/web/hooks/useUserLikedTracksIDs'
|
} from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||||
import { player, state } from '@/web/store'
|
import { player, state } from '@/web/store'
|
||||||
import { resizeImage } from '@/web/utils/common'
|
import { resizeImage } from '@/web/utils/common'
|
||||||
import {
|
import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player'
|
||||||
State as PlayerState,
|
|
||||||
Mode as PlayerMode,
|
|
||||||
} from '@/web/utils/player'
|
|
||||||
import { RepeatMode as PlayerRepeatMode } from '@/shared/playerDataTypes'
|
import { RepeatMode as PlayerRepeatMode } from '@/shared/playerDataTypes'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
@ -60,7 +57,7 @@ const PlayingTrack = () => {
|
||||||
onClick={toAlbum}
|
onClick={toAlbum}
|
||||||
className='flex aspect-square h-full items-center justify-center rounded-md bg-black/[.04] shadow-sm'
|
className='flex aspect-square h-full items-center justify-center rounded-md bg-black/[.04] shadow-sm'
|
||||||
>
|
>
|
||||||
<SvgIcon className='h-6 w-6 text-gray-300' name='music-note' />
|
<Icon className='h-6 w-6 text-gray-300' name='music-note' />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -82,7 +79,7 @@ const PlayingTrack = () => {
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => track?.id && mutationLikeATrack.mutate(track.id)}
|
onClick={() => track?.id && mutationLikeATrack.mutate(track.id)}
|
||||||
>
|
>
|
||||||
<SvgIcon
|
<Icon
|
||||||
className='h-5 w-5 text-black dark:text-white'
|
className='h-5 w-5 text-black dark:text-white'
|
||||||
name={
|
name={
|
||||||
track?.id && userLikedSongs?.ids?.includes(track.id)
|
track?.id && userLikedSongs?.ids?.includes(track.id)
|
||||||
|
@ -111,12 +108,12 @@ const MediaControls = () => {
|
||||||
onClick={() => track && player.prevTrack()}
|
onClick={() => track && player.prevTrack()}
|
||||||
disabled={!track}
|
disabled={!track}
|
||||||
>
|
>
|
||||||
<SvgIcon className='h-6 w-6' name='previous' />
|
<Icon className='h-6 w-6' name='previous' />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
{mode === PlayerMode.FM && (
|
{mode === PlayerMode.FM && (
|
||||||
<IconButton onClick={() => player.fmTrash()}>
|
<IconButton onClick={() => player.fmTrash()}>
|
||||||
<SvgIcon className='h-6 w-6' name='dislike' />
|
<Icon className='h-6 w-6' name='dislike' />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
|
@ -124,7 +121,7 @@ const MediaControls = () => {
|
||||||
disabled={!track}
|
disabled={!track}
|
||||||
className='after:rounded-xl'
|
className='after:rounded-xl'
|
||||||
>
|
>
|
||||||
<SvgIcon
|
<Icon
|
||||||
className='h-7 w-7'
|
className='h-7 w-7'
|
||||||
name={
|
name={
|
||||||
[PlayerState.Playing, PlayerState.Loading].includes(state)
|
[PlayerState.Playing, PlayerState.Loading].includes(state)
|
||||||
|
@ -134,7 +131,7 @@ const MediaControls = () => {
|
||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton onClick={() => track && player.nextTrack()} disabled={!track}>
|
<IconButton onClick={() => track && player.nextTrack()} disabled={!track}>
|
||||||
<SvgIcon className='h-6 w-6' name='next' />
|
<Icon className='h-6 w-6' name='next' />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -159,13 +156,13 @@ const Others = () => {
|
||||||
onClick={() => toast('Work in progress')}
|
onClick={() => toast('Work in progress')}
|
||||||
disabled={playerSnapshot.mode === PlayerMode.FM}
|
disabled={playerSnapshot.mode === PlayerMode.FM}
|
||||||
>
|
>
|
||||||
<SvgIcon className='h-6 w-6' name='playlist' />
|
<Icon className='h-6 w-6' name='playlist' />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={switchRepeatMode}
|
onClick={switchRepeatMode}
|
||||||
disabled={playerSnapshot.mode === PlayerMode.FM}
|
disabled={playerSnapshot.mode === PlayerMode.FM}
|
||||||
>
|
>
|
||||||
<SvgIcon
|
<Icon
|
||||||
className={cx(
|
className={cx(
|
||||||
'h-6 w-6',
|
'h-6 w-6',
|
||||||
playerSnapshot.repeatMode !== PlayerRepeatMode.Off &&
|
playerSnapshot.repeatMode !== PlayerRepeatMode.Off &&
|
||||||
|
@ -182,15 +179,15 @@ const Others = () => {
|
||||||
onClick={() => toast('施工中...')}
|
onClick={() => toast('施工中...')}
|
||||||
disabled={playerSnapshot.mode === PlayerMode.FM}
|
disabled={playerSnapshot.mode === PlayerMode.FM}
|
||||||
>
|
>
|
||||||
<SvgIcon className='h-6 w-6' name='shuffle' />
|
<Icon className='h-6 w-6' name='shuffle' />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton onClick={() => toast('施工中...')}>
|
<IconButton onClick={() => toast('施工中...')}>
|
||||||
<SvgIcon className='h-6 w-6' name='volume' />
|
<Icon className='h-6 w-6' name='volume' />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
{/* Lyric */}
|
{/* Lyric */}
|
||||||
<IconButton onClick={() => (state.uiStates.showLyricPanel = true)}>
|
<IconButton onClick={() => (state.uiStates.showLyricPanel = true)}>
|
||||||
<SvgIcon className='h-6 w-6' name='lyrics' />
|
<Icon className='h-6 w-6' name='lyrics' />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,29 +1,107 @@
|
||||||
import React from 'react'
|
import { NavLink } from 'react-router-dom'
|
||||||
import cx from 'classnames'
|
import Icon from './Icon'
|
||||||
import SvgIcon from './SvgIcon'
|
import useUserPlaylists from '@/web/api/hooks/useUserPlaylists'
|
||||||
|
import { scrollToTop } from '@/web/utils/common'
|
||||||
|
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
|
||||||
|
import { player } from '@/web/store'
|
||||||
|
import { Mode, TrackListSourceType } from '@/web/utils/player'
|
||||||
|
import { cx } from '@emotion/css'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
|
||||||
|
const primaryTabs = [
|
||||||
|
{
|
||||||
|
name: '主页',
|
||||||
|
icon: 'home',
|
||||||
|
route: '/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '播客',
|
||||||
|
icon: 'podcast',
|
||||||
|
route: '/podcast',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '音乐库',
|
||||||
|
icon: 'music-library',
|
||||||
|
route: '/library',
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const PrimaryTabs = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={cx(window.env?.isMac && 'app-region-drag', 'h-14')}></div>
|
||||||
|
{primaryTabs.map(tab => (
|
||||||
|
<NavLink
|
||||||
|
onClick={() => scrollToTop()}
|
||||||
|
key={tab.route}
|
||||||
|
to={tab.route}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cx(
|
||||||
|
'btn-hover-animation mx-3 flex cursor-default items-center rounded-lg px-3 py-2 transition-colors duration-200 after:scale-[0.97] after:bg-black/[.06] dark:after:bg-white/20',
|
||||||
|
!isActive && 'text-gray-700 dark:text-white',
|
||||||
|
isActive && 'text-brand-500 '
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Playlists = () => {
|
||||||
|
const { data: playlists } = useUserPlaylists()
|
||||||
|
const playerSnapshot = useSnapshot(player)
|
||||||
|
const currentPlaylistID = useMemo(
|
||||||
|
() => playerSnapshot.trackListSource?.id,
|
||||||
|
[playerSnapshot.trackListSource]
|
||||||
|
)
|
||||||
|
const playlistMode = useMemo(
|
||||||
|
() => playerSnapshot.trackListSource?.type,
|
||||||
|
[playerSnapshot.trackListSource]
|
||||||
|
)
|
||||||
|
const mode = useMemo(() => playerSnapshot.mode, [playerSnapshot.mode])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='mb-16 overflow-auto pb-2'>
|
||||||
|
{playlists?.playlist?.map(playlist => (
|
||||||
|
<NavLink
|
||||||
|
onMouseOver={() => prefetchPlaylist({ id: playlist.id })}
|
||||||
|
key={playlist.id}
|
||||||
|
onClick={() => scrollToTop()}
|
||||||
|
to={`/playlist/${playlist.id}`}
|
||||||
|
className={({ isActive }: { isActive: boolean }) =>
|
||||||
|
cx(
|
||||||
|
'btn-hover-animation line-clamp-1 my-px mx-3 flex cursor-default items-center justify-between rounded-lg px-3 py-[0.38rem] text-sm text-black opacity-70 transition-colors duration-200 after:scale-[0.97] after:bg-black/[.06] dark:text-white dark:after:bg-white/20',
|
||||||
|
isActive && 'after:scale-100 after:opacity-100'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className='line-clamp-1'>{playlist.name}</span>
|
||||||
|
{playlistMode === TrackListSourceType.Playlist &&
|
||||||
|
mode === Mode.TrackList &&
|
||||||
|
currentPlaylistID === playlist.id && (
|
||||||
|
<Icon className='h-5 w-5' name='volume-half' />
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const Sidebar = () => {
|
const Sidebar = () => {
|
||||||
return (
|
return (
|
||||||
<div className='relative flex h-full w-[104px] flex-col justify-center'>
|
<div
|
||||||
<div className='grid grid-cols-1 justify-items-center gap-12 text-black/10 dark:text-white/20'>
|
id='sidebar'
|
||||||
<SvgIcon
|
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'
|
||||||
name='my'
|
>
|
||||||
className='h-10 w-10 text-brand-600 dark:text-brand-700'
|
<PrimaryTabs />
|
||||||
/>
|
<Playlists />
|
||||||
<SvgIcon name='explore' className='h-10 w-10' />
|
|
||||||
<SvgIcon name='discovery' className='h-10 w-10' />
|
|
||||||
<SvgIcon name='lyrics' className='h-10 w-10' />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className='absolute bottom-8 right-0 left-0 flex rotate-180 items-center font-medium text-brand-600 dark:text-brand-700'
|
|
||||||
style={{
|
|
||||||
writingMode: 'vertical-rl',
|
|
||||||
textOrientation: 'mixed',
|
|
||||||
letterSpacing: '0.5px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>USER PAGE</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
|
|
||||||
const Skeleton = ({
|
const Skeleton = ({
|
||||||
children,
|
children,
|
||||||
|
|
44
packages/web/components/Slider.stories.tsx
Normal file
44
packages/web/components/Slider.stories.tsx
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||||
|
import Slider from './Slider'
|
||||||
|
import { useArgs } from '@storybook/client-api'
|
||||||
|
import { cx } from '@emotion/css'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Basic/Slider',
|
||||||
|
component: Slider,
|
||||||
|
args: {
|
||||||
|
value: 50,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
onlyCallOnChangeAfterDragEnded: false,
|
||||||
|
orientation: 'horizontal',
|
||||||
|
alwaysShowTrack: false,
|
||||||
|
alwaysShowThumb: false,
|
||||||
|
},
|
||||||
|
} as ComponentMeta<typeof Slider>
|
||||||
|
|
||||||
|
const Template: ComponentStory<typeof Slider> = args => {
|
||||||
|
const [, updateArgs] = useArgs()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'h-full rounded-24 bg-[#F8F8F8] dark:bg-black',
|
||||||
|
args.orientation === 'horizontal' && 'py-4 px-5',
|
||||||
|
args.orientation === 'vertical' && 'h-64 w-min py-5 px-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Slider {...args} onChange={value => updateArgs({ value })} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Default = Template.bind({})
|
||||||
|
|
||||||
|
export const Vertical = Template.bind({})
|
||||||
|
Vertical.args = {
|
||||||
|
orientation: 'vertical',
|
||||||
|
alwaysShowTrack: true,
|
||||||
|
alwaysShowThumb: true,
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import { useRef, useState, useMemo, useCallback, useEffect } from 'react'
|
import { useRef, useState, useMemo, useCallback, useEffect } from 'react'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
|
|
||||||
const Slider = ({
|
const Slider = ({
|
||||||
value,
|
value,
|
||||||
|
@ -8,6 +8,8 @@ const Slider = ({
|
||||||
onChange,
|
onChange,
|
||||||
onlyCallOnChangeAfterDragEnded = false,
|
onlyCallOnChangeAfterDragEnded = false,
|
||||||
orientation = 'horizontal',
|
orientation = 'horizontal',
|
||||||
|
alwaysShowTrack = false,
|
||||||
|
alwaysShowThumb = false,
|
||||||
}: {
|
}: {
|
||||||
value: number
|
value: number
|
||||||
min: number
|
min: number
|
||||||
|
@ -15,6 +17,8 @@ const Slider = ({
|
||||||
onChange: (value: number) => void
|
onChange: (value: number) => void
|
||||||
onlyCallOnChangeAfterDragEnded?: boolean
|
onlyCallOnChangeAfterDragEnded?: boolean
|
||||||
orientation?: 'horizontal' | 'vertical'
|
orientation?: 'horizontal' | 'vertical'
|
||||||
|
alwaysShowTrack?: boolean
|
||||||
|
alwaysShowThumb?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const sliderRef = useRef<HTMLInputElement>(null)
|
const sliderRef = useRef<HTMLInputElement>(null)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
@ -29,24 +33,26 @@ const Slider = ({
|
||||||
* Get the value of the slider based on the position of the pointer
|
* Get the value of the slider based on the position of the pointer
|
||||||
*/
|
*/
|
||||||
const getNewValue = useCallback(
|
const getNewValue = useCallback(
|
||||||
(val: number) => {
|
(pointer: { x: number; y: number }) => {
|
||||||
if (!sliderRef?.current) return 0
|
if (!sliderRef?.current) return 0
|
||||||
const sliderWidth = sliderRef.current.getBoundingClientRect().width
|
const slider = sliderRef.current.getBoundingClientRect()
|
||||||
const newValue = (val / sliderWidth) * max
|
const newValue =
|
||||||
|
orientation === 'horizontal'
|
||||||
|
? ((pointer.x - slider.x) / slider.width) * max
|
||||||
|
: ((slider.height - (pointer.y - slider.y)) / slider.height) * max
|
||||||
if (newValue < min) return min
|
if (newValue < min) return min
|
||||||
if (newValue > max) return max
|
if (newValue > max) return max
|
||||||
return newValue
|
return newValue
|
||||||
},
|
},
|
||||||
[sliderRef, max, min]
|
[sliderRef, max, min, orientation]
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle slider click event
|
* Handle slider click event
|
||||||
*/
|
*/
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
(e: React.MouseEvent<HTMLDivElement>) =>
|
||||||
onChange(getNewValue(e.clientX))
|
onChange(getNewValue({ x: e.clientX, y: e.clientY })),
|
||||||
},
|
|
||||||
[getNewValue, onChange]
|
[getNewValue, onChange]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -63,7 +69,7 @@ const Slider = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePointerMove = (e: { clientX: number; clientY: number }) => {
|
const handlePointerMove = (e: { clientX: number; clientY: number }) => {
|
||||||
if (!isDragging) return
|
if (!isDragging) return
|
||||||
const newValue = getNewValue(e.clientX)
|
const newValue = getNewValue({ x: e.clientX, y: e.clientY })
|
||||||
onlyCallOnChangeAfterDragEnded
|
onlyCallOnChangeAfterDragEnded
|
||||||
? setDraggingValue(newValue)
|
? setDraggingValue(newValue)
|
||||||
: onChange(newValue)
|
: onChange(newValue)
|
||||||
|
@ -109,32 +115,47 @@ const Slider = ({
|
||||||
/**
|
/**
|
||||||
* Track and thumb styles
|
* Track and thumb styles
|
||||||
*/
|
*/
|
||||||
const usedTrackStyle = useMemo(
|
const usedTrackStyle = useMemo(() => {
|
||||||
() => ({ width: `${(memoedValue / max) * 100}%` }),
|
const percentage = `${(memoedValue / max) * 100}%`
|
||||||
[max, memoedValue]
|
return orientation === 'horizontal'
|
||||||
)
|
? { width: percentage }
|
||||||
const thumbStyle = useMemo(
|
: { height: percentage }
|
||||||
() => ({
|
}, [max, memoedValue, orientation])
|
||||||
left: `${(memoedValue / max) * 100}%`,
|
const thumbStyle = useMemo(() => {
|
||||||
transform: `translateX(-10px)`,
|
const percentage = `${(memoedValue / max) * 100}%`
|
||||||
}),
|
return orientation === 'horizontal'
|
||||||
[max, memoedValue]
|
? { left: percentage }
|
||||||
)
|
: { bottom: percentage }
|
||||||
|
}, [max, memoedValue, orientation])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='group flex h-2 -translate-y-[3px] items-center'
|
className={cx(
|
||||||
|
'group relative flex items-center',
|
||||||
|
orientation === 'horizontal' && 'h-2',
|
||||||
|
orientation === 'vertical' && 'h-full w-2 flex-col'
|
||||||
|
)}
|
||||||
ref={sliderRef}
|
ref={sliderRef}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{/* Track */}
|
{/* Track */}
|
||||||
<div className='absolute h-[2px] w-full bg-gray-500 bg-opacity-10'></div>
|
<div
|
||||||
|
className={cx(
|
||||||
|
'absolute bg-gray-500 bg-opacity-10',
|
||||||
|
orientation === 'horizontal' && 'h-[2px] w-full',
|
||||||
|
orientation === 'vertical' && 'h-full w-[2px]'
|
||||||
|
)}
|
||||||
|
></div>
|
||||||
|
|
||||||
{/* Passed track */}
|
{/* Passed track */}
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
'absolute h-[2px] group-hover:bg-brand-500',
|
'absolute group-hover:bg-brand-500',
|
||||||
isDragging ? 'bg-brand-500' : 'bg-gray-300 dark:bg-gray-500'
|
isDragging || alwaysShowTrack
|
||||||
|
? 'bg-brand-500'
|
||||||
|
: 'bg-gray-300 dark:bg-gray-500',
|
||||||
|
orientation === 'horizontal' && 'h-[2px]',
|
||||||
|
orientation === 'vertical' && 'bottom-0 w-[2px]'
|
||||||
)}
|
)}
|
||||||
style={usedTrackStyle}
|
style={usedTrackStyle}
|
||||||
></div>
|
></div>
|
||||||
|
@ -142,8 +163,12 @@ const Slider = ({
|
||||||
{/* Thumb */}
|
{/* Thumb */}
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
'absolute flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 bg-opacity-20 transition-opacity ',
|
'absolute flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 bg-opacity-20 transition-opacity',
|
||||||
isDragging ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
isDragging || alwaysShowThumb
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-0 group-hover:opacity-100',
|
||||||
|
orientation === 'horizontal' && '-translate-x-2.5',
|
||||||
|
orientation === 'vertical' && 'translate-y-2.5'
|
||||||
)}
|
)}
|
||||||
style={thumbStyle}
|
style={thumbStyle}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
|
|
48
packages/web/components/SliderNative.stories.tsx
Normal file
48
packages/web/components/SliderNative.stories.tsx
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { ComponentStory, ComponentMeta } from '@storybook/react'
|
||||||
|
import Slider from './SliderNative'
|
||||||
|
import { useArgs } from '@storybook/client-api'
|
||||||
|
import { cx } from '@emotion/css'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Basic/Slider (Native Input)',
|
||||||
|
component: Slider,
|
||||||
|
args: {
|
||||||
|
value: 50,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
onlyCallOnChangeAfterDragEnded: false,
|
||||||
|
orientation: 'horizontal',
|
||||||
|
alwaysShowTrack: false,
|
||||||
|
alwaysShowThumb: false,
|
||||||
|
},
|
||||||
|
} as ComponentMeta<typeof Slider>
|
||||||
|
|
||||||
|
const Template: ComponentStory<typeof Slider> = args => {
|
||||||
|
const [, updateArgs] = useArgs()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'h-full rounded-24 bg-[#F8F8F8] dark:bg-black',
|
||||||
|
args.orientation === 'horizontal' && 'py-4 px-5',
|
||||||
|
args.orientation === 'vertical' && 'h-64 w-min py-5 px-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Slider {...args} onChange={value => updateArgs({ value })} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Default = Template.bind({})
|
||||||
|
Default.args = {
|
||||||
|
alwaysShowTrack: true,
|
||||||
|
alwaysShowThumb: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Vertical = Template.bind({})
|
||||||
|
Vertical.args = {
|
||||||
|
orientation: 'vertical',
|
||||||
|
alwaysShowTrack: true,
|
||||||
|
alwaysShowThumb: true,
|
||||||
|
}
|
78
packages/web/components/SliderNative.tsx
Normal file
78
packages/web/components/SliderNative.tsx
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import { useRef, useState, useMemo, useCallback, useEffect } from 'react'
|
||||||
|
import { css, cx } from '@emotion/css'
|
||||||
|
|
||||||
|
const style = css`
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 9999px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::-webkit-slider-runnable-track {
|
||||||
|
border-radius: 9999px;
|
||||||
|
height: 2px;
|
||||||
|
width: 100%;
|
||||||
|
background-color: hsla(215 28% 17% / 0.1);
|
||||||
|
}
|
||||||
|
&::-moz-range-track {
|
||||||
|
border-radius: 9999px;
|
||||||
|
height: 8px;
|
||||||
|
width: 100%;
|
||||||
|
background-color: hsla(215 28% 17% / 0.1);
|
||||||
|
}
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
background-color: hsl(var(--brand-color-500));
|
||||||
|
border-radius: 9999px;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
border: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
top: 50%;
|
||||||
|
color: hsl(215 28% 17%);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
&::-moz-range-thumb {
|
||||||
|
background-color: hsl(0 0% 100%);
|
||||||
|
border-radius: 9999px;
|
||||||
|
height: 16px;
|
||||||
|
width: 16%;
|
||||||
|
border: none;
|
||||||
|
top: 50%;
|
||||||
|
color: hsl(215 28% 17%);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const Slider = ({
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
onChange,
|
||||||
|
onlyCallOnChangeAfterDragEnded = false,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
alwaysShowTrack = false,
|
||||||
|
alwaysShowThumb = false,
|
||||||
|
step = 0.0001,
|
||||||
|
}: {
|
||||||
|
value: number
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
onChange: (value: number) => void
|
||||||
|
onlyCallOnChangeAfterDragEnded?: boolean
|
||||||
|
orientation?: 'horizontal' | 'vertical'
|
||||||
|
alwaysShowTrack?: boolean
|
||||||
|
alwaysShowThumb?: boolean
|
||||||
|
step?: number
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type='range'
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={value}
|
||||||
|
step={step}
|
||||||
|
onChange={e => onChange(Number(e.target.value))}
|
||||||
|
className={style}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Slider
|
|
@ -1,10 +1,10 @@
|
||||||
import { player } from '@/web/store'
|
import { player } from '@/web/store'
|
||||||
import SvgIcon from './SvgIcon'
|
import Icon from './Icon'
|
||||||
import { IpcChannels } from '@/shared/IpcChannels'
|
import { IpcChannels } from '@/shared/IpcChannels'
|
||||||
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
|
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
|
|
||||||
const Controls = () => {
|
const Controls = () => {
|
||||||
const [isMaximized, setIsMaximized] = useState(false)
|
const [isMaximized, setIsMaximized] = useState(false)
|
||||||
|
@ -31,13 +31,13 @@ const Controls = () => {
|
||||||
onClick={minimize}
|
onClick={minimize}
|
||||||
className='flex w-[2.875rem] items-center justify-center hover:bg-[#e9e9e9]'
|
className='flex w-[2.875rem] items-center justify-center hover:bg-[#e9e9e9]'
|
||||||
>
|
>
|
||||||
<SvgIcon className='h-3 w-3' name='windows-minimize' />
|
<Icon className='h-3 w-3' name='windows-minimize' />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={maxRestore}
|
onClick={maxRestore}
|
||||||
className='flex w-[2.875rem] items-center justify-center hover:bg-[#e9e9e9]'
|
className='flex w-[2.875rem] items-center justify-center hover:bg-[#e9e9e9]'
|
||||||
>
|
>
|
||||||
<SvgIcon
|
<Icon
|
||||||
className='h-3 w-3'
|
className='h-3 w-3'
|
||||||
name={isMaximized ? 'windows-un-maximize' : 'windows-maximize'}
|
name={isMaximized ? 'windows-un-maximize' : 'windows-maximize'}
|
||||||
/>
|
/>
|
||||||
|
@ -46,7 +46,7 @@ const Controls = () => {
|
||||||
onClick={close}
|
onClick={close}
|
||||||
className='flex w-[2.875rem] items-center justify-center hover:bg-[#c42b1c] hover:text-white'
|
className='flex w-[2.875rem] items-center justify-center hover:bg-[#c42b1c] hover:text-white'
|
||||||
>
|
>
|
||||||
<SvgIcon className='h-3 w-3' name='windows-close' />
|
<Icon className='h-3 w-3' name='windows-close' />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,44 +1,114 @@
|
||||||
import SvgIcon from './SvgIcon'
|
import Icon from '@/web/components/Icon'
|
||||||
|
import useScroll from '@/web/hooks/useScroll'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import Avatar from './Avatar'
|
||||||
|
import { cx } from '@emotion/css'
|
||||||
|
|
||||||
|
const NavigationButtons = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
enum ACTION {
|
||||||
|
Back = 'back',
|
||||||
|
Forward = 'forward',
|
||||||
|
}
|
||||||
|
const handleNavigate = (action: ACTION) => {
|
||||||
|
if (action === ACTION.Back) navigate(-1)
|
||||||
|
if (action === ACTION.Forward) navigate(1)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<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-2 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'
|
||||||
|
>
|
||||||
|
<Icon className='h-5 w-5' name={action} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchBox = () => {
|
||||||
|
const { type } = useParams()
|
||||||
|
const [keywords, setKeywords] = useState('')
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const toSearch = (e: React.KeyboardEvent) => {
|
||||||
|
if (!keywords) return
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
navigate(`/search/${keywords}${type ? `/${type}` : ''}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='app-region-no-drag group flex w-[16rem] cursor-text items-center rounded-full bg-gray-500 bg-opacity-5 pl-2.5 pr-2 transition duration-300 hover:bg-opacity-10 dark:bg-gray-300 dark:bg-opacity-5'>
|
||||||
|
<Icon
|
||||||
|
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
|
||||||
|
value={keywords}
|
||||||
|
onChange={e => setKeywords(e.target.value)}
|
||||||
|
onKeyDown={toSearch}
|
||||||
|
type='text'
|
||||||
|
className='flex-grow bg-transparent placeholder:text-gray-500 dark:text-white dark:placeholder:text-gray-400'
|
||||||
|
placeholder='搜索'
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
onClick={() => setKeywords('')}
|
||||||
|
className={cx(
|
||||||
|
'cursor-default rounded-full p-1 text-gray-600 transition hover:bg-gray-400/20 dark:text-white/50 dark:hover:bg-white/20',
|
||||||
|
!keywords && 'hidden'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className='h-4 w-4' name='x' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Settings = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => navigate('/settings')}
|
||||||
|
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'
|
||||||
|
>
|
||||||
|
<Icon className='h-[1.125rem] w-[1.125rem]' name='settings' />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const Topbar = () => {
|
const Topbar = () => {
|
||||||
|
/**
|
||||||
|
* Show topbar background when scroll down
|
||||||
|
*/
|
||||||
|
const [mainContainer, setMainContainer] = useState<HTMLElement | null>(null)
|
||||||
|
const scroll = useScroll(mainContainer, { throttle: 100 })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMainContainer(document.getElementById('mainContainer'))
|
||||||
|
}, [setMainContainer])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex w-full items-center justify-between pt-11 pb-10'>
|
<div
|
||||||
{/* Left Part */}
|
className={cx(
|
||||||
<div className='flex items-center'>
|
'sticky z-30 flex h-16 min-h-[4rem] w-full cursor-default items-center justify-between px-8 transition duration-300',
|
||||||
{/* Navigation Buttons */}
|
window.env?.isMac && 'app-region-drag',
|
||||||
<button className='rounded-full bg-[#E9E9E9] p-[10px] dark:bg-[#0E0E0E]'>
|
window.env?.isEnableTitlebar ? 'top-8' : 'top-0',
|
||||||
<SvgIcon name='back' className='h-7 w-7 text-[#717171]' />
|
!scroll.arrivedState.top &&
|
||||||
</button>
|
'bg-white bg-opacity-[.86] backdrop-blur-xl backdrop-saturate-[1.8] dark:bg-[#222] dark:bg-opacity-[.86]'
|
||||||
<button className='ml-[10px] rounded-full bg-[#E9E9E9] p-[10px] dark:bg-[#0E0E0E]'>
|
)}
|
||||||
<SvgIcon name='forward' className='h-7 w-7 text-[#717171]' />
|
>
|
||||||
</button>
|
<div className='flex gap-2'>
|
||||||
|
<NavigationButtons />
|
||||||
{/* Dividing line */}
|
<SearchBox />
|
||||||
<div className='mx-6 h-4 w-px bg-black/20 dark:bg-white/20'></div>
|
|
||||||
|
|
||||||
{/* Search Box */}
|
|
||||||
<div className='flex min-w-[284px] items-center rounded-full bg-[#E9E9E9] p-[10px] text-[#717171] dark:bg-[#0E0E0E]'>
|
|
||||||
<SvgIcon name='search' className='mr-[10px] h-7 w-7' />
|
|
||||||
<input
|
|
||||||
placeholder='Artist, songs and more'
|
|
||||||
className='bg-transparent placeholder:text-[#717171]'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Part */}
|
<div className='flex items-center gap-3'>
|
||||||
<div className='flex'>
|
<Settings />
|
||||||
<button className='rounded-full bg-[#E9E9E9] p-[10px] dark:bg-[#0E0E0E]'>
|
<Avatar />
|
||||||
<SvgIcon name='placeholder' className='h-7 w-7 text-[#717171]' />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Avatar */}
|
|
||||||
<div>
|
|
||||||
<img
|
|
||||||
className='ml-3 h-12 w-12 rounded-full'
|
|
||||||
src='http://p1.music.126.net/AetIV1GOZiLKk1yy8PMPfw==/109951165378042240.jpg'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import { memo, useCallback, useMemo } from 'react'
|
import { memo, useCallback, useMemo } from 'react'
|
||||||
import ArtistInline from '@/web/components/ArtistsInline'
|
import ArtistInline from '@/web/components/ArtistsInline'
|
||||||
import Skeleton from '@/web/components/Skeleton'
|
import Skeleton from '@/web/components/Skeleton'
|
||||||
import SvgIcon from '@/web/components/SvgIcon'
|
import Icon from '@/web/components/Icon'
|
||||||
import useUserLikedTracksIDs, {
|
import useUserLikedTracksIDs, {
|
||||||
useMutationLikeATrack,
|
useMutationLikeATrack,
|
||||||
} from '@/web/hooks/useUserLikedTracksIDs'
|
} from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||||
import { player } from '@/web/store'
|
import { player } from '@/web/store'
|
||||||
import { formatDuration } from '@/web/utils/common'
|
import { formatDuration } from '@/web/utils/common'
|
||||||
import { State as PlayerState } from '@/web/utils/player'
|
import { State as PlayerState } from '@/web/utils/player'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
|
|
||||||
const PlayOrPauseButtonInTrack = memo(
|
const PlayOrPauseButtonInTrack = memo(
|
||||||
|
@ -31,7 +31,7 @@ const PlayOrPauseButtonInTrack = memo(
|
||||||
!isHighlight && 'hidden group-hover:block'
|
!isHighlight && 'hidden group-hover:block'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SvgIcon
|
<Icon
|
||||||
className='h-5 w-5 text-brand-500'
|
className='h-5 w-5 text-brand-500'
|
||||||
name={isPlaying && isHighlight ? 'pause' : 'play'}
|
name={isPlaying && isHighlight ? 'pause' : 'play'}
|
||||||
/>
|
/>
|
||||||
|
@ -118,7 +118,7 @@ const Track = memo(
|
||||||
<span className='flex items-center'>
|
<span className='flex items-center'>
|
||||||
{track.name}
|
{track.name}
|
||||||
{track.mark === 1318912 && (
|
{track.mark === 1318912 && (
|
||||||
<SvgIcon
|
<Icon
|
||||||
name='explicit'
|
name='explicit'
|
||||||
className='ml-1.5 mt-[2px] h-4 w-4 text-gray-300 dark:text-gray-500'
|
className='ml-1.5 mt-[2px] h-4 w-4 text-gray-300 dark:text-gray-500'
|
||||||
/>
|
/>
|
||||||
|
@ -169,7 +169,7 @@ const Track = memo(
|
||||||
!isSkeleton && 'group-hover:opacity-100'
|
!isSkeleton && 'group-hover:opacity-100'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SvgIcon
|
<Icon
|
||||||
name={isLiked ? 'heart' : 'heart-outline'}
|
name={isLiked ? 'heart' : 'heart-outline'}
|
||||||
className='h-5 w-5'
|
className='h-5 w-5'
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -2,8 +2,8 @@ import ArtistInline from '@/web/components/ArtistsInline'
|
||||||
import Skeleton from '@/web/components/Skeleton'
|
import Skeleton from '@/web/components/Skeleton'
|
||||||
import { player } from '@/web/store'
|
import { player } from '@/web/store'
|
||||||
import { resizeImage } from '@/web/utils/common'
|
import { resizeImage } from '@/web/utils/common'
|
||||||
import SvgIcon from './SvgIcon'
|
import Icon from './Icon'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
|
|
||||||
|
@ -65,7 +65,7 @@ const Track = ({
|
||||||
) : (
|
) : (
|
||||||
<span className='flex items-center'>
|
<span className='flex items-center'>
|
||||||
{track.mark === 1318912 && (
|
{track.mark === 1318912 && (
|
||||||
<SvgIcon
|
<Icon
|
||||||
name='explicit'
|
name='explicit'
|
||||||
className={cx(
|
className={cx(
|
||||||
'mr-1 h-3 w-3',
|
'mr-1 h-3 w-3',
|
||||||
|
|
|
@ -2,13 +2,13 @@ import { memo, useMemo } from 'react'
|
||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import ArtistInline from '@/web/components/ArtistsInline'
|
import ArtistInline from '@/web/components/ArtistsInline'
|
||||||
import Skeleton from '@/web/components/Skeleton'
|
import Skeleton from '@/web/components/Skeleton'
|
||||||
import SvgIcon from '@/web/components/SvgIcon'
|
import Icon from '@/web/components/Icon'
|
||||||
import useUserLikedTracksIDs, {
|
import useUserLikedTracksIDs, {
|
||||||
useMutationLikeATrack,
|
useMutationLikeATrack,
|
||||||
} from '@/web/hooks/useUserLikedTracksIDs'
|
} from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||||
import { formatDuration, resizeImage } from '@/web/utils/common'
|
import { formatDuration, resizeImage } from '@/web/utils/common'
|
||||||
import { player } from '@/web/store'
|
import { player } from '@/web/store'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
|
|
||||||
const Track = memo(
|
const Track = memo(
|
||||||
|
@ -96,7 +96,7 @@ const Track = memo(
|
||||||
) : (
|
) : (
|
||||||
<span className='inline-flex items-center'>
|
<span className='inline-flex items-center'>
|
||||||
{track.mark === 1318912 && (
|
{track.mark === 1318912 && (
|
||||||
<SvgIcon
|
<Icon
|
||||||
name='explicit'
|
name='explicit'
|
||||||
className='mr-1 h-3.5 w-3.5 text-gray-300 dark:text-gray-500'
|
className='mr-1 h-3.5 w-3.5 text-gray-300 dark:text-gray-500'
|
||||||
/>
|
/>
|
||||||
|
@ -141,7 +141,7 @@ const Track = memo(
|
||||||
!isSkeleton && 'group-hover:opacity-100'
|
!isSkeleton && 'group-hover:opacity-100'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SvgIcon
|
<Icon
|
||||||
name={isLiked ? 'heart' : 'heart-outline'}
|
name={isLiked ? 'heart' : 'heart-outline'}
|
||||||
className='h-5 w-5'
|
className='h-5 w-5'
|
||||||
/>
|
/>
|
||||||
|
|
11
packages/web/hooks/useBreakpoint.ts
Normal file
11
packages/web/hooks/useBreakpoint.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { createBreakpoint } from 'react-use'
|
||||||
|
|
||||||
|
const useBreakpoint = createBreakpoint({
|
||||||
|
sm: 767,
|
||||||
|
md: 1023,
|
||||||
|
lg: 1279,
|
||||||
|
xl: 1535,
|
||||||
|
'2xl': 1536,
|
||||||
|
}) as () => 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||||
|
|
||||||
|
export default useBreakpoint
|
|
@ -6,12 +6,14 @@ import { BrowserRouter } from 'react-router-dom'
|
||||||
import * as Sentry from '@sentry/react'
|
import * as Sentry from '@sentry/react'
|
||||||
import { BrowserTracing } from '@sentry/tracing'
|
import { BrowserTracing } from '@sentry/tracing'
|
||||||
import 'virtual:svg-icons-register'
|
import 'virtual:svg-icons-register'
|
||||||
import './styles/global.scss'
|
import './styles/global.css'
|
||||||
import './styles/accentColor.scss'
|
import './styles/accentColor.css'
|
||||||
import App from './App'
|
import App from './AppNew'
|
||||||
import pkg from '../../package.json'
|
import pkg from '../../package.json'
|
||||||
import ReactGA from 'react-ga4'
|
import ReactGA from 'react-ga4'
|
||||||
import { ipcRenderer } from './ipcRenderer'
|
import { ipcRenderer } from './ipcRenderer'
|
||||||
|
import { QueryClientProvider } from 'react-query'
|
||||||
|
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||||
|
|
||||||
ReactGA.initialize('G-KMJJCFZDKF')
|
ReactGA.initialize('G-KMJJCFZDKF')
|
||||||
|
|
||||||
|
@ -35,7 +37,9 @@ const root = ReactDOMClient.createRoot(container)
|
||||||
root.render(
|
root.render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<QueryClientProvider client={reactQueryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|
|
@ -13,20 +13,22 @@
|
||||||
"analyze:css": "npx windicss-analysis",
|
"analyze:css": "npx windicss-analysis",
|
||||||
"analyze:js": "npm run build && open-cli bundle-stats-renderer.html",
|
"analyze:js": "npm run build && open-cli bundle-stats-renderer.html",
|
||||||
"storybook": "start-storybook -p 6006",
|
"storybook": "start-storybook -p 6006",
|
||||||
"storybook:build": "build-storybook"
|
"storybook:build": "build-storybook",
|
||||||
|
"generate:accent-color-css": "node ./scripts/generate.accent.color.css.js",
|
||||||
|
"api:netease": "npx NeteaseCloudMusicApi"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^14.13.1 || >=16.0.0"
|
"node": "^14.13.1 || >=16.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emotion/css": "^11.9.0",
|
||||||
"@sentry/react": "^6.19.7",
|
"@sentry/react": "^6.19.7",
|
||||||
"@sentry/tracing": "^6.19.7",
|
"@sentry/tracing": "^6.19.7",
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"classnames": "^2.3.1",
|
|
||||||
"color.js": "^1.2.0",
|
"color.js": "^1.2.0",
|
||||||
"colord": "^2.9.2",
|
"colord": "^2.9.2",
|
||||||
"dayjs": "^1.11.1",
|
"dayjs": "^1.11.1",
|
||||||
"framer-motion": "^6.3.3",
|
"framer-motion": "^6.3.4",
|
||||||
"howler": "^2.2.3",
|
"howler": "^2.2.3",
|
||||||
"js-cookie": "^3.0.1",
|
"js-cookie": "^3.0.1",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
|
@ -38,49 +40,48 @@
|
||||||
"react-hot-toast": "^2.2.0",
|
"react-hot-toast": "^2.2.0",
|
||||||
"react-query": "^3.38.0",
|
"react-query": "^3.38.0",
|
||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^6.3.0",
|
||||||
"react-use": "^17.3.2",
|
"react-use": "^17.4.0",
|
||||||
"valtio": "^1.6.0"
|
"valtio": "^1.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@storybook/addon-actions": "^6.4.22",
|
"@storybook/addon-actions": "^6.5.5",
|
||||||
"@storybook/addon-essentials": "^6.4.22",
|
"@storybook/addon-essentials": "^6.5.5",
|
||||||
"@storybook/addon-interactions": "^6.4.22",
|
"@storybook/addon-interactions": "^6.5.5",
|
||||||
"@storybook/addon-links": "^6.4.22",
|
"@storybook/addon-links": "^6.5.5",
|
||||||
"@storybook/addon-postcss": "^2.0.0",
|
"@storybook/addon-postcss": "^2.0.0",
|
||||||
"@storybook/addon-viewport": "^6.4.22",
|
"@storybook/addon-viewport": "^6.5.5",
|
||||||
"@storybook/builder-vite": "^0.1.33",
|
"@storybook/builder-vite": "^0.1.35",
|
||||||
"@storybook/react": "^6.4.22",
|
"@storybook/react": "^6.5.5",
|
||||||
"@storybook/testing-library": "^0.0.11",
|
"@storybook/testing-library": "^0.0.11",
|
||||||
"@testing-library/react": "^13.1.1",
|
"@testing-library/react": "^13.3.0",
|
||||||
"@types/howler": "^2.2.7",
|
"@types/howler": "^2.2.7",
|
||||||
"@types/js-cookie": "^3.0.2",
|
"@types/js-cookie": "^3.0.2",
|
||||||
"@types/lodash-es": "^4.17.6",
|
"@types/lodash-es": "^4.17.6",
|
||||||
"@types/md5": "^2.3.2",
|
"@types/md5": "^2.3.2",
|
||||||
"@types/qrcode": "^1.4.2",
|
"@types/qrcode": "^1.4.2",
|
||||||
"@types/react": "^18.0.8",
|
"@types/react": "^18.0.8",
|
||||||
"@types/react-dom": "^18.0.3",
|
"@types/react-dom": "^18.0.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.21.0",
|
"@typescript-eslint/eslint-plugin": "^5.26.0",
|
||||||
"@typescript-eslint/parser": "^5.21.0",
|
"@typescript-eslint/parser": "^5.26.0",
|
||||||
"@vitejs/plugin-react": "^1.3.1",
|
"@vitejs/plugin-react": "^1.3.1",
|
||||||
"@vitest/ui": "^0.12.4",
|
"@vitest/ui": "^0.12.9",
|
||||||
"autoprefixer": "^10.4.5",
|
"autoprefixer": "^10.4.5",
|
||||||
"c8": "^7.11.2",
|
"c8": "^7.11.3",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"eslint": "*",
|
"eslint": "*",
|
||||||
"eslint-plugin-react": "^7.29.4",
|
"eslint-plugin-react": "^7.30.0",
|
||||||
"eslint-plugin-react-hooks": "^4.5.0",
|
"eslint-plugin-react-hooks": "^4.5.0",
|
||||||
"jsdom": "^19.0.0",
|
"jsdom": "^19.0.0",
|
||||||
"open-cli": "^7.0.1",
|
"open-cli": "^7.0.1",
|
||||||
"postcss": "^8.4.13",
|
"postcss": "^8.4.14",
|
||||||
"prettier": "*",
|
"prettier": "*",
|
||||||
"prettier-plugin-tailwindcss": "^0.1.10",
|
"prettier-plugin-tailwindcss": "^0.1.11",
|
||||||
"rollup-plugin-visualizer": "^5.6.0",
|
"rollup-plugin-visualizer": "^5.6.0",
|
||||||
"sass": "^1.51.0",
|
|
||||||
"storybook-tailwind-dark-mode": "^1.0.12",
|
"storybook-tailwind-dark-mode": "^1.0.12",
|
||||||
"tailwindcss": "^3.0.24",
|
"tailwindcss": "^3.0.24",
|
||||||
"typescript": "*",
|
"typescript": "*",
|
||||||
"vite": "^2.9.6",
|
"vite": "^2.9.6",
|
||||||
"vite-plugin-svg-icons": "^2.0.1",
|
"vite-plugin-svg-icons": "^2.0.1",
|
||||||
"vitest": "^0.12.4"
|
"vitest": "^0.12.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,10 +3,10 @@ import { NavLink, useParams } from 'react-router-dom'
|
||||||
import Button, { Color as ButtonColor } from '@/web/components/Button'
|
import Button, { Color as ButtonColor } from '@/web/components/Button'
|
||||||
import CoverRow, { Subtitle } from '@/web/components/CoverRow'
|
import CoverRow, { Subtitle } from '@/web/components/CoverRow'
|
||||||
import Skeleton from '@/web/components/Skeleton'
|
import Skeleton from '@/web/components/Skeleton'
|
||||||
import SvgIcon from '@/web/components/SvgIcon'
|
import Icon from '@/web/components/Icon'
|
||||||
import TracksAlbum from '@/web/components/TracksAlbum'
|
import TracksAlbum from '@/web/components/TracksAlbum'
|
||||||
import useAlbum from '@/web/hooks/useAlbum'
|
import useAlbum from '@/web/api/hooks/useAlbum'
|
||||||
import useArtistAlbums from '@/web/hooks/useArtistAlbums'
|
import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
|
||||||
import { player } from '@/web/store'
|
import { player } from '@/web/store'
|
||||||
import {
|
import {
|
||||||
Mode as PlayerMode,
|
Mode as PlayerMode,
|
||||||
|
@ -19,10 +19,10 @@ import {
|
||||||
resizeImage,
|
resizeImage,
|
||||||
scrollToTop,
|
scrollToTop,
|
||||||
} from '@/web/utils/common'
|
} from '@/web/utils/common'
|
||||||
import useTracks from '@/web/hooks/useTracks'
|
import useTracks from '@/web/api/hooks/useTracks'
|
||||||
import useUserAlbums, {
|
import useUserAlbums, {
|
||||||
useMutationLikeAAlbum,
|
useMutationLikeAAlbum,
|
||||||
} from '@/web/hooks/useUserAlbums'
|
} from '@/web/api/hooks/useUserAlbums'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
|
@ -64,7 +64,7 @@ const PlayButton = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button onClick={wrappedHandlePlay} isSkelton={isLoading}>
|
<Button onClick={wrappedHandlePlay} isSkelton={isLoading}>
|
||||||
<SvgIcon
|
<Icon
|
||||||
name={isPlaying ? 'pause' : 'play'}
|
name={isPlaying ? 'pause' : 'play'}
|
||||||
className='mr-1 -ml-1 h-6 w-6'
|
className='mr-1 -ml-1 h-6 w-6'
|
||||||
/>
|
/>
|
||||||
|
@ -135,7 +135,7 @@ const Header = ({
|
||||||
{!isLoading && isCoverError ? (
|
{!isLoading && isCoverError ? (
|
||||||
// Fallback cover
|
// Fallback cover
|
||||||
<div className='flex h-full w-full items-center justify-center rounded-2xl border border-black border-opacity-5 bg-gray-100 text-gray-300'>
|
<div className='flex h-full w-full items-center justify-center rounded-2xl border border-black border-opacity-5 bg-gray-100 text-gray-300'>
|
||||||
<SvgIcon name='music-note' className='h-1/2 w-1/2' />
|
<Icon name='music-note' className='h-1/2 w-1/2' />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
coverUrl && (
|
coverUrl && (
|
||||||
|
@ -183,7 +183,7 @@ const Header = ({
|
||||||
) : (
|
) : (
|
||||||
<div className='flex items-center text-sm text-gray-500 dark:text-gray-400'>
|
<div className='flex items-center text-sm text-gray-500 dark:text-gray-400'>
|
||||||
{album?.mark === 1056768 && (
|
{album?.mark === 1056768 && (
|
||||||
<SvgIcon
|
<Icon
|
||||||
name='explicit'
|
name='explicit'
|
||||||
className='mt-px mr-1 h-4 w-4 text-gray-400 dark:text-gray-500'
|
className='mt-px mr-1 h-4 w-4 text-gray-400 dark:text-gray-500'
|
||||||
/>
|
/>
|
||||||
|
@ -216,7 +216,7 @@ const Header = ({
|
||||||
isSkelton={isLoading}
|
isSkelton={isLoading}
|
||||||
onClick={() => album?.id && mutationLikeAAlbum.mutate(album)}
|
onClick={() => album?.id && mutationLikeAAlbum.mutate(album)}
|
||||||
>
|
>
|
||||||
<SvgIcon
|
<Icon
|
||||||
name={isThisAlbumLiked ? 'heart' : 'heart-outline'}
|
name={isThisAlbumLiked ? 'heart' : 'heart-outline'}
|
||||||
className='h-6 w-6'
|
className='h-6 w-6'
|
||||||
/>
|
/>
|
||||||
|
@ -228,7 +228,7 @@ const Header = ({
|
||||||
isSkelton={isLoading}
|
isSkelton={isLoading}
|
||||||
onClick={() => toast('施工中...')}
|
onClick={() => toast('施工中...')}
|
||||||
>
|
>
|
||||||
<SvgIcon name='more' className='h-6 w-6' />
|
<Icon name='more' className='h-6 w-6' />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
import Button, { Color as ButtonColor } from '@/web/components/Button'
|
import Button, { Color as ButtonColor } from '@/web/components/Button'
|
||||||
import SvgIcon from '@/web/components/SvgIcon'
|
import Icon from '@/web/components/Icon'
|
||||||
import Cover from '@/web/components/Cover'
|
import Cover from '@/web/components/Cover'
|
||||||
import useArtist from '@/web/hooks/useArtist'
|
import useArtist from '@/web/api/hooks/useArtist'
|
||||||
import useArtistAlbums from '@/web/hooks/useArtistAlbums'
|
import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
|
||||||
import { resizeImage } from '@/web/utils/common'
|
import { resizeImage } from '@/web/utils/common'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import TracksGrid from '@/web/components/TracksGrid'
|
import TracksGrid from '@/web/components/TracksGrid'
|
||||||
import CoverRow, { Subtitle } from '@/web/components/CoverRow'
|
import CoverRow, { Subtitle } from '@/web/components/CoverRow'
|
||||||
import Skeleton from '@/web/components/Skeleton'
|
import Skeleton from '@/web/components/Skeleton'
|
||||||
import useTracks from '@/web/hooks/useTracks'
|
import useTracks from '@/web/api/hooks/useTracks'
|
||||||
import { player } from '@/web/store'
|
import { player } from '@/web/store'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
import CoverRow, { Subtitle } from '@/web/components/CoverRow'
|
import CoverRow, { Subtitle } from '@/web/components/CoverRow'
|
||||||
import SvgIcon, { SvgName } from '@/web/components/SvgIcon'
|
import Icon, { SvgName } from '@/web/components/Icon'
|
||||||
import useUserAlbums from '@/web/hooks/useUserAlbums'
|
import useUserAlbums from '@/web/api/hooks/useUserAlbums'
|
||||||
import useLyric from '@/web/hooks/useLyric'
|
import useLyric from '@/web/api/hooks/useLyric'
|
||||||
import usePlaylist from '@/web/hooks/usePlaylist'
|
import usePlaylist from '@/web/api/hooks/usePlaylist'
|
||||||
import useUser from '@/web/hooks/useUser'
|
import useUser from '@/web/api/hooks/useUser'
|
||||||
import useUserPlaylists from '@/web/hooks/useUserPlaylists'
|
import useUserPlaylists from '@/web/api/hooks/useUserPlaylists'
|
||||||
import { player } from '@/web/store'
|
import { player } from '@/web/store'
|
||||||
import { resizeImage } from '@/web/utils/common'
|
import { resizeImage } from '@/web/utils/common'
|
||||||
import { sample, chunk } from 'lodash-es'
|
import { sample, chunk } from 'lodash-es'
|
||||||
import useUserArtists from '@/web/hooks/useUserArtists'
|
import useUserArtists from '@/web/api/hooks/useUserArtists'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
@ -97,7 +97,7 @@ const LikedTracksCard = ({ className }: { className?: string }) => {
|
||||||
onClick={handlePlay}
|
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'
|
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' />
|
<Icon name='play-fill' className='ml-0.5 h-6 w-6' />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -119,7 +119,7 @@ const OtherCard = ({
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SvgIcon name={icon} className='ml-3 mt-3 h-12 w-12' />
|
<Icon name={icon} className='ml-3 mt-3 h-12 w-12' />
|
||||||
<span className='m-4'>{name}</span>
|
<span className='m-4'>{name}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -6,11 +6,11 @@ import {
|
||||||
loginWithEmail,
|
loginWithEmail,
|
||||||
loginWithPhone,
|
loginWithPhone,
|
||||||
} from '@/web/api/auth'
|
} from '@/web/api/auth'
|
||||||
import SvgIcon from '@/web/components/SvgIcon'
|
import Icon from '@/web/components/Icon'
|
||||||
import { state } from '@/web/store'
|
import { state } from '@/web/store'
|
||||||
import { setCookies } from '@/web/utils/cookie'
|
import { setCookies } from '@/web/utils/cookie'
|
||||||
import { useInterval } from 'react-use'
|
import { useInterval } from 'react-use'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
import { useState, useMemo, useEffect } from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { useMutation, useQuery } from 'react-query'
|
import { useMutation, useQuery } from 'react-query'
|
||||||
|
@ -113,10 +113,7 @@ const PasswordInput = ({
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
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'
|
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
|
<Icon className='h-5 w-5' name={showPassword ? 'eye-off' : 'eye'} />
|
||||||
className='h-5 w-5'
|
|
||||||
name={showPassword ? 'eye-off' : 'eye'}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -188,7 +185,7 @@ const OtherLoginMethods = ({
|
||||||
onClick={() => setMethod(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'
|
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} />
|
<Icon className='mr-2 h-5 w-5' name={id} />
|
||||||
<span>{name}</span>
|
<span>{name}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|
50
packages/web/pages/New/Album.tsx
Normal file
50
packages/web/pages/New/Album.tsx
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import TrackListHeader from '@/web/components/New/TrackListHeader'
|
||||||
|
import useAlbum from '@/web/api/hooks/useAlbum'
|
||||||
|
import useTracks from '@/web/api/hooks/useTracks'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import PageTransition from '@/web/components/New/PageTransition'
|
||||||
|
import TrackList from '@/web/components/New/TrackList'
|
||||||
|
import { player } from '@/web/store'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
|
||||||
|
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 playerSnapshot = useSnapshot(player)
|
||||||
|
|
||||||
|
const onPlay = async (trackID: number | null = null) => {
|
||||||
|
if (!album?.album.id) {
|
||||||
|
toast('无法播放专辑,该专辑不存在')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
playerSnapshot.trackListSource?.type === 'album' &&
|
||||||
|
playerSnapshot.trackListSource?.id === album.album.id
|
||||||
|
) {
|
||||||
|
await player.playTrack(trackID ?? album.songs[0].id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await player.playAlbum(album.album.id, trackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageTransition>
|
||||||
|
<TrackListHeader album={album?.album} onPlay={() => onPlay()} />
|
||||||
|
<TrackList
|
||||||
|
tracks={tracks?.songs}
|
||||||
|
className='z-10 mt-20'
|
||||||
|
onPlay={onPlay}
|
||||||
|
/>
|
||||||
|
</PageTransition>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Album
|
12
packages/web/pages/New/Discover.tsx
Normal file
12
packages/web/pages/New/Discover.tsx
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import CoverWall from '@/web/components/New/CoverWall'
|
||||||
|
import PageTransition from '@/web/components/New/PageTransition'
|
||||||
|
|
||||||
|
const Discover = () => {
|
||||||
|
return (
|
||||||
|
<PageTransition disableEnterAnimation={true}>
|
||||||
|
<CoverWall />
|
||||||
|
</PageTransition>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Discover
|
58
packages/web/pages/New/My.tsx
Normal file
58
packages/web/pages/New/My.tsx
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import { css, cx } from '@emotion/css'
|
||||||
|
import PlayLikedSongsCard from '@/web/components/New/PlayLikedSongsCard'
|
||||||
|
import PageTransition from '@/web/components/New/PageTransition'
|
||||||
|
import useUserArtists from '@/web/api/hooks/useUserArtists'
|
||||||
|
import ArtistRow from '@/web/components/New/ArtistRow'
|
||||||
|
import Tabs from '@/web/components/New/Tabs'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import CoverRow from '@/web/components/New/CoverRow'
|
||||||
|
import useUserPlaylists from '@/web/api/hooks/useUserPlaylists'
|
||||||
|
import useUserAlbums from '@/web/api/hooks/useUserAlbums'
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
id: 'playlists',
|
||||||
|
name: 'Playlists',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'albums',
|
||||||
|
name: 'Albums',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'artists',
|
||||||
|
name: 'Artists',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'videos',
|
||||||
|
name: 'Videos',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const My = () => {
|
||||||
|
const { data: artists } = useUserArtists()
|
||||||
|
const { data: playlists } = useUserPlaylists()
|
||||||
|
const { data: albums } = useUserAlbums()
|
||||||
|
const [selectedTab, setSelectedTab] = useState(tabs[0].id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageTransition>
|
||||||
|
<div className='grid grid-cols-1 gap-10'>
|
||||||
|
<PlayLikedSongsCard />
|
||||||
|
<div>
|
||||||
|
<ArtistRow artists={artists?.data} title='ARTISTS' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Tabs
|
||||||
|
tabs={tabs}
|
||||||
|
value={selectedTab}
|
||||||
|
onChange={(id: string) => setSelectedTab(id)}
|
||||||
|
/>
|
||||||
|
<CoverRow albums={albums?.data} className='mt-6' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageTransition>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default My
|
|
@ -1,17 +1,17 @@
|
||||||
import { memo, useCallback, useEffect, useMemo } from 'react'
|
import { memo, useCallback, useEffect, useMemo } from 'react'
|
||||||
import Button, { Color as ButtonColor } from '@/web/components/Button'
|
import Button, { Color as ButtonColor } from '@/web/components/Button'
|
||||||
import Skeleton from '@/web/components/Skeleton'
|
import Skeleton from '@/web/components/Skeleton'
|
||||||
import SvgIcon from '@/web/components/SvgIcon'
|
import Icon from '@/web/components/Icon'
|
||||||
import TracksList from '@/web/components/TracksList'
|
import TracksList from '@/web/components/TracksList'
|
||||||
import usePlaylist from '@/web/hooks/usePlaylist'
|
import usePlaylist from '@/web/api/hooks/usePlaylist'
|
||||||
import useScroll from '@/web/hooks/useScroll'
|
import useScroll from '@/web/hooks/useScroll'
|
||||||
import useTracksInfinite from '@/web/hooks/useTracksInfinite'
|
import useTracksInfinite from '@/web/api/hooks/useTracksInfinite'
|
||||||
import { player } from '@/web/store'
|
import { player } from '@/web/store'
|
||||||
import { formatDate, resizeImage } from '@/web/utils/common'
|
import { formatDate, resizeImage } from '@/web/utils/common'
|
||||||
import useUserPlaylists, {
|
import useUserPlaylists, {
|
||||||
useMutationLikeAPlaylist,
|
useMutationLikeAPlaylist,
|
||||||
} from '@/web/hooks/useUserPlaylists'
|
} from '@/web/api/hooks/useUserPlaylists'
|
||||||
import useUser from '@/web/hooks/useUser'
|
import useUser from '@/web/api/hooks/useUser'
|
||||||
import {
|
import {
|
||||||
Mode as PlayerMode,
|
Mode as PlayerMode,
|
||||||
TrackListSourceType,
|
TrackListSourceType,
|
||||||
|
@ -58,7 +58,7 @@ const PlayButton = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button onClick={wrappedHandlePlay} isSkelton={isLoading}>
|
<Button onClick={wrappedHandlePlay} isSkelton={isLoading}>
|
||||||
<SvgIcon
|
<Icon
|
||||||
name={isPlaying ? 'pause' : 'play'}
|
name={isPlaying ? 'pause' : 'play'}
|
||||||
className='-ml-1 mr-1 h-6 w-6'
|
className='-ml-1 mr-1 h-6 w-6'
|
||||||
/>
|
/>
|
||||||
|
@ -191,7 +191,7 @@ const Header = memo(
|
||||||
playlist?.id && mutationLikeAPlaylist.mutate(playlist)
|
playlist?.id && mutationLikeAPlaylist.mutate(playlist)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SvgIcon
|
<Icon
|
||||||
name={isThisPlaylistLiked ? 'heart' : 'heart-outline'}
|
name={isThisPlaylistLiked ? 'heart' : 'heart-outline'}
|
||||||
className='h-6 w-6'
|
className='h-6 w-6'
|
||||||
/>
|
/>
|
||||||
|
@ -204,7 +204,7 @@ const Header = memo(
|
||||||
isSkelton={isLoading}
|
isSkelton={isLoading}
|
||||||
onClick={() => toast('施工中...')}
|
onClick={() => toast('施工中...')}
|
||||||
>
|
>
|
||||||
<SvgIcon name='more' className='h-6 w-6' />
|
<Icon name='more' className='h-6 w-6' />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { state } from '@/web/store'
|
import { state } from '@/web/store'
|
||||||
import { changeAccentColor } from '@/web/utils/theme'
|
import { changeAccentColor } from '@/web/utils/theme'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
|
|
||||||
const AccentColor = () => {
|
const AccentColor = () => {
|
||||||
const colors = {
|
const colors = {
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import Avatar from '@/web/components/Avatar'
|
import Avatar from '@/web/components/Avatar'
|
||||||
import SvgIcon from '@/web/components/SvgIcon'
|
import Icon from '@/web/components/Icon'
|
||||||
import useUser from '@/web/hooks/useUser'
|
import useUser from '@/web/api/hooks/useUser'
|
||||||
import Appearance from './Appearance'
|
import Appearance from './Appearance'
|
||||||
import UnblockNeteaseMusic from './UnblockNeteaseMusic'
|
import UnblockNeteaseMusic from './UnblockNeteaseMusic'
|
||||||
import cx from 'classnames'
|
import { cx } from '@emotion/css'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
@ -38,7 +38,7 @@ const UserCard = () => {
|
||||||
className='btn-pressed-animation btn-hover-animation flex items-center px-4 py-3 text-lg font-medium text-gray-600 after:rounded-xl after:bg-black/[.06] dark:text-gray-300 dark:after:bg-white/5'
|
className='btn-pressed-animation btn-hover-animation flex items-center px-4 py-3 text-lg font-medium text-gray-600 after:rounded-xl after:bg-black/[.06] dark:text-gray-300 dark:after:bg-white/5'
|
||||||
>
|
>
|
||||||
{/* TODO: 画登入登出图标 */}
|
{/* TODO: 画登入登出图标 */}
|
||||||
<SvgIcon name='x' className='mr-1 h-6 w-6' />
|
<Icon name='x' className='mr-1 h-6 w-6' />
|
||||||
{user?.profile ? '登出' : '登录'}
|
{user?.profile ? '登出' : '登录'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,10 +4,12 @@ const colors = require('tailwindcss/colors')
|
||||||
|
|
||||||
const replaceBrandColorWithCSSVar = () => {
|
const replaceBrandColorWithCSSVar = () => {
|
||||||
const blues = Object.entries(colors.blue).map(([key, value]) => {
|
const blues = Object.entries(colors.blue).map(([key, value]) => {
|
||||||
const c = colord(value).toRgb()
|
const rgb = colord(value).toHsl()
|
||||||
|
const hsl = colord(value).toHsl()
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
rgb: `${c.r} ${c.g} ${c.b}`,
|
hsl: `${hsl.h} ${hsl.s} ${hsl.l}`,
|
||||||
|
rgb: `${rgb.r} ${rgb.g} ${rgb.b}`,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
|
@ -20,7 +22,7 @@ const replaceBrandColorWithCSSVar = () => {
|
||||||
}
|
}
|
||||||
value = value.replace(
|
value = value.replace(
|
||||||
`rgb(${blue.rgb}`,
|
`rgb(${blue.rgb}`,
|
||||||
`rgb(var(--brand-color-${blue.key})`
|
`hsl(var(--brand-color-${blue.key})`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
// if (decl.value !== value) {
|
// if (decl.value !== value) {
|
||||||
|
|
|
@ -2,16 +2,16 @@
|
||||||
const { colord } = require('colord')
|
const { colord } = require('colord')
|
||||||
const prettier = require('prettier')
|
const prettier = require('prettier')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const prettierConfig = require('../../prettier.config.js')
|
const prettierConfig = require('../../../prettier.config.js')
|
||||||
const pickedColors = require('./pickedColors.js')
|
const pickedColors = require('./pickedColors.js')
|
||||||
|
|
||||||
const colorsCss = {}
|
const colorsCss = {}
|
||||||
Object.entries(pickedColors).forEach(([name, colors]) => {
|
Object.entries(pickedColors).forEach(([name, colors]) => {
|
||||||
let tmp = ''
|
let tmp = ''
|
||||||
Object.entries(colors).map(([key, value]) => {
|
Object.entries(colors).map(([key, value]) => {
|
||||||
const c = colord(value).toRgb()
|
const c = colord(value).toHsl()
|
||||||
tmp = `${tmp}
|
tmp = `${tmp}
|
||||||
--brand-color-${key}: ${c.r} ${c.g} ${c.b};`
|
--brand-color-${key}: ${c.h} ${c.s}% ${c.l}%;`
|
||||||
})
|
})
|
||||||
colorsCss[name] = tmp
|
colorsCss[name] = tmp
|
||||||
})
|
})
|
||||||
|
@ -25,4 +25,4 @@ ${name === 'blue' ? ':root' : `[data-accent-color='${name}']`} {${color}
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatted = prettier.format(css, { ...prettierConfig, parser: 'css' })
|
const formatted = prettier.format(css, { ...prettierConfig, parser: 'css' })
|
||||||
fs.writeFileSync('./styles/accentColor.scss', formatted)
|
fs.writeFileSync('./styles/accentColor.css', formatted)
|
||||||
|
|
220
packages/web/styles/accentColor.css
Normal file
220
packages/web/styles/accentColor.css
Normal file
|
@ -0,0 +1,220 @@
|
||||||
|
:root {
|
||||||
|
--brand-color-50: 214 100% 97%;
|
||||||
|
--brand-color-100: 214 95% 93%;
|
||||||
|
--brand-color-200: 213 97% 87%;
|
||||||
|
--brand-color-300: 212 96% 78%;
|
||||||
|
--brand-color-400: 213 94% 68%;
|
||||||
|
--brand-color-500: 217 91% 60%;
|
||||||
|
--brand-color-600: 221 83% 53%;
|
||||||
|
--brand-color-700: 224 76% 48%;
|
||||||
|
--brand-color-800: 226 71% 40%;
|
||||||
|
--brand-color-900: 224 64% 33%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-accent-color='red'] {
|
||||||
|
--brand-color-50: 0 86% 97%;
|
||||||
|
--brand-color-100: 0 93% 94%;
|
||||||
|
--brand-color-200: 0 96% 89%;
|
||||||
|
--brand-color-300: 0 94% 82%;
|
||||||
|
--brand-color-400: 0 91% 71%;
|
||||||
|
--brand-color-500: 0 84% 60%;
|
||||||
|
--brand-color-600: 0 72% 51%;
|
||||||
|
--brand-color-700: 0 74% 42%;
|
||||||
|
--brand-color-800: 0 70% 35%;
|
||||||
|
--brand-color-900: 0 63% 31%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-accent-color='orange'] {
|
||||||
|
--brand-color-50: 33 100% 96%;
|
||||||
|
--brand-color-100: 34 100% 92%;
|
||||||
|
--brand-color-200: 32 98% 83%;
|
||||||
|
--brand-color-300: 31 97% 72%;
|
||||||
|
--brand-color-400: 27 96% 61%;
|
||||||
|
--brand-color-500: 25 95% 53%;
|
||||||
|
--brand-color-600: 21 90% 48%;
|
||||||
|
--brand-color-700: 17 88% 40%;
|
||||||
|
--brand-color-800: 15 79% 34%;
|
||||||
|
--brand-color-900: 15 75% 28%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-accent-color='amber'] {
|
||||||
|
--brand-color-50: 48 100% 96%;
|
||||||
|
--brand-color-100: 48 96% 89%;
|
||||||
|
--brand-color-200: 48 97% 77%;
|
||||||
|
--brand-color-300: 46 97% 65%;
|
||||||
|
--brand-color-400: 43 96% 56%;
|
||||||
|
--brand-color-500: 38 92% 50%;
|
||||||
|
--brand-color-600: 32 95% 44%;
|
||||||
|
--brand-color-700: 26 90% 37%;
|
||||||
|
--brand-color-800: 23 82% 31%;
|
||||||
|
--brand-color-900: 22 78% 26%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-accent-color='yellow'] {
|
||||||
|
--brand-color-50: 55 92% 95%;
|
||||||
|
--brand-color-100: 55 97% 88%;
|
||||||
|
--brand-color-200: 53 98% 77%;
|
||||||
|
--brand-color-300: 50 98% 64%;
|
||||||
|
--brand-color-400: 48 96% 53%;
|
||||||
|
--brand-color-500: 45 93% 47%;
|
||||||
|
--brand-color-600: 41 96% 40%;
|
||||||
|
--brand-color-700: 35 92% 33%;
|
||||||
|
--brand-color-800: 32 81% 29%;
|
||||||
|
--brand-color-900: 28 73% 26%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-accent-color='lime'] {
|
||||||
|
--brand-color-50: 78 92% 95%;
|
||||||
|
--brand-color-100: 80 89% 89%;
|
||||||
|
--brand-color-200: 81 88% 80%;
|
||||||
|
--brand-color-300: 82 85% 67%;
|
||||||
|
--brand-color-400: 83 78% 55%;
|
||||||
|
--brand-color-500: 84 81% 44%;
|
||||||
|
--brand-color-600: 85 85% 35%;
|
||||||
|
--brand-color-700: 86 78% 27%;
|
||||||
|
--brand-color-800: 86 69% 23%;
|
||||||
|
--brand-color-900: 88 61% 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-accent-color='green'] {
|
||||||
|
--brand-color-50: 138 76% 97%;
|
||||||
|
--brand-color-100: 141 84% 93%;
|
||||||
|
--brand-color-200: 141 79% 85%;
|
||||||
|
--brand-color-300: 142 77% 73%;
|
||||||
|
--brand-color-400: 142 69% 58%;
|
||||||
|
--brand-color-500: 142 71% 45%;
|
||||||
|
--brand-color-600: 142 76% 36%;
|
||||||
|
--brand-color-700: 142 72% 29%;
|
||||||
|
--brand-color-800: 143 64% 24%;
|
||||||
|
--brand-color-900: 144 61% 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-accent-color='emerald'] {
|
||||||
|
--brand-color-50: 152 81% 96%;
|
||||||
|
--brand-color-100: 149 80% 90%;
|
||||||
|
--brand-color-200: 152 76% 80%;
|
||||||
|
--brand-color-300: 156 72% 67%;
|
||||||
|
--brand-color-400: 158 64% 52%;
|
||||||
|
--brand-color-500: 160 84% 39%;
|
||||||
|
--brand-color-600: 161 94% 30%;
|
||||||
|
--brand-color-700: 163 94% 24%;
|
||||||
|
--brand-color-800: 163 88% 20%;
|
||||||
|
--brand-color-900: 164 86% 16%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-accent-color='teal'] {
|
||||||
|
--brand-color-50: 166 76% 97%;
|
||||||
|
--brand-color-100: 167 85% 89%;
|
||||||
|
--brand-color-200: 168 84% 78%;
|
||||||
|
--brand-color-300: 171 77% 64%;
|
||||||
|
--brand-color-400: 172 66% 50%;
|
||||||
|
--brand-color-500: 173 80% 40%;
|
||||||
|
--brand-color-600: 175 84% 32%;
|
||||||
|
--brand-color-700: 175 77% 26%;
|
||||||
|
--brand-color-800: 176 69% 22%;
|
||||||
|
--brand-color-900: 176 61% 19%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-accent-color='cyan'] {
|
||||||
|
--brand-color-50: 183 100% 96%;
|
||||||
|
--brand-color-100: 185 96% 90%;
|
||||||
|
--brand-color-200: 186 94% 82%;
|
||||||
|
--brand-color-300: 187 92% 69%;
|
||||||
|
--brand-color-400: 188 86% 53%;
|
||||||
|
--brand-color-500: 189 94% 43%;
|
||||||
|
--brand-color-600: 192 91% 36%;
|
||||||
|
--brand-color-700: 193 82% 31%;
|
||||||
|
--brand-color-800: 194 70% 27%;
|
||||||
|
--brand-color-900: 196 64% 24%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-accent-color='sky'] {
|
||||||
|
--brand-color-50: 204 100% 97%;
|
||||||
|
--brand-color-100: 204 94% 94%;
|
||||||
|
--brand-color-200: 201 94% 86%;
|
||||||
|
--brand-color-300: 199 95% 74%;
|
||||||
|
--brand-color-400: 198 93% 60%;
|
||||||
|
--brand-color-500: 199 89% 48%;
|
||||||
|
--brand-color-600: 200 98% 39%;
|
||||||
|
--brand-color-700: 201 96% 32%;
|
||||||
|
--brand-color-800: 201 90% 27%;
|
||||||
|
--brand-color-900: 202 80% 24%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-accent-color='indigo'] {
|
||||||
|
--brand-color-50: 226 100% 97%;
|
||||||
|
--brand-color-100: 226 100% 94%;
|
||||||
|
--brand-color-200: 228 96% 89%;
|
||||||
|
--brand-color-300: 230 94% 82%;
|
||||||
|
--brand-color-400: 234 89% 74%;
|
||||||
|
--brand-color-500: 239 84% 67%;
|
||||||
|
--brand-color-600: 243 75% 59%;
|
||||||
|
--brand-color-700: 245 58% 51%;
|
||||||
|
--brand-color-800: 244 55% 41%;
|
||||||
|
--brand-color-900: 242 47% 34%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-accent-color='violet'] {
|
||||||
|
--brand-color-50: 250 100% 98%;
|
||||||
|
--brand-color-100: 251 91% 95%;
|
||||||
|
--brand-color-200: 251 95% 92%;
|
||||||
|
--brand-color-300: 252 95% 85%;
|
||||||
|
--brand-color-400: 255 92% 76%;
|
||||||
|
--brand-color-500: 258 90% 66%;
|
||||||
|
--brand-color-600: 262 83% 58%;
|
||||||
|
--brand-color-700: 263 70% 50%;
|
||||||
|
--brand-color-800: 263 69% 42%;
|
||||||
|
--brand-color-900: 264 67% 35%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-accent-color='purple'] {
|
||||||
|
--brand-color-50: 270 100% 98%;
|
||||||
|
--brand-color-100: 269 100% 95%;
|
||||||
|
--brand-color-200: 269 100% 92%;
|
||||||
|
--brand-color-300: 269 97% 85%;
|
||||||
|
--brand-color-400: 270 95% 75%;
|
||||||
|
--brand-color-500: 271 91% 65%;
|
||||||
|
--brand-color-600: 271 81% 56%;
|
||||||
|
--brand-color-700: 272 72% 47%;
|
||||||
|
--brand-color-800: 273 67% 39%;
|
||||||
|
--brand-color-900: 274 66% 32%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-accent-color='fuchsia'] {
|
||||||
|
--brand-color-50: 289 100% 98%;
|
||||||
|
--brand-color-100: 287 100% 95%;
|
||||||
|
--brand-color-200: 288 96% 91%;
|
||||||
|
--brand-color-300: 291 93% 83%;
|
||||||
|
--brand-color-400: 292 91% 73%;
|
||||||
|
--brand-color-500: 292 84% 61%;
|
||||||
|
--brand-color-600: 293 69% 49%;
|
||||||
|
--brand-color-700: 295 72% 40%;
|
||||||
|
--brand-color-800: 295 70% 33%;
|
||||||
|
--brand-color-900: 297 64% 28%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-accent-color='pink'] {
|
||||||
|
--brand-color-50: 327 73% 97%;
|
||||||
|
--brand-color-100: 326 78% 95%;
|
||||||
|
--brand-color-200: 326 85% 90%;
|
||||||
|
--brand-color-300: 327 87% 82%;
|
||||||
|
--brand-color-400: 329 86% 70%;
|
||||||
|
--brand-color-500: 330 81% 60%;
|
||||||
|
--brand-color-600: 333 71% 51%;
|
||||||
|
--brand-color-700: 335 78% 42%;
|
||||||
|
--brand-color-800: 336 74% 35%;
|
||||||
|
--brand-color-900: 336 69% 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-accent-color='rose'] {
|
||||||
|
--brand-color-50: 356 100% 97%;
|
||||||
|
--brand-color-100: 356 100% 95%;
|
||||||
|
--brand-color-200: 353 96% 90%;
|
||||||
|
--brand-color-300: 353 96% 82%;
|
||||||
|
--brand-color-400: 351 95% 71%;
|
||||||
|
--brand-color-500: 350 89% 60%;
|
||||||
|
--brand-color-600: 347 77% 50%;
|
||||||
|
--brand-color-700: 345 83% 41%;
|
||||||
|
--brand-color-800: 343 80% 35%;
|
||||||
|
--brand-color-900: 342 75% 30%;
|
||||||
|
}
|
|
@ -1,220 +0,0 @@
|
||||||
:root {
|
|
||||||
--brand-color-50: 239 246 255;
|
|
||||||
--brand-color-100: 219 234 254;
|
|
||||||
--brand-color-200: 191 219 254;
|
|
||||||
--brand-color-300: 147 197 253;
|
|
||||||
--brand-color-400: 96 165 250;
|
|
||||||
--brand-color-500: 59 130 246;
|
|
||||||
--brand-color-600: 37 99 235;
|
|
||||||
--brand-color-700: 29 78 216;
|
|
||||||
--brand-color-800: 30 64 175;
|
|
||||||
--brand-color-900: 30 58 138;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-accent-color='red'] {
|
|
||||||
--brand-color-50: 254 242 242;
|
|
||||||
--brand-color-100: 254 226 226;
|
|
||||||
--brand-color-200: 254 202 202;
|
|
||||||
--brand-color-300: 252 165 165;
|
|
||||||
--brand-color-400: 248 113 113;
|
|
||||||
--brand-color-500: 239 68 68;
|
|
||||||
--brand-color-600: 220 38 38;
|
|
||||||
--brand-color-700: 185 28 28;
|
|
||||||
--brand-color-800: 153 27 27;
|
|
||||||
--brand-color-900: 127 29 29;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-accent-color='orange'] {
|
|
||||||
--brand-color-50: 255 247 237;
|
|
||||||
--brand-color-100: 255 237 213;
|
|
||||||
--brand-color-200: 254 215 170;
|
|
||||||
--brand-color-300: 253 186 116;
|
|
||||||
--brand-color-400: 251 146 60;
|
|
||||||
--brand-color-500: 249 115 22;
|
|
||||||
--brand-color-600: 234 88 12;
|
|
||||||
--brand-color-700: 194 65 12;
|
|
||||||
--brand-color-800: 154 52 18;
|
|
||||||
--brand-color-900: 124 45 18;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-accent-color='amber'] {
|
|
||||||
--brand-color-50: 255 251 235;
|
|
||||||
--brand-color-100: 254 243 199;
|
|
||||||
--brand-color-200: 253 230 138;
|
|
||||||
--brand-color-300: 252 211 77;
|
|
||||||
--brand-color-400: 251 191 36;
|
|
||||||
--brand-color-500: 245 158 11;
|
|
||||||
--brand-color-600: 217 119 6;
|
|
||||||
--brand-color-700: 180 83 9;
|
|
||||||
--brand-color-800: 146 64 14;
|
|
||||||
--brand-color-900: 120 53 15;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-accent-color='yellow'] {
|
|
||||||
--brand-color-50: 254 252 232;
|
|
||||||
--brand-color-100: 254 249 195;
|
|
||||||
--brand-color-200: 254 240 138;
|
|
||||||
--brand-color-300: 253 224 71;
|
|
||||||
--brand-color-400: 250 204 21;
|
|
||||||
--brand-color-500: 234 179 8;
|
|
||||||
--brand-color-600: 202 138 4;
|
|
||||||
--brand-color-700: 161 98 7;
|
|
||||||
--brand-color-800: 133 77 14;
|
|
||||||
--brand-color-900: 113 63 18;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-accent-color='lime'] {
|
|
||||||
--brand-color-50: 247 254 231;
|
|
||||||
--brand-color-100: 236 252 203;
|
|
||||||
--brand-color-200: 217 249 157;
|
|
||||||
--brand-color-300: 190 242 100;
|
|
||||||
--brand-color-400: 163 230 53;
|
|
||||||
--brand-color-500: 132 204 22;
|
|
||||||
--brand-color-600: 101 163 13;
|
|
||||||
--brand-color-700: 77 124 15;
|
|
||||||
--brand-color-800: 63 98 18;
|
|
||||||
--brand-color-900: 54 83 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-accent-color='green'] {
|
|
||||||
--brand-color-50: 240 253 244;
|
|
||||||
--brand-color-100: 220 252 231;
|
|
||||||
--brand-color-200: 187 247 208;
|
|
||||||
--brand-color-300: 134 239 172;
|
|
||||||
--brand-color-400: 74 222 128;
|
|
||||||
--brand-color-500: 34 197 94;
|
|
||||||
--brand-color-600: 22 163 74;
|
|
||||||
--brand-color-700: 21 128 61;
|
|
||||||
--brand-color-800: 22 101 52;
|
|
||||||
--brand-color-900: 20 83 45;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-accent-color='emerald'] {
|
|
||||||
--brand-color-50: 236 253 245;
|
|
||||||
--brand-color-100: 209 250 229;
|
|
||||||
--brand-color-200: 167 243 208;
|
|
||||||
--brand-color-300: 110 231 183;
|
|
||||||
--brand-color-400: 52 211 153;
|
|
||||||
--brand-color-500: 16 185 129;
|
|
||||||
--brand-color-600: 5 150 105;
|
|
||||||
--brand-color-700: 4 120 87;
|
|
||||||
--brand-color-800: 6 95 70;
|
|
||||||
--brand-color-900: 6 78 59;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-accent-color='teal'] {
|
|
||||||
--brand-color-50: 240 253 250;
|
|
||||||
--brand-color-100: 204 251 241;
|
|
||||||
--brand-color-200: 153 246 228;
|
|
||||||
--brand-color-300: 94 234 212;
|
|
||||||
--brand-color-400: 45 212 191;
|
|
||||||
--brand-color-500: 20 184 166;
|
|
||||||
--brand-color-600: 13 148 136;
|
|
||||||
--brand-color-700: 15 118 110;
|
|
||||||
--brand-color-800: 17 94 89;
|
|
||||||
--brand-color-900: 19 78 74;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-accent-color='cyan'] {
|
|
||||||
--brand-color-50: 236 254 255;
|
|
||||||
--brand-color-100: 207 250 254;
|
|
||||||
--brand-color-200: 165 243 252;
|
|
||||||
--brand-color-300: 103 232 249;
|
|
||||||
--brand-color-400: 34 211 238;
|
|
||||||
--brand-color-500: 6 182 212;
|
|
||||||
--brand-color-600: 8 145 178;
|
|
||||||
--brand-color-700: 14 116 144;
|
|
||||||
--brand-color-800: 21 94 117;
|
|
||||||
--brand-color-900: 22 78 99;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-accent-color='sky'] {
|
|
||||||
--brand-color-50: 240 249 255;
|
|
||||||
--brand-color-100: 224 242 254;
|
|
||||||
--brand-color-200: 186 230 253;
|
|
||||||
--brand-color-300: 125 211 252;
|
|
||||||
--brand-color-400: 56 189 248;
|
|
||||||
--brand-color-500: 14 165 233;
|
|
||||||
--brand-color-600: 2 132 199;
|
|
||||||
--brand-color-700: 3 105 161;
|
|
||||||
--brand-color-800: 7 89 133;
|
|
||||||
--brand-color-900: 12 74 110;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-accent-color='indigo'] {
|
|
||||||
--brand-color-50: 238 242 255;
|
|
||||||
--brand-color-100: 224 231 255;
|
|
||||||
--brand-color-200: 199 210 254;
|
|
||||||
--brand-color-300: 165 180 252;
|
|
||||||
--brand-color-400: 129 140 248;
|
|
||||||
--brand-color-500: 99 102 241;
|
|
||||||
--brand-color-600: 79 70 229;
|
|
||||||
--brand-color-700: 67 56 202;
|
|
||||||
--brand-color-800: 55 48 163;
|
|
||||||
--brand-color-900: 49 46 129;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-accent-color='violet'] {
|
|
||||||
--brand-color-50: 245 243 255;
|
|
||||||
--brand-color-100: 237 233 254;
|
|
||||||
--brand-color-200: 221 214 254;
|
|
||||||
--brand-color-300: 196 181 253;
|
|
||||||
--brand-color-400: 167 139 250;
|
|
||||||
--brand-color-500: 139 92 246;
|
|
||||||
--brand-color-600: 124 58 237;
|
|
||||||
--brand-color-700: 109 40 217;
|
|
||||||
--brand-color-800: 91 33 182;
|
|
||||||
--brand-color-900: 76 29 149;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-accent-color='purple'] {
|
|
||||||
--brand-color-50: 250 245 255;
|
|
||||||
--brand-color-100: 243 232 255;
|
|
||||||
--brand-color-200: 233 213 255;
|
|
||||||
--brand-color-300: 216 180 254;
|
|
||||||
--brand-color-400: 192 132 252;
|
|
||||||
--brand-color-500: 168 85 247;
|
|
||||||
--brand-color-600: 147 51 234;
|
|
||||||
--brand-color-700: 126 34 206;
|
|
||||||
--brand-color-800: 107 33 168;
|
|
||||||
--brand-color-900: 88 28 135;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-accent-color='fuchsia'] {
|
|
||||||
--brand-color-50: 253 244 255;
|
|
||||||
--brand-color-100: 250 232 255;
|
|
||||||
--brand-color-200: 245 208 254;
|
|
||||||
--brand-color-300: 240 171 252;
|
|
||||||
--brand-color-400: 232 121 249;
|
|
||||||
--brand-color-500: 217 70 239;
|
|
||||||
--brand-color-600: 192 38 211;
|
|
||||||
--brand-color-700: 162 28 175;
|
|
||||||
--brand-color-800: 134 25 143;
|
|
||||||
--brand-color-900: 112 26 117;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-accent-color='pink'] {
|
|
||||||
--brand-color-50: 253 242 248;
|
|
||||||
--brand-color-100: 252 231 243;
|
|
||||||
--brand-color-200: 251 207 232;
|
|
||||||
--brand-color-300: 249 168 212;
|
|
||||||
--brand-color-400: 244 114 182;
|
|
||||||
--brand-color-500: 236 72 153;
|
|
||||||
--brand-color-600: 219 39 119;
|
|
||||||
--brand-color-700: 190 24 93;
|
|
||||||
--brand-color-800: 157 23 77;
|
|
||||||
--brand-color-900: 131 24 67;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-accent-color='rose'] {
|
|
||||||
--brand-color-50: 255 241 242;
|
|
||||||
--brand-color-100: 255 228 230;
|
|
||||||
--brand-color-200: 254 205 211;
|
|
||||||
--brand-color-300: 253 164 175;
|
|
||||||
--brand-color-400: 251 113 133;
|
|
||||||
--brand-color-500: 244 63 94;
|
|
||||||
--brand-color-600: 225 29 72;
|
|
||||||
--brand-color-700: 190 18 60;
|
|
||||||
--brand-color-800: 159 18 57;
|
|
||||||
--brand-color-900: 136 19 55;
|
|
||||||
}
|
|
|
@ -1,15 +1,10 @@
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@100;300;400;500;700;900&display=swap');
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Roboto+Mono&display=swap');
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@mixin line-clamp($lines: 1) {
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
word-break: break-all;
|
|
||||||
-webkit-line-clamp: $lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.app-region-drag {
|
.app-region-drag {
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
|
@ -25,29 +20,44 @@
|
||||||
|
|
||||||
.btn-pressed-animation {
|
.btn-pressed-animation {
|
||||||
@apply transition-transform duration-300;
|
@apply transition-transform duration-300;
|
||||||
&:active {
|
|
||||||
@apply scale-95;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.btn-pressed-animation:active {
|
||||||
|
@apply scale-95;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-hover-animation {
|
.btn-hover-animation {
|
||||||
@apply relative transform;
|
@apply relative transform;
|
||||||
&::after {
|
}
|
||||||
@apply absolute top-0 left-0 z-[-1] h-full w-full scale-[.92] rounded-lg opacity-0 transition-all duration-300;
|
.btn-hover-animation:after {
|
||||||
content: '';
|
@apply absolute top-0 left-0 z-[-1] h-full w-full scale-[.92] rounded-lg opacity-0 transition-all duration-300;
|
||||||
}
|
content: '';
|
||||||
|
}
|
||||||
|
.btn-hover-animation:hover::after {
|
||||||
|
@apply scale-100 opacity-100;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover::after {
|
|
||||||
@apply scale-100 opacity-100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.line-clamp-1 {
|
.line-clamp-1 {
|
||||||
@include line-clamp(1);
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
word-break: break-all;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-clamp-2 {
|
.line-clamp-2 {
|
||||||
@include line-clamp(2);
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
word-break: break-all;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-clamp-3 {
|
.line-clamp-3 {
|
||||||
@include line-clamp(3);
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
word-break: break-all;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,11 +98,9 @@
|
||||||
url('@/web/assets/fonts/Barlow-Black.ttf') format('truetype');
|
url('@/web/assets/fonts/Barlow-Black.ttf') format('truetype');
|
||||||
}
|
}
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@100;300;400;500;700;900&display=swap');
|
|
||||||
|
|
||||||
body,
|
body,
|
||||||
input {
|
input {
|
||||||
font-family: 'Roboto', 'Barlow', ui-sans-serif, system-ui, -apple-system,
|
font-family: Roboto, ui-sans-serif, system-ui, -apple-system,
|
||||||
BlinkMacSystemFont, Helvetica Neue, PingFang SC, Microsoft YaHei,
|
BlinkMacSystemFont, Helvetica Neue, PingFang SC, Microsoft YaHei,
|
||||||
Source Han Sans SC, Noto Sans CJK SC, WenQuanYi Micro Hei, microsoft uighur,
|
Source Han Sans SC, Noto Sans CJK SC, WenQuanYi Micro Hei, microsoft uighur,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
|
@ -118,3 +126,7 @@ img,
|
||||||
a {
|
a {
|
||||||
-webkit-user-drag: none;
|
-webkit-user-drag: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user