feat: updates

This commit is contained in:
qier222 2022-05-29 17:53:27 +08:00
parent ffcc60b793
commit dd5361b8c4
No known key found for this signature in database
GPG Key ID: 9C85007ED905F14D
106 changed files with 11989 additions and 4143 deletions

1
.npmrc
View File

@ -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

View File

@ -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": {}
} }

View File

@ -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)

View File

@ -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 })
} }
) )
} }

View File

@ -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)
})
} }
/** /**

View File

@ -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: [
{ {

View File

@ -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"
}, },

View File

@ -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

View File

@ -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, '../../'),
},
},
}) })
}, },
} }

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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

View File

@ -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'

View File

@ -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

View File

@ -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()

View File

@ -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,

View File

@ -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,

View File

@ -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',

View File

@ -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,

View File

@ -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>
)} )}

View File

@ -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'
/> />

View File

@ -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({})

View File

@ -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

View File

@ -1,13 +0,0 @@
@keyframes move {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50%);
}
}
.animation {
animation: move 38s infinite;
animation-direction: alternate;
}

View File

@ -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>
) )

View File

@ -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>

View File

@ -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

View File

@ -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,

View File

@ -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]

View File

@ -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]

View File

@ -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>

View File

@ -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>
) )

View File

@ -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 (

View 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

View 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

View 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({})

View 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

View 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

View 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

View 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

View 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

View 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({})

View 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

View 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

View 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

View 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

View 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({})

View 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

View 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

View File

@ -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({})

View 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

View 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

View 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

View File

@ -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({})

View 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

View 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

View 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({})

View 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

View File

@ -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({})

View File

@ -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

View File

@ -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>
) )

View File

@ -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>
) )
} }

View File

@ -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,

View 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,
}

View File

@ -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()}

View 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,
}

View 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

View File

@ -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>
) )

View File

@ -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>
) )

View File

@ -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'
/> />

View File

@ -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',

View File

@ -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'
/> />

View 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

View File

@ -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>
) )

View File

@ -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"
} }
} }

View File

@ -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>

View File

@ -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'

View File

@ -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>
) )

View File

@ -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>
) )

View 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

View 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

View 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

View File

@ -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>

View File

@ -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 = {

View File

@ -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>

View File

@ -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) {

View File

@ -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)

View 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%;
}

View File

@ -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;
}

View File

@ -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