mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-16 13:17:46 +00:00
feat: updates
This commit is contained in:
parent
ebebf2a733
commit
a1b0bcf4d3
68 changed files with 4776 additions and 5559 deletions
|
|
@ -17,6 +17,7 @@
|
|||
"build": "cross-env-shell IS_ELECTRON=yes turbo run build",
|
||||
"build:web": "turbo run build:web",
|
||||
"pack": "turbo run build pack",
|
||||
"pack:test": "turbo run pack:test",
|
||||
"dev": "cross-env-shell IS_ELECTRON=yes turbo run dev --parallel",
|
||||
"lint": "turbo run lint",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,md}\"",
|
||||
|
|
@ -25,9 +26,9 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.17.0",
|
||||
"prettier": "^2.6.2",
|
||||
"turbo": "^1.2.16",
|
||||
"typescript": "^4.7.3"
|
||||
"eslint": "^8.21.0",
|
||||
"prettier": "^2.7.1",
|
||||
"turbo": "^1.4.2",
|
||||
"typescript": "^4.7.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,20 +3,21 @@
|
|||
* @see https://www.electron.build/configuration/configuration
|
||||
*/
|
||||
|
||||
const pkg = require('../../package.json')
|
||||
const pkg = require('./package.json')
|
||||
const electronVersion = pkg.devDependencies.electron.replaceAll('^', '')
|
||||
|
||||
module.exports = {
|
||||
appId: 'com.qier222.yesplaymusic.alpha',
|
||||
productName: pkg.productName,
|
||||
copyright: 'Copyright © 2022 qier222',
|
||||
asar: false,
|
||||
asar: true,
|
||||
directories: {
|
||||
output: 'release',
|
||||
buildResources: 'build',
|
||||
},
|
||||
npmRebuild: false,
|
||||
buildDependenciesFromSource: true,
|
||||
electronVersion: '19.0.3',
|
||||
electronVersion,
|
||||
publish: [
|
||||
{
|
||||
provider: 'github',
|
||||
|
|
@ -32,14 +33,14 @@ module.exports = {
|
|||
target: 'nsis',
|
||||
arch: ['x64'],
|
||||
},
|
||||
{
|
||||
target: 'nsis',
|
||||
arch: ['arm64'],
|
||||
},
|
||||
{
|
||||
target: 'portable',
|
||||
arch: ['x64'],
|
||||
},
|
||||
// {
|
||||
// target: 'nsis',
|
||||
// arch: ['arm64'],
|
||||
// },
|
||||
// {
|
||||
// target: 'portable',
|
||||
// arch: ['x64'],
|
||||
// },
|
||||
],
|
||||
publisherName: 'qier222',
|
||||
icon: 'build/icons/icon.ico',
|
||||
|
|
@ -58,7 +59,11 @@ module.exports = {
|
|||
target: [
|
||||
{
|
||||
target: 'dmg',
|
||||
arch: ['x64', 'arm64', 'universal'],
|
||||
arch: [
|
||||
'x64',
|
||||
'arm64',
|
||||
// 'universal'
|
||||
],
|
||||
},
|
||||
],
|
||||
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
||||
|
|
@ -72,28 +77,32 @@ module.exports = {
|
|||
target: [
|
||||
{
|
||||
target: 'deb',
|
||||
arch: ['x64', 'arm64', 'armv7l'],
|
||||
arch: [
|
||||
'x64',
|
||||
// 'arm64',
|
||||
// 'armv7l'
|
||||
],
|
||||
},
|
||||
{
|
||||
target: 'AppImage',
|
||||
arch: ['x64'],
|
||||
},
|
||||
{
|
||||
target: 'snap',
|
||||
arch: ['x64'],
|
||||
},
|
||||
{
|
||||
target: 'pacman',
|
||||
arch: ['x64'],
|
||||
},
|
||||
{
|
||||
target: 'rpm',
|
||||
arch: ['x64'],
|
||||
},
|
||||
{
|
||||
target: 'tar.gz',
|
||||
arch: ['x64'],
|
||||
},
|
||||
// {
|
||||
// target: 'snap',
|
||||
// arch: ['x64'],
|
||||
// },
|
||||
// {
|
||||
// target: 'pacman',
|
||||
// arch: ['x64'],
|
||||
// },
|
||||
// {
|
||||
// target: 'rpm',
|
||||
// arch: ['x64'],
|
||||
// },
|
||||
// {
|
||||
// target: 'tar.gz',
|
||||
// arch: ['x64'],
|
||||
// },
|
||||
],
|
||||
artifactName: '${productName}-${version}-${os}.${ext}',
|
||||
category: 'Music',
|
||||
|
|
@ -101,7 +110,7 @@ module.exports = {
|
|||
},
|
||||
files: [
|
||||
'!**/*.ts',
|
||||
// '!**/node_modules/better-sqlite3/{bin,build,deps}/**',
|
||||
'!**/node_modules/better-sqlite3/{bin,build,deps}/**',
|
||||
'!**/node_modules/*/{*.MD,*.md,README,readme}',
|
||||
'!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}',
|
||||
'!**/node_modules/*.d.ts',
|
||||
|
|
|
|||
|
|
@ -95,15 +95,19 @@ class DB {
|
|||
this.initTables()
|
||||
this.migrate()
|
||||
|
||||
log.info('[db] Database initialized')
|
||||
log.info('[db] Database initialized.')
|
||||
}
|
||||
|
||||
initTables() {
|
||||
log.info('[db] Initializing database tables...')
|
||||
const init = readSqlFile('init.sql')
|
||||
this.sqlite.exec(init)
|
||||
log.info('[db] Database tables initialized.')
|
||||
}
|
||||
|
||||
migrate() {
|
||||
log.info('[db] Migrating database..')
|
||||
|
||||
const key = 'appVersion'
|
||||
const appVersion = this.find(Tables.AppData, key)
|
||||
const updateAppVersionInDB = () => {
|
||||
|
|
@ -129,6 +133,8 @@ class DB {
|
|||
})
|
||||
|
||||
updateAppVersionInDB()
|
||||
|
||||
log.info('[db] Database migrated.')
|
||||
}
|
||||
|
||||
find<T extends TableNames>(
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { createTaskbar, Thumbar } from './windowsTaskbar'
|
|||
import { createMenu } from './menu'
|
||||
import { isDev, isWindows, isLinux, isMac } from './utils'
|
||||
import store from './store'
|
||||
import Airplay from './airplay'
|
||||
// import Airplay from './airplay'
|
||||
|
||||
class Main {
|
||||
win: BrowserWindow | null = null
|
||||
|
|
@ -26,7 +26,6 @@ class Main {
|
|||
|
||||
constructor() {
|
||||
log.info('[index] Main process start')
|
||||
|
||||
// Disable GPU Acceleration for Windows 7
|
||||
if (release().startsWith('6.1')) app.disableHardwareAcceleration()
|
||||
|
||||
|
|
@ -49,7 +48,7 @@ class Main {
|
|||
this.createThumbar()
|
||||
initIpcMain(this.win, this.tray, this.thumbar, store)
|
||||
this.initDevTools()
|
||||
new Airplay(this.win)
|
||||
// new Airplay(this.win)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -92,7 +91,8 @@ class Main {
|
|||
titleBarStyle: isMac ? 'customButtonsOnHover' : 'hidden',
|
||||
trafficLightPosition: { x: 24, y: 24 },
|
||||
frame: false,
|
||||
transparent: true,
|
||||
backgroundColor: '#000',
|
||||
show: false,
|
||||
}
|
||||
if (store.get('window')) {
|
||||
options.x = store.get('window.x')
|
||||
|
|
@ -110,6 +110,11 @@ class Main {
|
|||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// 减少显示空白窗口的时间
|
||||
this.win.once('ready-to-show', () => {
|
||||
this.win && this.win.show()
|
||||
})
|
||||
|
||||
this.disableCORS()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,4 +16,4 @@ if (process.env.PORTABLE_EXECUTABLE_DIR) {
|
|||
app.setPath('appData', portableUserDataPath)
|
||||
}
|
||||
|
||||
log.info(`[index] userData path: ${app.getPath('userData')}`)
|
||||
log.info(`[preload] userData path: ${app.getPath('userData')}`)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { isLinux, isMac, isWindows } from './utils'
|
||||
import { isLinux, isMac, isProd, isWindows } from './utils'
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
const log = require('electron-log')
|
||||
|
||||
log.transports.file.level = 'info'
|
||||
log.transports.ipc.level = false
|
||||
log.variables.process = 'renderer'
|
||||
contextBridge.exposeInMainWorld('log', log)
|
||||
if (isProd) {
|
||||
const log = require('electron-log')
|
||||
log.transports.file.level = 'info'
|
||||
log.transports.ipc.level = false
|
||||
log.variables.process = 'renderer'
|
||||
contextBridge.exposeInMainWorld('log', log)
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('ipcRenderer', {
|
||||
sendSync: ipcRenderer.sendSync,
|
||||
|
|
|
|||
|
|
@ -333,7 +333,7 @@ class Server {
|
|||
}
|
||||
|
||||
listen() {
|
||||
this.app.listen(this.port, '0.0.0.0', () => {
|
||||
this.app.listen(this.port, '127.0.0.1', () => {
|
||||
log.info(`[server] API server listening on port ${this.port}`)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"dev": "node scripts/build.main.mjs --watch",
|
||||
"build": "node scripts/build.main.mjs",
|
||||
"pack": "electron-builder build -c .electron-builder.config.js",
|
||||
"pack:test": "electron-builder build -c .electron-builder.config.js --publish never --mac --dir --arm64",
|
||||
"test:types": "tsc --noEmit --project ./tsconfig.json",
|
||||
"lint": "eslint --ext .ts,.js ./",
|
||||
"format": "prettier --write './**/*.{ts,js,tsx,jsx}'",
|
||||
|
|
@ -24,49 +25,48 @@
|
|||
"@sentry/electron": "^3.0.7",
|
||||
"@unblockneteasemusic/rust-napi": "^0.3.0",
|
||||
"NeteaseCloudMusicApi": "^4.6.7",
|
||||
"better-sqlite3": "7.5.1",
|
||||
"better-sqlite3": "7.6.2",
|
||||
"change-case": "^4.1.2",
|
||||
"chromecast-api": "^0.4.0",
|
||||
"compare-versions": "^4.1.3",
|
||||
"connect-history-api-fallback": "^2.0.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"electron-log": "^4.4.8",
|
||||
"electron-store": "^8.0.2",
|
||||
"electron-store": "^8.1.0",
|
||||
"express": "^4.18.1",
|
||||
"fast-folder-size": "^1.7.0",
|
||||
"m3u8-parser": "^4.7.1",
|
||||
"pretty-bytes": "^6.0.0",
|
||||
"zx": "^7.0.7"
|
||||
"zx": "^7.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/universal": "1.2.1",
|
||||
"@types/better-sqlite3": "^7.5.0",
|
||||
"@electron/universal": "1.3.0",
|
||||
"@types/better-sqlite3": "^7.6.0",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/express-fileupload": "^1.2.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.7",
|
||||
"@typescript-eslint/parser": "^5.30.7",
|
||||
"@vitejs/plugin-react": "^1.3.1",
|
||||
"@vitest/ui": "^0.12.10",
|
||||
"@types/express-fileupload": "^1.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.32.0",
|
||||
"@typescript-eslint/parser": "^5.32.0",
|
||||
"@vitejs/plugin-react": "^2.0.0",
|
||||
"@vitest/ui": "^0.20.3",
|
||||
"axios": "^0.27.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^16.0.0",
|
||||
"electron": "^19.0.8",
|
||||
"electron-builder": "23.3.1",
|
||||
"electron": "^18.3.6",
|
||||
"electron-builder": "23.3.3",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-rebuild": "^3.2.8",
|
||||
"electron-releases": "^3.1072.0",
|
||||
"esbuild": "^0.14.49",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"electron-releases": "^3.1091.0",
|
||||
"esbuild": "^0.14.53",
|
||||
"eslint": "*",
|
||||
"express-fileupload": "^1.4.0",
|
||||
"minimist": "^1.2.6",
|
||||
"music-metadata": "^7.12.4",
|
||||
"music-metadata": "^7.12.5",
|
||||
"open-cli": "^7.0.1",
|
||||
"ora": "^6.1.2",
|
||||
"picocolors": "^1.0.0",
|
||||
"prettier": "*",
|
||||
"typescript": "*",
|
||||
"vitest": "^0.12.10",
|
||||
"vitest": "^0.20.3",
|
||||
"wait-on": "^6.0.1"
|
||||
},
|
||||
"resolutions": {
|
||||
|
|
|
|||
|
|
@ -21,12 +21,22 @@ const betterSqlite3Version = pkg.dependencies['better-sqlite3'].replaceAll(
|
|||
const electronModuleVersion = releases.find(r =>
|
||||
r.version.includes(electronVersion)
|
||||
)?.deps?.modules
|
||||
if (!electronModuleVersion) {
|
||||
console.error(
|
||||
pc.red('Can not find electron module version in electron-releases')
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
const argv = minimist(process.argv.slice(2))
|
||||
|
||||
const projectDir = path.resolve(process.cwd(), '../../')
|
||||
const distDir = `${projectDir}/packages/desktop/dist/binary`
|
||||
console.log(pc.cyan(`projectDir=${projectDir}`))
|
||||
console.log(pc.cyan(`distDir=${distDir}`))
|
||||
|
||||
if (!fs.existsSync(`${projectDir}/packages/desktop/dist/binary`)) {
|
||||
fs.mkdirSync(`${projectDir}/packages/desktop/dist/binary`, {
|
||||
if (!fs.existsSync(distDir)) {
|
||||
console.log(pc.cyan(`Creating dist/binary directory: ${distDir}`))
|
||||
fs.mkdirSync(distDir, {
|
||||
recursive: true,
|
||||
})
|
||||
}
|
||||
|
|
@ -37,12 +47,12 @@ const download = async arch => {
|
|||
console.log(pc.red('No electron module version found! Skip download.'))
|
||||
return false
|
||||
}
|
||||
const dir = `${projectDir}/tmp/better-sqlite3`
|
||||
const tmpDir = `${projectDir}/tmp/better-sqlite3`
|
||||
const fileName = `better-sqlite3-v${betterSqlite3Version}-electron-v${electronModuleVersion}-${process.platform}-${arch}`
|
||||
const zipFileName = `${fileName}.tar.gz`
|
||||
const url = `https://github.com/JoshuaWise/better-sqlite3/releases/download/v${betterSqlite3Version}/${zipFileName}`
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, {
|
||||
if (!fs.existsSync(tmpDir)) {
|
||||
fs.mkdirSync(tmpDir, {
|
||||
recursive: true,
|
||||
})
|
||||
}
|
||||
|
|
@ -53,7 +63,7 @@ const download = async arch => {
|
|||
url,
|
||||
responseType: 'stream',
|
||||
}).then(response => {
|
||||
response.data.pipe(fs.createWriteStream(`${dir}/${zipFileName}`))
|
||||
response.data.pipe(fs.createWriteStream(`${tmpDir}/${zipFileName}`))
|
||||
return true
|
||||
})
|
||||
} catch (e) {
|
||||
|
|
@ -62,7 +72,7 @@ const download = async arch => {
|
|||
}
|
||||
|
||||
try {
|
||||
execSync(`tar -xvzf ${dir}/${zipFileName} -C ${dir}`)
|
||||
execSync(`tar -xvzf ${tmpDir}/${zipFileName} -C ${tmpDir}`)
|
||||
} catch (e) {
|
||||
console.log(pc.red('Extract failed! Skip extract.'))
|
||||
return false
|
||||
|
|
@ -70,8 +80,8 @@ const download = async arch => {
|
|||
|
||||
try {
|
||||
fs.copyFileSync(
|
||||
`${dir}/build/Release/better_sqlite3.node`,
|
||||
`${projectDir}/packages/desktop/dist/binary/better_sqlite3_${arch}.node`
|
||||
`${tmpDir}/build/Release/better_sqlite3.node`,
|
||||
`${distDir}/better_sqlite3_${arch}.node`
|
||||
)
|
||||
} catch (e) {
|
||||
console.log(pc.red('Copy failed! Skip copy.', e))
|
||||
|
|
@ -79,7 +89,7 @@ const download = async arch => {
|
|||
}
|
||||
|
||||
try {
|
||||
fs.rmSync(`${dir}/build`, { recursive: true, force: true })
|
||||
fs.rmSync(`${tmpDir}/build`, { recursive: true, force: true })
|
||||
} catch (e) {
|
||||
console.log(pc.red('Delete failed! Skip delete.'))
|
||||
return false
|
||||
|
|
@ -103,10 +113,10 @@ const build = async arch => {
|
|||
})
|
||||
.then(() => {
|
||||
console.info('Build succeeded')
|
||||
fs.copyFileSync(
|
||||
`${projectDir}/node_modules/better-sqlite3/build/Release/better_sqlite3.node`,
|
||||
`${projectDir}/packages/desktop/dist/binary/better_sqlite3_${arch}.node`
|
||||
)
|
||||
const from = `${projectDir}/node_modules/better-sqlite3/build/Release/better_sqlite3.node`
|
||||
const to = `${distDir}/better_sqlite3_${arch}.node`
|
||||
console.info(`copy ${from} to ${to}`)
|
||||
fs.copyFileSync(from, to)
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(pc.red('Build failed!'))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export enum SearchApiNames {
|
||||
Search = 'search',
|
||||
MultiMatchSearch = 'multiMatchSearch',
|
||||
FetchSearchSuggestions = 'fetchSearchSuggestions',
|
||||
}
|
||||
|
||||
// 搜索
|
||||
|
|
@ -80,3 +81,19 @@ export interface MultiMatchSearchResponse {
|
|||
orders: Array<'artist' | 'album'>
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索建议
|
||||
export interface FetchSearchSuggestionsParams {
|
||||
keywords: string
|
||||
type?: 'mobile'
|
||||
}
|
||||
export interface FetchSearchSuggestionsResponse {
|
||||
code: number
|
||||
result: {
|
||||
albums?: Album[]
|
||||
artists?: Artist[]
|
||||
playlists?: Playlist[]
|
||||
songs?: Track[]
|
||||
order: Array<'songs' | 'artists' | 'albums' | 'playlists'>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
packages/shared/interface.d.ts
vendored
1
packages/shared/interface.d.ts
vendored
|
|
@ -112,6 +112,7 @@ declare interface Track {
|
|||
v: number
|
||||
version: number
|
||||
tns: (string | null)[]
|
||||
duration?: number
|
||||
}
|
||||
declare interface Artist {
|
||||
alias: unknown[]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { Toaster } from 'react-hot-toast'
|
||||
import TitleBar from '@/web/components/TitleBar'
|
||||
import IpcRendererReact from '@/web/IpcRendererReact'
|
||||
import Layout from '@/web/components/New/Layout'
|
||||
|
|
@ -7,20 +6,18 @@ import ErrorBoundary from '@/web/components/New/ErrorBoundary'
|
|||
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||
import LayoutMobile from '@/web/components/New/LayoutMobile'
|
||||
import ScrollRestoration from '@/web/components/New/ScrollRestoration'
|
||||
import Toaster from './components/New/Toaster'
|
||||
|
||||
const App = () => {
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div>
|
||||
{window.env?.isEnableTitlebar && <TitleBar />}
|
||||
{isMobile ? <LayoutMobile /> : <Layout />}
|
||||
<Toaster position='bottom-center' containerStyle={{ bottom: '5rem' }} />
|
||||
<Toaster />
|
||||
<ScrollRestoration />
|
||||
<IpcRendererReact />
|
||||
<Devtool />
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
AlbumApiNames,
|
||||
FetchAlbumResponse,
|
||||
} from '@/shared/api/Album'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { QueryOptions, useQuery } from '@tanstack/react-query'
|
||||
|
||||
const fetch = async (params: FetchAlbumParams) => {
|
||||
const album = await fetchAlbum(params)
|
||||
|
|
@ -23,11 +23,15 @@ const fetchFromCache = (params: FetchAlbumParams): FetchAlbumResponse =>
|
|||
query: params,
|
||||
})
|
||||
|
||||
export default function useAlbum(params: FetchAlbumParams) {
|
||||
export default function useAlbum(
|
||||
params: FetchAlbumParams
|
||||
// queryOptions?: QueryOptions
|
||||
) {
|
||||
return useQuery([AlbumApiNames.FetchAlbum, params], () => fetch(params), {
|
||||
enabled: !!params.id,
|
||||
staleTime: 24 * 60 * 60 * 1000, // 24 hours
|
||||
placeholderData: () => fetchFromCache(params),
|
||||
// ...queryOptions,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||
export default function useArtists(ids: number[]) {
|
||||
return useQuery(
|
||||
['fetchArtists', ids],
|
||||
() => Promise.all(ids.map(id => fetchArtist({ id }, false))),
|
||||
() => Promise.all(ids.map(id => fetchArtist({ id }))),
|
||||
{
|
||||
enabled: !!ids && ids.length > 0,
|
||||
staleTime: 5 * 60 * 1000, // 5 mins
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import {
|
|||
SearchTypes,
|
||||
MultiMatchSearchParams,
|
||||
MultiMatchSearchResponse,
|
||||
FetchSearchSuggestionsParams,
|
||||
FetchSearchSuggestionsResponse,
|
||||
} from '@/shared/api/Search'
|
||||
|
||||
// 搜索
|
||||
|
|
@ -29,3 +31,14 @@ export function multiMatchSearch(
|
|||
params: params,
|
||||
})
|
||||
}
|
||||
|
||||
// 搜索建议
|
||||
export function fetchSearchSuggestions(
|
||||
params: FetchSearchSuggestionsParams
|
||||
): Promise<FetchSearchSuggestionsResponse> {
|
||||
return request({
|
||||
url: '/search/suggest',
|
||||
method: 'get',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
import axios, { AxiosInstance } from 'axios'
|
||||
|
||||
const baseURL = String(
|
||||
import.meta.env.DEV ? '/yesplaymusic' : `http://127.0.0.1:42710/yesplaymusic`
|
||||
)
|
||||
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL,
|
||||
baseURL: '/yesplaymusic',
|
||||
withCredentials: true,
|
||||
timeout: 15000,
|
||||
})
|
||||
|
|
|
|||
1
packages/web/assets/icons/plus.svg
Normal file
1
packages/web/assets/icons/plus.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-plus"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
|
After Width: | Height: | Size: 304 B |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 1,012 B |
|
|
@ -1,52 +0,0 @@
|
|||
export type SvgName =
|
||||
| 'back'
|
||||
| 'dislike'
|
||||
| 'dj'
|
||||
| 'email'
|
||||
| 'explicit'
|
||||
| 'eye-off'
|
||||
| 'eye'
|
||||
| 'fm'
|
||||
| 'forward'
|
||||
| 'heart-outline'
|
||||
| 'heart'
|
||||
| 'home'
|
||||
| 'lock'
|
||||
| 'lyrics'
|
||||
| 'more'
|
||||
| 'music-library'
|
||||
| 'music-note'
|
||||
| 'next'
|
||||
| 'pause'
|
||||
| 'phone'
|
||||
| 'play-fill'
|
||||
| 'play'
|
||||
| 'playlist'
|
||||
| 'podcast'
|
||||
| 'previous'
|
||||
| 'qrcode'
|
||||
| 'repeat'
|
||||
| 'repeat-1'
|
||||
| 'search'
|
||||
| 'settings'
|
||||
| 'shuffle'
|
||||
| 'user'
|
||||
| 'volume-half'
|
||||
| 'volume-mute'
|
||||
| 'volume'
|
||||
| 'windows-close'
|
||||
| 'windows-minimize'
|
||||
| 'windows-maximize'
|
||||
| 'windows-un-maximize'
|
||||
| 'x'
|
||||
|
||||
const Icon = ({ name, className }: { name: SvgName; className?: string }) => {
|
||||
const symbolId = `#icon-${name}`
|
||||
return (
|
||||
<svg aria-hidden='true' className={className}>
|
||||
<use href={symbolId} fill='currentColor' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default Icon
|
||||
12
packages/web/components/Icon/Icon.tsx
Normal file
12
packages/web/components/Icon/Icon.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { IconNames } from './iconNamesType'
|
||||
|
||||
const Icon = ({ name, className }: { name: IconNames; className?: string }) => {
|
||||
const symbolId = `#icon-${name}`
|
||||
return (
|
||||
<svg aria-hidden='true' className={className}>
|
||||
<use href={symbolId} fill='currentColor' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default Icon
|
||||
1
packages/web/components/Icon/iconNamesType.ts
Normal file
1
packages/web/components/Icon/iconNamesType.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type IconNames = 'back' | 'discovery' | 'dislike' | 'dj' | 'email' | 'explicit' | 'explore' | 'eye-off' | 'eye' | 'fm' | 'forward' | 'heart-outline' | 'heart' | 'hide-list' | 'lock' | 'lyrics' | 'more' | 'music-note' | 'my' | 'next' | 'pause' | 'phone' | 'play-fill' | 'play' | 'player-handler' | 'playlist' | 'plus' | 'previous' | 'qrcode' | 'repeat-1' | 'repeat' | 'search' | 'settings' | 'shuffle' | 'user' | 'volume-half' | 'volume-mute' | 'volume' | 'windows-close' | 'windows-maximize' | 'windows-minimize' | 'windows-un-maximize' | 'x'
|
||||
2
packages/web/components/Icon/index.ts
Normal file
2
packages/web/components/Icon/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import Icon from './Icon'
|
||||
export default Icon
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import { cx } from '@emotion/css'
|
||||
import { openContextMenu } from '@/web/states/contextMenus'
|
||||
|
||||
const ArtistInline = ({
|
||||
artists,
|
||||
|
|
@ -37,6 +38,16 @@ const ArtistInline = ({
|
|||
<span key={`${artist.id}-${artist.name}`}>
|
||||
<span
|
||||
onClick={() => handleClick(artist.id)}
|
||||
onContextMenu={event => {
|
||||
openContextMenu({
|
||||
event,
|
||||
type: 'artist',
|
||||
dataSourceID: artist.id,
|
||||
options: {
|
||||
useCursorPosition: true,
|
||||
},
|
||||
})
|
||||
}}
|
||||
className={cx(!!artist.id && !disableLink && hoverClassName)}
|
||||
>
|
||||
{artist.name}
|
||||
|
|
|
|||
107
packages/web/components/New/ContextMenus/AlbumContextMenu.tsx
Normal file
107
packages/web/components/New/ContextMenus/AlbumContextMenu.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import useUserAlbums, {
|
||||
useMutationLikeAAlbum,
|
||||
} from '@/web/api/hooks/useUserAlbums'
|
||||
import contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
|
||||
import player from '@/web/states/player'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { useMemo, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useCopyToClipboard } from 'react-use'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import BasicContextMenu from './BasicContextMenu'
|
||||
|
||||
const AlbumContextMenu = () => {
|
||||
const { cursorPosition, type, dataSourceID, target, options } =
|
||||
useSnapshot(contextMenus)
|
||||
const likeAAlbum = useMutationLikeAAlbum()
|
||||
const [, copyToClipboard] = useCopyToClipboard()
|
||||
|
||||
const { data: likedAlbums } = useUserAlbums()
|
||||
const addToLibraryLabel = useMemo(() => {
|
||||
return likedAlbums?.data?.find(a => a.id === Number(dataSourceID))
|
||||
? 'Remove from Library'
|
||||
: 'Add to Library'
|
||||
}, [dataSourceID, likedAlbums?.data])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{cursorPosition && type === 'album' && dataSourceID && target && (
|
||||
<BasicContextMenu
|
||||
target={target}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={closeContextMenu}
|
||||
options={options}
|
||||
items={[
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Add to Queue',
|
||||
onClick: () => {
|
||||
toast('开发中')
|
||||
|
||||
// toast.success('Added to Queue', { duration: 100000000 })
|
||||
// toast.error('Not implemented yet', { duration: 100000000 })
|
||||
// toast.loading('Loading...')
|
||||
// toast('ADADADAD', { duration: 100000000 })
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: addToLibraryLabel,
|
||||
onClick: () => {
|
||||
if (type !== 'album' || !dataSourceID) {
|
||||
return
|
||||
}
|
||||
likeAAlbum.mutateAsync(Number(dataSourceID)).then(res => {
|
||||
if (res?.code === 200) {
|
||||
toast.success('Added to Library')
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Add to playlist',
|
||||
onClick: () => {
|
||||
toast('开发中')
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
type: 'submenu',
|
||||
label: 'Share',
|
||||
items: [
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Copy Netease Link',
|
||||
onClick: () => {
|
||||
copyToClipboard(
|
||||
`https://music.163.com/#/album?id=${dataSourceID}`
|
||||
)
|
||||
toast.success('Copied')
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Copy YPM Link',
|
||||
onClick: () => {
|
||||
copyToClipboard(
|
||||
`${window.location.origin}/album/${dataSourceID}`
|
||||
)
|
||||
toast.success('Copied')
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlbumContextMenu
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import useUserArtists, {
|
||||
useMutationLikeAArtist,
|
||||
} from '@/web/api/hooks/useUserArtists'
|
||||
import contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { useMemo, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useCopyToClipboard } from 'react-use'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import BasicContextMenu from './BasicContextMenu'
|
||||
|
||||
const ArtistContextMenu = () => {
|
||||
const { cursorPosition, type, dataSourceID, target, options } =
|
||||
useSnapshot(contextMenus)
|
||||
const likeAArtist = useMutationLikeAArtist()
|
||||
const [, copyToClipboard] = useCopyToClipboard()
|
||||
|
||||
const { data: likedArtists } = useUserArtists()
|
||||
const followLabel = useMemo(() => {
|
||||
return likedArtists?.data?.find(a => a.id === Number(dataSourceID))
|
||||
? 'Follow'
|
||||
: 'Unfollow'
|
||||
}, [dataSourceID, likedArtists?.data])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{cursorPosition && type === 'artist' && dataSourceID && target && (
|
||||
<BasicContextMenu
|
||||
target={target}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={closeContextMenu}
|
||||
options={options}
|
||||
items={[
|
||||
{
|
||||
type: 'item',
|
||||
label: followLabel,
|
||||
onClick: () => {
|
||||
if (type !== 'artist' || !dataSourceID) {
|
||||
return
|
||||
}
|
||||
likeAArtist.mutateAsync(Number(dataSourceID)).then(res => {
|
||||
if (res?.code === 200) {
|
||||
toast.success(
|
||||
followLabel === 'Unfollow' ? 'Followed' : 'Unfollowed'
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
type: 'submenu',
|
||||
label: 'Share',
|
||||
items: [
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Copy Netease Link',
|
||||
onClick: () => {
|
||||
copyToClipboard(
|
||||
`https://music.163.com/#/artist?id=${dataSourceID}`
|
||||
)
|
||||
toast.success('Copied')
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Copy YPM Link',
|
||||
onClick: () => {
|
||||
copyToClipboard(
|
||||
`${window.location.origin}/artist/${dataSourceID}`
|
||||
)
|
||||
toast.success('Copied')
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArtistContextMenu
|
||||
238
packages/web/components/New/ContextMenus/BasicContextMenu.tsx
Normal file
238
packages/web/components/New/ContextMenus/BasicContextMenu.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import {
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useClickAway } from 'react-use'
|
||||
import Icon from '../../Icon'
|
||||
import useLockMainScroll from '@/web/hooks/useLockMainScroll'
|
||||
import { motion } from 'framer-motion'
|
||||
import useMeasure from 'react-use-measure'
|
||||
|
||||
interface ContextMenuItem {
|
||||
type: 'item' | 'submenu' | 'divider'
|
||||
label?: string
|
||||
onClick?: (e: MouseEvent) => void
|
||||
items?: ContextMenuItem[]
|
||||
}
|
||||
|
||||
const Divider = () => (
|
||||
<div className='my-2 h-px w-full px-3'>
|
||||
<div className='h-full w-full bg-white/5'></div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const Item = ({
|
||||
item,
|
||||
onClose,
|
||||
}: {
|
||||
item: ContextMenuItem
|
||||
onClose: (e: MouseEvent) => void
|
||||
}) => {
|
||||
const [isHover, setIsHover] = useState(false)
|
||||
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
const submenuRef = useRef<HTMLDivElement>(null)
|
||||
const getSubmenuPosition = () => {
|
||||
if (!itemRef.current || !submenuRef.current) {
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
const item = itemRef.current.getBoundingClientRect()
|
||||
const submenu = submenuRef.current.getBoundingClientRect()
|
||||
|
||||
const isRightSide = item.x + item.width + submenu.width <= window.innerWidth
|
||||
const x = isRightSide ? item.x + item.width : item.x - submenu.width
|
||||
|
||||
const isTopSide = item.y - 8 + submenu.height <= window.innerHeight
|
||||
const y = isTopSide ? item.y - 8 : item.y + item.height + 8 - submenu.height
|
||||
|
||||
const transformOriginTable = {
|
||||
top: {
|
||||
right: 'origin-top-left',
|
||||
left: 'origin-top-right',
|
||||
},
|
||||
bottom: {
|
||||
right: 'origin-bottom-left',
|
||||
left: 'origin-bottom-right',
|
||||
},
|
||||
} as const
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
transformOrigin:
|
||||
transformOriginTable[isTopSide ? 'top' : 'bottom'][
|
||||
isRightSide ? 'right' : 'left'
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type === 'divider') return <Divider />
|
||||
return (
|
||||
<div
|
||||
ref={itemRef}
|
||||
onClick={e => {
|
||||
if (!item.onClick) {
|
||||
return
|
||||
}
|
||||
const event = e as unknown as MouseEvent
|
||||
item.onClick?.(event)
|
||||
onClose(event)
|
||||
}}
|
||||
onMouseOver={() => setIsHover(true)}
|
||||
onMouseLeave={() => setIsHover(false)}
|
||||
className='relative px-2'
|
||||
>
|
||||
<div className='flex w-full items-center justify-between whitespace-nowrap rounded-md px-3 py-2 text-white/70 transition-colors duration-400 hover:bg-white/10 hover:text-white/80'>
|
||||
<div>{item.label}</div>
|
||||
{item.type === 'submenu' && (
|
||||
<Icon name='more' className='ml-8 h-4 w-4' />
|
||||
)}
|
||||
{item.type === 'submenu' && item.items && (
|
||||
<Menu
|
||||
position={{ x: 99999, y: 99999 }}
|
||||
items={item.items}
|
||||
ref={submenuRef}
|
||||
onClose={onClose}
|
||||
forMeasure={true}
|
||||
/>
|
||||
)}
|
||||
{item.type === 'submenu' && item.items && isHover && (
|
||||
<Menu
|
||||
position={getSubmenuPosition()}
|
||||
items={item.items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Menu = forwardRef(
|
||||
(
|
||||
{
|
||||
position,
|
||||
items,
|
||||
onClose,
|
||||
forMeasure,
|
||||
}: {
|
||||
position: {
|
||||
x: number
|
||||
y: number
|
||||
transformOrigin?:
|
||||
| 'origin-top-left'
|
||||
| 'origin-top-right'
|
||||
| 'origin-bottom-left'
|
||||
| 'origin-bottom-right'
|
||||
}
|
||||
items: ContextMenuItem[]
|
||||
onClose: (e: MouseEvent) => void
|
||||
forMeasure?: boolean
|
||||
},
|
||||
ref: ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: forMeasure ? 1 : 0.96 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
},
|
||||
}}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
ref={ref}
|
||||
className={cx(
|
||||
'fixed z-10 rounded-12 border border-day-500 bg-day-600 py-2 font-medium',
|
||||
position.transformOrigin || 'origin-top-left'
|
||||
)}
|
||||
style={{ left: position.x, top: position.y }}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<Item key={index} item={item} onClose={onClose} />
|
||||
))}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Menu.displayName = 'Menu'
|
||||
|
||||
const BasicContextMenu = ({
|
||||
onClose,
|
||||
items,
|
||||
target,
|
||||
cursorPosition,
|
||||
options,
|
||||
}: {
|
||||
onClose: (e: MouseEvent) => void
|
||||
items: ContextMenuItem[]
|
||||
target: HTMLElement
|
||||
cursorPosition: { x: number; y: number }
|
||||
options?: {
|
||||
useCursorPosition?: boolean
|
||||
} | null
|
||||
}) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [measureRef, menu] = useMeasure()
|
||||
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(
|
||||
null
|
||||
)
|
||||
|
||||
useClickAway(menuRef, onClose)
|
||||
useLockMainScroll(!!position)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (options?.useCursorPosition) {
|
||||
const leftX = cursorPosition.x
|
||||
const rightX = cursorPosition.x - menu.width
|
||||
const bottomY = cursorPosition.y
|
||||
const topY = cursorPosition.y - menu.height
|
||||
const position = {
|
||||
x: leftX + menu.width < window.innerWidth ? leftX : rightX,
|
||||
y: bottomY + menu.height < window.innerHeight ? bottomY : topY,
|
||||
}
|
||||
setPosition(position)
|
||||
} else {
|
||||
const button = target.getBoundingClientRect()
|
||||
const leftX = button.x
|
||||
const rightX = button.x - menu.width + button.width
|
||||
const bottomY = button.y + button.height + 8
|
||||
const topY = button.y - menu.height - 8
|
||||
const position = {
|
||||
x: leftX + menu.width < window.innerWidth ? leftX : rightX,
|
||||
y: bottomY + menu.height < window.innerHeight ? bottomY : topY,
|
||||
}
|
||||
setPosition(position)
|
||||
}
|
||||
}, [target, menu, options?.useCursorPosition, cursorPosition])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
position={{ x: 99999, y: 99999 }}
|
||||
items={items}
|
||||
ref={measureRef}
|
||||
onClose={onClose}
|
||||
forMeasure={true}
|
||||
/>
|
||||
{position && (
|
||||
<Menu
|
||||
position={position}
|
||||
items={items}
|
||||
ref={menuRef}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BasicContextMenu
|
||||
15
packages/web/components/New/ContextMenus/ContextMenus.tsx
Normal file
15
packages/web/components/New/ContextMenus/ContextMenus.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import AlbumContextMenu from './AlbumContextMenu'
|
||||
import ArtistContextMenu from './ArtistContextMenu'
|
||||
import TrackContextMenu from './TrackContextMenu'
|
||||
|
||||
const ContextMenus = () => {
|
||||
return (
|
||||
<>
|
||||
<TrackContextMenu />
|
||||
<AlbumContextMenu />
|
||||
<ArtistContextMenu />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContextMenus
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useCopyToClipboard } from 'react-use'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import BasicContextMenu from './BasicContextMenu'
|
||||
|
||||
const TrackContextMenu = () => {
|
||||
const navigate = useNavigate()
|
||||
const [, copyToClipboard] = useCopyToClipboard()
|
||||
|
||||
const { type, dataSourceID, target, cursorPosition, options } =
|
||||
useSnapshot(contextMenus)
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{type === 'track' && dataSourceID && target && cursorPosition && (
|
||||
<BasicContextMenu
|
||||
target={target}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={closeContextMenu}
|
||||
options={options}
|
||||
items={[
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Add to Queue',
|
||||
onClick: () => {
|
||||
toast('开发中')
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Go to artist',
|
||||
onClick: () => {
|
||||
toast('开发中')
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Go to album',
|
||||
onClick: () => {
|
||||
toast('开发中')
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Add to Liked Tracks',
|
||||
onClick: () => {
|
||||
toast('开发中')
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Add to playlist',
|
||||
onClick: () => {
|
||||
toast('开发中')
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'submenu',
|
||||
label: 'Share',
|
||||
items: [
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Copy Netease Link',
|
||||
onClick: () => {
|
||||
copyToClipboard(
|
||||
`https://music.163.com/#/album?id=${dataSourceID}`
|
||||
)
|
||||
toast.success('Copied')
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Copy YPM Link',
|
||||
onClick: () => {
|
||||
copyToClipboard(
|
||||
`${window.location.origin}/album/${dataSourceID}`
|
||||
)
|
||||
toast.success('Copied')
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrackContextMenu
|
||||
|
|
@ -6,8 +6,20 @@ import { prefetchAlbum } from '@/web/api/hooks/useAlbum'
|
|||
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
|
||||
import { memo, useCallback } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import ArtistInline from './ArtistsInLine'
|
||||
|
||||
const Album = ({ album }: { album: Album }) => {
|
||||
type ItemTitle = undefined | 'name'
|
||||
type ItemSubTitle = undefined | 'artist' | 'year'
|
||||
|
||||
const Album = ({
|
||||
album,
|
||||
itemTitle,
|
||||
itemSubtitle,
|
||||
}: {
|
||||
album: Album
|
||||
itemTitle?: ItemTitle
|
||||
itemSubtitle?: ItemSubTitle
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const goTo = () => {
|
||||
navigate(`/album/${album.id}`)
|
||||
|
|
@ -16,6 +28,24 @@ const Album = ({ album }: { album: Album }) => {
|
|||
prefetchAlbum({ id: album.id })
|
||||
}
|
||||
|
||||
const title =
|
||||
itemTitle &&
|
||||
{
|
||||
name: album.name,
|
||||
}[itemTitle]
|
||||
|
||||
const subtitle =
|
||||
itemSubtitle &&
|
||||
{
|
||||
artist: (
|
||||
<ArtistInline
|
||||
artists={album.artists}
|
||||
hoverClassName='hover:text-white/50 transition-colors duration-400'
|
||||
/>
|
||||
),
|
||||
year: dayjs(album.publishTime || 0).year(),
|
||||
}[itemSubtitle]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Image
|
||||
|
|
@ -25,12 +55,16 @@ const Album = ({ album }: { album: Album }) => {
|
|||
className='aspect-square rounded-24'
|
||||
onMouseOver={prefetch}
|
||||
/>
|
||||
{title && (
|
||||
<div className='line-clamp-2 mt-2 text-14 font-medium text-neutral-300'>
|
||||
{album.name}
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div className='mt-1 text-14 font-medium text-neutral-700'>
|
||||
{dayjs(album.publishTime || 0).year()}
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -60,11 +94,15 @@ const CoverRow = ({
|
|||
playlists,
|
||||
title,
|
||||
className,
|
||||
itemTitle,
|
||||
itemSubtitle,
|
||||
}: {
|
||||
title?: string
|
||||
className?: string
|
||||
albums?: Album[]
|
||||
playlists?: Playlist[]
|
||||
itemTitle?: ItemTitle
|
||||
itemSubtitle?: ItemSubTitle
|
||||
}) => {
|
||||
return (
|
||||
<div className={className}>
|
||||
|
|
@ -78,7 +116,12 @@ const CoverRow = ({
|
|||
{/* Items */}
|
||||
<div className='grid grid-cols-3 gap-4 lg:gap-6 xl:grid-cols-4 2xl:grid-cols-5'>
|
||||
{albums?.map(album => (
|
||||
<Album key={album.id} album={album} />
|
||||
<Album
|
||||
key={album.id}
|
||||
album={album}
|
||||
itemTitle={itemTitle}
|
||||
itemSubtitle={itemSubtitle}
|
||||
/>
|
||||
))}
|
||||
{playlists?.map(playlist => (
|
||||
<Playlist key={playlist.id} playlist={playlist} />
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ const CoverRow = ({
|
|||
'',
|
||||
'md'
|
||||
)}
|
||||
className='rounded-24'
|
||||
className='aspect-square w-full rounded-24'
|
||||
onMouseOver={() => prefetch(item.id)}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import BlurBackground from './BlurBackground'
|
|||
import Airplay from './Airplay'
|
||||
import TitleBar from './TitleBar'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import ContextMenus from './ContextMenus/ContextMenus'
|
||||
|
||||
const Layout = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
|
|
@ -21,8 +22,8 @@ const Layout = () => {
|
|||
<div
|
||||
id='layout'
|
||||
className={cx(
|
||||
'relative grid h-screen select-none overflow-hidden bg-white dark:bg-black',
|
||||
window.env?.isElectron && !fullscreen && 'rounded-24'
|
||||
'relative grid h-screen select-none overflow-hidden bg-white dark:bg-black'
|
||||
// window.env?.isElectron && !fullscreen && 'rounded-24'
|
||||
)}
|
||||
>
|
||||
<BlurBackground />
|
||||
|
|
@ -38,7 +39,9 @@ const Layout = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{window.env?.isWindows && <TitleBar />}
|
||||
{(window.env?.isWindows || window.env?.isLinux) && <TitleBar />}
|
||||
|
||||
<ContextMenus />
|
||||
|
||||
{/* {window.env?.isElectron && <Airplay />} */}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ const NowPlaying = () => {
|
|||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Controls (for Animation) */}
|
||||
{/* Controls */}
|
||||
<Controls />
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import { useWindowSize } from 'react-use'
|
|||
import { playerWidth, topbarHeight } from '@/web/utils/const'
|
||||
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import toast from 'react-hot-toast'
|
||||
import { openContextMenu } from '@/web/states/contextMenus'
|
||||
|
||||
const Header = () => {
|
||||
return (
|
||||
|
|
@ -23,10 +25,10 @@ const Header = () => {
|
|||
PLAYING NEXT
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<div className='mr-2'>
|
||||
<div onClick={() => toast('开发中')} className='mr-2'>
|
||||
<Icon name='repeat-1' className='h-7 w-7 opacity-40' />
|
||||
</div>
|
||||
<div>
|
||||
<div onClick={() => toast('开发中')}>
|
||||
<Icon name='shuffle' className='h-7 w-7 opacity-40' />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -51,6 +53,17 @@ const Track = ({
|
|||
onClick={e => {
|
||||
if (e.detail === 2 && track?.id) player.playTrack(track.id)
|
||||
}}
|
||||
onContextMenu={event => {
|
||||
track?.id &&
|
||||
openContextMenu({
|
||||
event,
|
||||
type: 'track',
|
||||
dataSourceID: track.id,
|
||||
options: {
|
||||
useCursorPosition: true,
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
{/* Cover */}
|
||||
<img
|
||||
|
|
@ -71,7 +84,7 @@ const Track = ({
|
|||
>
|
||||
{track?.name}
|
||||
</div>
|
||||
<div className='line-clamp-1 mt-1 text-14 font-bold text-neutral-200 dark:text-neutral-700'>
|
||||
<div className='line-clamp-1 mt-1 text-14 font-bold text-neutral-200 dark:text-white/25'>
|
||||
{track?.ar.map(a => a.name).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const Album = React.lazy(() => import('@/web/pages/New/Album'))
|
|||
const Playlist = React.lazy(() => import('@/web/pages/New/Playlist'))
|
||||
const Artist = React.lazy(() => import('@/web/pages/New/Artist'))
|
||||
const MV = React.lazy(() => import('@/web/pages/New/MV'))
|
||||
const Lyrics = React.lazy(() => import('@/web/pages/New/Lyrics'))
|
||||
|
||||
const lazy = (component: ReactNode) => {
|
||||
return <Suspense>{component}</Suspense>
|
||||
|
|
@ -30,6 +31,7 @@ const Router = () => {
|
|||
<Route path='/artist/:id' element={lazy(<Artist />)} />
|
||||
<Route path='/mv/:id' element={lazy(<MV />)} />
|
||||
<Route path='/settings' element={lazy(<Settings />)} />
|
||||
<Route path='/lyrics' element={lazy(<Lyrics />)} />
|
||||
<Route path='/search/:keywords' element={lazy(<Search />)}>
|
||||
<Route path=':type' element={lazy(<Search />)} />
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ const Controls = () => {
|
|||
classNames,
|
||||
css`
|
||||
margin-right: 5px;
|
||||
border-radius: 4px 20px 4px 4px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
37
packages/web/components/New/Toaster.tsx
Normal file
37
packages/web/components/New/Toaster.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import { Toaster as ReactHotToaster } from 'react-hot-toast'
|
||||
|
||||
const Toaster = () => {
|
||||
return (
|
||||
<ReactHotToaster
|
||||
position='top-center'
|
||||
containerStyle={{ top: '80px' }}
|
||||
toastOptions={{
|
||||
className: cx(
|
||||
css`
|
||||
border-radius: 99999px !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
color: #000 !important;
|
||||
box-shadow: none !important;
|
||||
line-height: unset !important;
|
||||
user-select: none !important;
|
||||
font-size: 12px !important;
|
||||
padding: 10px 16px !important;
|
||||
font-weight: 500 !important;
|
||||
& div[role='status'] {
|
||||
margin: 0 8px !important;
|
||||
}
|
||||
`
|
||||
),
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: 'green',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Toaster
|
||||
|
|
@ -2,13 +2,126 @@ import { css, cx } from '@emotion/css'
|
|||
import Icon from '../../Icon'
|
||||
import { breakpoint as bp } from '@/web/utils/const'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState, useEffect, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchSearchSuggestions } from '@/web/api/search'
|
||||
import { SearchApiNames } from '@/shared/api/Search'
|
||||
import { useClickAway, useDebounce } from 'react-use'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
|
||||
const SearchSuggestions = ({ searchText }: { searchText: string }) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [debouncedSearchText, setDebouncedSearchText] = useState('')
|
||||
useDebounce(() => setDebouncedSearchText(searchText), 500, [searchText])
|
||||
const { data: suggestions } = useQuery(
|
||||
[SearchApiNames.FetchSearchSuggestions, debouncedSearchText],
|
||||
() => fetchSearchSuggestions({ keywords: debouncedSearchText }),
|
||||
{
|
||||
enabled: debouncedSearchText.length > 0,
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
const suggestionsArray = useMemo(() => {
|
||||
if (suggestions?.code !== 200) {
|
||||
return []
|
||||
}
|
||||
const suggestionsArray: {
|
||||
name: string
|
||||
type: 'album' | 'artist' | 'track'
|
||||
id: number
|
||||
}[] = []
|
||||
const rawItems = [
|
||||
...(suggestions.result.artists || []),
|
||||
...(suggestions.result.albums || []),
|
||||
...(suggestions.result.songs || []),
|
||||
]
|
||||
rawItems.forEach(item => {
|
||||
const type = (item as Artist).albumSize
|
||||
? 'artist'
|
||||
: (item as Track).duration
|
||||
? 'track'
|
||||
: 'album'
|
||||
suggestionsArray.push({
|
||||
name: item.name,
|
||||
type,
|
||||
id: item.id,
|
||||
})
|
||||
})
|
||||
return suggestionsArray
|
||||
}, [suggestions])
|
||||
|
||||
const [clickedSearchText, setClickedSearchText] = useState('')
|
||||
useEffect(() => {
|
||||
if (clickedSearchText !== searchText) {
|
||||
setClickedSearchText('')
|
||||
}
|
||||
}, [clickedSearchText, searchText])
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
useClickAway(panelRef, () => setClickedSearchText(searchText))
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{searchText.length > 0 &&
|
||||
suggestionsArray.length > 0 &&
|
||||
!clickedSearchText &&
|
||||
searchText === debouncedSearchText && (
|
||||
<motion.div
|
||||
ref={panelRef}
|
||||
initial={{ opacity: 0, scaleY: 0.96 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scaleY: 1,
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
},
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scaleY: 0.96,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
className={cx(
|
||||
'absolute mt-2 origin-top rounded-24 border border-white/10 bg-white/10 p-2 backdrop-blur-3xl',
|
||||
css`
|
||||
width: 286px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
{suggestionsArray?.map(suggestion => (
|
||||
<div
|
||||
key={`${suggestion.type}-${suggestion.id}`}
|
||||
className='line-clamp-1 rounded-12 p-2 text-white hover:bg-white/10'
|
||||
onClick={() => {
|
||||
setClickedSearchText(searchText)
|
||||
if (['album', 'artist'].includes(suggestion.type)) {
|
||||
navigate(`${suggestion.type}/${suggestion.id}`)
|
||||
}
|
||||
if (suggestion.type === 'track') {
|
||||
// TODO: play song
|
||||
}
|
||||
}}
|
||||
>
|
||||
{suggestion.type} -{suggestion.name}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
const SearchBox = () => {
|
||||
const navigate = useNavigate()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
{/* Input */}
|
||||
<div
|
||||
className={cx(
|
||||
'app-region-no-drag flex items-center rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl',
|
||||
|
|
@ -39,6 +152,9 @@ const SearchBox = () => {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SearchSuggestions searchText={searchText} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import Icon from '@/web/components/Icon'
|
||||
import { cx } from '@emotion/css'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const SettingsButton = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<button
|
||||
onClick={() => toast('开发中')}
|
||||
className={cx(
|
||||
'app-region-no-drag rounded-full bg-day-600 p-2.5 dark:bg-night-600',
|
||||
'app-region-no-drag flex h-12 w-12 items-center justify-center rounded-full bg-day-600 text-neutral-500 transition duration-400 dark:bg-white/10 dark:hover:bg-white/20 dark:hover:text-neutral-300',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon name='placeholder' className='h-7 w-7 text-neutral-500' />
|
||||
<Icon name='settings' className='h-5 w-5 ' />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ const TopbarDesktop = () => {
|
|||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'app-region-drag fixed top-0 left-0 right-0 z-20 flex items-center justify-between overflow-hidden bg-contain pt-11 pb-10 pr-6',
|
||||
'app-region-drag fixed top-0 left-0 right-0 z-20 flex items-center justify-between bg-contain pt-11 pb-10 pr-6',
|
||||
css`
|
||||
padding-left: 144px;
|
||||
`,
|
||||
|
|
|
|||
|
|
@ -1,25 +1,127 @@
|
|||
import { formatDuration } from '@/web/utils/common'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import { useMemo } from 'react'
|
||||
import { cx } from '@emotion/css'
|
||||
import player from '@/web/states/player'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import Wave from './Wave'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||
import useUserLikedTracksIDs, {
|
||||
useMutationLikeATrack,
|
||||
} from '@/web/api/hooks/useUserLikedTracksIDs'
|
||||
import toast from 'react-hot-toast'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import contextMenus, { openContextMenu } from '@/web/states/contextMenus'
|
||||
|
||||
const Actions = ({ track }: { track: Track }) => {
|
||||
const { data: likedTracksIDs } = useUserLikedTracksIDs()
|
||||
const likeATrack = useMutationLikeATrack()
|
||||
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
||||
const menu = useSnapshot(contextMenus)
|
||||
useEffect(() => {
|
||||
if (menu.type !== 'track' || !menu.dataSourceID) {
|
||||
setIsContextMenuOpen(false)
|
||||
}
|
||||
}, [menu.dataSourceID, menu.type])
|
||||
|
||||
return (
|
||||
<div className='mr-5 lg:flex' onClick={e => e.stopPropagation()}>
|
||||
{/* Context menu */}
|
||||
<div
|
||||
className={cx(
|
||||
'transition-opacity group-hover:opacity-100',
|
||||
isContextMenuOpen ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={event => {
|
||||
setIsContextMenuOpen(true)
|
||||
openContextMenu({
|
||||
event,
|
||||
type: 'track',
|
||||
dataSourceID: track.id,
|
||||
})
|
||||
}}
|
||||
className='mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white/40 transition-colors duration-400 hover:bg-white/30 hover:text-white/70'
|
||||
>
|
||||
<Icon name='more' className='pointer-events-none h-5 w-5' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add to playlist */}
|
||||
<button
|
||||
className={cx(
|
||||
'opacity-0 transition-opacity group-hover:opacity-100',
|
||||
isContextMenuOpen ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
onClick={() => toast('开发中...')}
|
||||
className='mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white/40 transition-colors duration-400 hover:bg-white/30 hover:text-white/70'
|
||||
>
|
||||
<Icon name='plus' className='h-5 w-5' />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Like */}
|
||||
<button
|
||||
className={cx(
|
||||
'rounded-full ',
|
||||
likedTracksIDs?.ids.includes(track.id)
|
||||
? 'group-hover:bg-white/10'
|
||||
: cx(
|
||||
'bg-white/10 transition-opacity group-hover:opacity-100',
|
||||
isContextMenuOpen ? 'opacity-100' : 'opacity-0'
|
||||
)
|
||||
)}
|
||||
>
|
||||
<div
|
||||
onClick={() => likeATrack.mutateAsync(track.id)}
|
||||
className='flex h-10 w-10 items-center justify-center rounded-full text-white/40 transition duration-400 hover:bg-white/20 hover:text-white/70'
|
||||
>
|
||||
<Icon
|
||||
name={
|
||||
likedTracksIDs?.ids.includes(track.id) ? 'heart' : 'heart-outline'
|
||||
}
|
||||
className='h-5 w-5'
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TrackList = ({
|
||||
tracks,
|
||||
onPlay,
|
||||
className,
|
||||
isLoading,
|
||||
placeholderRows = 12,
|
||||
}: {
|
||||
tracks?: Track[]
|
||||
onPlay: (id: number) => void
|
||||
className?: string
|
||||
isLoading?: boolean
|
||||
placeholderRows?: number
|
||||
}) => {
|
||||
const { track: playingTrack, state } = useSnapshot(player)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => {
|
||||
if (isLoading) return
|
||||
if (e.type === 'contextmenu') {
|
||||
e.preventDefault()
|
||||
openContextMenu({
|
||||
event: e,
|
||||
type: 'track',
|
||||
dataSourceID: trackID,
|
||||
options: {
|
||||
useCursorPosition: true,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
onPlay?.(trackID)
|
||||
} else {
|
||||
|
|
@ -27,15 +129,14 @@ const TrackList = ({
|
|||
}
|
||||
}
|
||||
|
||||
const playing = state === 'playing'
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{tracks?.map(track => (
|
||||
{(isLoading ? [] : tracks)?.map(track => (
|
||||
<div
|
||||
key={track.id}
|
||||
onClick={e => handleClick(e, track.id)}
|
||||
className='group relative flex items-center py-2 text-16 font-medium text-neutral-200 transition duration-300 ease-in-out'
|
||||
onContextMenu={e => handleClick(e, track.id)}
|
||||
className='group relative flex h-14 items-center py-2 text-16 font-medium text-neutral-200 transition duration-300'
|
||||
>
|
||||
{/* Track no */}
|
||||
<div className='mr-3 lg:mr-6'>
|
||||
|
|
@ -47,23 +148,13 @@ const TrackList = ({
|
|||
<span className='line-clamp-1 mr-4'>{track.name}</span>
|
||||
{playingTrack?.id === track.id && (
|
||||
<span className='mr-4 inline-block'>
|
||||
<Wave playing={playing} />
|
||||
<Wave playing={state === 'playing'} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop context menu */}
|
||||
<div className='mr-12 hidden opacity-0 transition-opacity group-hover:opacity-100 lg:flex'>
|
||||
<div className='mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-brand-600 text-white/80'>
|
||||
{/* <Icon name='play' className='h-7 w-7' /> */}
|
||||
</div>
|
||||
<div className='mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-night-900 text-white/80'>
|
||||
{/* <Icon name='play' className='h-7 w-7' /> */}
|
||||
</div>
|
||||
<div className='flex h-10 w-10 items-center justify-center rounded-full bg-night-900 text-white/80'>
|
||||
{/* <Icon name='play' className='h-7 w-7' /> */}
|
||||
</div>
|
||||
</div>
|
||||
{/* Desktop menu */}
|
||||
<Actions track={track} />
|
||||
|
||||
{/* Mobile menu */}
|
||||
<div className='lg:hidden'>
|
||||
|
|
@ -76,8 +167,35 @@ const TrackList = ({
|
|||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(isLoading ? Array.from(new Array(placeholderRows).keys()) : []).map(
|
||||
index => (
|
||||
<div
|
||||
key={index}
|
||||
className='group relative flex h-14 items-center py-2 text-16 font-medium text-neutral-200 transition duration-300 ease-in-out'
|
||||
>
|
||||
{/* Track no */}
|
||||
<div className='mr-3 rounded-full bg-white/10 text-transparent lg:mr-6'>
|
||||
00
|
||||
</div>
|
||||
|
||||
{/* Track name */}
|
||||
<div className='flex flex-grow items-center text-transparent'>
|
||||
<span className='mr-4 rounded-full bg-white/10'>
|
||||
PLACEHOLDER1234567
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Track duration */}
|
||||
<div className='hidden text-right text-transparent lg:block'>
|
||||
<span className='rounded-full bg-white/10'>00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrackList
|
||||
const memorizedTrackList = memo(TrackList)
|
||||
memorizedTrackList.displayName = 'TrackList'
|
||||
export default memorizedTrackList
|
||||
|
|
|
|||
|
|
@ -1,26 +1,52 @@
|
|||
import { openContextMenu } from '@/web/states/contextMenus'
|
||||
import { cx } from '@emotion/css'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import Icon from '../../Icon'
|
||||
|
||||
const Actions = ({
|
||||
onPlay,
|
||||
onLike,
|
||||
isLiked,
|
||||
isLoading,
|
||||
}: {
|
||||
isLiked?: boolean
|
||||
isLoading?: boolean
|
||||
onPlay: () => void
|
||||
onLike?: () => void
|
||||
}) => {
|
||||
const params = useParams()
|
||||
return (
|
||||
<div className='mt-11 flex items-end justify-between lg:mt-4 lg:justify-start'>
|
||||
<div className='flex items-end'>
|
||||
{/* Menu */}
|
||||
<button className='mr-2.5 flex h-14 w-14 items-center justify-center rounded-full text-white/40 transition duration-400 hover:text-white/70 dark:bg-white/10 hover:dark:bg-white/30'>
|
||||
<Icon name='more' className='h-7 w-7' />
|
||||
<button
|
||||
onClick={event => {
|
||||
params?.id &&
|
||||
openContextMenu({
|
||||
event,
|
||||
type: 'album',
|
||||
dataSourceID: params.id,
|
||||
})
|
||||
}}
|
||||
className={cx(
|
||||
'mr-2.5 flex h-14 w-14 items-center justify-center rounded-full bg-white/10 transition duration-400',
|
||||
isLoading
|
||||
? 'text-transparent'
|
||||
: 'text-white/40 hover:text-white/70 hover:dark:bg-white/30'
|
||||
)}
|
||||
>
|
||||
<Icon name='more' className='pointer-events-none h-7 w-7' />
|
||||
</button>
|
||||
{/* Like */}
|
||||
{onLike && (
|
||||
<button
|
||||
onClick={() => onLike()}
|
||||
className='flex h-14 w-14 items-center justify-center rounded-full text-white/40 transition duration-400 hover:text-white/70 dark:bg-white/10 hover:dark:bg-white/30 lg:mr-2.5'
|
||||
className={cx(
|
||||
'flex h-14 w-14 items-center justify-center rounded-full bg-white/10 transition duration-400 lg:mr-2.5',
|
||||
isLoading
|
||||
? 'text-transparent'
|
||||
: 'text-white/40 hover:text-white/70 hover:dark:bg-white/30'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={isLiked ? 'heart' : 'heart-outline'}
|
||||
|
|
@ -31,7 +57,10 @@ const Actions = ({
|
|||
</div>
|
||||
<button
|
||||
onClick={() => onPlay()}
|
||||
className='h-14 rounded-full px-10 text-18 font-medium text-white dark:bg-brand-700'
|
||||
className={cx(
|
||||
'h-14 rounded-full px-10 text-18 font-medium',
|
||||
isLoading ? 'bg-white/10 text-transparent' : 'bg-brand-700 text-white'
|
||||
)}
|
||||
>
|
||||
Play
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import { isIOS, isSafari, resizeImage } from '@/web/utils/common'
|
||||
import { resizeImage } from '@/web/utils/common'
|
||||
import Image from '@/web/components/New/Image'
|
||||
import { memo, useEffect } from 'react'
|
||||
import useVideoCover from '@/web/hooks/useVideoCover'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ease } from '@/web/utils/const'
|
||||
import useAppleMusicAlbum from '@/web/hooks/useAppleMusicAlbum'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import VideoCover from './VideoCover'
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import dayjs from 'dayjs'
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import useIsMobile from '@/web/hooks/useIsMobile'
|
||||
import { ReactNode } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
|
||||
const Info = ({
|
||||
title,
|
||||
|
|
@ -11,12 +12,14 @@ const Info = ({
|
|||
creatorLink,
|
||||
description,
|
||||
extraInfo,
|
||||
isLoading,
|
||||
}: {
|
||||
title?: string
|
||||
creatorName?: string
|
||||
creatorLink?: string
|
||||
description?: string
|
||||
extraInfo?: string | ReactNode
|
||||
isLoading?: boolean
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useIsMobile()
|
||||
|
|
@ -24,33 +27,56 @@ const Info = ({
|
|||
return (
|
||||
<div>
|
||||
{/* Title */}
|
||||
<div className='mt-2.5 text-28 font-semibold dark:text-white/80 lg:mt-0 lg:text-36 lg:font-medium'>
|
||||
{isLoading ? (
|
||||
<div className='mt-2.5 text-28 font-semibold text-transparent lg:mt-0 lg:text-36 lg:font-medium'>
|
||||
<span className='rounded-full bg-white/10'>PLACEHOLDER</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='mt-2.5 text-28 font-semibold transition-colors duration-300 dark:text-white/80 lg:mt-0 lg:text-36 lg:font-medium'>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Creator */}
|
||||
{isLoading ? (
|
||||
<div className='mt-2.5 lg:mt-6'>
|
||||
<span className='text-24 font-medium text-transparent'>
|
||||
<span className='rounded-full bg-white/10'>PLACEHOLDER</span>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='mt-2.5 lg:mt-6'>
|
||||
<span
|
||||
onClick={() => creatorLink && navigate(creatorLink)}
|
||||
className='text-24 font-medium transition-colors duration-300 dark:text-white/40 hover:dark:text-neutral-100 '
|
||||
className='text-24 font-medium transition-colors duration-300 dark:text-white/40 hover:dark:text-neutral-100'
|
||||
>
|
||||
{creatorName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra info */}
|
||||
<div className='mt-1 flex items-center text-12 font-medium dark:text-white/40 lg:text-14 lg:font-bold'>
|
||||
{isLoading ? (
|
||||
<div className='mt-1 flex items-center text-12 font-medium text-transparent lg:text-14 lg:font-bold'>
|
||||
<span className='rounded-full bg-white/10'>PLACEHOLDER</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='mt-1 flex items-center text-12 font-medium transition-colors duration-300 dark:text-white/40 lg:text-14 lg:font-bold'>
|
||||
{extraInfo}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{!isMobile && (
|
||||
<div
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className='line-clamp-3 mt-6 whitespace-pre-wrap text-14 font-bold dark:text-white/40'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: description || '',
|
||||
}}
|
||||
></div>
|
||||
></motion.div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import React from 'react'
|
|||
|
||||
interface Props {
|
||||
className?: string
|
||||
isLoading?: boolean
|
||||
title?: string
|
||||
creatorName?: string
|
||||
creatorLink?: string
|
||||
|
|
@ -20,6 +21,7 @@ interface Props {
|
|||
|
||||
const TrackListHeader = ({
|
||||
className,
|
||||
isLoading,
|
||||
title,
|
||||
creatorName,
|
||||
creatorLink,
|
||||
|
|
@ -46,9 +48,16 @@ const TrackListHeader = ({
|
|||
|
||||
<div className='flex flex-col justify-between'>
|
||||
<Info
|
||||
{...{ title, creatorName, creatorLink, description, extraInfo }}
|
||||
{...{
|
||||
title,
|
||||
creatorName,
|
||||
creatorLink,
|
||||
description,
|
||||
extraInfo,
|
||||
isLoading,
|
||||
}}
|
||||
/>
|
||||
<Actions {...{ onPlay, onLike, isLiked }} />
|
||||
<Actions {...{ onPlay, onLike, isLiked, isLoading }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
21
packages/web/hooks/useLockMainScroll.ts
Normal file
21
packages/web/hooks/useLockMainScroll.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { useEffect } from 'react'
|
||||
|
||||
const useLockMainScroll = (lock: boolean) => {
|
||||
useEffect(() => {
|
||||
const main = document.querySelector('#main') as HTMLElement | null
|
||||
if (!main) {
|
||||
throw new Error('Main element not found')
|
||||
}
|
||||
|
||||
if (lock) {
|
||||
main.style.overflow = 'hidden'
|
||||
} else {
|
||||
main.style.overflow = 'auto'
|
||||
}
|
||||
return () => {
|
||||
main.style.overflow = 'auto'
|
||||
}
|
||||
}, [lock])
|
||||
}
|
||||
|
||||
export default useLockMainScroll
|
||||
|
|
@ -3,7 +3,6 @@ import './utils/theme'
|
|||
import { StrictMode } from 'react'
|
||||
import * as ReactDOMClient from 'react-dom/client'
|
||||
import {
|
||||
Routes,
|
||||
BrowserRouter,
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
|
|
|
|||
|
|
@ -23,32 +23,33 @@
|
|||
"node": "^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "^11.9.0",
|
||||
"@sentry/react": "^7.8.0",
|
||||
"@sentry/tracing": "^7.8.0",
|
||||
"@emotion/css": "^11.10.0",
|
||||
"@sentry/react": "^7.8.1",
|
||||
"@sentry/tracing": "^7.8.1",
|
||||
"@tanstack/react-query": "^4.0.10",
|
||||
"@tanstack/react-query-devtools": "^4.0.10",
|
||||
"ahooks": "^3.4.1",
|
||||
"ahooks": "^3.6.2",
|
||||
"axios": "^0.27.2",
|
||||
"color.js": "^1.2.0",
|
||||
"colord": "^2.9.2",
|
||||
"dayjs": "^1.11.1",
|
||||
"framer-motion": "^6.3.4",
|
||||
"hls.js": "^1.1.5",
|
||||
"dayjs": "^1.11.4",
|
||||
"framer-motion": "^6.5.1",
|
||||
"hls.js": "^1.2.0",
|
||||
"howler": "^2.2.3",
|
||||
"js-cookie": "^3.0.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"md5": "^2.3.0",
|
||||
"plyr-react": "^5.0.2",
|
||||
"qrcode": "^1.5.0",
|
||||
"react": "^18.1.0",
|
||||
"react-dom": "^18.1.0",
|
||||
"plyr-react": "^5.1.0",
|
||||
"qrcode": "^1.5.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-ga4": "^1.4.1",
|
||||
"react-hot-toast": "^2.2.0",
|
||||
"react-hot-toast": "^2.3.0",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-use": "^17.4.0",
|
||||
"react-virtuoso": "^2.16.5",
|
||||
"valtio": "^1.6.1"
|
||||
"react-use-measure": "^2.1.1",
|
||||
"react-virtuoso": "^2.16.6",
|
||||
"valtio": "^1.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-actions": "^6.5.5",
|
||||
|
|
@ -66,30 +67,30 @@
|
|||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/md5": "^2.3.2",
|
||||
"@types/qrcode": "^1.4.2",
|
||||
"@types/react": "^18.0.11",
|
||||
"@types/react-dom": "^18.0.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.27.0",
|
||||
"@typescript-eslint/parser": "^5.27.0",
|
||||
"@vitejs/plugin-react": "^1.3.1",
|
||||
"@vitest/ui": "^0.12.10",
|
||||
"autoprefixer": "^10.4.5",
|
||||
"c8": "^7.11.3",
|
||||
"dotenv": "^16.0.0",
|
||||
"@types/react": "^18.0.15",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^5.32.0",
|
||||
"@typescript-eslint/parser": "^5.32.0",
|
||||
"@vitejs/plugin-react": "^2.0.0",
|
||||
"@vitest/ui": "^0.20.3",
|
||||
"autoprefixer": "^10.4.8",
|
||||
"c8": "^7.12.0",
|
||||
"dotenv": "^16.0.1",
|
||||
"eslint": "*",
|
||||
"eslint-plugin-react": "^7.30.0",
|
||||
"eslint-plugin-react-hooks": "^4.5.0",
|
||||
"jsdom": "^19.0.0",
|
||||
"eslint-plugin-react": "^7.30.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"jsdom": "^20.0.0",
|
||||
"open-cli": "^7.0.1",
|
||||
"postcss": "^8.4.14",
|
||||
"prettier": "*",
|
||||
"prettier-plugin-tailwindcss": "^0.1.11",
|
||||
"rollup-plugin-visualizer": "^5.6.0",
|
||||
"storybook-tailwind-dark-mode": "^1.0.12",
|
||||
"tailwindcss": "^3.0.24",
|
||||
"tailwindcss": "^3.1.7",
|
||||
"typescript": "*",
|
||||
"vite": "^2.9.6",
|
||||
"vite-plugin-pwa": "^0.12.0",
|
||||
"vite": "^3.0.4",
|
||||
"vite-plugin-pwa": "^0.12.3",
|
||||
"vite-plugin-svg-icons": "^2.0.1",
|
||||
"vitest": "^0.12.10"
|
||||
"vitest": "^0.20.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,17 @@
|
|||
import TrackListHeader from '@/web/components/New/TrackListHeader'
|
||||
import useAlbum from '@/web/api/hooks/useAlbum'
|
||||
import useTracks from '@/web/api/hooks/useTracks'
|
||||
import { NavLink, useParams } from 'react-router-dom'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import PageTransition from '@/web/components/New/PageTransition'
|
||||
import TrackList from '@/web/components/New/TrackList'
|
||||
import player from '@/web/states/player'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import CoverRow from '@/web/components/New/CoverRow'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import MoreByArtist from './MoreByArtist'
|
||||
import Header from './Header'
|
||||
|
||||
const Album = () => {
|
||||
const params = useParams()
|
||||
const { data: album } = useAlbum({
|
||||
const { data: album, isLoading } = useAlbum({
|
||||
id: Number(params.id),
|
||||
})
|
||||
|
||||
|
|
@ -39,9 +34,18 @@ const Album = () => {
|
|||
<PageTransition>
|
||||
<Header />
|
||||
<TrackList
|
||||
tracks={tracks?.songs || album?.album.songs || album?.songs}
|
||||
tracks={
|
||||
tracks?.songs?.length
|
||||
? tracks?.songs
|
||||
: album?.album?.songs?.length
|
||||
? album?.album.songs
|
||||
: album?.songs?.length
|
||||
? album.songs
|
||||
: undefined
|
||||
}
|
||||
className='z-10 mx-2.5 mt-3 lg:mx-0 lg:mt-10'
|
||||
onPlay={onPlay}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<MoreByArtist album={album?.album} />
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const Header = () => {
|
|||
const params = useParams()
|
||||
const { data: userLikedAlbums } = useUserAlbums()
|
||||
|
||||
const { data: albumRaw } = useAlbum({
|
||||
const { data: albumRaw, isLoading: isLoadingAlbum } = useAlbum({
|
||||
id: Number(params.id),
|
||||
})
|
||||
const album = useMemo(() => albumRaw?.album, [albumRaw])
|
||||
|
|
@ -89,6 +89,7 @@ const Header = () => {
|
|||
return (
|
||||
<TrackListHeader
|
||||
{...{
|
||||
isLoading: isLoadingAlbum,
|
||||
title,
|
||||
creatorName,
|
||||
creatorLink,
|
||||
|
|
|
|||
|
|
@ -78,7 +78,12 @@ const MoreByArtist = ({ album }: { album?: Album }) => {
|
|||
</NavLink>
|
||||
</div>
|
||||
|
||||
<CoverRow albums={filteredAlbums} className='mx-2.5 lg:mx-0' />
|
||||
<CoverRow
|
||||
albums={filteredAlbums}
|
||||
itemTitle='name'
|
||||
itemSubtitle='year'
|
||||
className='mx-2.5 lg:mx-0'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
import useArtist from '@/web/api/hooks/useArtist'
|
||||
import { cx, css } from '@emotion/css'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import Header from './Header'
|
||||
import Popular from './Popular'
|
||||
import ArtistAlbum from './ArtistAlbums'
|
||||
|
|
@ -8,20 +5,12 @@ import FansAlsoLike from './FansAlsoLike'
|
|||
import ArtistMVs from './ArtistMVs'
|
||||
|
||||
const Artist = () => {
|
||||
const params = useParams()
|
||||
|
||||
const { data: artist, isLoading: isLoadingArtist } = useArtist({
|
||||
id: Number(params.id) || 0,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header artist={artist?.artist} />
|
||||
|
||||
<Header />
|
||||
{/* Dividing line */}
|
||||
<div className='mt-10 mb-7.5 h-px w-full bg-white/20'></div>
|
||||
|
||||
<Popular tracks={artist?.hotSongs} />
|
||||
<Popular />
|
||||
<ArtistAlbum />
|
||||
<ArtistMVs />
|
||||
<FansAlsoLike />
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ const ArtistAlbum = () => {
|
|||
<CoverRow
|
||||
key={index}
|
||||
albums={page}
|
||||
itemTitle='name'
|
||||
itemSubtitle='year'
|
||||
className='h-full w-full flex-shrink-0'
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@ import useUserArtists, {
|
|||
useMutationLikeAArtist,
|
||||
} from '@/web/api/hooks/useUserArtists'
|
||||
import Icon from '@/web/components/Icon'
|
||||
import { openContextMenu } from '@/web/states/contextMenus'
|
||||
import player from '@/web/states/player'
|
||||
import { cx } from '@emotion/css'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
const Actions = () => {
|
||||
const Actions = ({ isLoading }: { isLoading: boolean }) => {
|
||||
const { data: likedArtists } = useUserArtists()
|
||||
const params = useParams()
|
||||
const id = Number(params.id) || 0
|
||||
|
|
@ -16,14 +19,33 @@ const Actions = () => {
|
|||
<div className='mt-11 flex items-end justify-between lg:z-10 lg:mt-6'>
|
||||
<div className='flex items-end'>
|
||||
{/* Menu */}
|
||||
<button className='mr-2.5 flex h-14 w-14 items-center justify-center rounded-full text-white/40 transition duration-400 hover:text-white/70 dark:bg-white/10 hover:dark:bg-white/30'>
|
||||
<Icon name='more' className='h-7 w-7' />
|
||||
<button
|
||||
onClick={event => {
|
||||
openContextMenu({
|
||||
event,
|
||||
type: 'artist',
|
||||
dataSourceID: id,
|
||||
})
|
||||
}}
|
||||
className={cx(
|
||||
'mr-2.5 flex h-14 w-14 items-center justify-center rounded-full transition duration-400 dark:bg-white/10 ',
|
||||
isLoading
|
||||
? 'text-transparent'
|
||||
: 'text-white/40 hover:text-white/70 hover:dark:bg-white/30 '
|
||||
)}
|
||||
>
|
||||
<Icon name='more' className='pointer-events-none h-7 w-7' />
|
||||
</button>
|
||||
|
||||
{/* Like */}
|
||||
<button
|
||||
onClick={() => likeArtist.mutateAsync(id)}
|
||||
className='flex h-14 w-14 items-center justify-center rounded-full text-white/40 transition duration-400 hover:text-white/70 dark:bg-white/10 hover:dark:bg-white/30'
|
||||
className={cx(
|
||||
'mr-2.5 flex h-14 w-14 items-center justify-center rounded-full transition duration-400 dark:bg-white/10 ',
|
||||
isLoading
|
||||
? 'text-transparent'
|
||||
: 'text-white/40 hover:text-white/70 hover:dark:bg-white/30 '
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={isLiked ? 'heart' : 'heart-outline'}
|
||||
|
|
@ -35,7 +57,10 @@ const Actions = () => {
|
|||
{/* Listen */}
|
||||
<button
|
||||
onClick={() => player.playArtistPopularTracks(id)}
|
||||
className='h-14 rounded-full px-10 text-18 font-medium text-white dark:bg-brand-700'
|
||||
className={cx(
|
||||
'h-14 rounded-full px-10 text-18 font-medium',
|
||||
isLoading ? 'bg-white/10 text-transparent' : 'bg-brand-700 text-white'
|
||||
)}
|
||||
>
|
||||
Listen
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@ import useIsMobile from '@/web/hooks/useIsMobile'
|
|||
import useAppleMusicArtist from '@/web/hooks/useAppleMusicArtist'
|
||||
import { cx, css } from '@emotion/css'
|
||||
|
||||
const ArtistInfo = ({ artist }: { artist?: Artist }) => {
|
||||
const ArtistInfo = ({
|
||||
artist,
|
||||
isLoading,
|
||||
}: {
|
||||
artist?: Artist
|
||||
isLoading: boolean
|
||||
}) => {
|
||||
const isMobile = useIsMobile()
|
||||
const { data: artistFromApple, isLoading: isLoadingArtistFromApple } =
|
||||
useAppleMusicArtist({
|
||||
|
|
@ -12,19 +18,56 @@ const ArtistInfo = ({ artist }: { artist?: Artist }) => {
|
|||
|
||||
return (
|
||||
<div>
|
||||
{/* Name */}
|
||||
{isLoading ? (
|
||||
<div className=' text-28 font-semibold text-transparent lg:text-32'>
|
||||
<span className='rounded-full bg-white/10'>PLACEHOLDER</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-28 font-semibold text-white/70 lg:text-32'>
|
||||
{artist?.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type */}
|
||||
{isLoading ? (
|
||||
<div className='mt-2.5 text-24 font-medium text-transparent lg:mt-6'>
|
||||
<span className='rounded-full bg-white/10'>Artist</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='mt-2.5 text-24 font-medium text-white/40 lg:mt-6'>
|
||||
Artist
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Counts */}
|
||||
{isLoading ? (
|
||||
<div className='mt-1 text-12 font-medium text-transparent'>
|
||||
<span className='rounded-full bg-white/10'>PLACEHOLDER12345</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='mt-1 text-12 font-medium text-white/40'>
|
||||
{artist?.musicSize} Tracks · {artist?.albumSize} Albums ·{' '}
|
||||
{artist?.mvSize} Videos
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{!isMobile && !isLoadingArtistFromApple && (
|
||||
{!isMobile &&
|
||||
(isLoading || isLoadingArtistFromApple ? (
|
||||
<div
|
||||
className={cx(
|
||||
'line-clamp-5 mt-6 text-14 font-bold text-transparent',
|
||||
css`
|
||||
height: 86px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<span className='rounded-full bg-white/10'>
|
||||
PLACEHOLDER1234567890
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cx(
|
||||
'line-clamp-5 mt-6 text-14 font-bold text-white/40',
|
||||
|
|
@ -35,7 +78,7 @@ const ArtistInfo = ({ artist }: { artist?: Artist }) => {
|
|||
>
|
||||
{artistFromApple?.attributes?.artistBio || artist?.briefDesc}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,16 @@ import { breakpoint as bp } from '@/web/utils/const'
|
|||
import ArtistInfo from './ArtistInfo'
|
||||
import Actions from './Actions'
|
||||
import LatestRelease from './LatestRelease'
|
||||
import Cover from "./Cover"
|
||||
const Header = ({ artist }: { artist?: Artist }) => {
|
||||
import Cover from './Cover'
|
||||
import useArtist from '@/web/api/hooks/useArtist'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
const Header = () => {
|
||||
const params = useParams()
|
||||
const { data: artistRaw, isLoading } = useArtist({
|
||||
id: Number(params.id) || 0,
|
||||
})
|
||||
const artist = artistRaw?.artist
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -39,8 +47,8 @@ const Header = ({ artist }: { artist?: Artist }) => {
|
|||
`
|
||||
)}
|
||||
>
|
||||
<ArtistInfo artist={artist} />
|
||||
<Actions />
|
||||
<ArtistInfo isLoading={isLoading} artist={artist} />
|
||||
<Actions isLoading={isLoading} />
|
||||
</div>
|
||||
|
||||
<LatestRelease />
|
||||
|
|
|
|||
|
|
@ -6,18 +6,11 @@ import Image from '@/web/components/New/Image'
|
|||
import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
|
||||
import { useMemo } from 'react'
|
||||
import useArtistMV from '@/web/api/hooks/useArtistMV'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
const Album = () => {
|
||||
const params = useParams()
|
||||
const Album = ({ album }: { album?: Album }) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: albumsRaw, isLoading: isLoadingAlbums } = useArtistAlbums({
|
||||
id: Number(params.id) || 0,
|
||||
limit: 1000,
|
||||
})
|
||||
|
||||
const album = useMemo(() => albumsRaw?.hotAlbums?.[0], [albumsRaw?.hotAlbums])
|
||||
|
||||
if (!album) {
|
||||
return <></>
|
||||
}
|
||||
|
|
@ -25,7 +18,7 @@ const Album = () => {
|
|||
return (
|
||||
<div
|
||||
onClick={() => navigate(`/album/${album.id}`)}
|
||||
className='flex rounded-24 bg-white/10 p-2.5'
|
||||
className='group flex rounded-24 bg-white/10 p-2.5 transition-colors duration-400 hover:bg-white/20'
|
||||
>
|
||||
<Image
|
||||
src={resizeImage(album.picUrl, 'sm')}
|
||||
|
|
@ -39,14 +32,14 @@ const Album = () => {
|
|||
)}
|
||||
/>
|
||||
<div className='flex-shrink-1 ml-2'>
|
||||
<div className='line-clamp-1 text-16 font-medium text-night-100'>
|
||||
<div className='line-clamp-1 text-16 font-medium text-night-100 transition-colors duration-400 group-hover:text-night-50'>
|
||||
{album.name}
|
||||
</div>
|
||||
<div className='mt-1 text-14 font-bold text-night-500'>
|
||||
<div className='mt-1 text-14 font-bold text-night-500 transition-colors duration-400 group-hover:text-night-200'>
|
||||
{album.type}
|
||||
{album.size > 1 ? `· ${album.size} Tracks` : ''}
|
||||
</div>
|
||||
<div className='mt-1.5 text-12 font-medium text-night-500'>
|
||||
<div className='mt-1.5 text-12 font-medium text-night-500 transition-colors duration-400 group-hover:text-night-200'>
|
||||
{dayjs(album?.publishTime || 0).format('MMM DD, YYYY')}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -54,17 +47,14 @@ const Album = () => {
|
|||
)
|
||||
}
|
||||
|
||||
const Video = () => {
|
||||
const params = useParams()
|
||||
const { data: videos } = useArtistMV({ id: Number(params.id) || 0 })
|
||||
const video = videos?.mvs?.[0]
|
||||
const Video = ({ video }: { video?: any }) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<>
|
||||
{video && (
|
||||
<div
|
||||
className='mt-4 flex rounded-24 bg-white/10 p-2.5'
|
||||
className='group mt-4 flex rounded-24 bg-white/10 p-2.5 transition-colors duration-400 hover:bg-white/20'
|
||||
onClick={() => navigate(`/mv/${video.id}`)}
|
||||
>
|
||||
<img
|
||||
|
|
@ -78,11 +68,13 @@ const Video = () => {
|
|||
)}
|
||||
/>
|
||||
<div className='flex-shrink-1 ml-2'>
|
||||
<div className='line-clamp-1 text-16 font-medium text-night-100'>
|
||||
<div className='line-clamp-1 text-16 font-medium text-night-100 transition-colors duration-400 group-hover:text-night-50'>
|
||||
{video.name}
|
||||
</div>
|
||||
<div className='mt-1 text-14 font-bold text-night-500'>MV</div>
|
||||
<div className='mt-1.5 text-12 font-medium text-night-500'>
|
||||
<div className='mt-1 text-14 font-bold text-night-500 transition-colors duration-400 group-hover:text-night-200'>
|
||||
MV
|
||||
</div>
|
||||
<div className='mt-1.5 text-12 font-medium text-night-500 transition-colors duration-400 group-hover:text-night-200'>
|
||||
{dayjs(video.publishTime).format('MMM DD, YYYY')}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -93,15 +85,37 @@ const Video = () => {
|
|||
}
|
||||
|
||||
const LatestRelease = () => {
|
||||
const params = useParams()
|
||||
|
||||
const { data: albumsRaw, isLoading: isLoadingAlbums } = useArtistAlbums({
|
||||
id: Number(params.id) || 0,
|
||||
limit: 1000,
|
||||
})
|
||||
|
||||
const album = useMemo(() => albumsRaw?.hotAlbums?.[0], [albumsRaw?.hotAlbums])
|
||||
|
||||
const { data: videos, isLoading: isLoadingVideos } = useArtistMV({
|
||||
id: Number(params.id) || 0,
|
||||
})
|
||||
const video = videos?.mvs?.[0]
|
||||
|
||||
return (
|
||||
<div className='mx-2.5 lg:mx-0'>
|
||||
<>
|
||||
{!isLoadingVideos && !isLoadingAlbums && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className='mx-2.5 lg:mx-0'
|
||||
>
|
||||
<div className='mb-3 mt-7 text-14 font-bold text-neutral-300'>
|
||||
Latest Releases
|
||||
</div>
|
||||
|
||||
<Album />
|
||||
<Video />
|
||||
</div>
|
||||
<Album album={album} />
|
||||
<Video video={video} />
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { State as PlayerState } from '@/web/utils/player'
|
|||
import useTracks from '@/web/api/hooks/useTracks'
|
||||
import { css, cx } from '@emotion/css'
|
||||
import Image from '@/web/components/New/Image'
|
||||
import useArtist from '@/web/api/hooks/useArtist'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
const Track = ({
|
||||
track,
|
||||
|
|
@ -49,7 +51,13 @@ const Track = ({
|
|||
)
|
||||
}
|
||||
|
||||
const Popular = ({ tracks }: { tracks?: Track[] }) => {
|
||||
const Popular = () => {
|
||||
const params = useParams()
|
||||
const { data: artist, isLoading: isLoadingArtist } = useArtist({
|
||||
id: Number(params.id) || 0,
|
||||
})
|
||||
|
||||
const tracks = artist?.hotSongs || []
|
||||
const onPlay = (id: number) => {
|
||||
if (!tracks) return
|
||||
player.playAList(
|
||||
|
|
|
|||
5
packages/web/pages/New/Lyrics.tsx
Normal file
5
packages/web/pages/New/Lyrics.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const Lyrics = () => {
|
||||
return <div className='text-white'>开发中</div>
|
||||
}
|
||||
|
||||
export default Lyrics
|
||||
|
|
@ -1,13 +1,19 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import useUserArtists from '@/web/api/hooks/useUserArtists'
|
||||
import Tabs from '@/web/components/New/Tabs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import CoverRow from '@/web/components/New/CoverRow'
|
||||
import useUserPlaylists from '@/web/api/hooks/useUserPlaylists'
|
||||
import useUserAlbums from '@/web/api/hooks/useUserAlbums'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import ArtistRow from '@/web/components/New/ArtistRow'
|
||||
import { playerWidth, topbarHeight } from '@/web/utils/const'
|
||||
import topbarBackground from '@/web/assets/images/topbar-background.png'
|
||||
import useIntersectionObserver from '@/web/hooks/useIntersectionObserver'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { scrollToBottom } from '@/web/utils/common'
|
||||
import { throttle } from 'lodash-es'
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
|
|
@ -31,7 +37,9 @@ const tabs = [
|
|||
const Albums = () => {
|
||||
const { data: albums } = useUserAlbums()
|
||||
|
||||
return <CoverRow albums={albums?.data} />
|
||||
return (
|
||||
<CoverRow albums={albums?.data} itemTitle='name' itemSubtitle='artist' />
|
||||
)
|
||||
}
|
||||
|
||||
const Playlists = () => {
|
||||
|
|
@ -45,7 +53,7 @@ const Artists = () => {
|
|||
return <ArtistRow artists={artists?.data || []} />
|
||||
}
|
||||
|
||||
const Collections = () => {
|
||||
const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
|
||||
const { librarySelectedTab: selectedTab } = useSnapshot(uiStates)
|
||||
const setSelectedTab = (
|
||||
id: 'playlists' | 'albums' | 'artists' | 'videos'
|
||||
|
|
@ -54,18 +62,73 @@ const Collections = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
{/* Topbar background */}
|
||||
<AnimatePresence>
|
||||
{showBg && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className={cx(
|
||||
'pointer-events-none fixed top-0 left-10 z-10 hidden lg:block',
|
||||
css`
|
||||
height: 230px;
|
||||
right: ${playerWidth + 32}px;
|
||||
background-image: url(${topbarBackground});
|
||||
`
|
||||
)}
|
||||
></motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
value={selectedTab}
|
||||
onChange={(id: string) => setSelectedTab(id)}
|
||||
className='px-2.5 lg:px-0'
|
||||
onChange={(id: string) => {
|
||||
setSelectedTab(id)
|
||||
scrollToBottom(true)
|
||||
}}
|
||||
className={cx(
|
||||
'sticky z-10 -mb-10 px-2.5 lg:px-0',
|
||||
css`
|
||||
top: ${topbarHeight}px;
|
||||
`
|
||||
)}
|
||||
/>
|
||||
<div className='mt-6 px-2.5 lg:px-0'>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Collections = () => {
|
||||
const { librarySelectedTab: selectedTab } = useSnapshot(uiStates)
|
||||
|
||||
const observePoint = useRef<HTMLDivElement | null>(null)
|
||||
const { onScreen: isScrollReachBottom } =
|
||||
useIntersectionObserver(observePoint)
|
||||
|
||||
const onScroll = throttle(() => {
|
||||
if (isScrollReachBottom) return
|
||||
scrollToBottom(true)
|
||||
}, 500)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CollectionTabs showBg={isScrollReachBottom} />
|
||||
<div
|
||||
className={cx(
|
||||
'no-scrollbar overflow-y-auto px-2.5 pt-16 pb-16 lg:px-0',
|
||||
css`
|
||||
height: calc(100vh - ${topbarHeight}px);
|
||||
`
|
||||
)}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{selectedTab === 'albums' && <Albums />}
|
||||
{selectedTab === 'playlists' && <Playlists />}
|
||||
{selectedTab === 'artists' && <Artists />}
|
||||
</div>
|
||||
<div ref={observePoint}></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
56
packages/web/states/contextMenus.ts
Normal file
56
packages/web/states/contextMenus.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { assign } from 'lodash-es'
|
||||
import { proxy, ref } from 'valtio'
|
||||
|
||||
interface ContextMenu {
|
||||
target: HTMLElement | null
|
||||
cursorPosition: {
|
||||
x: number
|
||||
y: number
|
||||
} | null
|
||||
type: 'album' | 'track' | 'playlist' | 'artist' | null
|
||||
dataSourceID: string | number | null
|
||||
options: {
|
||||
useCursorPosition?: boolean
|
||||
} | null
|
||||
}
|
||||
|
||||
const initContextMenu: ContextMenu = {
|
||||
target: null,
|
||||
cursorPosition: null,
|
||||
type: null,
|
||||
dataSourceID: null,
|
||||
options: null,
|
||||
}
|
||||
|
||||
const contextMenus = proxy<ContextMenu>(initContextMenu)
|
||||
export default contextMenus
|
||||
|
||||
export const openContextMenu = ({
|
||||
event,
|
||||
type,
|
||||
dataSourceID,
|
||||
options = null,
|
||||
}: {
|
||||
event: React.MouseEvent<HTMLElement, MouseEvent>
|
||||
type: ContextMenu['type']
|
||||
dataSourceID: ContextMenu['dataSourceID']
|
||||
options?: ContextMenu['options']
|
||||
}) => {
|
||||
const target = event.target as HTMLElement
|
||||
contextMenus.target = ref(target)
|
||||
|
||||
contextMenus.type = type
|
||||
contextMenus.dataSourceID = dataSourceID
|
||||
contextMenus.options = options
|
||||
contextMenus.cursorPosition = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
}
|
||||
}
|
||||
|
||||
export const closeContextMenu = (event: MouseEvent) => {
|
||||
if (event.target === contextMenus.target) {
|
||||
return
|
||||
}
|
||||
assign(contextMenus, initContextMenu)
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
@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');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
|
|
|
|||
|
|
@ -93,6 +93,11 @@ module.exports = {
|
|||
transitionDuration: {
|
||||
400: '400ms',
|
||||
},
|
||||
opacity: {
|
||||
5: '0.05',
|
||||
15: '.15',
|
||||
25: '.25',
|
||||
},
|
||||
},
|
||||
},
|
||||
variants: {},
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@
|
|||
"baseUrl": "../",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"types": ["vite-plugin-svg-icons/client"]
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx", "../shared/**/*.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,9 +110,15 @@ export function formatDuration(
|
|||
}
|
||||
|
||||
export function scrollToTop(smooth = false) {
|
||||
const main = document.getElementById('mainContainer')
|
||||
if (!main) return
|
||||
main.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' })
|
||||
document
|
||||
.querySelector('#main')
|
||||
?.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' })
|
||||
}
|
||||
|
||||
export function scrollToBottom(smooth = false) {
|
||||
document
|
||||
.querySelector('#main')
|
||||
?.scrollTo({ top: 100000, behavior: smooth ? 'smooth' : 'auto' })
|
||||
}
|
||||
|
||||
export async function getCoverColor(coverUrl: string) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { defineConfig } from 'vite'
|
|||
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import filenamesToType from './vitePluginFilenamesToType'
|
||||
|
||||
dotenv.config({ path: path.resolve(process.cwd(), '../../.env') })
|
||||
const IS_ELECTRON = process.env.IS_ELECTRON
|
||||
|
|
@ -25,6 +26,12 @@ export default defineConfig({
|
|||
},
|
||||
plugins: [
|
||||
react(),
|
||||
filenamesToType([
|
||||
{
|
||||
dictionary: './assets/icons',
|
||||
typeFile: './components/Icon/iconNamesType.ts',
|
||||
},
|
||||
]),
|
||||
|
||||
/**
|
||||
* @see https://vite-plugin-pwa.netlify.app/guide/generate.html
|
||||
|
|
@ -85,8 +92,8 @@ export default defineConfig({
|
|||
strictPort: IS_ELECTRON ? true : false,
|
||||
proxy: {
|
||||
'/netease/': {
|
||||
target: `http://192.168.50.111:${
|
||||
// target: `http://127.0.0.1:${
|
||||
// target: `http://192.168.50.111:${
|
||||
target: `http://127.0.0.1:${
|
||||
process.env.ELECTRON_DEV_NETEASE_API_PORT || 3000
|
||||
}`,
|
||||
changeOrigin: true,
|
||||
|
|
|
|||
49
packages/web/vitePluginFilenamesToType.ts
Normal file
49
packages/web/vitePluginFilenamesToType.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { promises as fs } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { Plugin } from 'vite'
|
||||
|
||||
export type Conversion = {
|
||||
dictionary: string
|
||||
typeFile: string
|
||||
}
|
||||
|
||||
const filenamesToType = (conversions: Conversion[]): Plugin => {
|
||||
const generateTypes = async (conversion: Conversion) => {
|
||||
const filenames = await fs.readdir(conversion.dictionary).catch(reason => {
|
||||
console.error(
|
||||
'vite-plugin-filenames-to-type: unable to read directory. ',
|
||||
reason
|
||||
)
|
||||
return []
|
||||
})
|
||||
if (!filenames.length) return
|
||||
|
||||
const iconNames = filenames.map(
|
||||
fileName => `'${fileName.replace(/\.[^.]+$/, '')}'`
|
||||
)
|
||||
await fs.writeFile(
|
||||
conversion.typeFile,
|
||||
`export type IconNames = ${iconNames.join(' | ')}`
|
||||
)
|
||||
}
|
||||
|
||||
const findConversion = (filePath: string) => {
|
||||
return conversions.find(conversion => {
|
||||
const path = resolve(__dirname, conversion.dictionary)
|
||||
return filePath.startsWith(path)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-filenames-to-type',
|
||||
buildStart: async () => {
|
||||
conversions.forEach(conversion => generateTypes(conversion))
|
||||
},
|
||||
handleHotUpdate: context => {
|
||||
const conversion = findConversion(context.file)
|
||||
if (conversion) generateTypes(conversion)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default filenamesToType
|
||||
8296
pnpm-lock.yaml
generated
8296
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -17,6 +17,11 @@
|
|||
"outputs": ["release/**"],
|
||||
"cache": false
|
||||
},
|
||||
"pack:test": {
|
||||
"dependsOn": ["build"],
|
||||
"outputs": ["release/**"],
|
||||
"cache": false
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": ["build"],
|
||||
"outputs": []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue