diff --git a/.vscode/i18n-ally-custom-framework.yml b/.vscode/i18n-ally-custom-framework.yml new file mode 100644 index 0000000..810c85e --- /dev/null +++ b/.vscode/i18n-ally-custom-framework.yml @@ -0,0 +1,25 @@ +# .vscode/i18n-ally-custom-framework.yml + +# An array of strings which contain Language Ids defined by VS Code +# You can check avaliable language ids here: https://code.visualstudio.com/docs/languages/overview#_language-id +languageIds: + - json + +# An array of RegExes to find the key usage. **The key should be captured in the first match group**. +# You should unescape RegEx strings in order to fit in the YAML file +# To help with this, you can use https://www.freeformatter.com/json-escape.html +usageMatchRegex: + # The following example shows how to detect `t("your.i18n.keys")` + # the `{key}` will be placed by a proper keypath matching regex, + # you can ignore it and use your own matching rules as well + - 't`({key})`' + +# An array of strings containing refactor templates. +# The "$1" will be replaced by the keypath specified. +# Optional: uncomment the following two lines to use + +# refactorTemplates: +# - i18n.get("$1") + +# If set to true, only enables this custom framework (will disable all built-in frameworks) +monopoly: false diff --git a/package.json b/package.json index 19bb18a..b1fdda7 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,8 @@ "install": "turbo run post-install --parallel --no-cache", "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", + "pack": "turbo run build && turbo run pack", + "pack:test": "turbo run build && 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}\"", @@ -28,7 +28,7 @@ "cross-env": "^7.0.3", "eslint": "^8.21.0", "prettier": "^2.7.1", - "turbo": "^1.4.2", + "turbo": "^1.6.1", "typescript": "^4.7.4" } } diff --git a/packages/desktop/.electron-builder.config.js b/packages/desktop/.electron-builder.config.js index d563d00..34ec180 100644 --- a/packages/desktop/.electron-builder.config.js +++ b/packages/desktop/.electron-builder.config.js @@ -16,8 +16,10 @@ module.exports = { buildResources: 'build', }, npmRebuild: false, - buildDependenciesFromSource: true, + buildDependenciesFromSource: false, electronVersion, + afterPack: './scripts/copySQLite3.js', + forceCodeSigning: false, publish: [ { provider: 'github', @@ -34,10 +36,6 @@ module.exports = { arch: ['x64'], }, // { - // target: 'nsis', - // arch: ['arm64'], - // }, - // { // target: 'portable', // arch: ['x64'], // }, @@ -59,16 +57,13 @@ module.exports = { target: [ { target: 'dmg', - arch: [ - 'x64', - 'arm64', - // 'universal' - ], + arch: ['x64', 'arm64', 'universal'], }, ], artifactName: '${productName}-${version}-${os}-${arch}.${ext}', darkModeSupport: true, category: 'public.app-category.music', + identity: null, }, dmg: { icon: 'build/icons/icon.icns', @@ -124,7 +119,6 @@ module.exports = { '!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json,pnpm-lock.yaml}', '!**/*.{map,debug.min.js}', - '!**/dist/binary', { from: './dist', to: './main', diff --git a/packages/desktop/main/appleMusic.ts b/packages/desktop/main/appleMusic.ts index a388a78..d38c995 100644 --- a/packages/desktop/main/appleMusic.ts +++ b/packages/desktop/main/appleMusic.ts @@ -6,7 +6,7 @@ const headers = { Authority: 'amp-api.music.apple.com', Accept: '*/*', Authorization: - 'Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IldlYlBsYXlLaWQifQ.eyJpc3MiOiJBTVBXZWJQbGF5IiwiaWF0IjoxNjQ2NjU1MDgwLCJleHAiOjE2NjIyMDcwODB9.pyOrt2FmP0cHkzYtO8KiEzQL2t1qpRszzxIYbLH7faXSddo6PQei771Ja3aGwGVU4hD99lZAw7bwat60iBcGiQ', + 'Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IldlYlBsYXlLaWQifQ.eyJpc3MiOiJBTVBXZWJQbGF5IiwiaWF0IjoxNjYxNDQwNDMyLCJleHAiOjE2NzY5OTI0MzIsInJvb3RfaHR0cHNfb3JpZ2luIjpbImFwcGxlLmNvbSJdfQ.z4BMv9_O4MpMK2iFhYkDqPsx53soPSnlXXK3jm99pHqGOrZADvTgEUw2U7_B1W0MAtFiWBYhYcGvWrzaOig6Bw', Referer: 'https://music.apple.com/', 'Sec-Fetch-Dest': 'empty', 'Sec-Fetch-Mode': 'cors', @@ -14,6 +14,7 @@ const headers = { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Cider/1.5.1 Chrome/100.0.4896.160 Electron/18.3.3 Safari/537.36', 'Accept-Encoding': 'gzip', + Origin: 'https://music.apple.com', } export const getAlbum = async ({ @@ -43,6 +44,7 @@ export const getAlbum = async ({ const albums: AppleMusicAlbum[] | undefined = searchResult?.data?.results?.albums?.data + const album = albums?.find( a => @@ -72,12 +74,13 @@ export const getArtist = async ( platform: 'web', limit: '1', l: 'en-us', // TODO: get from settings + with: 'serverBubbles', }, }).catch(e => { log.debug('[appleMusic] Search artist error', e) }) - const artist = searchResult?.data?.results?.artists?.data?.[0] + const artist = searchResult?.data?.results?.artist?.data?.[0] if ( artist && artist?.attributes?.name?.toLowerCase() === name.toLowerCase() diff --git a/packages/desktop/main/cache.ts b/packages/desktop/main/cache.ts index c1d842c..cb159d5 100644 --- a/packages/desktop/main/cache.ts +++ b/packages/desktop/main/cache.ts @@ -5,7 +5,7 @@ import { Request, Response } from 'express' import log from './log' import fs from 'fs' import * as musicMetadata from 'music-metadata' -import { APIs, APIsParams, APIsResponse } from '@/shared/CacheAPIs' +import { APIs, APIsParams } from '@/shared/CacheAPIs' import { TablesStructures } from './db' class Cache { diff --git a/packages/desktop/main/cacheWithSQLite.ts b/packages/desktop/main/cacheWithSQLite.ts new file mode 100644 index 0000000..c1d842c --- /dev/null +++ b/packages/desktop/main/cacheWithSQLite.ts @@ -0,0 +1,342 @@ +import { db, Tables } from './db' +import type { FetchTracksResponse } from '@/shared/api/Track' +import { app } from 'electron' +import { Request, Response } from 'express' +import log from './log' +import fs from 'fs' +import * as musicMetadata from 'music-metadata' +import { APIs, APIsParams, APIsResponse } from '@/shared/CacheAPIs' +import { TablesStructures } from './db' + +class Cache { + constructor() { + // + } + + async set(api: string, data: any, query: any = {}) { + switch (api) { + case APIs.UserPlaylist: + case APIs.UserAccount: + case APIs.Personalized: + case APIs.RecommendResource: + case APIs.UserAlbums: + case APIs.UserArtists: + case APIs.ListenedRecords: + case APIs.Likelist: { + if (!data) return + db.upsert(Tables.AccountData, { + id: api, + json: JSON.stringify(data), + updatedAt: Date.now(), + }) + break + } + case APIs.Track: { + const res = data as FetchTracksResponse + if (!res.songs) return + const tracks = res.songs.map(t => ({ + id: t.id, + json: JSON.stringify(t), + updatedAt: Date.now(), + })) + db.upsertMany(Tables.Track, tracks) + break + } + case APIs.Album: { + if (!data.album) return + data.album.songs = data.songs + db.upsert(Tables.Album, { + id: data.album.id, + json: JSON.stringify(data.album), + updatedAt: Date.now(), + }) + break + } + case APIs.Playlist: { + if (!data.playlist) return + db.upsert(Tables.Playlist, { + id: data.playlist.id, + json: JSON.stringify(data), + updatedAt: Date.now(), + }) + break + } + case APIs.Artist: { + if (!data.artist) return + db.upsert(Tables.Artist, { + id: data.artist.id, + json: JSON.stringify(data), + updatedAt: Date.now(), + }) + break + } + case APIs.ArtistAlbum: { + if (!data.hotAlbums) return + db.createMany( + Tables.Album, + data.hotAlbums.map((a: Album) => ({ + id: a.id, + json: JSON.stringify(a), + updatedAt: Date.now(), + })) + ) + const modifiedData = { + ...data, + hotAlbums: data.hotAlbums.map((a: Album) => a.id), + } + db.upsert(Tables.ArtistAlbum, { + id: data.artist.id, + json: JSON.stringify(modifiedData), + updatedAt: Date.now(), + }) + break + } + case APIs.Lyric: { + if (!data.lrc) return + db.upsert(Tables.Lyric, { + id: query.id, + json: JSON.stringify(data), + updatedAt: Date.now(), + }) + break + } + case APIs.CoverColor: { + if (!data.id || !data.color) return + if (/^#([a-fA-F0-9]){3}$|[a-fA-F0-9]{6}$/.test(data.color) === false) { + return + } + db.upsert(Tables.CoverColor, { + id: data.id, + color: data.color, + queriedAt: Date.now(), + }) + break + } + case APIs.AppleMusicAlbum: { + if (!data.id) return + db.upsert(Tables.AppleMusicAlbum, { + id: data.id, + json: data.album ? JSON.stringify(data.album) : 'no', + updatedAt: Date.now(), + }) + break + } + case APIs.AppleMusicArtist: { + if (!data) return + db.upsert(Tables.AppleMusicArtist, { + id: data.id, + json: data.artist ? JSON.stringify(data.artist) : 'no', + updatedAt: Date.now(), + }) + break + } + } + } + + get(api: T, params: any): any { + switch (api) { + case APIs.UserPlaylist: + case APIs.UserAccount: + case APIs.Personalized: + case APIs.RecommendResource: + case APIs.UserArtists: + case APIs.ListenedRecords: + case APIs.Likelist: { + const data = db.find(Tables.AccountData, api) + if (data?.json) return JSON.parse(data.json) + break + } + case APIs.Track: { + const ids: number[] = params?.ids + .split(',') + .map((id: string) => Number(id)) + if (ids.length === 0) return + + if (ids.includes(NaN)) return + + const tracksRaw = db.findMany(Tables.Track, ids) + + if (tracksRaw.length !== ids.length) { + return + } + const tracks = ids.map(id => { + const track = tracksRaw.find(t => t.id === Number(id)) as any + return JSON.parse(track.json) + }) + return { + code: 200, + songs: tracks, + privileges: {}, + } + } + case APIs.Album: { + if (isNaN(Number(params?.id))) return + const data = db.find(Tables.Album, params.id) + if (data?.json) + return { + resourceState: true, + songs: [], + code: 200, + album: JSON.parse(data.json), + } + break + } + case APIs.Playlist: { + if (isNaN(Number(params?.id))) return + const data = db.find(Tables.Playlist, params.id) + if (data?.json) return JSON.parse(data.json) + break + } + case APIs.Artist: { + if (isNaN(Number(params?.id))) return + const data = db.find(Tables.Artist, params.id) + const fromAppleData = db.find(Tables.AppleMusicArtist, params.id) + const fromApple = fromAppleData?.json && JSON.parse(fromAppleData.json) + const fromNetease = data?.json && JSON.parse(data.json) + if (fromNetease && fromApple && fromApple !== 'no') { + fromNetease.artist.img1v1Url = fromApple.attributes.artwork.url + fromNetease.artist.briefDesc = fromApple.attributes.artistBio + } + return fromNetease ? fromNetease : undefined + } + case APIs.ArtistAlbum: { + if (isNaN(Number(params?.id))) return + + const artistAlbumsRaw = db.find(Tables.ArtistAlbum, params.id) + if (!artistAlbumsRaw?.json) return + const artistAlbums = JSON.parse(artistAlbumsRaw.json) + + const albumsRaw = db.findMany(Tables.Album, artistAlbums.hotAlbums) + if (albumsRaw.length !== artistAlbums.hotAlbums.length) return + const albums = albumsRaw.map(a => JSON.parse(a.json)) + + artistAlbums.hotAlbums = artistAlbums.hotAlbums.map((id: number) => + albums.find(a => a.id === id) + ) + return artistAlbums + } + case APIs.Lyric: { + if (isNaN(Number(params?.id))) return + const data = db.find(Tables.Lyric, params.id) + if (data?.json) return JSON.parse(data.json) + break + } + case APIs.CoverColor: { + if (isNaN(Number(params?.id))) return + return db.find(Tables.CoverColor, params.id)?.color + } + case APIs.Artists: { + if (!params.ids?.length) return + const artists = db.findMany(Tables.Artist, params.ids) + if (artists.length !== params.ids.length) return + const result = artists.map(a => JSON.parse(a.json)) + result.sort((a, b) => { + const indexA: number = params.ids.indexOf(a.artist.id) + const indexB: number = params.ids.indexOf(b.artist.id) + return indexA - indexB + }) + return result + } + case APIs.AppleMusicAlbum: { + if (isNaN(Number(params?.id))) return + const data = db.find(Tables.AppleMusicAlbum, params.id) + if (data?.json && data.json !== 'no') return JSON.parse(data.json) + break + } + case APIs.AppleMusicArtist: { + if (isNaN(Number(params?.id))) return + const data = db.find(Tables.AppleMusicArtist, params.id) + if (data?.json && data.json !== 'no') return JSON.parse(data.json) + break + } + } + } + + getForExpress(api: string, req: Request) { + // Get track detail cache + if (api === APIs.Track) { + const cache = this.get(api, req.query) + if (cache) { + log.debug(`[cache] Cache hit for ${req.path}`) + return cache + } + } + } + + getAudio(fileName: string, res: Response) { + if (!fileName) { + return res.status(400).send({ error: 'No filename provided' }) + } + const id = Number(fileName.split('-')[0]) + + try { + const path = `${app.getPath('userData')}/audio_cache/${fileName}` + const audio = fs.readFileSync(path) + if (audio.byteLength === 0) { + db.delete(Tables.Audio, id) + fs.unlinkSync(path) + return res.status(404).send({ error: 'Audio not found' }) + } + res + .status(206) + .setHeader('Accept-Ranges', 'bytes') + .setHeader('Connection', 'keep-alive') + .setHeader( + 'Content-Range', + `bytes 0-${audio.byteLength - 1}/${audio.byteLength}` + ) + .send(audio) + } catch (error) { + res.status(500).send({ error }) + } + } + + async setAudio(buffer: Buffer, { id, url }: { id: number; url: string }) { + const path = `${app.getPath('userData')}/audio_cache` + + try { + fs.statSync(path) + } catch (e) { + fs.mkdirSync(path) + } + + const meta = await musicMetadata.parseBuffer(buffer) + const br = + meta?.format?.codec === 'OPUS' ? 165000 : meta.format.bitrate ?? 0 + const type = + { + 'MPEG 1 Layer 3': 'mp3', + 'Ogg Vorbis': 'ogg', + AAC: 'm4a', + FLAC: 'flac', + OPUS: 'opus', + }[meta.format.codec ?? ''] ?? 'unknown' + + let source: TablesStructures[Tables.Audio]['source'] = 'unknown' + if (url.includes('googlevideo.com')) source = 'youtube' + if (url.includes('126.net')) source = 'netease' + if (url.includes('migu.cn')) source = 'migu' + if (url.includes('kuwo.cn')) source = 'kuwo' + if (url.includes('bilivideo.com')) source = 'bilibili' + // TODO: missing kugou qq joox + + fs.writeFile(`${path}/${id}-${br}.${type}`, buffer, error => { + if (error) { + return log.error(`[cache] cacheAudio failed: ${error}`) + } + log.info(`Audio file ${id}-${br}.${type} cached!`) + + db.upsert(Tables.Audio, { + id, + br, + type: type as TablesStructures[Tables.Audio]['type'], + source, + queriedAt: Date.now(), + }) + + log.info(`[cache] cacheAudio ${id}-${br}.${type}`) + }) + } +} + +export default new Cache() diff --git a/packages/desktop/main/cacheWithSurreal.ts b/packages/desktop/main/cacheWithSurreal.ts new file mode 100644 index 0000000..2d42625 --- /dev/null +++ b/packages/desktop/main/cacheWithSurreal.ts @@ -0,0 +1,344 @@ +import db from './surrealdb' +import type { FetchTracksResponse } from '@/shared/api/Track' +import { app } from 'electron' +import { Request, Response } from 'express' +import log from './log' +import fs from 'fs' +import * as musicMetadata from 'music-metadata' +import { APIs, APIsParams } from '@/shared/CacheAPIs' +// import { TablesStructures } from './db' + +class Cache { + constructor() { + // + } + + async set(api: string, data: any, query: any = {}) { + switch (api) { + case APIs.UserPlaylist: + case APIs.UserAccount: + case APIs.Personalized: + case APIs.RecommendResource: + case APIs.UserAlbums: + case APIs.UserArtists: + case APIs.ListenedRecords: + case APIs.Likelist: { + if (!data) return + db.upsert('netease', 'accountData', api, { + json: JSON.stringify(data), + }) + break + } + case APIs.Track: { + const res = data as FetchTracksResponse + if (!res.songs) return + const tracks = res.songs.map(t => ({ + key: t.id, + data: { + json: JSON.stringify(t), + }, + })) + db.upsertMany('netease', 'track', tracks) + break + } + case APIs.Album: { + if (!data.album) return + data.album.songs = data.song + db.upsert('netease', 'album', data.album.id, data.album) + break + } + case APIs.Playlist: { + if (!data.playlist) return + db.upsert('netease', 'playlist', data.playlist.id, { + json: JSON.stringify(data), + updatedAt: Date.now(), + }) + break + } + case APIs.Artist: { + if (!data.artist) return + db.upsert('netease', 'artist', data.artist.id, { + json: JSON.stringify(data), + updatedAt: Date.now(), + }) + break + } + case APIs.ArtistAlbum: { + if (!data.hotAlbums) return + db.upsertMany( + 'netease', + 'album', + data.hotAlbums.map((a: Album) => ({ + key: a.id, + data: { + json: JSON.stringify(a), + updatedAt: Date.now(), + }, + })) + ) + const modifiedData = { + ...data, + hotAlbums: data.hotAlbums.map((a: Album) => a.id), + } + db.upsert('netease', 'artistAlbums', data.artist.id, { + json: JSON.stringify(modifiedData), + updatedAt: Date.now(), + }) + break + } + case APIs.Lyric: { + if (!data.lrc) return + db.upsert('netease', 'lyric', query.id, { + json: JSON.stringify(data), + updatedAt: Date.now(), + }) + break + } + // case APIs.CoverColor: { + // if (!data.id || !data.color) return + // if (/^#([a-fA-F0-9]){3}$|[a-fA-F0-9]{6}$/.test(data.color) === false) { + // return + // } + // db.upsert(Tables.CoverColor, { + // id: data.id, + // color: data.color, + // queriedAt: Date.now(), + // }) + // break + // } + case APIs.AppleMusicAlbum: { + if (!data.id) return + db.upsert('appleMusic', 'album', data.id, { + json: data.album ? JSON.stringify(data.album) : 'no', + // updatedAt: Date.now(), + }) + break + } + case APIs.AppleMusicArtist: { + if (!data) return + db.upsert('appleMusic', 'artist', data.id, { + json: data.artist ? JSON.stringify(data.artist) : 'no', + // updatedAt: Date.now(), + }) + break + } + } + } + + async get(api: T, params: any): Promise { + switch (api) { + case APIs.UserPlaylist: + case APIs.UserAccount: + case APIs.Personalized: + case APIs.RecommendResource: + case APIs.UserArtists: + case APIs.ListenedRecords: + case APIs.Likelist: { + const data = await db.find('netease', 'accountData', api) + if (!data?.[0]?.json) return + return JSON.parse(data[0].json) + } + case APIs.Track: { + const ids: number[] = params?.ids + .split(',') + .map((id: string) => Number(id)) + if (ids.length === 0) return + + if (ids.includes(NaN)) return + + const tracksRaw = await db.findMany('netease', 'track', ids) + + if (!tracksRaw || tracksRaw.length !== ids.length) { + return + } + const tracks = ids.map(id => { + const track = tracksRaw.find(t => t.id === Number(id)) as any + return JSON.parse(track.json) + }) + return { + code: 200, + songs: tracks, + privileges: {}, + } + } + case APIs.Album: { + if (isNaN(Number(params?.id))) return + const data = await db.find('netease', 'album', params.id) + const json = data?.[0]?.json + if (!json) return + return { + resourceState: true, + songs: [], + code: 200, + album: JSON.parse(json), + } + } + case APIs.Playlist: { + if (isNaN(Number(params?.id))) return + const data = await db.find('netease', 'playlist', params.id) + if (!data?.[0]?.json) return + return JSON.parse(data[0].json) + } + case APIs.Artist: { + if (isNaN(Number(params?.id))) return + const data = await db.find('netease', 'artist', params.id) + const fromAppleData = await db.find('appleMusic', 'artist', params.id) + const fromApple = fromAppleData?.json && JSON.parse(fromAppleData.json) + const fromNetease = data?.json && JSON.parse(data.json) + if (fromNetease && fromApple && fromApple !== 'no') { + fromNetease.artist.img1v1Url = fromApple.attributes.artwork.url + fromNetease.artist.briefDesc = fromApple.attributes.artistBio + } + return fromNetease || undefined + } + case APIs.ArtistAlbum: { + if (isNaN(Number(params?.id))) return + + const artistAlbumsRaw = await db.find( + 'netease', + 'artistAlbums', + params.id + ) + if (!artistAlbumsRaw?.json) return + const artistAlbums = JSON.parse(artistAlbumsRaw.json) + + const albumsRaw = await db.findMany( + 'netease', + 'album', + artistAlbums.hotAlbums + ) + if (albumsRaw?.length !== artistAlbums.hotAlbums.length) return + const albums = albumsRaw.map(a => JSON.parse(a.json)) + + artistAlbums.hotAlbums = artistAlbums.hotAlbums.map((id: number) => + albums.find(a => a.id === id) + ) + return artistAlbums + } + case APIs.Lyric: { + if (isNaN(Number(params?.id))) return + const data = await db.find('netease', 'lyric', params.id) + if (data?.json) return JSON.parse(data.json) + return + } + // case APIs.CoverColor: { + // if (isNaN(Number(params?.id))) return + // return await db.find(Tables.CoverColor, params.id)?.color + // } + case APIs.Artists: { + if (!params.ids?.length) return + const artists = await db.findMany('netease', 'artist', params.ids) + if (artists?.length !== params.ids.length) return + const result = artists?.map(a => JSON.parse(a.json)) + result?.sort((a, b) => { + const indexA: number = params.ids.indexOf(a.artist.id) + const indexB: number = params.ids.indexOf(b.artist.id) + return indexA - indexB + }) + return result + } + case APIs.AppleMusicAlbum: { + if (isNaN(Number(params?.id))) return + const data = await db.find('appleMusic', 'album', params.id) + if (data?.json && data.json !== 'no') return JSON.parse(data.json) + return + } + case APIs.AppleMusicArtist: { + if (isNaN(Number(params?.id))) return + const data = await db.find('appleMusic', 'artist', params.id) + if (data?.json && data.json !== 'no') return JSON.parse(data.json) + return + } + } + } + + getForExpress(api: string, req: Request) { + // Get track detail cache + if (api === APIs.Track) { + const cache = this.get(api, req.query) + if (cache) { + log.debug(`[cache] Cache hit for ${req.path}`) + return cache + } + } + } + + getAudio(fileName: string, res: Response) { + if (!fileName) { + return res.status(400).send({ error: 'No filename provided' }) + } + const id = Number(fileName.split('-')[0]) + + try { + const path = `${app.getPath('userData')}/audio_cache/${fileName}` + const audio = fs.readFileSync(path) + // if audio file is empty, delete it + if (audio.byteLength === 0) { + db.delete('netease', 'audio', id) + fs.unlinkSync(path) + return res.status(404).send({ error: 'Audio not found' }) + } + res + .status(206) + .setHeader('Accept-Ranges', 'bytes') + .setHeader('Connection', 'keep-alive') + .setHeader( + 'Content-Range', + `bytes 0-${audio.byteLength - 1}/${audio.byteLength}` + ) + .send(audio) + } catch (error) { + res.status(500).send({ error }) + } + } + + async setAudio(buffer: Buffer, { id, url }: { id: number; url: string }) { + const path = `${app.getPath('userData')}/audio_cache` + + try { + fs.statSync(path) + } catch (e) { + fs.mkdirSync(path) + } + + const meta = await musicMetadata.parseBuffer(buffer) + const br = + meta?.format?.codec === 'OPUS' ? 165000 : meta.format.bitrate ?? 0 + const type = + { + 'MPEG 1 Layer 3': 'mp3', + 'Ogg Vorbis': 'ogg', + AAC: 'm4a', + FLAC: 'flac', + OPUS: 'opus', + }[meta.format.codec ?? ''] ?? 'unknown' + + // let source: TablesStructures[Tables.Audio]['source'] = 'unknown' + let source = 'unknown' + if (url.includes('googlevideo.com')) source = 'youtube' + if (url.includes('126.net')) source = 'netease' + if (url.includes('migu.cn')) source = 'migu' + if (url.includes('kuwo.cn')) source = 'kuwo' + if (url.includes('bilivideo.com')) source = 'bilibili' + // TODO: missing kugou qq joox + + fs.writeFile(`${path}/${id}-${br}.${type}`, buffer, error => { + if (error) { + return log.error(`[cache] cacheAudio failed: ${error}`) + } + log.info(`Audio file ${id}-${br}.${type} cached!`) + + db.upsert('netease', 'audio', id, { + id, + br, + type, + source, + queriedAt: Date.now(), + }) + + log.info(`[cache] cacheAudio ${id}-${br}.${type}`) + }) + } +} + +export default new Cache() diff --git a/packages/desktop/main/db.ts b/packages/desktop/main/db.ts index f65f0ba..eb90398 100644 --- a/packages/desktop/main/db.ts +++ b/packages/desktop/main/db.ts @@ -3,9 +3,10 @@ import { app } from 'electron' import fs from 'fs' import SQLite3 from 'better-sqlite3' import log from './log' -import { createFileIfNotExist, dirname } from './utils' +import { createFileIfNotExist, dirname, isProd } from './utils' import pkg from '../../../package.json' import { compare, validate } from 'compare-versions' +import os from 'os' export const enum Tables { Track = 'Track', @@ -83,19 +84,43 @@ class DB { constructor() { log.info('[db] Initializing database...') - createFileIfNotExist(this.dbFilePath) + try { + createFileIfNotExist(this.dbFilePath) - this.sqlite = new SQLite3(this.dbFilePath, { - nativeBinding: path.join( - __dirname, - `./binary/better_sqlite3_${process.arch}.node` + this.sqlite = new SQLite3(this.dbFilePath, { + nativeBinding: this.getBinPath(), + }) + this.sqlite.pragma('auto_vacuum = FULL') + this.initTables() + this.migrate() + + log.info('[db] Database initialized.') + } catch (e) { + log.error('[db] Database initialization failed.') + log.error(e) + } + } + + private getBinPath() { + console + const devBinPath = path.resolve( + app.getPath('userData'), + `../../bin/better_sqlite3_${os.platform}_${os.arch}.node` + ) + const prodBinPaths = { + darwin: path.resolve( + app.getPath('exe'), + `../../Resources/bin/better_sqlite3.node` ), - }) - this.sqlite.pragma('auto_vacuum = FULL') - this.initTables() - this.migrate() - - log.info('[db] Database initialized.') + win32: path.resolve( + app.getPath('exe'), + `../resources/bin/better_sqlite3.node` + ), + linux: '', + } + return isProd + ? prodBinPaths[os.platform as unknown as 'darwin' | 'win32' | 'linux'] + : devBinPath } initTables() { diff --git a/packages/desktop/main/index.ts b/packages/desktop/main/index.ts index 4e5b72d..5b8b4a8 100644 --- a/packages/desktop/main/index.ts +++ b/packages/desktop/main/index.ts @@ -17,6 +17,7 @@ import { createTaskbar, Thumbar } from './windowsTaskbar' import { createMenu } from './menu' import { isDev, isWindows, isLinux, isMac } from './utils' import store from './store' +// import './surrealdb' // import Airplay from './airplay' class Main { @@ -91,7 +92,7 @@ class Main { titleBarStyle: isMac ? 'customButtonsOnHover' : 'hidden', trafficLightPosition: { x: 24, y: 24 }, frame: false, - backgroundColor: '#000', + transparent: true, show: false, } if (store.get('window')) { diff --git a/packages/desktop/main/ipcMain.ts b/packages/desktop/main/ipcMain.ts index 355b0fa..b5795cf 100644 --- a/packages/desktop/main/ipcMain.ts +++ b/packages/desktop/main/ipcMain.ts @@ -1,5 +1,5 @@ import { BrowserWindow, ipcMain, app } from 'electron' -import { db, Tables } from './db' +// import { db, Tables } from './db' import { IpcChannels, IpcChannelsParams } from '@/shared/IpcChannels' import cache from './cache' import log from './log' @@ -67,9 +67,9 @@ function initWindowIpcMain(win: BrowserWindow | null) { win?.setSize(1440, 1024, true) }) - on(IpcChannels.IsMaximized, e => { + handle(IpcChannels.IsMaximized, () => { if (!win) return - e.returnValue = win.isMaximized() + return win.isMaximized() }) } @@ -118,23 +118,36 @@ function initOtherIpcMain() { * 清除API缓存 */ on(IpcChannels.ClearAPICache, () => { - db.truncate(Tables.Track) - db.truncate(Tables.Album) - db.truncate(Tables.Artist) - db.truncate(Tables.Playlist) - db.truncate(Tables.ArtistAlbum) - db.truncate(Tables.AccountData) - db.truncate(Tables.Audio) - db.vacuum() + // db.truncate(Tables.Track) + // db.truncate(Tables.Album) + // db.truncate(Tables.Artist) + // db.truncate(Tables.Playlist) + // db.truncate(Tables.ArtistAlbum) + // db.truncate(Tables.AccountData) + // db.truncate(Tables.Audio) + // db.vacuum() }) /** * Get API cache */ - on(IpcChannels.GetApiCacheSync, (event, args) => { + // on(IpcChannels.GetApiCache, (event, args) => { + // const { api, query } = args + // const data = cache.get(api, query) + // event.returnValue = data + // }) + + handle(IpcChannels.GetApiCache, async (event, args) => { const { api, query } = args - const data = cache.get(api, query) - event.returnValue = data + if (api !== 'user/account') { + return null + } + try { + const data = await cache.get(api, query) + return data + } catch { + return null + } }) /** @@ -193,35 +206,42 @@ function initOtherIpcMain() { event.returnValue = artist === 'no' ? undefined : artist }) + /** + * 退出登陆 + */ + handle(IpcChannels.Logout, async () => { + // db.truncate(Tables.AccountData) + return true + }) + /** * 导出tables到json文件,方便查看table大小(dev环境) */ if (process.env.NODE_ENV === 'development') { - on(IpcChannels.DevDbExportJson, () => { - const tables = [ - Tables.ArtistAlbum, - Tables.Playlist, - Tables.Album, - Tables.Track, - Tables.Artist, - Tables.Audio, - Tables.AccountData, - Tables.Lyric, - ] - tables.forEach(table => { - const data = db.findAll(table) - - fs.writeFile( - `./tmp/${table}.json`, - JSON.stringify(data), - function (err) { - if (err) { - return console.log(err) - } - console.log('The file was saved!') - } - ) - }) - }) + // on(IpcChannels.DevDbExportJson, () => { + // const tables = [ + // Tables.ArtistAlbum, + // Tables.Playlist, + // Tables.Album, + // Tables.Track, + // Tables.Artist, + // Tables.Audio, + // Tables.AccountData, + // Tables.Lyric, + // ] + // tables.forEach(table => { + // const data = db.findAll(table) + // fs.writeFile( + // `./tmp/${table}.json`, + // JSON.stringify(data), + // function (err) { + // if (err) { + // return console.log(err) + // } + // console.log('The file was saved!') + // } + // ) + // }) + // }) } } diff --git a/packages/desktop/main/rendererPreload.ts b/packages/desktop/main/rendererPreload.ts index 3dfb7c4..b488293 100644 --- a/packages/desktop/main/rendererPreload.ts +++ b/packages/desktop/main/rendererPreload.ts @@ -12,7 +12,6 @@ if (isProd) { } contextBridge.exposeInMainWorld('ipcRenderer', { - sendSync: ipcRenderer.sendSync, invoke: ipcRenderer.invoke, send: ipcRenderer.send, on: ( diff --git a/packages/desktop/main/server.ts b/packages/desktop/main/server.ts index 4d9654b..3dd6070 100644 --- a/packages/desktop/main/server.ts +++ b/packages/desktop/main/server.ts @@ -6,20 +6,20 @@ import cache from './cache' import fileUpload from 'express-fileupload' import path from 'path' import fs from 'fs' -import { db, Tables } from './db' +// import { db, Tables } from './db' import { app } from 'electron' import type { FetchAudioSourceResponse } from '@/shared/api/Track' -import UNM from '@unblockneteasemusic/rust-napi' import { APIs as CacheAPIs } from '@/shared/CacheAPIs' import { isProd } from './utils' import { APIs } from '@/shared/CacheAPIs' import history from 'connect-history-api-fallback' +import { db, Tables } from './db' class Server { port = Number( isProd - ? process.env.ELECTRON_WEB_SERVER_PORT ?? 42710 - : process.env.ELECTRON_DEV_NETEASE_API_PORT ?? 3000 + ? process.env.ELECTRON_WEB_SERVER_PORT || 42710 + : process.env.ELECTRON_DEV_NETEASE_API_PORT || 3000 ) app = express() // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -152,7 +152,7 @@ class Server { } } - const unmExecutor = new UNM.Executor() + // const unmExecutor = new UNM.Executor() const getFromUNM = async (id: number, req: Request) => { log.debug('[server] Fetching audio url from UNM') let track: Track = cache.get(CacheAPIs.Track, { ids: String(id) }) @@ -269,15 +269,15 @@ class Server { return } - try { - const fromUNM = await getFromUNM(id, req) - if (fromUNM) { - res.status(200).send(fromUNM) - return - } - } catch (error) { - log.error(`[server] getFromUNM failed: ${String(error)}`) - } + // try { + // const fromUNM = await getFromUNM(id, req) + // if (fromUNM) { + // res.status(200).send(fromUNM) + // return + // } + // } catch (error) { + // log.error(`[server] getFromUNM failed: ${String(error)}`) + // } if (fromNetease?.data?.[0].freeTrialInfo) { fromNetease.data[0].url = '' diff --git a/packages/desktop/main/surrealdb.ts b/packages/desktop/main/surrealdb.ts new file mode 100644 index 0000000..15822a0 --- /dev/null +++ b/packages/desktop/main/surrealdb.ts @@ -0,0 +1,301 @@ +import axios, { AxiosInstance } from 'axios' +import { app, ipcMain } from 'electron' +import path from 'path' +import { Get } from 'type-fest' +import { $, fs } from 'zx' +import log from './log' +import { flatten } from 'lodash' + +// surreal start --bind 127.0.0.1:37421 --user user --pass pass --log trace file:///Users/max/Developer/GitHub/replay/tmp/UserData/api_cache/surreal + +interface Databases { + appleMusic: { + artist: { + key: string + data: { + json: string + } + } + album: { + key: string + data: { + json: string + } + } + } + replay: { + appData: { + id: 'appVersion' | 'skippedVersion' + value: string + } + } + netease: { + track: { + id: number + json: string + updatedAt: number + } + artist: { + id: number + json: string + updatedAt: number + } + album: { + id: number + json: string + updatedAt: number + } + artistAlbums: { + id: number + json: string + updatedAt: number + } + lyric: { + id: number + json: string + updatedAt: number + } + playlist: { + id: number + json: string + updatedAt: number + } + accountData: { + id: string + json: string + updatedAt: number + } + audio: { + id: number + br: number + type: 'mp3' | 'flac' | 'ogg' | 'wav' | 'm4a' | 'aac' | 'unknown' | 'opus' + source: + | 'unknown' + | 'netease' + | 'migu' + | 'kuwo' + | 'kugou' + | 'youtube' + | 'qq' + | 'bilibili' + | 'joox' + queriedAt: number + } + } +} + +interface SurrealSuccessResult { + time: string + status: 'OK' | 'ERR' + result?: R[] + detail?: string +} + +interface SurrealErrorResult { + code: 400 + details: string + description: string + information: string +} + +class Surreal { + private port = 37421 + private username = 'user' + private password = 'pass' + private request: AxiosInstance + + constructor() { + this.start() + this.request = axios.create({ + baseURL: `http://127.0.0.1:${this.port}`, + timeout: 15000, + auth: { + username: this.username, + password: this.password, + }, + headers: { + NS: 'replay', + Accept: 'application/json', + }, + responseType: 'json', + }) + } + + getSurrealBinPath() { + return path.join(__dirname, `./binary/surreal`) + } + + getDatabasePath() { + return path.resolve(app.getPath('userData'), './api_cache/surreal') + } + + getKey(table: string, key: string) { + if (key.includes('/')) { + return `${table}:⟨${key}⟩` + } + return `${table}:${key}` + } + + async query( + database: keyof Databases, + query: string + ): Promise { + type DBResponse = + | SurrealSuccessResult + | Array> + | SurrealErrorResult + + const result = await this.request + .post('/sql', query, { + headers: { DB: database }, + }) + .catch(e => { + log.error( + `[surreal] Axios Error: ${e}, response: ${JSON.stringify( + e.response.data, + null, + 2 + )}` + ) + }) + + if (!result?.data) { + log.error(`[surreal] No result`) + return [] + } + const data = result.data + + if (Array.isArray(data)) { + return flatten(data.map(item => item?.result).filter(Boolean) as R[][]) + } + + if ('status' in data) { + if (data.status === 'OK') { + return data.result + } + if (data.status === 'ERR') { + log.error(`[surreal] ${data.detail}`) + throw new Error(`[surreal] query error: ${data.detail}`) + } + } + + if ('code' in data && data.code !== 400) { + throw new Error(`[surreal] query error: ${data.description}`) + } + + throw new Error('[surreal] query error: unknown error') + } + + async start() { + log.info(`[surreal] Starting surreal, listen on 127.0.0.1:${this.port}`) + + await $`${this.getSurrealBinPath()} start --bind 127.0.0.1:${ + this.port + } --user ${this.username} --pass ${ + this.password + } --log warn file://${this.getDatabasePath()}` + } + + async create( + database: D, + table: T, + key: Get, + data: Get + ) { + const result = await this.query>( + database, + `CREATE ${String(table)}:(${String(key)}) CONTENT ${JSON.stringify(data)}` + ) + return result?.[0] + } + + async upsert( + database: D, + table: T, + key: Get, + data: Get + ) { + fs.writeFile( + 'tmp.json', + `INSERT INTO ${String(table)} ${JSON.stringify({ ...data, id: key })}` + ) + const result = await this.query>( + database, + `INSERT INTO ${String(table)} ${JSON.stringify({ ...data, id: key })} ` + ) + return result?.[0] + } + + upsertMany( + database: D, + table: T, + data: { + key: Get + data: Get + }[] + ) { + const queries = data.map(query => { + return `INSERT INTO ${String(table)} ${JSON.stringify(query.data)};` + }) + return this.query>(database, queries.join(' ')) + } + + async find( + database: D, + table: T, + key: Get + ) { + return this.query>( + database, + `SELECT * FROM ${String(table)} WHERE id = "${this.getKey( + String(table), + String(key) + )}" LIMIT 1` + ) as Promise[]> + } + + async findMany( + database: D, + table: T, + keys: Get[] + ) { + const idsQuery = keys + .map(key => `id = "${this.getKey(String(table), String(key))}"`) + .join(' OR ') + return this.query>( + database, + `SELECT * FROM ${String(table)} WHERE ${idsQuery} TIMEOUT 5s` + ) + } + + async delete( + database: D, + table: T, + key: Get + ) { + try { + await this.query( + database, + `SELECT ${this.getKey(String(table), String(key))}` + ) + return true + } catch (error) { + return false + } + } + + async deleteTable( + database: D, + table: T + ) { + try { + await this.query(database, `DELETE ${String(table)}`) + return true + } catch (error) { + return false + } + } +} + +const surreal = new Surreal() +export default surreal diff --git a/packages/desktop/package.json b/packages/desktop/package.json index cf9af92..d6322ae 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -23,7 +23,6 @@ }, "dependencies": { "@sentry/electron": "^3.0.7", - "@unblockneteasemusic/rust-napi": "^0.3.0", "NeteaseCloudMusicApi": "^4.6.7", "better-sqlite3": "7.6.2", "change-case": "^4.1.2", @@ -36,17 +35,16 @@ "express": "^4.18.1", "fast-folder-size": "^1.7.0", "pretty-bytes": "^6.0.0", + "type-fest": "^3.0.0", "zx": "^7.0.8" }, "devDependencies": { - "@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.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", @@ -68,8 +66,5 @@ "typescript": "*", "vitest": "^0.20.3", "wait-on": "^6.0.1" - }, - "resolutions": { - "@electron/universal": "1.3.0" } } diff --git a/packages/desktop/scripts/build.main.mjs b/packages/desktop/scripts/build.main.mjs index ed93e12..7123175 100644 --- a/packages/desktop/scripts/build.main.mjs +++ b/packages/desktop/scripts/build.main.mjs @@ -37,7 +37,7 @@ const options = { 'electron', 'NeteaseCloudMusicApi', 'better-sqlite3', - '@unblockneteasemusic/rust-napi', + // '@unblockneteasemusic/rust-napi', ], } diff --git a/packages/desktop/scripts/build.sqlite3.js b/packages/desktop/scripts/build.sqlite3.js index 8ad56e4..a49ca51 100644 --- a/packages/desktop/scripts/build.sqlite3.js +++ b/packages/desktop/scripts/build.sqlite3.js @@ -7,7 +7,7 @@ const releases = require('electron-releases') const pkg = require(`${process.cwd()}/package.json`) const axios = require('axios') const { execSync } = require('child_process') -const path = require('path') +const { resolve } = require('path') const isWindows = process.platform === 'win32' const isMac = process.platform === 'darwin' @@ -29,14 +29,15 @@ if (!electronModuleVersion) { } const argv = minimist(process.argv.slice(2)) -const projectDir = path.resolve(process.cwd(), '../../') -const distDir = `${projectDir}/packages/desktop/dist/binary` +const projectDir = resolve(process.cwd(), '../../') +const tmpDir = resolve(projectDir, `./tmp/better-sqlite3`) +const binDir = resolve(projectDir, `./tmp/bin`) console.log(pc.cyan(`projectDir=${projectDir}`)) -console.log(pc.cyan(`distDir=${distDir}`)) +console.log(pc.cyan(`binDir=${binDir}`)) -if (!fs.existsSync(distDir)) { - console.log(pc.cyan(`Creating dist/binary directory: ${distDir}`)) - fs.mkdirSync(distDir, { +if (!fs.existsSync(binDir)) { + console.log(pc.cyan(`Creating dist/binary directory: ${binDir}`)) + fs.mkdirSync(binDir, { recursive: true, }) } @@ -47,7 +48,6 @@ const download = async arch => { console.log(pc.red('No electron module version found! Skip download.')) return false } - 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}` @@ -63,7 +63,9 @@ const download = async arch => { url, responseType: 'stream', }).then(response => { - response.data.pipe(fs.createWriteStream(`${tmpDir}/${zipFileName}`)) + response.data.pipe( + fs.createWriteStream(resolve(tmpDir, `./${zipFileName}`)) + ) return true }) } catch (e) { @@ -80,8 +82,8 @@ const download = async arch => { try { fs.copyFileSync( - `${tmpDir}/build/Release/better_sqlite3.node`, - `${distDir}/better_sqlite3_${arch}.node` + resolve(tmpDir, './build/Release/better_sqlite3.node'), + resolve(binDir, `./better_sqlite3_${process.platform}_${arch}.node`) ) } catch (e) { console.log(pc.red('Copy failed! Skip copy.', e)) @@ -89,7 +91,7 @@ const download = async arch => { } try { - fs.rmSync(`${tmpDir}/build`, { recursive: true, force: true }) + fs.rmSync(resolve(tmpDir, `./build`), { recursive: true, force: true }) } catch (e) { console.log(pc.red('Delete failed! Skip delete.')) return false @@ -113,8 +115,15 @@ const build = async arch => { }) .then(() => { console.info('Build succeeded') - const from = `${projectDir}/node_modules/better-sqlite3/build/Release/better_sqlite3.node` - const to = `${distDir}/better_sqlite3_${arch}.node` + + const from = resolve( + projectDir, + `./node_modules/better-sqlite3/build/Release/better_sqlite3.node` + ) + const to = resolve( + binDir, + `./better_sqlite3_${process.platform}_${arch}.node` + ) console.info(`copy ${from} to ${to}`) fs.copyFileSync(from, to) }) @@ -130,7 +139,9 @@ const main = async () => { if (argv.arm64) await build('arm64') if (argv.arm) await build('arm') } else { - if (isWindows || isMac) { + if (isWindows) { + await build('x64') + } else if (isMac) { await build('x64') await build('arm64') } else if (isLinux) { diff --git a/packages/desktop/scripts/copySQLite3.js b/packages/desktop/scripts/copySQLite3.js new file mode 100644 index 0000000..5d6d90b --- /dev/null +++ b/packages/desktop/scripts/copySQLite3.js @@ -0,0 +1,63 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const path = require('path') +const pc = require('picocolors') +const fs = require('fs') + +const archs = ['ia32', 'x64', 'armv7l', 'arm64', 'universal'] + +const projectDir = path.resolve(process.cwd(), '../../') +const binDir = `${projectDir}/tmp/bin` +console.log(pc.cyan(`projectDir=${projectDir}`)) +console.log(pc.cyan(`binDir=${binDir}`)) + +exports.default = async function (context) { + // console.log(context) + const platform = context.electronPlatformName + const arch = archs?.[context.arch] + + // Mac + if (platform === 'darwin') { + if (arch === 'universal') return // Skip universal we already copy binary for x64 and arm64 + if (arch !== 'x64' && arch !== 'arm64') return // Skip other archs + + const from = `${binDir}/better_sqlite3_darwin_${arch}.node` + const to = `${context.appOutDir}/${context.packager.appInfo.productFilename}.app/Contents/Resources/bin/better_sqlite3.node` + console.info(`copy ${from} to ${to}`) + + const toFolder = to.replace('/better_sqlite3.node', '') + if (!fs.existsSync(toFolder)) { + fs.mkdirSync(toFolder, { + recursive: true, + }) + } + + try { + fs.copyFileSync(from, to) + } catch (e) { + console.log(pc.red('Copy failed! Process stopped.')) + throw e + } + } + + if (platform === 'win32') { + if (arch !== 'x64') return // Skip other archs + + const from = `${binDir}/better_sqlite3_win32_${arch}.node` + const to = `${context.appOutDir}/resources/bin/better_sqlite3.node` + console.info(`copy ${from} to ${to}`) + + const toFolder = to.replace('/better_sqlite3.node', '') + if (!fs.existsSync(toFolder)) { + fs.mkdirSync(toFolder, { + recursive: true, + }) + } + + try { + fs.copyFileSync(from, to) + } catch (e) { + console.log(pc.red('Copy failed! Process stopped.')) + throw e + } + } +} diff --git a/packages/server/.gitignore b/packages/server/.gitignore new file mode 100644 index 0000000..f4cefe8 --- /dev/null +++ b/packages/server/.gitignore @@ -0,0 +1,65 @@ +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +# 0x +profile-* + +# mac files +.DS_Store + +# vim swap files +*.swp + +# webstorm +.idea + +# vscode +.vscode +*code-workspace + +# clinic +profile* +*clinic* +*flamegraph* + +# generated code +examples/typescript-server.js +test/types/index.js + +# compiled app +dist diff --git a/packages/server/.taprc b/packages/server/.taprc new file mode 100644 index 0000000..d6fd534 --- /dev/null +++ b/packages/server/.taprc @@ -0,0 +1,4 @@ +test-env: [ + TS_NODE_FILES=true, + TS_NODE_PROJECT=./test/tsconfig.json +] diff --git a/packages/server/README.md b/packages/server/README.md new file mode 100644 index 0000000..be35d93 --- /dev/null +++ b/packages/server/README.md @@ -0,0 +1,23 @@ +# Getting Started with [Fastify-CLI](https://www.npmjs.com/package/fastify-cli) +This project was bootstrapped with Fastify-CLI. + +## Available Scripts + +In the project directory, you can run: + +### `npm run dev` + +To start the app in dev mode.\ +Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +### `npm start` + +For production mode + +### `npm run test` + +Run the test cases. + +## Learn More + +To learn Fastify, check out the [Fastify documentation](https://www.fastify.io/docs/latest/). diff --git a/packages/server/fly.toml b/packages/server/fly.toml new file mode 100644 index 0000000..e24f3a7 --- /dev/null +++ b/packages/server/fly.toml @@ -0,0 +1,41 @@ +# fly.toml file generated for ypm on 2022-09-06T00:57:21+08:00 + +app = "ypm" +kill_signal = "SIGINT" +kill_timeout = 5 +processes = ["npm run start"] + +[build] + builder = "heroku/buildpacks:20" + +[env] + PORT = "8080" + +[experimental] + allowed_public_ports = [] + auto_rollback = true + +[[services]] + http_checks = [] + internal_port = 35530 + processes = ["app"] + protocol = "tcp" + script_checks = [] + [services.concurrency] + hard_limit = 25 + soft_limit = 20 + type = "connections" + + [[services.ports]] + handlers = ["http"] + port = 80 + + [[services.ports]] + handlers = ["tls", "http"] + port = 443 + + [[services.tcp_checks]] + grace_period = "1s" + interval = "15s" + restart_limit = 0 + timeout = "2s" diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000..f0a53ec --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,37 @@ +{ + "name": "server", + "version": "1.0.0", + "description": "This project was bootstrapped with Fastify-CLI.", + "main": "app.ts", + "directories": { + "test": "test" + }, + "scripts": { + "test": "npm run build:ts && tsc -p test/tsconfig.json && tap --ts \"test/**/*.test.ts\"", + "start": "fastify start --port 35530 --address 0.0.0.0 -l info dist/app.js", + "build:ts": "tsc", + "watch:ts": "tsc -w", + "dev": "npm run build:ts && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"npm:watch:ts\" \"npm:dev:start\"", + "dev:start": "fastify start --ignore-watch=.ts$ -w --port 35530 --address 0.0.0.0 -l info -P dist/app.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@fastify/autoload": "^5.0.0", + "@fastify/sensible": "^4.1.0", + "axios": "^0.27.2", + "fastify": "^4.0.0", + "fastify-cli": "^4.4.0", + "fastify-plugin": "^3.0.0" + }, + "devDependencies": { + "@types/node": "^18.0.0", + "@types/tap": "^15.0.5", + "concurrently": "^7.0.0", + "fastify-tsconfig": "^1.0.1", + "tap": "^16.1.0", + "ts-node": "^10.4.0", + "typescript": "^4.5.4" + } +} diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts new file mode 100644 index 0000000..e1f077b --- /dev/null +++ b/packages/server/src/app.ts @@ -0,0 +1,35 @@ +import { join } from 'path'; +import AutoLoad, {AutoloadPluginOptions} from '@fastify/autoload'; +import { FastifyPluginAsync } from 'fastify'; + +export type AppOptions = { + // Place your custom options for app below here. +} & Partial; + +const app: FastifyPluginAsync = async ( + fastify, + opts +): Promise => { + // Place here your custom code! + + // Do not touch the following lines + + // This loads all plugins defined in plugins + // those should be support plugins that are reused + // through your application + void fastify.register(AutoLoad, { + dir: join(__dirname, 'plugins'), + options: opts + }) + + // This loads all plugins defined in routes + // define your routes in one of these + void fastify.register(AutoLoad, { + dir: join(__dirname, 'routes'), + options: opts + }) + +}; + +export default app; +export { app } diff --git a/packages/server/src/plugins/README.md b/packages/server/src/plugins/README.md new file mode 100644 index 0000000..02fd5f9 --- /dev/null +++ b/packages/server/src/plugins/README.md @@ -0,0 +1,16 @@ +# Plugins Folder + +Plugins define behavior that is common to all the routes in your +application. Authentication, caching, templates, and all the other cross +cutting concerns should be handled by plugins placed in this folder. + +Files in this folder are typically defined through the +[`fastify-plugin`](https://github.com/fastify/fastify-plugin) module, +making them non-encapsulated. They can define decorators and set hooks +that will then be used in the rest of your application. + +Check out: + +* [The hitchhiker's guide to plugins](https://www.fastify.io/docs/latest/Guides/Plugins-Guide/) +* [Fastify decorators](https://www.fastify.io/docs/latest/Reference/Decorators/). +* [Fastify lifecycle](https://www.fastify.io/docs/latest/Reference/Lifecycle/). diff --git a/packages/server/src/plugins/sensible.ts b/packages/server/src/plugins/sensible.ts new file mode 100644 index 0000000..fb33816 --- /dev/null +++ b/packages/server/src/plugins/sensible.ts @@ -0,0 +1,13 @@ +import fp from 'fastify-plugin' +import sensible, { SensibleOptions } from '@fastify/sensible' + +/** + * This plugins adds some utilities to handle http errors + * + * @see https://github.com/fastify/fastify-sensible + */ +export default fp(async (fastify, opts) => { + fastify.register(sensible, { + errorHandler: false + }) +}) diff --git a/packages/server/src/plugins/support.ts b/packages/server/src/plugins/support.ts new file mode 100644 index 0000000..94bae4f --- /dev/null +++ b/packages/server/src/plugins/support.ts @@ -0,0 +1,20 @@ +import fp from 'fastify-plugin' + +export interface SupportPluginOptions { + // Specify Support plugin options here +} + +// The use of fastify-plugin is required to be able +// to export the decorators to the outer scope +export default fp(async (fastify, opts) => { + fastify.decorate('someSupport', function () { + return 'hugs' + }) +}) + +// When using .decorate you have to specify added properties for Typescript +declare module 'fastify' { + export interface FastifyInstance { + someSupport(): string; + } +} diff --git a/packages/server/src/routes/apple-music/album.ts b/packages/server/src/routes/apple-music/album.ts new file mode 100644 index 0000000..e6b94b1 --- /dev/null +++ b/packages/server/src/routes/apple-music/album.ts @@ -0,0 +1,38 @@ +import { FastifyPluginAsync } from 'fastify' +import appleMusicRequest from '../../utils/appleMusicRequest' + +const example: FastifyPluginAsync = async (fastify, opts): Promise => { + fastify.get<{ + Querystring: { + name: string + artist: string + lang: 'zh-CN' | 'en-US' + } + }>('/album', async function (request, reply) { + const { name, lang, artist } = request.query + + const fromApple = await appleMusicRequest({ + method: 'GET', + url: '/search', + params: { + term: name, + types: 'albums', + 'fields[albums]': 'artistName,name,editorialVideo,editorialNotes', + limit: '1', + l: lang.toLowerCase(), + }, + }) + + const albums = fromApple?.results?.album?.data + const album = + albums?.find( + (a: any) => + a.attributes.name.toLowerCase() === name.toLowerCase() && + a.attributes.artistName.toLowerCase() === artist.toLowerCase() + ) || albums?.[0] + + return album + }) +} + +export default example diff --git a/packages/server/src/routes/apple-music/artist.ts b/packages/server/src/routes/apple-music/artist.ts new file mode 100644 index 0000000..e722caf --- /dev/null +++ b/packages/server/src/routes/apple-music/artist.ts @@ -0,0 +1,46 @@ +import { FastifyPluginAsync } from 'fastify' +import appleMusicRequest from '../../utils/appleMusicRequest' + +const example: FastifyPluginAsync = async (fastify, opts): Promise => { + fastify.get<{ + Querystring: { + name: string + lang: 'zh-CN' | 'en-US' + } + }>('/artist', async function (request, reply) { + const { name, lang } = request.query + + if (!name) { + return { + code: 400, + message: 'params "name" is required', + } + } + + const fromApple = await appleMusicRequest({ + method: 'GET', + url: '/search', + params: { + term: name, + types: 'artists', + 'fields[artists]': 'url,name,artwork,editorialVideo,artistBio', + 'omit[resource:artists]': 'relationships', + platform: 'web', + limit: '1', + l: lang?.toLowerCase() || 'en-us', + with: 'serverBubbles', + }, + }) + + const artist = fromApple?.results?.artist?.data?.[0] + + if ( + artist && + artist?.attributes?.name?.toLowerCase() === name.toLowerCase() + ) { + return artist + } + }) +} + +export default example diff --git a/packages/server/src/routes/root.ts b/packages/server/src/routes/root.ts new file mode 100644 index 0000000..2a1b334 --- /dev/null +++ b/packages/server/src/routes/root.ts @@ -0,0 +1,9 @@ +import { FastifyPluginAsync } from 'fastify' + +const root: FastifyPluginAsync = async (fastify, opts): Promise => { + fastify.get('/', async function (request, reply) { + return { root: true } + }) +} + +export default root; diff --git a/packages/server/src/utils/appleMusicRequest.ts b/packages/server/src/utils/appleMusicRequest.ts new file mode 100644 index 0000000..b029a71 --- /dev/null +++ b/packages/server/src/utils/appleMusicRequest.ts @@ -0,0 +1,57 @@ +import axios, { + AxiosError, + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, +} from 'axios' + +export const baseURL = 'https://amp-api.music.apple.com/v1/catalog/us' + +export const headers = { + Authority: 'amp-api.music.apple.com', + Accept: '*/*', + Authorization: + 'Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IldlYlBsYXlLaWQifQ.eyJpc3MiOiJBTVBXZWJQbGF5IiwiaWF0IjoxNjYxNDQwNDMyLCJleHAiOjE2NzY5OTI0MzIsInJvb3RfaHR0cHNfb3JpZ2luIjpbImFwcGxlLmNvbSJdfQ.z4BMv9_O4MpMK2iFhYkDqPsx53soPSnlXXK3jm99pHqGOrZADvTgEUw2U7_B1W0MAtFiWBYhYcGvWrzaOig6Bw', + Referer: 'https://music.apple.com/', + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'cross-site', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Cider/1.5.1 Chrome/100.0.4896.160 Electron/18.3.3 Safari/537.36', + 'Accept-Encoding': 'gzip', + Origin: 'https://music.apple.com', +} + +export const params = { + platform: 'web', + with: 'serverBubbles', +} + +const service: AxiosInstance = axios.create({ + baseURL, + withCredentials: true, + timeout: 15000, +}) + +service.interceptors.request.use((config: AxiosRequestConfig) => { + config.headers = { ...headers, ...config.headers } + config.params = { ...params, ...config.params } + return config +}) + +service.interceptors.response.use( + (response: AxiosResponse) => { + const res = response + return res + }, + (error: AxiosError) => { + return Promise.reject(error) + } +) + +const appleMusicRequest = async (config: AxiosRequestConfig) => { + const { data } = await service.request(config) + return data as any +} + +export default appleMusicRequest diff --git a/packages/server/test/helper.ts b/packages/server/test/helper.ts new file mode 100644 index 0000000..72aad8d --- /dev/null +++ b/packages/server/test/helper.ts @@ -0,0 +1,35 @@ +// This file contains code that we reuse between our tests. +import Fastify from 'fastify' +import fp from 'fastify-plugin' +import App from '../src/app' +import * as tap from 'tap'; + +export type Test = typeof tap['Test']['prototype']; + +// Fill in this config with all the configurations +// needed for testing the application +async function config () { + return {} +} + +// Automatically build and tear down our instance +async function build (t: Test) { + const app = Fastify() + + // fastify-plugin ensures that all decorators + // are exposed for testing purposes, this is + // different from the production setup + void app.register(fp(App), await config()) + + await app.ready(); + + // Tear down our app after we are done + t.teardown(() => void app.close()) + + return app +} + +export { + config, + build +} diff --git a/packages/server/test/plugins/support.test.ts b/packages/server/test/plugins/support.test.ts new file mode 100644 index 0000000..d67c06a --- /dev/null +++ b/packages/server/test/plugins/support.test.ts @@ -0,0 +1,11 @@ +import { test } from 'tap' +import Fastify from 'fastify' +import Support from '../../src/plugins/support' + +test('support works standalone', async (t) => { + const fastify = Fastify() + void fastify.register(Support) + await fastify.ready() + + t.equal(fastify.someSupport(), 'hugs') +}) diff --git a/packages/server/test/routes/example.test.ts b/packages/server/test/routes/example.test.ts new file mode 100644 index 0000000..4db7e94 --- /dev/null +++ b/packages/server/test/routes/example.test.ts @@ -0,0 +1,12 @@ +import { test } from 'tap' +import { build } from '../helper' + +test('example is loaded', async (t) => { + const app = await build(t) + + const res = await app.inject({ + url: '/example' + }) + + t.equal(res.payload, 'this is an example') +}) diff --git a/packages/server/test/routes/root.test.ts b/packages/server/test/routes/root.test.ts new file mode 100644 index 0000000..902b0f9 --- /dev/null +++ b/packages/server/test/routes/root.test.ts @@ -0,0 +1,11 @@ +import { test } from 'tap' +import { build } from '../helper' + +test('default root route', async (t) => { + const app = await build(t) + + const res = await app.inject({ + url: '/' + }) + t.same(JSON.parse(res.payload), { root: true }) +}) diff --git a/packages/server/test/tsconfig.json b/packages/server/test/tsconfig.json new file mode 100644 index 0000000..384d171 --- /dev/null +++ b/packages/server/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "noEmit": true + }, + "include": ["../src/**/*.ts", "**/*.ts"] +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 0000000..50dd099 --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "fastify-tsconfig", + "compilerOptions": { + "outDir": "dist", + "sourceMap": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/shared/IpcChannels.ts b/packages/shared/IpcChannels.ts index ac817ad..825fd40 100644 --- a/packages/shared/IpcChannels.ts +++ b/packages/shared/IpcChannels.ts @@ -9,7 +9,7 @@ export const enum IpcChannels { Close = 'Close', IsMaximized = 'IsMaximized', FullscreenStateChange = 'FullscreenStateChange', - GetApiCacheSync = 'GetApiCacheSync', + GetApiCache = 'GetApiCache', DevDbExportJson = 'DevDbExportJson', CacheCoverColor = 'CacheCoverColor', SetTrayTooltip = 'SetTrayTooltip', @@ -26,6 +26,7 @@ export const enum IpcChannels { ResetWindowSize = 'ResetWindowSize', GetAlbumFromAppleMusic = 'GetAlbumFromAppleMusic', GetArtistFromAppleMusic = 'GetArtistFromAppleMusic', + Logout = 'Logout', } // ipcMain.on params @@ -36,7 +37,7 @@ export interface IpcChannelsParams { [IpcChannels.Close]: void [IpcChannels.IsMaximized]: void [IpcChannels.FullscreenStateChange]: void - [IpcChannels.GetApiCacheSync]: { + [IpcChannels.GetApiCache]: { api: APIs query?: any } @@ -68,6 +69,7 @@ export interface IpcChannelsParams { artist: string } [IpcChannels.GetArtistFromAppleMusic]: { id: number; name: string } + [IpcChannels.Logout]: void } // ipcRenderer.on params @@ -78,7 +80,7 @@ export interface IpcChannelsReturns { [IpcChannels.Close]: void [IpcChannels.IsMaximized]: boolean [IpcChannels.FullscreenStateChange]: boolean - [IpcChannels.GetApiCacheSync]: any + [IpcChannels.GetApiCache]: any [IpcChannels.DevDbExportJson]: void [IpcChannels.CacheCoverColor]: void [IpcChannels.SetTrayTooltip]: void @@ -92,4 +94,5 @@ export interface IpcChannelsReturns { [IpcChannels.GetAudioCacheSize]: void [IpcChannels.GetAlbumFromAppleMusic]: AppleMusicAlbum | undefined [IpcChannels.GetArtistFromAppleMusic]: AppleMusicArtist | undefined + [IpcChannels.Logout]: void } diff --git a/packages/shared/db/appleMusic.ts b/packages/shared/db/appleMusic.ts new file mode 100644 index 0000000..1bf995c --- /dev/null +++ b/packages/shared/db/appleMusic.ts @@ -0,0 +1,15 @@ +export const enum AppleMusicTables { + Album = 'Album', + Artist = 'Artist', +} + +export interface AppleMusicTablesStructures { + [AppleMusicTables.Album]: { + id: string + json: string + } + [AppleMusicTables.Artist]: { + id: string + json: string + } +} diff --git a/packages/shared/db/netease.ts b/packages/shared/db/netease.ts new file mode 100644 index 0000000..92f3324 --- /dev/null +++ b/packages/shared/db/netease.ts @@ -0,0 +1,44 @@ +export const enum NeteaseTables { + AccountData = 'AccountData', + Album = 'Album', + Artist = 'Artist', + ArtistAlbum = 'ArtistAlbum', + Audio = 'Audio', + Lyric = 'Lyric', + Playlist = 'Playlist', + Track = 'Track', +} +interface CommonTableStructure { + id: number + json: string + updatedAt: number +} +export interface NeteaseTablesStructures { + [NeteaseTables.AccountData]: { + id: string + json: string + updatedAt: number + } + [NeteaseTables.Album]: CommonTableStructure + [NeteaseTables.Artist]: CommonTableStructure + [NeteaseTables.ArtistAlbum]: CommonTableStructure + [NeteaseTables.Audio]: { + id: number + br: number + type: 'mp3' | 'flac' | 'ogg' | 'wav' | 'm4a' | 'aac' | 'unknown' | 'opus' + source: + | 'unknown' + | 'netease' + | 'migu' + | 'kuwo' + | 'kugou' + | 'youtube' + | 'qq' + | 'bilibili' + | 'joox' + queriedAt: number + } + [NeteaseTables.Lyric]: CommonTableStructure + [NeteaseTables.Playlist]: CommonTableStructure + [NeteaseTables.Track]: CommonTableStructure +} diff --git a/packages/shared/db/replay.ts b/packages/shared/db/replay.ts new file mode 100644 index 0000000..b7a4570 --- /dev/null +++ b/packages/shared/db/replay.ts @@ -0,0 +1,20 @@ +export const enum ReplayTables { + CoverColor = 'CoverColor', + AppData = 'AppData', +} + +export interface ReplayTableKeys { + [ReplayTables.CoverColor]: number + [ReplayTables.AppData]: 'appVersion' | 'skippedVersion' +} + +export interface ReplayTableStructures { + [ReplayTables.CoverColor]: { + id: number + color: string + queriedAt: number + } + [ReplayTables.AppData]: { + value: string + } +} diff --git a/packages/web/App.tsx b/packages/web/App.tsx index 72d16e9..bde1175 100644 --- a/packages/web/App.tsx +++ b/packages/web/App.tsx @@ -1,44 +1,23 @@ -import { Toaster } from 'react-hot-toast' -import { QueryClientProvider } from '@tanstack/react-query' -import { ReactQueryDevtools } from 'react-query/devtools' -import Player from '@/web/components/Player' -import Sidebar from '@/web/components/Sidebar' -import reactQueryClient from '@/web/utils/reactQueryClient' -import Main from '@/web/components/Main' -import TitleBar from '@/web/components/TitleBar' -import Lyric from '@/web/components/Lyric' import IpcRendererReact from '@/web/IpcRendererReact' +import Layout from '@/web/components/Layout' +import Devtool from '@/web/components/Devtool' +import ErrorBoundary from '@/web/components/ErrorBoundary' +import useIsMobile from '@/web/hooks/useIsMobile' +import LayoutMobile from '@/web/components/LayoutMobile' +import ScrollRestoration from '@/web/components/ScrollRestoration' +import Toaster from './components/Toaster' const App = () => { + const isMobile = useIsMobile() + return ( - - {window.env?.isEnableTitlebar && } - -
- -
- -
- - - - - + + {isMobile ? : } + + - - {/* Devtool */} - -
+ + ) } diff --git a/packages/web/AppNew.tsx b/packages/web/AppNew.tsx deleted file mode 100644 index 53e0244..0000000 --- a/packages/web/AppNew.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import TitleBar from '@/web/components/TitleBar' -import IpcRendererReact from '@/web/IpcRendererReact' -import Layout from '@/web/components/New/Layout' -import Devtool from '@/web/components/New/Devtool' -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 ( - - {isMobile ? : } - - - - - - ) -} - -export default App diff --git a/packages/web/api/hooks/useAlbum.ts b/packages/web/api/hooks/useAlbum.ts index 3bf26f6..e52254f 100644 --- a/packages/web/api/hooks/useAlbum.ts +++ b/packages/web/api/hooks/useAlbum.ts @@ -7,7 +7,7 @@ import { AlbumApiNames, FetchAlbumResponse, } from '@/shared/api/Album' -import { QueryOptions, useQuery } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' const fetch = async (params: FetchAlbumParams) => { const album = await fetchAlbum(params) @@ -17,22 +17,34 @@ const fetch = async (params: FetchAlbumParams) => { return album } -const fetchFromCache = (params: FetchAlbumParams): FetchAlbumResponse => - window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, { +const fetchFromCache = async ( + params: FetchAlbumParams +): Promise => + window.ipcRenderer?.invoke(IpcChannels.GetApiCache, { api: APIs.Album, query: params, }) -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, - }) +export default function useAlbum(params: FetchAlbumParams) { + const key = [AlbumApiNames.FetchAlbum, params] + return useQuery( + key, + () => { + // fetch from cache as placeholder + fetchFromCache(params).then(cache => { + const existsQueryData = reactQueryClient.getQueryData(key) + if (!existsQueryData && cache) { + reactQueryClient.setQueryData(key, cache) + } + }) + + return fetch(params) + }, + { + enabled: !!params.id, + staleTime: 24 * 60 * 60 * 1000, // 24 hours + } + ) } export function fetchAlbumWithReactQuery(params: FetchAlbumParams) { @@ -46,7 +58,7 @@ export function fetchAlbumWithReactQuery(params: FetchAlbumParams) { } export async function prefetchAlbum(params: FetchAlbumParams) { - if (fetchFromCache(params)) return + if (await fetchFromCache(params)) return await reactQueryClient.prefetchQuery( [AlbumApiNames.FetchAlbum, params], () => fetch(params), diff --git a/packages/web/api/hooks/useArtist.ts b/packages/web/api/hooks/useArtist.ts index f43db44..6148510 100644 --- a/packages/web/api/hooks/useArtist.ts +++ b/packages/web/api/hooks/useArtist.ts @@ -9,22 +9,32 @@ import { import { useQuery } from '@tanstack/react-query' import reactQueryClient from '@/web/utils/reactQueryClient' -const fetchFromCache = (id: number): FetchArtistResponse => - window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, { +const fetchFromCache = async ( + params: FetchArtistParams +): Promise => + window.ipcRenderer?.invoke(IpcChannels.GetApiCache, { api: APIs.Artist, - query: { - id, - }, + query: params, }) export default function useArtist(params: FetchArtistParams) { + const key = [ArtistApiNames.FetchArtist, params] return useQuery( - [ArtistApiNames.FetchArtist, params], - () => fetchArtist(params), + key, + () => { + // fetch from cache as placeholder + fetchFromCache(params).then(cache => { + const existsQueryData = reactQueryClient.getQueryData(key) + if (!existsQueryData && cache) { + reactQueryClient.setQueryData(key, cache) + } + }) + + return fetchArtist(params) + }, { enabled: !!params.id && params.id > 0 && !isNaN(Number(params.id)), staleTime: 5 * 60 * 1000, // 5 mins - placeholderData: () => fetchFromCache(params.id), } ) } @@ -40,7 +50,7 @@ export function fetchArtistWithReactQuery(params: FetchArtistParams) { } export async function prefetchArtist(params: FetchArtistParams) { - if (fetchFromCache(params.id)) return + if (await fetchFromCache(params)) return await reactQueryClient.prefetchQuery( [ArtistApiNames.FetchArtist, params], () => fetchArtist(params), diff --git a/packages/web/api/hooks/useArtistAlbums.ts b/packages/web/api/hooks/useArtistAlbums.ts index 4ad15bf..4b07457 100644 --- a/packages/web/api/hooks/useArtistAlbums.ts +++ b/packages/web/api/hooks/useArtistAlbums.ts @@ -1,30 +1,35 @@ import { fetchArtistAlbums } from '@/web/api/artist' import { IpcChannels } from '@/shared/IpcChannels' import { APIs } from '@/shared/CacheAPIs' -import { - FetchArtistAlbumsParams, - ArtistApiNames, - FetchArtistAlbumsResponse, -} from '@/shared/api/Artist' +import { FetchArtistAlbumsParams, ArtistApiNames } from '@/shared/api/Artist' import { useQuery } from '@tanstack/react-query' +import reactQueryClient from '@/web/utils/reactQueryClient' export default function useArtistAlbums(params: FetchArtistAlbumsParams) { + const key = [ArtistApiNames.FetchArtistAlbums, params] return useQuery( - [ArtistApiNames.FetchArtistAlbums, params], + key, async () => { - const data = await fetchArtistAlbums(params) - return data - }, - { - enabled: !!params.id && params.id !== 0, - staleTime: 3600000, - placeholderData: (): FetchArtistAlbumsResponse => - window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, { + // fetch from cache as placeholder + window.ipcRenderer + ?.invoke(IpcChannels.GetApiCache, { api: APIs.ArtistAlbum, query: { id: params.id, }, - }), + }) + .then(cache => { + const existsQueryData = reactQueryClient.getQueryData(key) + if (!existsQueryData && cache) { + reactQueryClient.setQueryData(key, cache) + } + }) + + return fetchArtistAlbums(params) + }, + { + enabled: !!params.id && params.id !== 0, + staleTime: 3600000, } ) } diff --git a/packages/web/api/hooks/useArtistMV.ts b/packages/web/api/hooks/useArtistMV.ts index cfcaf36..c2b680f 100644 --- a/packages/web/api/hooks/useArtistMV.ts +++ b/packages/web/api/hooks/useArtistMV.ts @@ -18,13 +18,6 @@ export default function useArtistMV(params: FetchArtistMVParams) { { enabled: !!params.id && params.id !== 0, staleTime: 3600000, - // placeholderData: (): FetchArtistMVResponse => - // window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, { - // api: APIs.ArtistAlbum, - // query: { - // id: params.id, - // }, - // }), } ) } diff --git a/packages/web/api/hooks/useArtists.ts b/packages/web/api/hooks/useArtists.ts index 11962fc..5be12bb 100644 --- a/packages/web/api/hooks/useArtists.ts +++ b/packages/web/api/hooks/useArtists.ts @@ -1,27 +1,39 @@ import { fetchArtist } from '@/web/api/artist' import { IpcChannels } from '@/shared/IpcChannels' import { APIs } from '@/shared/CacheAPIs' -import { - FetchArtistParams, - ArtistApiNames, - FetchArtistResponse, -} from '@/shared/api/Artist' +import { ArtistApiNames } from '@/shared/api/Artist' import { useQuery } from '@tanstack/react-query' +import reactQueryClient from '@/web/utils/reactQueryClient' export default function useArtists(ids: number[]) { return useQuery( ['fetchArtists', ids], - () => Promise.all(ids.map(id => fetchArtist({ id }))), + () => + Promise.all( + ids.map(async id => { + const queryData = reactQueryClient.getQueryData([ + ArtistApiNames.FetchArtist, + { id }, + ]) + if (queryData) return queryData + + const cache = await window.ipcRenderer?.invoke( + IpcChannels.GetApiCache, + { + api: APIs.Artist, + query: { + id, + }, + } + ) + if (cache) return cache + + return fetchArtist({ id }) + }) + ), { enabled: !!ids && ids.length > 0, staleTime: 5 * 60 * 1000, // 5 mins - initialData: (): FetchArtistResponse[] => - window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, { - api: APIs.Artists, - query: { - ids, - }, - }), } ) } diff --git a/packages/web/api/hooks/useLyric.ts b/packages/web/api/hooks/useLyric.ts index 792b853..7396b0f 100644 --- a/packages/web/api/hooks/useLyric.ts +++ b/packages/web/api/hooks/useLyric.ts @@ -1,18 +1,25 @@ import { fetchLyric } from '@/web/api/track' import reactQueryClient from '@/web/utils/reactQueryClient' -import { - FetchLyricParams, - FetchLyricResponse, - TrackApiNames, -} from '@/shared/api/Track' +import { FetchLyricParams, TrackApiNames } from '@/shared/api/Track' import { APIs } from '@/shared/CacheAPIs' import { IpcChannels } from '@/shared/IpcChannels' import { useQuery } from '@tanstack/react-query' export default function useLyric(params: FetchLyricParams) { + const key = [TrackApiNames.FetchLyric, params] return useQuery( - [TrackApiNames.FetchLyric, params], - () => { + key, + async () => { + // fetch from cache as initial data + const cache = window.ipcRenderer?.invoke(IpcChannels.GetApiCache, { + api: APIs.Lyric, + query: { + id: params.id, + }, + }) + + if (cache) return cache + return fetchLyric(params) }, { @@ -20,18 +27,11 @@ export default function useLyric(params: FetchLyricParams) { refetchInterval: false, refetchOnWindowFocus: false, staleTime: Infinity, - initialData: (): FetchLyricResponse | undefined => - window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, { - api: APIs.Lyric, - query: { - id: params.id, - }, - }), } ) } -export function fetchTracksWithReactQuery(params: FetchLyricParams) { +export function fetchLyricWithReactQuery(params: FetchLyricParams) { return reactQueryClient.fetchQuery( [TrackApiNames.FetchLyric, params], () => { diff --git a/packages/web/api/hooks/useMV.ts b/packages/web/api/hooks/useMV.ts index 44612f0..f1dafa0 100644 --- a/packages/web/api/hooks/useMV.ts +++ b/packages/web/api/hooks/useMV.ts @@ -13,13 +13,6 @@ export default function useMV(params: FetchMVParams) { return useQuery([MVApiNames.FetchMV, params], () => fetchMV(params), { enabled: !!params.mvid && params.mvid > 0 && !isNaN(Number(params.mvid)), staleTime: 5 * 60 * 1000, // 5 mins - // placeholderData: (): FetchMVResponse => - // window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, { - // api: APIs.SimilarArtist, - // query: { - // id: params.id, - // }, - // }), }) } diff --git a/packages/web/api/hooks/usePlaylist.ts b/packages/web/api/hooks/usePlaylist.ts index ce39bb2..8373a68 100644 --- a/packages/web/api/hooks/usePlaylist.ts +++ b/packages/web/api/hooks/usePlaylist.ts @@ -13,20 +13,32 @@ const fetch = (params: FetchPlaylistParams) => { return fetchPlaylist(params) } -export const fetchFromCache = (id: number): FetchPlaylistResponse | undefined => - window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, { +export const fetchFromCache = async ( + params: FetchPlaylistParams +): Promise => + window.ipcRenderer?.invoke(IpcChannels.GetApiCache, { api: APIs.Playlist, - query: { id }, + query: params, }) export default function usePlaylist(params: FetchPlaylistParams) { + const key = [PlaylistApiNames.FetchPlaylist, params] return useQuery( - [PlaylistApiNames.FetchPlaylist, params], - () => fetch(params), + key, + async () => { + // fetch from cache as placeholder + fetchFromCache(params).then(cache => { + const existsQueryData = reactQueryClient.getQueryData(key) + if (!existsQueryData && cache) { + reactQueryClient.setQueryData(key, cache) + } + }) + + return fetch(params) + }, { enabled: !!(params.id && params.id > 0 && !isNaN(Number(params.id))), refetchOnWindowFocus: true, - placeholderData: () => fetchFromCache(params.id), } ) } @@ -42,7 +54,7 @@ export function fetchPlaylistWithReactQuery(params: FetchPlaylistParams) { } export async function prefetchPlaylist(params: FetchPlaylistParams) { - if (fetchFromCache(params.id)) return + if (await fetchFromCache(params)) return await reactQueryClient.prefetchQuery( [PlaylistApiNames.FetchPlaylist, params], () => fetch(params), diff --git a/packages/web/api/hooks/useSimilarArtists.ts b/packages/web/api/hooks/useSimilarArtists.ts index d579259..e3e4251 100644 --- a/packages/web/api/hooks/useSimilarArtists.ts +++ b/packages/web/api/hooks/useSimilarArtists.ts @@ -1,28 +1,35 @@ import { fetchSimilarArtists } from '@/web/api/artist' import { IpcChannels } from '@/shared/IpcChannels' import { APIs } from '@/shared/CacheAPIs' -import { - FetchSimilarArtistsParams, - ArtistApiNames, - FetchSimilarArtistsResponse, -} from '@/shared/api/Artist' +import { FetchSimilarArtistsParams, ArtistApiNames } from '@/shared/api/Artist' import { useQuery } from '@tanstack/react-query' +import reactQueryClient from '@/web/utils/reactQueryClient' export default function useSimilarArtists(params: FetchSimilarArtistsParams) { + const key = [ArtistApiNames.FetchSimilarArtists, params] return useQuery( - [ArtistApiNames.FetchSimilarArtists, params], - () => fetchSimilarArtists(params), - { - enabled: !!params.id && params.id > 0 && !isNaN(Number(params.id)), - staleTime: 5 * 60 * 1000, // 5 mins - retry: 0, - placeholderData: (): FetchSimilarArtistsResponse => - window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, { + key, + () => { + window.ipcRenderer + ?.invoke(IpcChannels.GetApiCache, { api: APIs.SimilarArtist, query: { id: params.id, }, - }), + }) + .then(cache => { + const existsQueryData = reactQueryClient.getQueryData(key) + if (!existsQueryData && cache) { + reactQueryClient.setQueryData(key, cache) + } + }) + + return fetchSimilarArtists(params) + }, + { + enabled: !!params.id && params.id > 0 && !isNaN(Number(params.id)), + staleTime: 5 * 60 * 1000, // 5 mins + retry: 0, } ) } diff --git a/packages/web/api/hooks/useTracks.ts b/packages/web/api/hooks/useTracks.ts index 0df83bb..47212ec 100644 --- a/packages/web/api/hooks/useTracks.ts +++ b/packages/web/api/hooks/useTracks.ts @@ -14,20 +14,23 @@ import { useQuery } from '@tanstack/react-query' export default function useTracks(params: FetchTracksParams) { return useQuery( [TrackApiNames.FetchTracks, params], - () => { + async () => { + // fetch from cache as initial data + const cache = await window.ipcRenderer?.invoke(IpcChannels.GetApiCache, { + api: APIs.Track, + query: { + ids: params.ids.join(','), + }, + }) + if (cache) return cache + return fetchTracks(params) }, { enabled: params.ids.length !== 0, refetchInterval: false, + refetchOnWindowFocus: false, staleTime: Infinity, - initialData: (): FetchTracksResponse | undefined => - window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, { - api: APIs.Track, - query: { - ids: params.ids.join(','), - }, - }), } ) } @@ -35,7 +38,14 @@ export default function useTracks(params: FetchTracksParams) { export function fetchTracksWithReactQuery(params: FetchTracksParams) { return reactQueryClient.fetchQuery( [TrackApiNames.FetchTracks, params], - () => { + async () => { + const cache = await window.ipcRenderer?.invoke(IpcChannels.GetApiCache, { + api: APIs.Track, + query: { + ids: params.ids.join(','), + }, + }) + if (cache) return cache as FetchTracksResponse return fetchTracks(params) }, { diff --git a/packages/web/api/hooks/useUser.ts b/packages/web/api/hooks/useUser.ts index 8e91e94..c7b8d27 100644 --- a/packages/web/api/hooks/useUser.ts +++ b/packages/web/api/hooks/useUser.ts @@ -2,14 +2,41 @@ import { fetchUserAccount } from '@/web/api/user' import { UserApiNames, FetchUserAccountResponse } from '@/shared/api/User' import { APIs } from '@/shared/CacheAPIs' import { IpcChannels } from '@/shared/IpcChannels' -import { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery } from '@tanstack/react-query' +import { logout } from '../auth' +import { removeAllCookies } from '@/web/utils/cookie' +import reactQueryClient from '@/web/utils/reactQueryClient' export default function useUser() { - return useQuery([UserApiNames.FetchUserAccount], fetchUserAccount, { - refetchOnWindowFocus: true, - placeholderData: (): FetchUserAccountResponse | undefined => - window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, { - api: APIs.UserAccount, - }), + const key = [UserApiNames.FetchUserAccount] + return useQuery( + key, + async () => { + const existsQueryData = reactQueryClient.getQueryData(key) + if (!existsQueryData) { + window.ipcRenderer + ?.invoke(IpcChannels.GetApiCache, { + api: APIs.UserAccount, + }) + .then(cache => { + if (cache) reactQueryClient.setQueryData(key, cache) + }) + } + + return fetchUserAccount() + }, + { + refetchOnWindowFocus: true, + } + ) +} + +export const useMutationLogout = () => { + const { refetch } = useUser() + return useMutation(async () => { + await logout() + removeAllCookies() + await window.ipcRenderer?.invoke(IpcChannels.Logout) + await refetch() }) } diff --git a/packages/web/api/hooks/useUserAlbums.ts b/packages/web/api/hooks/useUserAlbums.ts index 77f08c0..5e0b869 100644 --- a/packages/web/api/hooks/useUserAlbums.ts +++ b/packages/web/api/hooks/useUserAlbums.ts @@ -17,16 +17,26 @@ import { AlbumApiNames, FetchAlbumResponse } from '@/shared/api/Album' export default function useUserAlbums(params: FetchUserAlbumsParams = {}) { const { data: user } = useUser() const uid = user?.profile?.userId ?? 0 + const key = [UserApiNames.FetchUserAlbums, uid] return useQuery( - [UserApiNames.FetchUserAlbums, uid], - () => fetchUserAlbums(params), + key, + () => { + const existsQueryData = reactQueryClient.getQueryData(key) + if (!existsQueryData) { + window.ipcRenderer + ?.invoke(IpcChannels.GetApiCache, { + api: APIs.UserAlbums, + query: params, + }) + .then(cache => { + if (cache) reactQueryClient.setQueryData(key, cache) + }) + } + + return fetchUserAlbums(params) + }, { refetchOnWindowFocus: true, - placeholderData: (): FetchUserAlbumsResponse | undefined => - window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, { - api: APIs.UserAlbums, - query: params, - }), } ) } diff --git a/packages/web/api/hooks/useUserArtists.ts b/packages/web/api/hooks/useUserArtists.ts index c0ce513..8912044 100644 --- a/packages/web/api/hooks/useUserArtists.ts +++ b/packages/web/api/hooks/useUserArtists.ts @@ -9,22 +9,32 @@ import { ArtistApiNames, FetchArtistResponse } from '@/shared/api/Artist' import reactQueryClient from '@/web/utils/reactQueryClient' import { cloneDeep } from 'lodash-es' -const KEYS = { - useUserArtists: [UserApiNames.FetchUserArtists], -} - export default function useUserArtists() { - return useQuery(KEYS.useUserArtists, fetchUserArtists, { - refetchOnWindowFocus: true, - placeholderData: (): FetchUserArtistsResponse => - window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, { - api: APIs.UserArtists, - }), - }) + const key = [UserApiNames.FetchUserArtists] + return useQuery( + key, + () => { + const existsQueryData = reactQueryClient.getQueryData(key) + if (!existsQueryData) { + window.ipcRenderer + ?.invoke(IpcChannels.GetApiCache, { + api: APIs.UserArtists, + }) + .then(cache => { + if (cache) reactQueryClient.setQueryData(key, cache) + }) + } + return fetchUserArtists() + }, + { + refetchOnWindowFocus: true, + } + ) } export const useMutationLikeAArtist = () => { const { data: userLikedArtists } = useUserArtists() + const key = [UserApiNames.FetchUserArtists] return useMutation( async (artistID: number) => { @@ -41,16 +51,16 @@ export const useMutationLikeAArtist = () => { { onMutate: async artistID => { // Cancel any outgoing refetches (so they don't overwrite our optimistic update) - await reactQueryClient.cancelQueries(KEYS.useUserArtists) + await reactQueryClient.cancelQueries(key) // 如果还未获取用户收藏的歌手列表,则获取一次 - if (!reactQueryClient.getQueryData(KEYS.useUserArtists)) { - await reactQueryClient.fetchQuery(KEYS.useUserArtists) + if (!reactQueryClient.getQueryData(key)) { + await reactQueryClient.fetchQuery(key) } // Snapshot the previous value const previousData = reactQueryClient.getQueryData( - KEYS.useUserArtists + key ) as FetchUserArtistsResponse const isLiked = !!previousData?.data.find(a => a.id === artistID) @@ -83,10 +93,10 @@ export const useMutationLikeAArtist = () => { newLikedArtists.data.unshift(artist.artist) // Optimistically update to the new value - reactQueryClient.setQueriesData(KEYS.useUserArtists, newLikedArtists) + reactQueryClient.setQueriesData(key, newLikedArtists) } - reactQueryClient.setQueriesData(KEYS.useUserArtists, newLikedArtists) + reactQueryClient.setQueriesData(key, newLikedArtists) // Return a context object with the snapshotted value return { previousData } @@ -94,10 +104,7 @@ export const useMutationLikeAArtist = () => { // If the mutation fails, use the context returned from onMutate to roll back onSettled: (data, error, artistID, context) => { if (data?.code !== 200) { - reactQueryClient.setQueryData( - KEYS.useUserArtists, - (context as any).previousData - ) + reactQueryClient.setQueryData(key, (context as any).previousData) toast((error as any).toString()) } }, diff --git a/packages/web/api/hooks/useUserLikedTracksIDs.ts b/packages/web/api/hooks/useUserLikedTracksIDs.ts index c907399..9862a1d 100644 --- a/packages/web/api/hooks/useUserLikedTracksIDs.ts +++ b/packages/web/api/hooks/useUserLikedTracksIDs.ts @@ -15,20 +15,30 @@ import reactQueryClient from '@/web/utils/reactQueryClient' export default function useUserLikedTracksIDs() { const { data: user } = useUser() const uid = user?.account?.id ?? 0 + const key = [UserApiNames.FetchUserLikedTracksIds, uid] return useQuery( - [UserApiNames.FetchUserLikedTracksIds, uid], - () => fetchUserLikedTracksIDs({ uid }), + key, + () => { + const existsQueryData = reactQueryClient.getQueryData(key) + if (!existsQueryData) { + window.ipcRenderer + ?.invoke(IpcChannels.GetApiCache, { + api: APIs.Likelist, + query: { + uid, + }, + }) + .then(cache => { + if (cache) reactQueryClient.setQueryData(key, cache) + }) + } + + return fetchUserLikedTracksIDs({ uid }) + }, { enabled: !!(uid && uid !== 0), refetchOnWindowFocus: true, - placeholderData: (): FetchUserLikedTracksIDsResponse | undefined => - window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, { - api: APIs.Likelist, - query: { - uid, - }, - }), } ) } diff --git a/packages/web/api/hooks/useUserListenedRecords.ts b/packages/web/api/hooks/useUserListenedRecords.ts index da98dd3..1e4c041 100644 --- a/packages/web/api/hooks/useUserListenedRecords.ts +++ b/packages/web/api/hooks/useUserListenedRecords.ts @@ -4,27 +4,37 @@ import { APIs } from '@/shared/CacheAPIs' import { IpcChannels } from '@/shared/IpcChannels' import { useQuery } from '@tanstack/react-query' import useUser from './useUser' +import reactQueryClient from '@/web/utils/reactQueryClient' export default function useUserListenedRecords(params: { type: 'week' | 'all' }) { const { data: user } = useUser() const uid = user?.account?.id || 0 + const key = [UserApiNames.FetchListenedRecords] return useQuery( - [UserApiNames.FetchListenedRecords], - () => - fetchListenedRecords({ + key, + () => { + const existsQueryData = reactQueryClient.getQueryData(key) + if (!existsQueryData) { + window.ipcRenderer + ?.invoke(IpcChannels.GetApiCache, { + api: APIs.ListenedRecords, + }) + .then(cache => { + if (cache) reactQueryClient.setQueryData(key, cache) + }) + } + + return fetchListenedRecords({ uid, type: params.type === 'week' ? 1 : 0, - }), + }) + }, { refetchOnWindowFocus: false, enabled: !!uid, - placeholderData: (): FetchListenedRecordsResponse => - window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, { - api: APIs.ListenedRecords, - }), } ) } diff --git a/packages/web/api/hooks/useUserPlaylists.ts b/packages/web/api/hooks/useUserPlaylists.ts index 3921070..a789933 100644 --- a/packages/web/api/hooks/useUserPlaylists.ts +++ b/packages/web/api/hooks/useUserPlaylists.ts @@ -20,14 +20,30 @@ export default function useUserPlaylists() { limit: 2000, } + const key = [UserApiNames.FetchUserPlaylists, uid] + return useQuery( - [UserApiNames.FetchUserPlaylists, uid], + key, async () => { if (!params.uid) { throw new Error('请登录后再请求用户收藏的歌单') } - const data = await fetchUserPlaylists(params) - return data + + const existsQueryData = reactQueryClient.getQueryData(key) + if (!existsQueryData) { + window.ipcRenderer + ?.invoke(IpcChannels.GetApiCache, { + api: APIs.UserPlaylist, + query: { + uid: params.uid, + }, + }) + .then(cache => { + if (cache) reactQueryClient.setQueryData(key, cache) + }) + } + + return fetchUserPlaylists(params) }, { enabled: !!( @@ -36,13 +52,6 @@ export default function useUserPlaylists() { params.offset !== undefined ), refetchOnWindowFocus: true, - placeholderData: (): FetchUserPlaylistsResponse => - window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, { - api: APIs.UserPlaylist, - query: { - uid: params.uid, - }, - }), } ) } diff --git a/packages/web/assets/icons/caret-right.svg b/packages/web/assets/icons/caret-right.svg new file mode 100644 index 0000000..9c5e4ea --- /dev/null +++ b/packages/web/assets/icons/caret-right.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/web/assets/icons/pause.svg b/packages/web/assets/icons/pause.svg index 87b2191..9c7bda8 100644 --- a/packages/web/assets/icons/pause.svg +++ b/packages/web/assets/icons/pause.svg @@ -1,4 +1 @@ - - - - + diff --git a/packages/web/components/New/Airplay.tsx b/packages/web/components/Airplay.tsx similarity index 100% rename from packages/web/components/New/Airplay.tsx rename to packages/web/components/Airplay.tsx diff --git a/packages/web/components/New/ArtistRow.tsx b/packages/web/components/ArtistRow.tsx similarity index 100% rename from packages/web/components/New/ArtistRow.tsx rename to packages/web/components/ArtistRow.tsx diff --git a/packages/web/components/New/ArtistsInLine.tsx b/packages/web/components/ArtistsInLine.tsx similarity index 100% rename from packages/web/components/New/ArtistsInLine.tsx rename to packages/web/components/ArtistsInLine.tsx diff --git a/packages/web/components/ArtistsInline.tsx b/packages/web/components/ArtistsInline.tsx deleted file mode 100644 index 259535b..0000000 --- a/packages/web/components/ArtistsInline.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useNavigate } from 'react-router-dom' -import { cx } from '@emotion/css' - -const ArtistInline = ({ - artists, - className, - disableLink, - onClick, -}: { - artists: Artist[] - className?: string - disableLink?: boolean - onClick?: (artistId: number) => void -}) => { - if (!artists) return
- - const navigate = useNavigate() - const handleClick = (id: number) => { - if (id === 0 || disableLink) return - if (!onClick) { - navigate(`/artist/${id}`) - } else { - onClick(id) - } - } - - return ( -
- {artists.map((artist, index) => ( - - handleClick(artist.id)} - className={cx({ - 'hover:underline': !!artist.id && !disableLink, - })} - > - {artist.name} - - {index < artists.length - 1 ? ', ' : ''}  - - ))} -
- ) -} - -export default ArtistInline diff --git a/packages/web/components/Avatar.tsx b/packages/web/components/Avatar.tsx deleted file mode 100644 index 1f2783e..0000000 --- a/packages/web/components/Avatar.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { resizeImage } from '../utils/common' -import useUser from '@/web/api/hooks/useUser' -import Icon from './Icon' -import { cx } from '@emotion/css' -import { useNavigate } from 'react-router-dom' - -const Avatar = ({ size }: { size?: string }) => { - const navigate = useNavigate() - const { data: user } = useUser() - - const avatarUrl = user?.profile?.avatarUrl - ? resizeImage(user?.profile?.avatarUrl ?? '', 'sm') - : '' - - return ( - <> - {avatarUrl ? ( - navigate('/login')} - className={cx( - 'app-region-no-drag rounded-full bg-gray-100 dark:bg-gray-700', - size || 'h-9 w-9' - )} - /> - ) : ( -
navigate('/login')}> - -
- )} - - ) -} - -export default Avatar diff --git a/packages/web/components/New/BlurBackground.tsx b/packages/web/components/BlurBackground.tsx similarity index 59% rename from packages/web/components/New/BlurBackground.tsx rename to packages/web/components/BlurBackground.tsx index d6e938b..854e0ea 100644 --- a/packages/web/components/New/BlurBackground.tsx +++ b/packages/web/components/BlurBackground.tsx @@ -6,7 +6,7 @@ import uiStates from '@/web/states/uiStates' import { AnimatePresence, motion, useAnimation } from 'framer-motion' import { ease } from '@/web/utils/const' import { useLocation } from 'react-router-dom' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' const BlurBackground = () => { const isMobile = useIsMobile() @@ -18,19 +18,29 @@ const BlurBackground = () => { uiStates.blurBackgroundImage = null }, [location.pathname]) - const onLoad = async () => { - animate.start({ opacity: 1 }) - } + const [isLoaded, setIsLoaded] = useState(false) + useEffect(() => { + setIsLoaded(false) + }, [blurBackgroundImage]) + + useEffect(() => { + if (!isMobile && blurBackgroundImage && hideTopbarBackground && isLoaded) { + animate.start({ opacity: 1 }) + } else { + animate.start({ opacity: 0 }) + } + }, [animate, blurBackgroundImage, hideTopbarBackground, isLoaded, isMobile]) return ( - {!isMobile && blurBackgroundImage && hideTopbarBackground && ( - + setIsLoaded(true)} className={cx( 'absolute z-0 object-cover opacity-70', css` @@ -41,9 +51,9 @@ const BlurBackground = () => { filter: blur(256px) saturate(1.2); ` )} - src={resizeImage(blurBackgroundImage, 'sm')} + src={resizeImage(blurBackgroundImage || '', 'sm')} /> - )} + ) } diff --git a/packages/web/components/Button.tsx b/packages/web/components/Button.tsx deleted file mode 100644 index 1e3fcac..0000000 --- a/packages/web/components/Button.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { ReactNode } from 'react' -import { cx } from '@emotion/css' - -export enum Color { - Primary = 'primary', - Gray = 'gray', -} - -export enum Shape { - Default = 'default', - Square = 'square', -} - -const Button = ({ - children, - onClick, - color = Color.Primary, - iconColor = Color.Primary, - isSkelton = false, -}: { - children: ReactNode - onClick: () => void - color?: Color - iconColor?: Color - isSkelton?: boolean -}) => { - return ( - - ) -} - -export default Button diff --git a/packages/web/components/New/ContextMenus/AlbumContextMenu.tsx b/packages/web/components/ContextMenus/AlbumContextMenu.tsx similarity index 88% rename from packages/web/components/New/ContextMenus/AlbumContextMenu.tsx rename to packages/web/components/ContextMenus/AlbumContextMenu.tsx index 2899f75..39aeb8d 100644 --- a/packages/web/components/New/ContextMenus/AlbumContextMenu.tsx +++ b/packages/web/components/ContextMenus/AlbumContextMenu.tsx @@ -6,11 +6,14 @@ import player from '@/web/states/player' import { AnimatePresence } from 'framer-motion' import { useMemo, useState } from 'react' import toast from 'react-hot-toast' +import { useTranslation } from 'react-i18next' import { useCopyToClipboard } from 'react-use' import { useSnapshot } from 'valtio' import BasicContextMenu from './BasicContextMenu' const AlbumContextMenu = () => { + const { t } = useTranslation() + const { cursorPosition, type, dataSourceID, target, options } = useSnapshot(contextMenus) const likeAAlbum = useMutationLikeAAlbum() @@ -34,7 +37,7 @@ const AlbumContextMenu = () => { items={[ { type: 'item', - label: 'Add to Queue', + label: t`context-menu.add-to-queue`, onClick: () => { toast('开发中') @@ -63,7 +66,7 @@ const AlbumContextMenu = () => { }, { type: 'item', - label: 'Add to playlist', + label: t`context-menu.add-to-playlist`, onClick: () => { toast('开发中') }, @@ -73,16 +76,16 @@ const AlbumContextMenu = () => { }, { type: 'submenu', - label: 'Share', + label: t`context-menu.share`, items: [ { type: 'item', - label: 'Copy Netease Link', + label: t`context-menu.copy-netease-link`, onClick: () => { copyToClipboard( `https://music.163.com/#/album?id=${dataSourceID}` ) - toast.success('Copied') + toast.success(t`toasts.copied`) }, }, { @@ -92,7 +95,7 @@ const AlbumContextMenu = () => { copyToClipboard( `${window.location.origin}/album/${dataSourceID}` ) - toast.success('Copied') + toast.success(t`toasts.copied`) }, }, ], diff --git a/packages/web/components/New/ContextMenus/ArtistContextMenu.tsx b/packages/web/components/ContextMenus/ArtistContextMenu.tsx similarity index 80% rename from packages/web/components/New/ContextMenus/ArtistContextMenu.tsx rename to packages/web/components/ContextMenus/ArtistContextMenu.tsx index 000897c..91905d5 100644 --- a/packages/web/components/New/ContextMenus/ArtistContextMenu.tsx +++ b/packages/web/components/ContextMenus/ArtistContextMenu.tsx @@ -5,11 +5,14 @@ import contextMenus, { closeContextMenu } from '@/web/states/contextMenus' import { AnimatePresence } from 'framer-motion' import { useMemo, useState } from 'react' import toast from 'react-hot-toast' +import { useTranslation } from 'react-i18next' import { useCopyToClipboard } from 'react-use' import { useSnapshot } from 'valtio' import BasicContextMenu from './BasicContextMenu' const ArtistContextMenu = () => { + const { t } = useTranslation() + const { cursorPosition, type, dataSourceID, target, options } = useSnapshot(contextMenus) const likeAArtist = useMutationLikeAArtist() @@ -18,9 +21,9 @@ const ArtistContextMenu = () => { const { data: likedArtists } = useUserArtists() const followLabel = useMemo(() => { return likedArtists?.data?.find(a => a.id === Number(dataSourceID)) - ? 'Follow' - : 'Unfollow' - }, [dataSourceID, likedArtists?.data]) + ? t`context-menu.unfollow` + : t`context-menu.follow` + }, [dataSourceID, likedArtists?.data, t]) return ( @@ -41,7 +44,9 @@ const ArtistContextMenu = () => { likeAArtist.mutateAsync(Number(dataSourceID)).then(res => { if (res?.code === 200) { toast.success( - followLabel === 'Unfollow' ? 'Followed' : 'Unfollowed' + followLabel === t`context-menu.unfollow` + ? t`context-menu.unfollowed` + : t`context-menu.followed` ) } }) @@ -52,16 +57,16 @@ const ArtistContextMenu = () => { }, { type: 'submenu', - label: 'Share', + label: t`context-menu.share`, items: [ { type: 'item', - label: 'Copy Netease Link', + label: t`context-menu.copy-netease-link`, onClick: () => { copyToClipboard( `https://music.163.com/#/artist?id=${dataSourceID}` ) - toast.success('Copied') + toast.success(t`toasts.copied`) }, }, { @@ -71,7 +76,7 @@ const ArtistContextMenu = () => { copyToClipboard( `${window.location.origin}/artist/${dataSourceID}` ) - toast.success('Copied') + toast.success(t`toasts.copied`) }, }, ], diff --git a/packages/web/components/ContextMenus/BasicContextMenu.tsx b/packages/web/components/ContextMenus/BasicContextMenu.tsx new file mode 100644 index 0000000..f65a1bd --- /dev/null +++ b/packages/web/components/ContextMenus/BasicContextMenu.tsx @@ -0,0 +1,85 @@ +import { useLayoutEffect, useRef, useState } from 'react' +import { useClickAway } from 'react-use' +import useLockMainScroll from '@/web/hooks/useLockMainScroll' +import useMeasure from 'react-use-measure' +import { ContextMenuItem } from './MenuItem' +import MenuPanel from './MenuPanel' + +const BasicContextMenu = ({ + onClose, + items, + target, + cursorPosition, + options, + classNames, +}: { + onClose: (e: MouseEvent) => void + items: ContextMenuItem[] + target: HTMLElement + cursorPosition: { x: number; y: number } + options?: { + useCursorPosition?: boolean + } | null + classNames?: string +}) => { + const menuRef = useRef(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 ( + <> + { + // + }} + forMeasure={true} + classNames={classNames} + /> + {position && ( + + )} + + ) +} + +export default BasicContextMenu diff --git a/packages/web/components/New/ContextMenus/ContextMenus.tsx b/packages/web/components/ContextMenus/ContextMenus.tsx similarity index 100% rename from packages/web/components/New/ContextMenus/ContextMenus.tsx rename to packages/web/components/ContextMenus/ContextMenus.tsx diff --git a/packages/web/components/ContextMenus/MenuItem.tsx b/packages/web/components/ContextMenus/MenuItem.tsx new file mode 100644 index 0000000..883e29a --- /dev/null +++ b/packages/web/components/ContextMenus/MenuItem.tsx @@ -0,0 +1,112 @@ +import { css, cx } from '@emotion/css' +import { ForwardedRef, forwardRef, useRef, useState } from 'react' +import Icon from '../Icon' + +export interface ContextMenuItem { + type: 'item' | 'submenu' | 'divider' + label?: string + onClick?: (e: MouseEvent) => void + items?: ContextMenuItem[] +} + +const MenuItem = ({ + item, + index, + onClose, + onSubmenuOpen, + onSubmenuClose, + className, +}: { + item: ContextMenuItem + index: number + onClose: (e: MouseEvent) => void + onSubmenuOpen: (props: { itemRect: DOMRect; index: number }) => void + onSubmenuClose: () => void + className?: string +}) => { + const itemRef = useRef(null) + const [isHover, setIsHover] = useState(false) + + if (item.type === 'divider') { + return ( +
+
+
+ ) + } + + return ( +
{ + if (!item.onClick) { + return + } + const event = e as unknown as MouseEvent + item.onClick?.(event) + onClose(event) + }} + onMouseOver={() => { + if (item.type !== 'submenu') return + setIsHover(true) + onSubmenuOpen({ + itemRect: itemRef.current!.getBoundingClientRect(), + index, + }) + }} + onMouseLeave={e => { + const relatedTarget = e.relatedTarget as HTMLElement | null + if (relatedTarget?.classList?.contains('submenu')) { + return + } + setIsHover(false) + onSubmenuClose() + }} + className={cx( + 'relative', + className, + css` + padding-right: 9px; + padding-left: 9px; + ` + )} + > +
+
{item.label}
+ {item.type === 'submenu' && ( + <> + + + {/* 将item变宽一点,避免移动鼠标时还没移动到submenu就关闭submenu了 */} +
+ + )} +
+
+ ) +} + +export default MenuItem diff --git a/packages/web/components/ContextMenus/MenuPanel.tsx b/packages/web/components/ContextMenus/MenuPanel.tsx new file mode 100644 index 0000000..a577e7e --- /dev/null +++ b/packages/web/components/ContextMenus/MenuPanel.tsx @@ -0,0 +1,173 @@ +import { css, cx } from '@emotion/css' +import { + ForwardedRef, + forwardRef, + useLayoutEffect, + useRef, + useState, +} from 'react' +import { motion } from 'framer-motion' +import MenuItem, { ContextMenuItem } from './MenuItem' + +interface PanelProps { + position: { + x: number + y: number + transformOrigin?: `origin-${'top' | 'bottom'}-${'left' | 'right'}` + } + items: ContextMenuItem[] + onClose: (e: MouseEvent) => void + forMeasure?: boolean + classNames?: string + isSubmenu?: boolean +} + +interface SubmenuProps { + itemRect: DOMRect + index: number +} + +const MenuPanel = forwardRef( + ( + { position, items, onClose, forMeasure, classNames, isSubmenu }: PanelProps, + ref: ForwardedRef + ) => { + const [submenuProps, setSubmenuProps] = useState(null) + + return ( + // Container (to add padding for submenus) + + {/* The real panel */} +
+ {items.map((item, index) => ( + setSubmenuProps(props)} + onSubmenuClose={() => setSubmenuProps(null)} + className={isSubmenu ? 'submenu' : ''} + /> + ))} +
+ + {/* Submenu */} + +
+ ) + } +) +MenuPanel.displayName = 'Menu' + +export default MenuPanel + +const SubMenu = ({ + items, + itemRect, + onClose, +}: { + items?: ContextMenuItem[] + itemRect?: DOMRect + onClose: (e: MouseEvent) => void +}) => { + const submenuRef = useRef(null) + + const [position, setPosition] = useState<{ + x: number + y: number + transformOrigin: `origin-${'top' | 'bottom'}-${'left' | 'right'}` + }>() + useLayoutEffect(() => { + if (!itemRect || !submenuRef.current) { + return + } + const item = itemRect + 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 - 10 + submenu.height <= window.innerHeight + const y = isTopSide + ? item.y - 10 + : item.y + item.height + 10 - submenu.height + + const transformOriginTable = { + top: { + right: 'origin-top-left', + left: 'origin-top-right', + }, + bottom: { + right: 'origin-bottom-left', + left: 'origin-bottom-right', + }, + } as const + + setPosition({ + x, + y, + transformOrigin: + transformOriginTable[isTopSide ? 'top' : 'bottom'][ + isRightSide ? 'right' : 'left' + ], + }) + }, [itemRect]) + + if (!items || !itemRect) { + return <> + } + + return ( + <> + { + // Do nothing + }} + forMeasure={true} + isSubmenu={true} + /> + + + ) +} diff --git a/packages/web/components/New/ContextMenus/TrackContextMenu.tsx b/packages/web/components/ContextMenus/TrackContextMenu.tsx similarity index 64% rename from packages/web/components/New/ContextMenus/TrackContextMenu.tsx rename to packages/web/components/ContextMenus/TrackContextMenu.tsx index 4b70dea..5158e45 100644 --- a/packages/web/components/New/ContextMenus/TrackContextMenu.tsx +++ b/packages/web/components/ContextMenus/TrackContextMenu.tsx @@ -1,6 +1,9 @@ +import { fetchTracksWithReactQuery } from '@/web/api/hooks/useTracks' +import { fetchTracks } from '@/web/api/track' import contextMenus, { closeContextMenu } from '@/web/states/contextMenus' import { AnimatePresence } from 'framer-motion' import toast from 'react-hot-toast' +import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' import { useCopyToClipboard } from 'react-use' import { useSnapshot } from 'valtio' @@ -8,6 +11,8 @@ import BasicContextMenu from './BasicContextMenu' const TrackContextMenu = () => { const navigate = useNavigate() + const { t } = useTranslation() + const [, copyToClipboard] = useCopyToClipboard() const { type, dataSourceID, target, cursorPosition, options } = @@ -24,7 +29,7 @@ const TrackContextMenu = () => { items={[ { type: 'item', - label: 'Add to Queue', + label: t`context-menu.add-to-queue`, onClick: () => { toast('开发中') }, @@ -34,16 +39,24 @@ const TrackContextMenu = () => { }, { type: 'item', - label: 'Go to artist', - onClick: () => { - toast('开发中') + label: t`context-menu.go-to-artist`, + onClick: async () => { + const tracks = await fetchTracksWithReactQuery({ + ids: [Number(dataSourceID)], + }) + const track = tracks?.songs?.[0] + if (track) navigate(`/artist/${track.ar[0].id}`) }, }, { type: 'item', - label: 'Go to album', - onClick: () => { - toast('开发中') + label: t`context-menu.go-to-album`, + onClick: async () => { + const tracks = await fetchTracksWithReactQuery({ + ids: [Number(dataSourceID)], + }) + const track = tracks?.songs?.[0] + if (track) navigate(`/album/${track.al.id}`) }, }, { @@ -51,30 +64,30 @@ const TrackContextMenu = () => { }, { type: 'item', - label: 'Add to Liked Tracks', + label: t`context-menu.add-to-liked-tracks`, onClick: () => { toast('开发中') }, }, { type: 'item', - label: 'Add to playlist', + label: t`context-menu.add-to-playlist`, onClick: () => { toast('开发中') }, }, { type: 'submenu', - label: 'Share', + label: t`context-menu.share`, items: [ { type: 'item', - label: 'Copy Netease Link', + label: t`context-menu.copy-netease-link`, onClick: () => { copyToClipboard( `https://music.163.com/#/album?id=${dataSourceID}` ) - toast.success('Copied') + toast.success(t`toasts.copied`) }, }, { @@ -84,7 +97,7 @@ const TrackContextMenu = () => { copyToClipboard( `${window.location.origin}/album/${dataSourceID}` ) - toast.success('Copied') + toast.success(t`toasts.copied`) }, }, ], diff --git a/packages/web/components/Cover.tsx b/packages/web/components/Cover.tsx deleted file mode 100644 index d8586f3..0000000 --- a/packages/web/components/Cover.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import Icon from '@/web/components/Icon' -import { cx } from '@emotion/css' -import { useState } from 'react' - -const Cover = ({ - imageUrl, - onClick, - roundedClass = 'rounded-xl', - showPlayButton = false, - showHover = true, - alwaysShowShadow = false, -}: { - imageUrl: string - onClick?: () => void - roundedClass?: string - showPlayButton?: boolean - showHover?: boolean - alwaysShowShadow?: boolean -}) => { - const [isError, setIsError] = useState(imageUrl.includes('3132508627578625')) - - return ( -
- {/* Neon shadow */} - {showHover && ( -
- )} - - {/* Cover */} - {isError ? ( -
- -
- ) : ( - imageUrl && setIsError(true)} - /> - )} - - {/* Play button */} - {showPlayButton && ( -
- -
- )} -
- ) -} - -export default Cover diff --git a/packages/web/components/CoverRow.tsx b/packages/web/components/CoverRow.tsx index 4012edc..9465795 100644 --- a/packages/web/components/CoverRow.tsx +++ b/packages/web/components/CoverRow.tsx @@ -1,229 +1,136 @@ -import Cover from '@/web/components/Cover' -import Skeleton from '@/web/components/Skeleton' -import Icon from '@/web/components/Icon' +import { resizeImage } from '@/web/utils/common' +import { cx } from '@emotion/css' +import { useNavigate } from 'react-router-dom' +import Image from './Image' import { prefetchAlbum } from '@/web/api/hooks/useAlbum' import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist' -import { formatDate, resizeImage, scrollToTop } from '@/web/utils/common' -import { cx } from '@emotion/css' -import { useMemo } from 'react' -import { useNavigate } from 'react-router-dom' +import { memo, useCallback } from 'react' +import dayjs from 'dayjs' +import ArtistInline from './ArtistsInLine' -export enum Subtitle { - Copywriter = 'copywriter', - Creator = 'creator', - TypeReleaseYear = 'type+releaseYear', - Artist = 'artist', -} +type ItemTitle = undefined | 'name' +type ItemSubTitle = undefined | 'artist' | 'year' -const Title = ({ - title, - seeMoreLink, +const Album = ({ + album, + itemTitle, + itemSubtitle, }: { - title: string - seeMoreLink: string + album: Album + itemTitle?: ItemTitle + itemSubtitle?: ItemSubTitle }) => { + const navigate = useNavigate() + const goTo = () => { + navigate(`/album/${album.id}`) + } + const prefetch = () => { + prefetchAlbum({ id: album.id }) + } + + const title = + itemTitle && + { + name: album.name, + }[itemTitle] + + const subtitle = + itemSubtitle && + { + artist: ( + + ), + year: dayjs(album.publishTime || 0).year(), + }[itemSubtitle] + return ( -
-
- {title} -
- {seeMoreLink && ( -
- See More +
+ + {title && ( +
+ {title} +
+ )} + {subtitle && ( +
+ {subtitle}
)}
) } -const getSubtitleText = ( - item: Album | Playlist | Artist, - subtitle: Subtitle -) => { - const nickname = 'creator' in item ? item.creator.nickname : 'someone' - const artist = - 'artist' in item - ? item.artist.name - : 'artists' in item - ? item.artists?.[0]?.name - : 'unknown' - const copywriter = 'copywriter' in item ? item.copywriter : 'unknown' - const releaseYear = - ('publishTime' in item && - formatDate(item.publishTime ?? 0, 'en', 'YYYY')) || - 'unknown' +const Playlist = ({ playlist }: { playlist: Playlist }) => { + const navigate = useNavigate() + const goTo = useCallback(() => { + navigate(`/playlist/${playlist.id}`) + }, [navigate, playlist.id]) + const prefetch = useCallback(() => { + prefetchPlaylist({ id: playlist.id }) + }, [playlist.id]) - const type = { - playlist: 'playlist', - album: 'Album', - 专辑: 'Album', - Single: 'Single', - 'EP/Single': 'EP', - EP: 'EP', - unknown: 'unknown', - 精选集: 'Collection', - }[('type' in item && typeof item.type !== 'number' && item.type) || 'unknown'] - - const table = { - [Subtitle.Creator]: `by ${nickname}`, - [Subtitle.TypeReleaseYear]: `${type} · ${releaseYear}`, - [Subtitle.Artist]: artist, - [Subtitle.Copywriter]: copywriter, - } - - return table[subtitle] -} - -const getImageUrl = (item: Album | Playlist | Artist) => { - let cover: string | undefined = '' - if ('coverImgUrl' in item) cover = item.coverImgUrl - if ('picUrl' in item) cover = item.picUrl - if ('img1v1Url' in item) cover = item.img1v1Url - return resizeImage(cover || '', 'md') + return ( + + ) } const CoverRow = ({ - title, albums, - artists, playlists, - subtitle = Subtitle.Copywriter, - seeMoreLink, - isSkeleton, + title, className, - rows = 2, - navigateCallback, // Callback function when click on the cover/title + itemTitle, + itemSubtitle, }: { title?: string - albums?: Album[] - artists?: Artist[] - playlists?: Playlist[] - subtitle?: Subtitle - seeMoreLink?: string - isSkeleton?: boolean className?: string - rows?: number - navigateCallback?: () => void + albums?: Album[] + playlists?: Playlist[] + itemTitle?: ItemTitle + itemSubtitle?: ItemSubTitle }) => { - const renderItems = useMemo(() => { - if (isSkeleton) { - return new Array(rows * 5).fill({}) as Array - } - return albums ?? playlists ?? artists ?? [] - }, [albums, artists, isSkeleton, playlists, rows]) - - const navigate = useNavigate() - const goTo = (id: number) => { - if (isSkeleton) return - if (albums) navigate(`/album/${id}`) - if (playlists) navigate(`/playlist/${id}`) - if (artists) navigate(`/artist/${id}`) - if (navigateCallback) navigateCallback() - scrollToTop() - } - - const prefetch = (id: number) => { - if (albums) prefetchAlbum({ id }) - if (playlists) prefetchPlaylist({ id }) - } - return ( -
- {title && } + <div className={className}> + {/* Title */} + {title && ( + <h4 className='mb-6 text-14 font-bold uppercase dark:text-neutral-300'> + {title} + </h4> + )} - <div - className={cx( - 'grid', - className, - !className && - 'grid-cols-3 gap-x-6 gap-y-7 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6' - )} - > - {renderItems.map((item, index) => ( - <div - key={item.id ?? index} - onMouseOver={() => prefetch(item.id)} - className='grid gap-x-6 gap-y-7' - > - <div> - {/* Cover */} - {isSkeleton ? ( - <Skeleton className='box-content aspect-square w-full rounded-xl border border-black border-opacity-0' /> - ) : ( - <Cover - onClick={() => goTo(item.id)} - imageUrl={getImageUrl(item)} - showPlayButton={true} - roundedClass={artists ? 'rounded-full' : 'rounded-xl'} - /> - )} - - {/* Info */} - <div className='mt-2'> - <div className='font-semibold'> - {/* Name */} - {isSkeleton ? ( - <div className='flex w-full -translate-y-px flex-col'> - <Skeleton className='w-full leading-tight'> - PLACEHOLDER - </Skeleton> - <Skeleton className='w-1/3 translate-y-px leading-tight'> - PLACEHOLDER - </Skeleton> - </div> - ) : ( - <span - className={cx( - 'line-clamp-2 leading-tight', - artists && 'mt-3 text-center' - )} - > - {/* Playlist private icon */} - {(item as Playlist).privacy === 10 && ( - <Icon - name='lock' - className='mr-1 mb-1 inline-block h-3 w-3 text-gray-300' - /> - )} - - {/* Explicit icon */} - {(item as Album)?.mark === 1056768 && ( - <Icon - name='explicit' - className='float-right mt-[2px] h-4 w-4 text-gray-300' - /> - )} - - {/* Name */} - <span - onClick={() => goTo(item.id)} - className='decoration-gray-600 decoration-2 hover:underline dark:text-white dark:decoration-gray-200' - > - {item.name} - </span> - </span> - )} - </div> - - {/* Subtitle */} - {isSkeleton ? ( - <Skeleton className='w-3/5 translate-y-px text-[12px]'> - PLACEHOLDER - </Skeleton> - ) : ( - !artists && ( - <div className='flex text-[12px] text-gray-500 dark:text-gray-400'> - <span>{getSubtitleText(item, subtitle)}</span> - </div> - ) - )} - </div> - </div> - </div> + {/* 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} + itemTitle={itemTitle} + itemSubtitle={itemSubtitle} + /> + ))} + {playlists?.map(playlist => ( + <Playlist key={playlist.id} playlist={playlist} /> ))} </div> </div> ) } -export default CoverRow +const memoizedCoverRow = memo(CoverRow) +memoizedCoverRow.displayName = 'CoverRow' +export default memoizedCoverRow diff --git a/packages/web/components/New/CoverRowVirtual.tsx b/packages/web/components/CoverRowVirtual.tsx similarity index 100% rename from packages/web/components/New/CoverRowVirtual.tsx rename to packages/web/components/CoverRowVirtual.tsx diff --git a/packages/web/components/New/CoverWall.stories.tsx b/packages/web/components/CoverWall.stories.tsx similarity index 100% rename from packages/web/components/New/CoverWall.stories.tsx rename to packages/web/components/CoverWall.stories.tsx diff --git a/packages/web/components/New/CoverWall.tsx b/packages/web/components/CoverWall.tsx similarity index 100% rename from packages/web/components/New/CoverWall.tsx rename to packages/web/components/CoverWall.tsx diff --git a/packages/web/components/DailyTracksCard.tsx b/packages/web/components/DailyTracksCard.tsx deleted file mode 100644 index 04886de..0000000 --- a/packages/web/components/DailyTracksCard.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import Icon from './Icon' -import { cx, css, keyframes } from '@emotion/css' - -const move = keyframes` - 0% { - transform: translateY(0); - } - 100% { - transform: translateY(-50%); -} -` - -const DailyTracksCard = () => { - return ( - <div className='relative h-[198px] cursor-pointer overflow-hidden rounded-2xl'> - {/* Cover */} - <img - className={cx( - 'absolute top-0 left-0 w-full will-change-transform', - css` - animation: ${move} 38s infinite; - animation-direction: alternate; - ` - )} - src='https://p2.music.126.net/QxJA2mr4hhb9DZyucIOIQw==/109951165422200291.jpg?param=1024y1024' - /> - - {/* 每日推荐 */} - <div className='absolute flex h-full w-1/2 items-center bg-gradient-to-r from-[#0000004d] to-transparent pl-8'> - <div className='grid grid-cols-2 grid-rows-2 gap-2 text-[64px] font-semibold leading-[64px] text-white opacity-[96]'> - {Array.from('每日推荐').map(word => ( - <div key={word}>{word}</div> - ))} - </div> - </div> - - {/* Play button */} - <button className='btn-pressed-animation absolute right-6 bottom-6 grid h-11 w-11 cursor-default place-content-center rounded-full border border-white border-opacity-[.08] bg-white bg-opacity-[.14] text-white backdrop-blur backdrop-filter transition-all hover:bg-opacity-[.44]'> - <Icon name='play-fill' className='ml-0.5 h-6 w-6' /> - </button> - </div> - ) -} - -export default DailyTracksCard diff --git a/packages/web/components/New/Devtool.tsx b/packages/web/components/Devtool.tsx similarity index 100% rename from packages/web/components/New/Devtool.tsx rename to packages/web/components/Devtool.tsx diff --git a/packages/web/components/New/ErrorBoundary.tsx b/packages/web/components/ErrorBoundary.tsx similarity index 100% rename from packages/web/components/New/ErrorBoundary.tsx rename to packages/web/components/ErrorBoundary.tsx diff --git a/packages/web/components/FMCard.tsx b/packages/web/components/FMCard.tsx deleted file mode 100644 index c490ba2..0000000 --- a/packages/web/components/FMCard.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import player from '@/web/states/player' -import { resizeImage } from '@/web/utils/common' -import Icon from './Icon' -import ArtistInline from './ArtistsInline' -import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player' -import useCoverColor from '../hooks/useCoverColor' -import { cx } from '@emotion/css' -import { useNavigate } from 'react-router-dom' -import { useSnapshot } from 'valtio' -import { useMemo } from 'react' - -const MediaControls = () => { - const classes = - 'btn-pressed-animation btn-hover-animation mr-1 cursor-default rounded-lg p-1.5 transition duration-200 after:bg-white/10' - - const playerSnapshot = useSnapshot(player) - const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state]) - - const playOrPause = () => { - if (playerSnapshot.mode === PlayerMode.FM) { - player.playOrPause() - } else { - player.playFM() - } - } - - return ( - <div> - <button - key='dislike' - className={classes} - onClick={() => player.fmTrash()} - > - <Icon name='dislike' className='h-6 w-6' /> - </button> - - <button key='play' className={classes} onClick={playOrPause}> - <Icon - className='h-6 w-6' - name={ - playerSnapshot.mode === PlayerMode.FM && - [PlayerState.Playing, PlayerState.Loading].includes(state) - ? 'pause' - : 'play' - } - /> - </button> - - <button - key='next' - className={classes} - onClick={() => player.nextTrack(true)} - > - <Icon name='next' className='h-6 w-6' /> - </button> - </div> - ) -} - -const FMCard = () => { - const navigate = useNavigate() - - const { track } = useSnapshot(player) - const coverUrl = useMemo( - () => resizeImage(track?.al?.picUrl ?? '', 'md'), - [track?.al?.picUrl] - ) - - const bgColor = useCoverColor(track?.al?.picUrl ?? '') - - return ( - <div - className='relative flex h-[198px] overflow-hidden rounded-2xl bg-gray-100 p-4 dark:bg-gray-800' - style={{ - background: `linear-gradient(to bottom, ${bgColor.from}, ${bgColor.to})`, - }} - > - {coverUrl ? ( - <img - onClick={() => track?.al?.id && navigate(`/album/${track.al.id}`)} - className='rounded-lg shadow-2xl' - src={coverUrl} - /> - ) : ( - <div className='aspect-square h-full rounded-lg bg-gray-200 dark:bg-white/5'></div> - )} - - <div className='ml-5 flex w-full flex-col justify-between text-white'> - {/* Track info */} - <div> - {track ? ( - <div className='line-clamp-2 text-xl font-semibold'> - {track?.name} - </div> - ) : ( - <div className='flex'> - <div className='bg-gray-200 text-xl text-transparent dark:bg-white/5'> - PLACEHOLDER12345 - </div> - </div> - )} - {track ? ( - <ArtistInline - className='line-clamp-2 opacity-75' - artists={track?.ar ?? []} - /> - ) : ( - <div className='mt-1 flex'> - <div className='bg-gray-200 text-transparent dark:bg-white/5'> - PLACEHOLDER - </div> - </div> - )} - </div> - - <div className='-mb-1 flex items-center justify-between'> - {track ? <MediaControls /> : <div className='h-9'></div>} - - {/* FM logo */} - <div - className={cx( - 'right-4 bottom-5 flex opacity-20', - track ? 'text-white ' : 'text-gray-700 dark:text-white' - )} - > - <Icon name='fm' className='mr-1 h-6 w-6' /> - <span className='font-semibold'>私人FM</span> - </div> - </div> - </div> - </div> - ) -} - -export default FMCard diff --git a/packages/web/components/Icon/iconNamesType.ts b/packages/web/components/Icon/iconNamesType.ts index ffaab67..5bc7626 100644 --- a/packages/web/components/Icon/iconNamesType.ts +++ b/packages/web/components/Icon/iconNamesType.ts @@ -1 +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' \ No newline at end of file +export type IconNames = 'back' | 'caret-right' | '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' \ No newline at end of file diff --git a/packages/web/components/IconButton.tsx b/packages/web/components/IconButton.tsx deleted file mode 100644 index add8004..0000000 --- a/packages/web/components/IconButton.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { ReactNode } from 'react' -import { cx } from '@emotion/css' - -const IconButton = ({ - children, - onClick, - disabled, - className, -}: { - children: ReactNode - onClick: () => void - disabled?: boolean | undefined - className?: string -}) => { - return ( - <button - onClick={onClick} - disabled={disabled} - className={cx( - className, - 'relative transform cursor-default p-1.5 transition duration-200', - !disabled && - 'btn-pressed-animation btn-hover-animation after:bg-black/[.06] dark:after:bg-white/10', - disabled && 'opacity-30' - )} - > - {children} - </button> - ) -} - -export default IconButton diff --git a/packages/web/components/New/Image.tsx b/packages/web/components/Image.tsx similarity index 100% rename from packages/web/components/New/Image.tsx rename to packages/web/components/Image.tsx diff --git a/packages/web/components/New/Layout.tsx b/packages/web/components/Layout.tsx similarity index 79% rename from packages/web/components/New/Layout.tsx rename to packages/web/components/Layout.tsx index 495204d..4928089 100644 --- a/packages/web/components/New/Layout.tsx +++ b/packages/web/components/Layout.tsx @@ -1,7 +1,7 @@ -import Main from '@/web/components/New/Main' -import Player from '@/web/components/New/Player' -import MenuBar from '@/web/components/New/MenuBar' -import Topbar from '@/web/components/New/Topbar/TopbarDesktop' +import Main from '@/web/components/Main' +import Player from '@/web/components/Player' +import MenuBar from '@/web/components/MenuBar' +import Topbar from '@/web/components/Topbar/TopbarDesktop' import { css, cx } from '@emotion/css' import player from '@/web/states/player' import { useSnapshot } from 'valtio' @@ -22,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 /> diff --git a/packages/web/components/New/LayoutMobile.tsx b/packages/web/components/LayoutMobile.tsx similarity index 94% rename from packages/web/components/New/LayoutMobile.tsx rename to packages/web/components/LayoutMobile.tsx index 4595a25..6621a04 100644 --- a/packages/web/components/New/LayoutMobile.tsx +++ b/packages/web/components/LayoutMobile.tsx @@ -1,9 +1,9 @@ -import Player from '@/web/components/New/PlayerMobile' +import Player from '@/web/components/PlayerMobile' import { css, cx } from '@emotion/css' import { useMemo } from 'react' import player from '@/web/states/player' import { useSnapshot } from 'valtio' -import Router from '@/web/components/New/Router' +import Router from '@/web/components/Router' import MenuBar from './MenuBar' import Topbar from './Topbar/TopbarMobile' import { isIOS, isIosPwa, isPWA, isSafari } from '@/web/utils/common' diff --git a/packages/web/components/New/Login/Login.tsx b/packages/web/components/Login/Login.tsx similarity index 94% rename from packages/web/components/New/Login/Login.tsx rename to packages/web/components/Login/Login.tsx index 72676e9..4c17c4e 100644 --- a/packages/web/components/New/Login/Login.tsx +++ b/packages/web/components/Login/Login.tsx @@ -9,6 +9,7 @@ import LoginWithPhoneOrEmail from './LoginWithPhoneOrEmail' import LoginWithQRCode from './LoginWithQRCode' import persistedUiStates from '@/web/states/persistedUiStates' import useUser from '@/web/api/hooks/useUser' +import { useTranslation } from 'react-i18next' const OR = ({ children, @@ -17,11 +18,13 @@ const OR = ({ children: React.ReactNode onClick: () => void }) => { + const { t } = useTranslation() + return ( <> <div className='mt-4 flex items-center'> <div className='h-px flex-grow bg-white/20'></div> - <div className='mx-2 text-16 font-medium text-white'>or</div> + <div className='mx-2 text-16 font-medium text-white'>{t`auth.or`}</div> <div className='h-px flex-grow bg-white/20'></div> </div> @@ -38,6 +41,8 @@ const OR = ({ } const Login = () => { + const { t } = useTranslation() + const { data: user, isLoading: isLoadingUser } = useUser() const { loginType } = useSnapshot(persistedUiStates) const { showLoginPanel } = useSnapshot(uiStates) @@ -132,8 +137,8 @@ const Login = () => { <OR onClick={handleSwitchCard}> {cardType === 'qrCode' - ? 'Use Phone or Email' - : 'Scan QR Code'} + ? t`auth.use-phone-or-email` + : t`auth.scan-qr-code`} </OR> </motion.div> </AnimatePresence> diff --git a/packages/web/components/New/Login/LoginWithPhoneOrEmail.tsx b/packages/web/components/Login/LoginWithPhoneOrEmail.tsx similarity index 89% rename from packages/web/components/New/Login/LoginWithPhoneOrEmail.tsx rename to packages/web/components/Login/LoginWithPhoneOrEmail.tsx index de22449..50f0d1c 100644 --- a/packages/web/components/New/Login/LoginWithPhoneOrEmail.tsx +++ b/packages/web/components/Login/LoginWithPhoneOrEmail.tsx @@ -17,8 +17,12 @@ import uiStates from '@/web/states/uiStates' import persistedUiStates from '@/web/states/persistedUiStates' import reactQueryClient from '@/web/utils/reactQueryClient' import { UserApiNames } from '@/shared/api/User' +import { useTranslation } from 'react-i18next' const LoginWithPhoneOrEmail = () => { + const { t, i18n } = useTranslation() + const isZH = i18n.language.startsWith('zh') + const { loginPhoneCountryCode, loginType: persistedLoginType } = useSnapshot(persistedUiStates) const [email, setEmail] = useState<string>('') @@ -130,7 +134,7 @@ const LoginWithPhoneOrEmail = () => { return ( <> <div className='text-center text-18 font-medium text-white/20'> - Log in with{' '} + {!isZH && 'Login with '} <span className={cx( 'transition-colors duration-300', @@ -142,9 +146,10 @@ const LoginWithPhoneOrEmail = () => { persistedUiStates.loginType = type }} > - Phone - </span>{' '} - /{' '} + {t`auth.phone`} + {isZH && '登录'} + </span> + {' / '} <span className={cx( 'transition-colors duration-300', @@ -154,7 +159,8 @@ const LoginWithPhoneOrEmail = () => { if (loginType !== 'email') setLoginType('email') }} > - Email + {t`auth.email`} + {isZH && '登录'} </span> </div> @@ -167,7 +173,7 @@ const LoginWithPhoneOrEmail = () => { initial='hidden' animate='show' exit='hidden' - className='flex items-center' + className='flex items-center ' > <input onChange={e => { @@ -175,7 +181,7 @@ const LoginWithPhoneOrEmail = () => { persistedUiStates.loginPhoneCountryCode = e.target.value }} className={cx( - 'my-3.5 flex-shrink-0 bg-transparent', + 'my-3.5 flex-shrink-0 bg-transparent placeholder:text-white/30', css` width: 28px; ` @@ -186,8 +192,8 @@ const LoginWithPhoneOrEmail = () => { <div className='mx-2 h-5 w-px flex-shrink-0 bg-white/20'></div> <input onChange={e => setPhone(e.target.value)} - className='my-3.5 flex-grow appearance-none bg-transparent' - placeholder='Phone' + className='my-3.5 flex-grow appearance-none bg-transparent placeholder:text-white/30' + placeholder={t`auth.phone`} type='tel' value={phone} /> @@ -209,8 +215,8 @@ const LoginWithPhoneOrEmail = () => { > <input onChange={e => setEmail(e.target.value)} - className='w-full flex-grow appearance-none bg-transparent' - placeholder='Email' + className='w-full flex-grow appearance-none bg-transparent placeholder:text-white/30' + placeholder={t`auth.email`} type='email' value={email} /> @@ -223,8 +229,8 @@ const LoginWithPhoneOrEmail = () => { <div className='mt-4 flex items-center rounded-12 bg-black/50 p-3 text-16 font-medium text-night-50'> <input onChange={e => setPassword(e.target.value)} - className='w-full bg-transparent' - placeholder='Password' + className='w-full bg-transparent placeholder:text-white/30' + placeholder={t`auth.password`} type='password' value={password} /> @@ -237,7 +243,7 @@ const LoginWithPhoneOrEmail = () => { } className='mt-4 rounded-full bg-brand-700 p-4 text-center text-16 font-medium text-white' > - LOG IN + {t`auth.login`} </div> </> ) diff --git a/packages/web/components/New/Login/LoginWithQRCode.tsx b/packages/web/components/Login/LoginWithQRCode.tsx similarity index 96% rename from packages/web/components/New/Login/LoginWithQRCode.tsx rename to packages/web/components/Login/LoginWithQRCode.tsx index db5e3d6..9ce8ea3 100644 --- a/packages/web/components/New/Login/LoginWithQRCode.tsx +++ b/packages/web/components/Login/LoginWithQRCode.tsx @@ -8,6 +8,7 @@ import { setCookies } from '@/web/utils/cookie' import uiStates from '@/web/states/uiStates' import reactQueryClient from '@/web/utils/reactQueryClient' import { UserApiNames } from '@/shared/api/User' +import { useTranslation } from 'react-i18next' const QRCode = ({ className, text }: { className?: string; text: string }) => { const [image, setImage] = useState<string>('') @@ -37,6 +38,8 @@ const QRCode = ({ className, text }: { className?: string; text: string }) => { } const LoginWithQRCode = () => { + const { t } = useTranslation() + const { data: key, status: keyStatus, @@ -101,7 +104,7 @@ const LoginWithQRCode = () => { return ( <> <div className='text-center text-18 font-medium text-white/20'> - Log in with NetEase QR + {t`auth.login-with-netease-qr`} </div> <div className='mt-4 rounded-24 bg-white p-2.5'> diff --git a/packages/web/components/New/Login/index.tsx b/packages/web/components/Login/index.tsx similarity index 100% rename from packages/web/components/New/Login/index.tsx rename to packages/web/components/Login/index.tsx diff --git a/packages/web/components/Main.tsx b/packages/web/components/Main.tsx index dea3065..4878839 100644 --- a/packages/web/components/Main.tsx +++ b/packages/web/components/Main.tsx @@ -1,24 +1,67 @@ +import { css, cx } from '@emotion/css' import Router from './Router' -import Topbar from './Topbar' -import { cx } from '@emotion/css' +import useIntersectionObserver from '@/web/hooks/useIntersectionObserver' +import uiStates from '@/web/states/uiStates' +import { useEffect, useRef, useState } from 'react' +import { breakpoint as bp, ease } from '@/web/utils/const' +import { useSnapshot } from 'valtio' +import persistedUiStates from '@/web/states/persistedUiStates' +import { motion, useAnimation } from 'framer-motion' +import { sleep } from '@/web/utils/common' +import player from '@/web/states/player' const Main = () => { + const playerSnapshot = useSnapshot(player) + + // Show/hide topbar background + const observePoint = useRef<HTMLDivElement | null>(null) + const { onScreen } = useIntersectionObserver(observePoint) + useEffect(() => { + uiStates.hideTopbarBackground = onScreen + return () => { + uiStates.hideTopbarBackground = false + } + }, [onScreen]) + + // Change width when player is minimized + + const { minimizePlayer } = useSnapshot(persistedUiStates) + const [isMaxWidth, setIsMaxWidth] = useState(minimizePlayer) + const controlsMain = useAnimation() + useEffect(() => { + const animate = async () => { + await controlsMain.start({ opacity: 0 }) + await sleep(100) + setIsMaxWidth(minimizePlayer) + await controlsMain.start({ opacity: 1 }) + } + if (minimizePlayer !== isMaxWidth) animate() + }, [controlsMain, isMaxWidth, minimizePlayer]) + return ( - <div - id='mainContainer' - className='relative flex h-screen max-h-screen flex-grow flex-col overflow-y-auto bg-white dark:bg-[#1d1d1d]' + <motion.main + id='main' + animate={controlsMain} + transition={{ ease, duration: 0.4 }} + className={cx( + 'no-scrollbar z-10 h-screen overflow-y-auto', + css` + ${bp.lg} { + margin-left: 144px; + margin-right: ${isMaxWidth || !playerSnapshot.track ? 92 : 382}px; + } + ` + )} > - <Topbar /> - <main - id='main' - className={cx( - 'mb-24 flex-grow px-8', - window.env?.isEnableTitlebar && 'mt-8' - )} + <div ref={observePoint}></div> + <div + className={css` + margin-top: 132px; + `} > <Router /> - </main> - </div> + </div> + </motion.main> ) } diff --git a/packages/web/components/New/MenuBar.tsx b/packages/web/components/MenuBar.tsx similarity index 95% rename from packages/web/components/New/MenuBar.tsx rename to packages/web/components/MenuBar.tsx index 79e18b3..dd76923 100644 --- a/packages/web/components/New/MenuBar.tsx +++ b/packages/web/components/MenuBar.tsx @@ -1,14 +1,11 @@ import React, { useEffect, useState } from 'react' import { css, cx } from '@emotion/css' -import Icon from '../Icon' +import Icon from './Icon' import { useLocation, useNavigate } from 'react-router-dom' import { useAnimation, motion } from 'framer-motion' import { ease } from '@/web/utils/const' import useIsMobile from '@/web/hooks/useIsMobile' import { breakpoint as bp } from '@/web/utils/const' -import { useSnapshot } from 'valtio' -import uiStates from '@/web/states/uiStates' -import persistedUiStates from '@/web/states/persistedUiStates' const tabs = [ { diff --git a/packages/web/components/New/ContextMenus/BasicContextMenu.tsx b/packages/web/components/New/ContextMenus/BasicContextMenu.tsx deleted file mode 100644 index d2a4361..0000000 --- a/packages/web/components/New/ContextMenus/BasicContextMenu.tsx +++ /dev/null @@ -1,238 +0,0 @@ -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 diff --git a/packages/web/components/New/CoverRow.tsx b/packages/web/components/New/CoverRow.tsx deleted file mode 100644 index 9465795..0000000 --- a/packages/web/components/New/CoverRow.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { resizeImage } from '@/web/utils/common' -import { cx } from '@emotion/css' -import { useNavigate } from 'react-router-dom' -import Image from './Image' -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' - -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}`) - } - const prefetch = () => { - 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 - onClick={goTo} - key={album.id} - src={resizeImage(album?.picUrl || '', 'md')} - className='aspect-square rounded-24' - onMouseOver={prefetch} - /> - {title && ( - <div className='line-clamp-2 mt-2 text-14 font-medium text-neutral-300'> - {title} - </div> - )} - {subtitle && ( - <div className='mt-1 text-14 font-medium text-neutral-700'> - {subtitle} - </div> - )} - </div> - ) -} - -const Playlist = ({ playlist }: { playlist: Playlist }) => { - const navigate = useNavigate() - const goTo = useCallback(() => { - navigate(`/playlist/${playlist.id}`) - }, [navigate, playlist.id]) - const prefetch = useCallback(() => { - prefetchPlaylist({ id: playlist.id }) - }, [playlist.id]) - - return ( - <Image - onClick={goTo} - key={playlist.id} - src={resizeImage(playlist.coverImgUrl || playlist?.picUrl || '', 'md')} - className='aspect-square rounded-24' - onMouseOver={prefetch} - /> - ) -} - -const CoverRow = ({ - albums, - playlists, - title, - className, - itemTitle, - itemSubtitle, -}: { - title?: string - className?: string - albums?: Album[] - playlists?: Playlist[] - itemTitle?: ItemTitle - itemSubtitle?: ItemSubTitle -}) => { - return ( - <div className={className}> - {/* Title */} - {title && ( - <h4 className='mb-6 text-14 font-bold uppercase dark:text-neutral-300'> - {title} - </h4> - )} - - {/* Items */} - <div className='grid grid-cols-3 gap-4 lg:gap-6 xl:grid-cols-4 2xl:grid-cols-5'> - {albums?.map(album => ( - <Album - key={album.id} - album={album} - itemTitle={itemTitle} - itemSubtitle={itemSubtitle} - /> - ))} - {playlists?.map(playlist => ( - <Playlist key={playlist.id} playlist={playlist} /> - ))} - </div> - </div> - ) -} - -const memoizedCoverRow = memo(CoverRow) -memoizedCoverRow.displayName = 'CoverRow' -export default memoizedCoverRow diff --git a/packages/web/components/New/Main.tsx b/packages/web/components/New/Main.tsx deleted file mode 100644 index 4878839..0000000 --- a/packages/web/components/New/Main.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { css, cx } from '@emotion/css' -import Router from './Router' -import useIntersectionObserver from '@/web/hooks/useIntersectionObserver' -import uiStates from '@/web/states/uiStates' -import { useEffect, useRef, useState } from 'react' -import { breakpoint as bp, ease } from '@/web/utils/const' -import { useSnapshot } from 'valtio' -import persistedUiStates from '@/web/states/persistedUiStates' -import { motion, useAnimation } from 'framer-motion' -import { sleep } from '@/web/utils/common' -import player from '@/web/states/player' - -const Main = () => { - const playerSnapshot = useSnapshot(player) - - // Show/hide topbar background - const observePoint = useRef<HTMLDivElement | null>(null) - const { onScreen } = useIntersectionObserver(observePoint) - useEffect(() => { - uiStates.hideTopbarBackground = onScreen - return () => { - uiStates.hideTopbarBackground = false - } - }, [onScreen]) - - // Change width when player is minimized - - const { minimizePlayer } = useSnapshot(persistedUiStates) - const [isMaxWidth, setIsMaxWidth] = useState(minimizePlayer) - const controlsMain = useAnimation() - useEffect(() => { - const animate = async () => { - await controlsMain.start({ opacity: 0 }) - await sleep(100) - setIsMaxWidth(minimizePlayer) - await controlsMain.start({ opacity: 1 }) - } - if (minimizePlayer !== isMaxWidth) animate() - }, [controlsMain, isMaxWidth, minimizePlayer]) - - return ( - <motion.main - id='main' - animate={controlsMain} - transition={{ ease, duration: 0.4 }} - className={cx( - 'no-scrollbar z-10 h-screen overflow-y-auto', - css` - ${bp.lg} { - margin-left: 144px; - margin-right: ${isMaxWidth || !playerSnapshot.track ? 92 : 382}px; - } - ` - )} - > - <div ref={observePoint}></div> - <div - className={css` - margin-top: 132px; - `} - > - <Router /> - </div> - </motion.main> - ) -} - -export default Main diff --git a/packages/web/components/New/Player.tsx b/packages/web/components/New/Player.tsx deleted file mode 100644 index 8bd9703..0000000 --- a/packages/web/components/New/Player.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { css, cx } from '@emotion/css' -import persistedUiStates from '@/web/states/persistedUiStates' -import { useSnapshot } from 'valtio' -import NowPlaying from './NowPlaying' -import PlayingNext from './PlayingNext' -import { AnimatePresence, motion, MotionConfig } from 'framer-motion' -import { ease } from '@/web/utils/const' - -const Player = () => { - const { minimizePlayer } = useSnapshot(persistedUiStates) - - return ( - <MotionConfig transition={{ duration: 0.6 }}> - <div - className={cx( - 'fixed right-6 bottom-6 flex w-full flex-col justify-between overflow-hidden', - css` - width: 318px; - ` - )} - > - <AnimatePresence> - {!minimizePlayer && ( - <motion.div - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - exit={{ opacity: 0 }} - > - <PlayingNext /> - </motion.div> - )} - </AnimatePresence> - - <NowPlaying /> - </div> - </MotionConfig> - ) -} - -export default Player diff --git a/packages/web/components/New/Router.tsx b/packages/web/components/New/Router.tsx deleted file mode 100644 index d04f598..0000000 --- a/packages/web/components/New/Router.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Route, Routes, useLocation } from 'react-router-dom' -import Search from '@/web/pages/Search' -import Settings from '@/web/pages/Settings' -import { AnimatePresence } from 'framer-motion' -import React, { ReactNode, Suspense } from 'react' - -const My = React.lazy(() => import('@/web/pages/New/My')) -const Discover = React.lazy(() => import('@/web/pages/New/Discover')) -const Browse = React.lazy(() => import('@/web/pages/New/Browse')) -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> -} - -const Router = () => { - const location = useLocation() - - return ( - <AnimatePresence exitBeforeEnter> - <Routes location={location} key={location.pathname}> - <Route path='/' element={lazy(<My />)} /> - <Route path='/discover' element={lazy(<Discover />)} /> - <Route path='/browse' element={lazy(<Browse />)} /> - <Route path='/album/:id' element={lazy(<Album />)} /> - <Route path='/playlist/:id' element={lazy(<Playlist />)} /> - <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> - </Routes> - </AnimatePresence> - ) -} - -export default Router diff --git a/packages/web/components/New/Slider.tsx b/packages/web/components/New/Slider.tsx deleted file mode 100644 index bbf7cc1..0000000 --- a/packages/web/components/New/Slider.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { useRef, useState, useMemo, useCallback, useEffect } from 'react' -import { cx } from '@emotion/css' - -const Slider = ({ - value, - min, - max, - onChange, - onlyCallOnChangeAfterDragEnded = false, - orientation = 'horizontal', - alwaysShowThumb = false, -}: { - value: number - min: number - max: number - onChange: (value: number) => void - onlyCallOnChangeAfterDragEnded?: boolean - orientation?: 'horizontal' | 'vertical' - alwaysShowTrack?: boolean - alwaysShowThumb?: boolean -}) => { - const sliderRef = useRef<HTMLInputElement>(null) - const [isDragging, setIsDragging] = useState(false) - const [draggingValue, setDraggingValue] = useState(value) - const memoedValue = useMemo( - () => - isDragging && onlyCallOnChangeAfterDragEnded ? draggingValue : value, - [isDragging, draggingValue, value, onlyCallOnChangeAfterDragEnded] - ) - - /** - * Get the value of the slider based on the position of the pointer - */ - const getNewValue = useCallback( - (pointer: { x: number; y: number }) => { - if (!sliderRef?.current) return 0 - const slider = sliderRef.current.getBoundingClientRect() - const newValue = - orientation === 'horizontal' - ? ((pointer.x - slider.x) / slider.width) * max - : ((slider.height - (pointer.y - slider.y)) / slider.height) * max - if (newValue < min) return min - if (newValue > max) return max - return newValue - }, - [sliderRef, max, min, orientation] - ) - - /** - * Handle slider click event - */ - const handleClick = useCallback( - (e: React.MouseEvent<HTMLDivElement>) => - onChange(getNewValue({ x: e.clientX, y: e.clientY })), - [getNewValue, onChange] - ) - - /** - * Handle pointer down event - */ - const handlePointerDown = () => { - setIsDragging(true) - } - - /** - * Handle pointer move events - */ - useEffect(() => { - const handlePointerMove = (e: { clientX: number; clientY: number }) => { - if (!isDragging) return - const newValue = getNewValue({ x: e.clientX, y: e.clientY }) - onlyCallOnChangeAfterDragEnded - ? setDraggingValue(newValue) - : onChange(newValue) - } - document.addEventListener('pointermove', handlePointerMove) - - return () => { - document.removeEventListener('pointermove', handlePointerMove) - } - }, [ - isDragging, - onChange, - setDraggingValue, - onlyCallOnChangeAfterDragEnded, - getNewValue, - ]) - - /** - * Handle pointer up events - */ - useEffect(() => { - const handlePointerUp = () => { - if (!isDragging) return - setIsDragging(false) - if (onlyCallOnChangeAfterDragEnded) { - onChange(draggingValue) - } - } - document.addEventListener('pointerup', handlePointerUp) - - return () => { - document.removeEventListener('pointerup', handlePointerUp) - } - }, [ - isDragging, - setIsDragging, - onlyCallOnChangeAfterDragEnded, - draggingValue, - onChange, - ]) - - /** - * Track and thumb styles - */ - const usedTrackStyle = useMemo(() => { - const percentage = `${(memoedValue / max) * 100}%` - return orientation === 'horizontal' - ? { width: percentage } - : { height: percentage } - }, [max, memoedValue, orientation]) - const thumbStyle = useMemo(() => { - const percentage = `${(memoedValue / max) * 100}%` - return orientation === 'horizontal' - ? { left: percentage } - : { bottom: percentage } - }, [max, memoedValue, orientation]) - - return ( - <div - className={cx( - 'group relative flex items-center', - orientation === 'horizontal' && 'h-2', - orientation === 'vertical' && 'h-full w-2 flex-col' - )} - ref={sliderRef} - onClick={handleClick} - > - {/* Track */} - <div - className={cx( - 'absolute overflow-hidden rounded-full bg-black/10 bg-opacity-10 dark:bg-white/10', - orientation === 'horizontal' && 'h-[3px] w-full', - orientation === 'vertical' && 'h-full w-[3px]' - )} - > - {/* Passed track */} - <div - className={cx( - 'bg-black dark:bg-white', - orientation === 'horizontal' && 'h-full rounded-r-full', - orientation === 'vertical' && 'bottom-0 w-full rounded-t-full' - )} - style={usedTrackStyle} - ></div> - </div> - - {/* Thumb */} - <div - className={cx( - 'absolute flex h-2 w-2 items-center justify-center rounded-full bg-black bg-opacity-20 transition-opacity dark:bg-white', - isDragging || alwaysShowThumb - ? 'opacity-100' - : 'opacity-0 group-hover:opacity-100', - orientation === 'horizontal' && '-translate-x-1', - orientation === 'vertical' && 'translate-y-1' - )} - style={thumbStyle} - onClick={e => e.stopPropagation()} - onPointerDown={handlePointerDown} - ></div> - </div> - ) -} - -export default Slider diff --git a/packages/web/components/New/TitleBar.tsx b/packages/web/components/New/TitleBar.tsx deleted file mode 100644 index 9c2da9f..0000000 --- a/packages/web/components/New/TitleBar.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import player from '@/web/states/player' -import Icon from '../Icon' -import { IpcChannels } from '@/shared/IpcChannels' -import useIpcRenderer from '@/web/hooks/useIpcRenderer' -import { useState, useMemo } from 'react' -import { useSnapshot } from 'valtio' -import { css, cx } from '@emotion/css' - -const Controls = () => { - const [isMaximized, setIsMaximized] = useState(false) - - useIpcRenderer(IpcChannels.IsMaximized, (e, value) => { - setIsMaximized(value) - }) - - const minimize = () => { - window.ipcRenderer?.send(IpcChannels.Minimize) - } - - const maxRestore = () => { - window.ipcRenderer?.send(IpcChannels.MaximizeOrUnmaximize) - } - - const close = () => { - window.ipcRenderer?.send(IpcChannels.Close) - } - - const classNames = cx( - 'flex items-center justify-center text-white/80 hover:text-white hover:bg-white/20 transition duration-400', - css` - height: 28px; - width: 48px; - border-radius: 4px; - ` - ) - - return ( - <div className='app-region-no-drag flex h-full items-center'> - <button onClick={minimize} className={classNames}> - <Icon className='h-3 w-3' name='windows-minimize' /> - </button> - <button onClick={maxRestore} className={classNames}> - <Icon - className='h-3 w-3' - name={isMaximized ? 'windows-un-maximize' : 'windows-maximize'} - /> - </button> - <button - onClick={close} - className={cx( - classNames, - css` - margin-right: 5px; - ` - )} - > - <Icon className='h-3 w-3' name='windows-close' /> - </button> - </div> - ) -} - -const TitleBar = () => { - return ( - <div className='app-region-drag fixed z-30'> - <div className='flex h-9 w-screen items-center justify-between'> - <div></div> - <Controls /> - </div> - </div> - ) -} - -export default TitleBar diff --git a/packages/web/components/New/Topbar/Avatar.tsx b/packages/web/components/New/Topbar/Avatar.tsx deleted file mode 100644 index 84728ab..0000000 --- a/packages/web/components/New/Topbar/Avatar.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { css, cx } from '@emotion/css' -import Icon from '../../Icon' -import { resizeImage } from '@/web/utils/common' -import useUser from '@/web/api/hooks/useUser' -import uiStates from '@/web/states/uiStates' - -const Avatar = ({ className }: { className?: string }) => { - const { data: user } = useUser() - - const avatarUrl = user?.profile?.avatarUrl - ? resizeImage(user?.profile?.avatarUrl ?? '', 'sm') - : '' - - return ( - <> - {avatarUrl ? ( - <img - src={avatarUrl} - onClick={() => (uiStates.showLoginPanel = true)} - className={cx( - 'app-region-no-drag rounded-full', - className || 'h-12 w-12' - )} - /> - ) : ( - <div - onClick={() => (uiStates.showLoginPanel = true)} - className={cx( - 'rounded-full bg-day-600 p-2.5 dark:bg-night-600', - className || 'h-12 w-12' - )} - > - <Icon name='user' className='h-7 w-7 text-neutral-500' /> - </div> - )} - </> - ) -} - -export default Avatar diff --git a/packages/web/components/New/NowPlaying/Controls.tsx b/packages/web/components/NowPlaying/Controls.tsx similarity index 99% rename from packages/web/components/New/NowPlaying/Controls.tsx rename to packages/web/components/NowPlaying/Controls.tsx index 8ca9ecf..d2d8b93 100644 --- a/packages/web/components/New/NowPlaying/Controls.tsx +++ b/packages/web/components/NowPlaying/Controls.tsx @@ -4,7 +4,7 @@ import { ease } from '@/web/utils/const' import { cx, css } from '@emotion/css' import { MotionConfig, motion } from 'framer-motion' import { useSnapshot } from 'valtio' -import Icon from '../../Icon' +import Icon from '../Icon' import { State as PlayerState } from '@/web/utils/player' import useUserLikedTracksIDs, { useMutationLikeATrack, diff --git a/packages/web/components/New/NowPlaying/Cover.tsx b/packages/web/components/NowPlaying/Cover.tsx similarity index 77% rename from packages/web/components/New/NowPlaying/Cover.tsx rename to packages/web/components/NowPlaying/Cover.tsx index 7d46fe9..73ec4c6 100644 --- a/packages/web/components/New/NowPlaying/Cover.tsx +++ b/packages/web/components/NowPlaying/Cover.tsx @@ -8,31 +8,28 @@ import { useNavigate } from 'react-router-dom' import { useSnapshot } from 'valtio' const Cover = () => { - const playerSnapshot = useSnapshot(player) - const [cover, setCover] = useState('') + const { track } = useSnapshot(player) + const [cover, setCover] = useState(track?.al.picUrl) const animationStartTime = useRef(0) const controls = useAnimation() const duration = 150 // ms const navigate = useNavigate() useEffect(() => { - const resizedCover = resizeImage( - playerSnapshot.track?.al.picUrl || '', - 'lg' - ) + const resizedCover = resizeImage(track?.al.picUrl || '', 'lg') const animate = async () => { animationStartTime.current = Date.now() await controls.start({ opacity: 0 }) setCover(resizedCover) } animate() - }, [controls, playerSnapshot.track?.al.picUrl]) + }, [controls, track?.al.picUrl]) // 防止狂点下一首或上一首造成封面与歌曲不匹配的问题 useEffect(() => { - const realCover = resizeImage(playerSnapshot.track?.al.picUrl ?? '', 'lg') + const realCover = resizeImage(track?.al.picUrl ?? '', 'lg') if (cover !== realCover) setCover(realCover) - }, [cover, playerSnapshot.track?.al.picUrl]) + }, [cover, track?.al.picUrl]) const onLoad = () => { const passedTime = Date.now() - animationStartTime.current @@ -52,7 +49,7 @@ const Cover = () => { src={cover} onLoad={onLoad} onClick={() => { - const id = playerSnapshot.track?.al.id + const id = track?.al.id if (id) navigate(`/album/${id}`) }} /> diff --git a/packages/web/components/New/NowPlaying/NowPlaying.stories.tsx b/packages/web/components/NowPlaying/NowPlaying.stories.tsx similarity index 100% rename from packages/web/components/New/NowPlaying/NowPlaying.stories.tsx rename to packages/web/components/NowPlaying/NowPlaying.stories.tsx diff --git a/packages/web/components/New/NowPlaying/NowPlaying.tsx b/packages/web/components/NowPlaying/NowPlaying.tsx similarity index 97% rename from packages/web/components/New/NowPlaying/NowPlaying.tsx rename to packages/web/components/NowPlaying/NowPlaying.tsx index e77fe3e..4d05b0a 100644 --- a/packages/web/components/New/NowPlaying/NowPlaying.tsx +++ b/packages/web/components/NowPlaying/NowPlaying.tsx @@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css' import player from '@/web/states/player' import { useSnapshot } from 'valtio' import { AnimatePresence, motion } from 'framer-motion' -import ArtistInline from '@/web/components/New/ArtistsInLine' +import ArtistInline from '@/web/components/ArtistsInline' import persistedUiStates from '@/web/states/persistedUiStates' import Controls from './Controls' import Cover from './Cover' diff --git a/packages/web/components/New/NowPlaying/Progress.tsx b/packages/web/components/NowPlaying/Progress.tsx similarity index 100% rename from packages/web/components/New/NowPlaying/Progress.tsx rename to packages/web/components/NowPlaying/Progress.tsx diff --git a/packages/web/components/New/NowPlaying/index.tsx b/packages/web/components/NowPlaying/index.tsx similarity index 100% rename from packages/web/components/New/NowPlaying/index.tsx rename to packages/web/components/NowPlaying/index.tsx diff --git a/packages/web/components/New/PageTransition.tsx b/packages/web/components/PageTransition.tsx similarity index 100% rename from packages/web/components/New/PageTransition.tsx rename to packages/web/components/PageTransition.tsx diff --git a/packages/web/components/Player.tsx b/packages/web/components/Player.tsx index 418ad1b..8bd9703 100644 --- a/packages/web/components/Player.tsx +++ b/packages/web/components/Player.tsx @@ -1,238 +1,39 @@ -import ArtistInline from './ArtistsInline' -import IconButton from './IconButton' -import Slider from './Slider' -import Icon from './Icon' -import useUserLikedTracksIDs, { - useMutationLikeATrack, -} from '@/web/api/hooks/useUserLikedTracksIDs' -import player from '@/web/states/player' -import { resizeImage } from '@/web/utils/common' -import { State as PlayerState, Mode as PlayerMode } from '@/web/utils/player' -import { RepeatMode as PlayerRepeatMode } from '@/shared/playerDataTypes' -import { cx } from '@emotion/css' +import { css, cx } from '@emotion/css' +import persistedUiStates from '@/web/states/persistedUiStates' import { useSnapshot } from 'valtio' -import { useMemo } from 'react' -import toast from 'react-hot-toast' -import { useNavigate } from 'react-router-dom' - -const PlayingTrack = () => { - const navigate = useNavigate() - const { track, trackListSource, mode } = useSnapshot(player) - - // Liked songs ids - const { data: userLikedSongs } = useUserLikedTracksIDs() - const mutationLikeATrack = useMutationLikeATrack() - - const hasTrackListSource = mode !== PlayerMode.FM && trackListSource?.type - - const toAlbum = () => { - const id = track?.al?.id - if (id) navigate(`/album/${id}`) - } - - const toTrackListSource = () => { - if (hasTrackListSource) - navigate(`/${trackListSource.type}/${trackListSource.id}`) - } - - return ( - <> - {track && ( - <div className='flex items-center gap-3'> - {track?.al?.picUrl && ( - <img - onClick={toAlbum} - className='aspect-square h-full rounded-md shadow-md' - src={resizeImage(track.al.picUrl, 'xs')} - /> - )} - {!track?.al?.picUrl && ( - <div - onClick={toAlbum} - className='flex aspect-square h-full items-center justify-center rounded-md bg-black/[.04] shadow-sm' - > - <Icon className='h-6 w-6 text-gray-300' name='music-note' /> - </div> - )} - - <div className='flex flex-col justify-center leading-tight'> - <div - onClick={toTrackListSource} - className={cx( - 'line-clamp-1 font-semibold text-black decoration-gray-600 decoration-2 dark:text-white dark:decoration-gray-300', - hasTrackListSource && 'hover:underline' - )} - > - {track?.name} - </div> - <div className='mt-0.5 text-xs text-gray-500 dark:text-gray-400'> - <ArtistInline artists={track?.ar ?? []} /> - </div> - </div> - - <IconButton - onClick={() => track?.id && mutationLikeATrack.mutate(track.id)} - > - <Icon - className='h-5 w-5 text-black dark:text-white' - name={ - track?.id && userLikedSongs?.ids?.includes(track.id) - ? 'heart' - : 'heart-outline' - } - /> - </IconButton> - </div> - )} - {!track && <div></div>} - </> - ) -} - -const MediaControls = () => { - const playerSnapshot = useSnapshot(player) - const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state]) - const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track]) - const mode = useMemo(() => playerSnapshot.mode, [playerSnapshot.mode]) - - return ( - <div className='flex items-center justify-center gap-2 text-black dark:text-white'> - {mode === PlayerMode.TrackList && ( - <IconButton - onClick={() => track && player.prevTrack()} - disabled={!track} - > - <Icon className='h-6 w-6' name='previous' /> - </IconButton> - )} - {mode === PlayerMode.FM && ( - <IconButton onClick={() => player.fmTrash()}> - <Icon className='h-6 w-6' name='dislike' /> - </IconButton> - )} - <IconButton - onClick={() => track && player.playOrPause()} - disabled={!track} - className='after:rounded-xl' - > - <Icon - className='h-7 w-7' - name={ - [PlayerState.Playing, PlayerState.Loading].includes(state) - ? 'pause' - : 'play' - } - /> - </IconButton> - <IconButton onClick={() => track && player.nextTrack()} disabled={!track}> - <Icon className='h-6 w-6' name='next' /> - </IconButton> - </div> - ) -} - -const Others = () => { - const playerSnapshot = useSnapshot(player) - - const switchRepeatMode = () => { - if (playerSnapshot.repeatMode === PlayerRepeatMode.Off) { - player.repeatMode = PlayerRepeatMode.On - } else if (playerSnapshot.repeatMode === PlayerRepeatMode.On) { - player.repeatMode = PlayerRepeatMode.One - } else { - player.repeatMode = PlayerRepeatMode.Off - } - } - - return ( - <div className='flex items-center justify-end gap-2 pr-2 text-black dark:text-white'> - <IconButton - onClick={() => toast('Work in progress')} - disabled={playerSnapshot.mode === PlayerMode.FM} - > - <Icon className='h-6 w-6' name='playlist' /> - </IconButton> - <IconButton - onClick={switchRepeatMode} - disabled={playerSnapshot.mode === PlayerMode.FM} - > - <Icon - className={cx( - 'h-6 w-6', - playerSnapshot.repeatMode !== PlayerRepeatMode.Off && - 'text-brand-500' - )} - name={ - playerSnapshot.repeatMode === PlayerRepeatMode.One - ? 'repeat-1' - : 'repeat' - } - /> - </IconButton> - <IconButton - onClick={() => toast('施工中...')} - disabled={playerSnapshot.mode === PlayerMode.FM} - > - <Icon className='h-6 w-6' name='shuffle' /> - </IconButton> - <IconButton onClick={() => toast('施工中...')}> - <Icon className='h-6 w-6' name='volume' /> - </IconButton> - - {/* Lyric */} - <IconButton - onClick={() => { - // - }} - > - <Icon className='h-6 w-6' name='lyrics' /> - </IconButton> - </div> - ) -} - -const Progress = () => { - const playerSnapshot = useSnapshot(player) - const progress = useMemo( - () => playerSnapshot.progress, - [playerSnapshot.progress] - ) - const state = useMemo(() => playerSnapshot.state, [playerSnapshot.state]) - const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track]) - - return ( - <div className='absolute w-screen'> - {track && ( - <Slider - min={0} - max={(track.dt ?? 0) / 1000} - value={ - state === PlayerState.Playing || state === PlayerState.Paused - ? progress - : 0 - } - onChange={value => { - player.progress = value - }} - onlyCallOnChangeAfterDragEnded={true} - /> - )} - {!track && ( - <div className='absolute h-[2px] w-full bg-gray-500 bg-opacity-10'></div> - )} - </div> - ) -} +import NowPlaying from './NowPlaying' +import PlayingNext from './PlayingNext' +import { AnimatePresence, motion, MotionConfig } from 'framer-motion' +import { ease } from '@/web/utils/const' const Player = () => { - return ( - <div className='fixed bottom-0 left-0 right-0 grid h-16 grid-cols-3 grid-rows-1 bg-white bg-opacity-[.86] py-2.5 px-5 backdrop-blur-xl backdrop-saturate-[1.8] dark:bg-[#222] dark:bg-opacity-[.86]'> - <Progress /> + const { minimizePlayer } = useSnapshot(persistedUiStates) - <PlayingTrack /> - <MediaControls /> - <Others /> - </div> + return ( + <MotionConfig transition={{ duration: 0.6 }}> + <div + className={cx( + 'fixed right-6 bottom-6 flex w-full flex-col justify-between overflow-hidden', + css` + width: 318px; + ` + )} + > + <AnimatePresence> + {!minimizePlayer && ( + <motion.div + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + > + <PlayingNext /> + </motion.div> + )} + </AnimatePresence> + + <NowPlaying /> + </div> + </MotionConfig> ) } diff --git a/packages/web/components/New/PlayerMobile.tsx b/packages/web/components/PlayerMobile.tsx similarity index 98% rename from packages/web/components/New/PlayerMobile.tsx rename to packages/web/components/PlayerMobile.tsx index 5ae9084..690e378 100644 --- a/packages/web/components/New/PlayerMobile.tsx +++ b/packages/web/components/PlayerMobile.tsx @@ -1,7 +1,7 @@ import player from '@/web/states/player' import { css, cx } from '@emotion/css' import { useSnapshot } from 'valtio' -import Image from '@/web/components/New/Image' +import Image from '@/web/components/Image' import Icon from '@/web/components/Icon' import useCoverColor from '@/web/hooks/useCoverColor' import { resizeImage } from '@/web/utils/common' diff --git a/packages/web/components/New/PlayingNext.stories.tsx b/packages/web/components/PlayingNext.stories.tsx similarity index 100% rename from packages/web/components/New/PlayingNext.stories.tsx rename to packages/web/components/PlayingNext.stories.tsx diff --git a/packages/web/components/New/PlayingNext.tsx b/packages/web/components/PlayingNext.tsx similarity index 97% rename from packages/web/components/New/PlayingNext.tsx rename to packages/web/components/PlayingNext.tsx index efb4b22..066dce7 100644 --- a/packages/web/components/New/PlayingNext.tsx +++ b/packages/web/components/PlayingNext.tsx @@ -12,8 +12,10 @@ import useIsMobile from '@/web/hooks/useIsMobile' import { Virtuoso } from 'react-virtuoso' import toast from 'react-hot-toast' import { openContextMenu } from '@/web/states/contextMenus' +import { useTranslation } from 'react-i18next' const Header = () => { + const { t } = useTranslation() return ( <div className={cx( @@ -22,7 +24,7 @@ const Header = () => { > <div className='flex'> <div className='mr-2 h-4 w-1 bg-brand-700'></div> - PLAYING NEXT + {t`player.queue`} </div> <div className='flex'> <div onClick={() => toast('开发中')} className='mr-2'> diff --git a/packages/web/components/New/PlayingNextMobile.tsx b/packages/web/components/PlayingNextMobile.tsx similarity index 100% rename from packages/web/components/New/PlayingNextMobile.tsx rename to packages/web/components/PlayingNextMobile.tsx diff --git a/packages/web/components/Router.tsx b/packages/web/components/Router.tsx index 863672e..c622967 100644 --- a/packages/web/components/Router.tsx +++ b/packages/web/components/Router.tsx @@ -1,63 +1,42 @@ -import type { RouteObject } from 'react-router-dom' -import { useRoutes } from 'react-router-dom' -import Album from '@/web/pages/Album' -import Home from '@/web/pages/Home' -import Login from '@/web/pages/Login' -import Playlist from '@/web/pages/Playlist' -import Artist from '@/web/pages/Artist' -import Search from '@/web/pages/Search' -import Library from '@/web/pages/Library' -import Podcast from '@/web/pages/Podcast' -import Settings from '@/web/pages/Settings' +import { Route, Routes, useLocation } from 'react-router-dom' +import { AnimatePresence } from 'framer-motion' +import React, { ReactNode, Suspense } from 'react' -const routes: RouteObject[] = [ - { - path: '/', - element: <Home />, - }, - { - path: '/podcast', - element: <Podcast />, - }, - { - path: '/library', - element: <Library />, - }, - { - path: '/settings', - element: <Settings />, - }, - { - path: '/login', - element: <Login />, - }, - { - path: '/search/:keywords', - element: <Search />, - children: [ - { - path: ':type', - element: <Search />, - }, - ], - }, - { - path: '/playlist/:id', - element: <Playlist />, - }, - { - path: '/album/:id', - element: <Album />, - }, - { - path: '/artist/:id', - element: <Artist />, - }, -] +const My = React.lazy(() => import('@/web/pages/My')) +const Discover = React.lazy(() => import('@/web/pages/Discover')) +const Browse = React.lazy(() => import('@/web/pages/Browse')) +const Album = React.lazy(() => import('@/web/pages/Album')) +const Playlist = React.lazy(() => import('@/web/pages/Playlist')) +const Artist = React.lazy(() => import('@/web/pages/Artist')) +const MV = React.lazy(() => import('@/web/pages/MV')) +const Lyrics = React.lazy(() => import('@/web/pages/Lyrics')) +const Search = React.lazy(() => import('@/web/pages/Search')) + +const lazy = (component: ReactNode) => { + return <Suspense>{component}</Suspense> +} const Router = () => { - const element = useRoutes(routes) - return <>{element}</> + const location = useLocation() + + return ( + <AnimatePresence exitBeforeEnter> + <Routes location={location} key={location.pathname}> + <Route path='/' element={lazy(<My />)} /> + <Route path='/discover' element={lazy(<Discover />)} /> + <Route path='/browse' element={lazy(<Browse />)} /> + <Route path='/album/:id' element={lazy(<Album />)} /> + <Route path='/playlist/:id' element={lazy(<Playlist />)} /> + <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> + </Routes> + </AnimatePresence> + ) } export default Router diff --git a/packages/web/components/New/ScrollRestoration.tsx b/packages/web/components/ScrollRestoration.tsx similarity index 100% rename from packages/web/components/New/ScrollRestoration.tsx rename to packages/web/components/ScrollRestoration.tsx diff --git a/packages/web/components/New/Sidebar.stories.tsx b/packages/web/components/Sidebar.stories.tsx similarity index 100% rename from packages/web/components/New/Sidebar.stories.tsx rename to packages/web/components/Sidebar.stories.tsx diff --git a/packages/web/components/Sidebar.tsx b/packages/web/components/Sidebar.tsx deleted file mode 100644 index a9a2f8c..0000000 --- a/packages/web/components/Sidebar.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { NavLink } from 'react-router-dom' -import Icon from './Icon' -import useUserPlaylists from '@/web/api/hooks/useUserPlaylists' -import { scrollToTop } from '@/web/utils/common' -import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist' -import player from '@/web/states/player' -import { Mode, TrackListSourceType } from '@/web/utils/player' -import { cx } from '@emotion/css' -import { useMemo } from 'react' -import { useSnapshot } from 'valtio' - -const primaryTabs = [ - { - name: '主页', - icon: 'home', - route: '/', - }, - { - name: '播客', - icon: 'podcast', - route: '/podcast', - }, - { - name: '音乐库', - icon: 'music-library', - route: '/library', - }, -] as const - -const PrimaryTabs = () => { - return ( - <div> - <div className={cx(window.env?.isMac && 'app-region-drag', 'h-14')}></div> - {primaryTabs.map(tab => ( - <NavLink - onClick={() => scrollToTop()} - key={tab.route} - to={tab.route} - className={({ isActive }) => - cx( - 'btn-hover-animation mx-3 flex cursor-default items-center rounded-lg px-3 py-2 transition-colors duration-200 after:scale-[0.97] after:bg-black/[.06] dark:after:bg-white/20', - !isActive && 'text-gray-700 dark:text-white', - isActive && 'text-brand-500 ' - ) - } - > - <Icon className='mr-3 h-6 w-6' name={tab.icon} /> - <span className='font-semibold'>{tab.name}</span> - </NavLink> - ))} - - <div className='mx-5 my-2 h-px bg-black opacity-5 dark:bg-white dark:opacity-10'></div> - </div> - ) -} - -const Playlists = () => { - const { data: playlists } = useUserPlaylists() - const playerSnapshot = useSnapshot(player) - const currentPlaylistID = useMemo( - () => playerSnapshot.trackListSource?.id, - [playerSnapshot.trackListSource] - ) - const playlistMode = useMemo( - () => playerSnapshot.trackListSource?.type, - [playerSnapshot.trackListSource] - ) - const mode = useMemo(() => playerSnapshot.mode, [playerSnapshot.mode]) - - return ( - <div className='mb-16 overflow-auto pb-2'> - {playlists?.playlist?.map(playlist => ( - <NavLink - onMouseOver={() => prefetchPlaylist({ id: playlist.id })} - key={playlist.id} - onClick={() => scrollToTop()} - to={`/playlist/${playlist.id}`} - className={({ isActive }: { isActive: boolean }) => - cx( - 'btn-hover-animation line-clamp-1 my-px mx-3 flex cursor-default items-center justify-between rounded-lg px-3 py-[0.38rem] text-sm text-black opacity-70 transition-colors duration-200 after:scale-[0.97] after:bg-black/[.06] dark:text-white dark:after:bg-white/20', - isActive && 'after:scale-100 after:opacity-100' - ) - } - > - <span className='line-clamp-1'>{playlist.name}</span> - {playlistMode === TrackListSourceType.Playlist && - mode === Mode.TrackList && - currentPlaylistID === playlist.id && ( - <Icon className='h-5 w-5' name='volume-half' /> - )} - </NavLink> - ))} - </div> - ) -} - -const Sidebar = () => { - return ( - <div - id='sidebar' - className='grid h-screen max-w-sm grid-rows-[12rem_auto] border-r border-gray-300/10 bg-gray-50 bg-opacity-[.85] dark:border-gray-500/10 dark:bg-gray-900 dark:bg-opacity-80' - > - <PrimaryTabs /> - <Playlists /> - </div> - ) -} - -export default Sidebar diff --git a/packages/web/components/Skeleton.tsx b/packages/web/components/Skeleton.tsx deleted file mode 100644 index 6799ca6..0000000 --- a/packages/web/components/Skeleton.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { ReactNode } from 'react' -import { cx } from '@emotion/css' - -const Skeleton = ({ - children, - className, -}: { - children?: ReactNode - className?: string -}) => { - return ( - <div - className={cx( - 'relative animate-pulse bg-gray-100 text-transparent dark:bg-gray-800', - className - )} - > - {children} - </div> - ) -} - -export default Skeleton diff --git a/packages/web/components/Slider.tsx b/packages/web/components/Slider.tsx index eb9431c..bbf7cc1 100644 --- a/packages/web/components/Slider.tsx +++ b/packages/web/components/Slider.tsx @@ -8,7 +8,6 @@ const Slider = ({ onChange, onlyCallOnChangeAfterDragEnded = false, orientation = 'horizontal', - alwaysShowTrack = false, alwaysShowThumb = false, }: { value: number @@ -95,7 +94,6 @@ const Slider = ({ if (!isDragging) return setIsDragging(false) if (onlyCallOnChangeAfterDragEnded) { - console.log('draggingValue', draggingValue) onChange(draggingValue) } } @@ -141,41 +139,36 @@ const Slider = ({ {/* Track */} <div className={cx( - 'absolute bg-gray-500 bg-opacity-10', - orientation === 'horizontal' && 'h-[2px] w-full', - orientation === 'vertical' && 'h-full w-[2px]' + 'absolute overflow-hidden rounded-full bg-black/10 bg-opacity-10 dark:bg-white/10', + orientation === 'horizontal' && 'h-[3px] w-full', + orientation === 'vertical' && 'h-full w-[3px]' )} - ></div> - - {/* Passed track */} - <div - className={cx( - 'absolute group-hover:bg-brand-500', - isDragging || alwaysShowTrack - ? 'bg-brand-500' - : 'bg-gray-300 dark:bg-gray-500', - orientation === 'horizontal' && 'h-[2px]', - orientation === 'vertical' && 'bottom-0 w-[2px]' - )} - style={usedTrackStyle} - ></div> + > + {/* Passed track */} + <div + className={cx( + 'bg-black dark:bg-white', + orientation === 'horizontal' && 'h-full rounded-r-full', + orientation === 'vertical' && 'bottom-0 w-full rounded-t-full' + )} + style={usedTrackStyle} + ></div> + </div> {/* Thumb */} <div className={cx( - 'absolute flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 bg-opacity-20 transition-opacity', + 'absolute flex h-2 w-2 items-center justify-center rounded-full bg-black bg-opacity-20 transition-opacity dark:bg-white', isDragging || alwaysShowThumb ? 'opacity-100' : 'opacity-0 group-hover:opacity-100', - orientation === 'horizontal' && '-translate-x-2.5', - orientation === 'vertical' && 'translate-y-2.5' + orientation === 'horizontal' && '-translate-x-1', + orientation === 'vertical' && 'translate-y-1' )} style={thumbStyle} onClick={e => e.stopPropagation()} onPointerDown={handlePointerDown} - > - <div className='absolute h-2 w-2 rounded-full bg-brand-500'></div> - </div> + ></div> </div> ) } diff --git a/packages/web/components/New/Tabs.tsx b/packages/web/components/Tabs.tsx similarity index 100% rename from packages/web/components/New/Tabs.tsx rename to packages/web/components/Tabs.tsx diff --git a/packages/web/components/TitleBar.tsx b/packages/web/components/TitleBar.tsx index f04880c..29f0a76 100644 --- a/packages/web/components/TitleBar.tsx +++ b/packages/web/components/TitleBar.tsx @@ -4,7 +4,7 @@ import { IpcChannels } from '@/shared/IpcChannels' import useIpcRenderer from '@/web/hooks/useIpcRenderer' import { useState, useMemo } from 'react' import { useSnapshot } from 'valtio' -import { cx } from '@emotion/css' +import { css, cx } from '@emotion/css' const Controls = () => { const [isMaximized, setIsMaximized] = useState(false) @@ -25,18 +25,21 @@ const Controls = () => { window.ipcRenderer?.send(IpcChannels.Close) } + const classNames = cx( + 'flex items-center justify-center text-white/80 hover:text-white hover:bg-white/20 transition duration-400', + css` + height: 28px; + width: 48px; + border-radius: 4px; + ` + ) + return ( - <div className='app-region-no-drag flex h-full'> - <button - onClick={minimize} - className='flex w-[2.875rem] items-center justify-center hover:bg-[#e9e9e9]' - > + <div className='app-region-no-drag flex h-full items-center'> + <button onClick={minimize} className={classNames}> <Icon className='h-3 w-3' name='windows-minimize' /> </button> - <button - onClick={maxRestore} - className='flex w-[2.875rem] items-center justify-center hover:bg-[#e9e9e9]' - > + <button onClick={maxRestore} className={classNames}> <Icon className='h-3 w-3' name={isMaximized ? 'windows-un-maximize' : 'windows-maximize'} @@ -44,7 +47,12 @@ const Controls = () => { </button> <button onClick={close} - className='flex w-[2.875rem] items-center justify-center hover:bg-[#c42b1c] hover:text-white' + className={cx( + classNames, + css` + margin-right: 5px; + ` + )} > <Icon className='h-3 w-3' name='windows-close' /> </button> @@ -52,46 +60,13 @@ const Controls = () => { ) } -const Title = ({ className }: { className?: string }) => { - const playerSnapshot = useSnapshot(player) - const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track]) - - return ( - <div className={cx('text-sm text-gray-500', className)}> - {track?.name && ( - <> - <span>{track.name}</span> - <span className='mx-2'>-</span> - </> - )} - <span>YesPlayMusic</span> - </div> - ) -} - -const Win = () => { - return ( - <div className='flex h-8 w-screen items-center justify-between bg-gray-50'> - <Title className='ml-3' /> - <Controls /> - </div> - ) -} - -const Linux = () => { - return ( - <div className='flex h-8 w-screen items-center justify-between bg-gray-50'> - <div></div> - <Title className='text-center' /> - <Controls /> - </div> - ) -} - const TitleBar = () => { return ( <div className='app-region-drag fixed z-30'> - {window.env?.isWindows ? <Win /> : <Linux />} + <div className='flex h-9 w-screen items-center justify-between'> + <div></div> + <Controls /> + </div> </div> ) } diff --git a/packages/web/components/New/Toaster.tsx b/packages/web/components/Toaster.tsx similarity index 100% rename from packages/web/components/New/Toaster.tsx rename to packages/web/components/Toaster.tsx diff --git a/packages/web/components/New/Topbar.stories.tsx b/packages/web/components/Topbar.stories.tsx similarity index 100% rename from packages/web/components/New/Topbar.stories.tsx rename to packages/web/components/Topbar.stories.tsx diff --git a/packages/web/components/Topbar.tsx b/packages/web/components/Topbar.tsx deleted file mode 100644 index 4aa5330..0000000 --- a/packages/web/components/Topbar.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import Icon from '@/web/components/Icon' -import useScroll from '@/web/hooks/useScroll' -import { useState, useEffect } from 'react' -import { useNavigate, useParams } from 'react-router-dom' -import Avatar from './Avatar' -import { cx } from '@emotion/css' - -const NavigationButtons = () => { - const navigate = useNavigate() - enum ACTION { - Back = 'back', - Forward = 'forward', - } - const handleNavigate = (action: ACTION) => { - if (action === ACTION.Back) navigate(-1) - if (action === ACTION.Forward) navigate(1) - } - return ( - <div className='flex gap-1'> - {[ACTION.Back, ACTION.Forward].map(action => ( - <div - onClick={() => handleNavigate(action)} - key={action} - className='app-region-no-drag btn-hover-animation rounded-lg p-2 text-gray-500 transition duration-300 after:rounded-full after:bg-black/[.06] hover:text-gray-900 dark:text-gray-300 dark:after:bg-white/10 dark:hover:text-gray-200' - > - <Icon className='h-5 w-5' name={action} /> - </div> - ))} - </div> - ) -} - -const SearchBox = () => { - const { type } = useParams() - const [keywords, setKeywords] = useState('') - const navigate = useNavigate() - const toSearch = (e: React.KeyboardEvent) => { - if (!keywords) return - if (e.key === 'Enter') { - navigate(`/search/${keywords}${type ? `/${type}` : ''}`) - } - } - - return ( - <div className='app-region-no-drag group flex w-[16rem] cursor-text items-center rounded-full bg-gray-500 bg-opacity-5 pl-2.5 pr-2 transition duration-300 hover:bg-opacity-10 dark:bg-gray-300 dark:bg-opacity-5'> - <Icon - className='mr-2 h-4 w-4 text-gray-500 transition duration-300 group-hover:text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-200' - name='search' - /> - <input - value={keywords} - onChange={e => setKeywords(e.target.value)} - onKeyDown={toSearch} - type='text' - className='flex-grow bg-transparent placeholder:text-gray-500 dark:text-white dark:placeholder:text-gray-400' - placeholder='搜索' - /> - <div - onClick={() => setKeywords('')} - className={cx( - 'cursor-default rounded-full p-1 text-gray-600 transition hover:bg-gray-400/20 dark:text-white/50 dark:hover:bg-white/20', - !keywords && 'hidden' - )} - > - <Icon className='h-4 w-4' name='x' /> - </div> - </div> - ) -} - -const Settings = () => { - const navigate = useNavigate() - return ( - <div - onClick={() => navigate('/settings')} - className='app-region-no-drag btn-hover-animation rounded-lg p-2.5 text-gray-500 transition duration-300 after:rounded-full after:bg-black/[.06] hover:text-gray-900 dark:text-gray-300 dark:after:bg-white/10 dark:hover:text-gray-200' - > - <Icon className='h-[1.125rem] w-[1.125rem]' name='settings' /> - </div> - ) -} - -const Topbar = () => { - /** - * Show topbar background when scroll down - */ - const [mainContainer, setMainContainer] = useState<HTMLElement | null>(null) - const scroll = useScroll(mainContainer, { throttle: 100 }) - - useEffect(() => { - setMainContainer(document.getElementById('mainContainer')) - }, [setMainContainer]) - - return ( - <div - className={cx( - 'sticky z-30 flex h-16 min-h-[4rem] w-full cursor-default items-center justify-between px-8 transition duration-300', - window.env?.isMac && 'app-region-drag', - window.env?.isEnableTitlebar ? 'top-8' : 'top-0', - !scroll.arrivedState.top && - 'bg-white bg-opacity-[.86] backdrop-blur-xl backdrop-saturate-[1.8] dark:bg-[#222] dark:bg-opacity-[.86]' - )} - > - <div className='flex gap-2'> - <NavigationButtons /> - <SearchBox /> - </div> - - <div className='flex items-center gap-3'> - <Settings /> - <Avatar /> - </div> - </div> - ) -} - -export default Topbar diff --git a/packages/web/components/Topbar/Avatar.tsx b/packages/web/components/Topbar/Avatar.tsx new file mode 100644 index 0000000..79d1f68 --- /dev/null +++ b/packages/web/components/Topbar/Avatar.tsx @@ -0,0 +1,104 @@ +import { css, cx } from '@emotion/css' +import Icon from '../Icon' +import { resizeImage } from '@/web/utils/common' +import useUser, { useMutationLogout } from '@/web/api/hooks/useUser' +import uiStates from '@/web/states/uiStates' +import { useRef, useState } from 'react' +import BasicContextMenu from '../ContextMenus/BasicContextMenu' +import { AnimatePresence } from 'framer-motion' +import toast from 'react-hot-toast' +import { useTranslation } from 'react-i18next' + +const Avatar = ({ className }: { className?: string }) => { + const { data: user } = useUser() + const { t } = useTranslation() + + const avatarUrl = user?.profile?.avatarUrl + ? resizeImage(user?.profile?.avatarUrl ?? '', 'sm') + : '' + + const avatarRef = useRef<HTMLImageElement>(null) + const [showMenu, setShowMenu] = useState(false) + + const logout = useMutationLogout() + + return ( + <> + {avatarUrl ? ( + <div> + <img + ref={avatarRef} + src={avatarUrl} + onClick={event => { + if (event.target === avatarRef.current && showMenu) { + setShowMenu(false) + return + } + setShowMenu(true) + }} + className={cx( + 'app-region-no-drag rounded-full', + className || 'h-12 w-12' + )} + /> + <AnimatePresence> + {avatarRef.current && showMenu && ( + <BasicContextMenu + classNames={css` + min-width: 162px; + `} + onClose={event => { + if (event.target === avatarRef.current) return + setShowMenu(false) + }} + items={[ + { + type: 'item', + label: 'Profile', + onClick: () => { + toast('开发中') + }, + }, + { + type: 'item', + label: t`settings.settings`, + onClick: () => { + toast('开发中') + }, + }, + { + type: 'divider', + }, + { + type: 'item', + label: t`auth.logout`, + onClick: () => { + logout.mutateAsync() + }, + }, + ]} + target={avatarRef.current} + cursorPosition={{ + x: 0, + y: 0, + }} + /> + )} + </AnimatePresence> + </div> + ) : ( + <div + onClick={() => (uiStates.showLoginPanel = true)} + className={cx( + 'rounded-full bg-day-600 p-2.5 dark:bg-night-600', + className || 'h-12 w-12' + )} + > + <Icon name='user' className='h-7 w-7 text-neutral-500' /> + </div> + )} + </> + ) +} + +export default Avatar diff --git a/packages/web/components/New/Topbar/NavigationButtons.tsx b/packages/web/components/Topbar/NavigationButtons.tsx similarity index 97% rename from packages/web/components/New/Topbar/NavigationButtons.tsx rename to packages/web/components/Topbar/NavigationButtons.tsx index 4e020d2..7896705 100644 --- a/packages/web/components/New/Topbar/NavigationButtons.tsx +++ b/packages/web/components/Topbar/NavigationButtons.tsx @@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css' import { motion, useAnimation } from 'framer-motion' import { useNavigate } from 'react-router-dom' import { ease } from '@/web/utils/const' -import Icon from '../../Icon' +import Icon from '../Icon' const buttonClassNames = 'app-region-no-drag rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl transition-colors duration-400 hover:bg-white/20 hover:text-white/60' diff --git a/packages/web/components/New/Topbar/SearchBox.tsx b/packages/web/components/Topbar/SearchBox.tsx similarity index 89% rename from packages/web/components/New/Topbar/SearchBox.tsx rename to packages/web/components/Topbar/SearchBox.tsx index 621d493..d84d5ce 100644 --- a/packages/web/components/New/Topbar/SearchBox.tsx +++ b/packages/web/components/Topbar/SearchBox.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css' -import Icon from '../../Icon' +import Icon from '../Icon' import { breakpoint as bp } from '@/web/utils/const' import { useNavigate } from 'react-router-dom' import { useMemo, useState, useEffect, useRef } from 'react' @@ -8,8 +8,15 @@ import { fetchSearchSuggestions } from '@/web/api/search' import { SearchApiNames } from '@/shared/api/Search' import { useClickAway, useDebounce } from 'react-use' import { AnimatePresence, motion } from 'framer-motion' +import { useTranslation } from 'react-i18next' -const SearchSuggestions = ({ searchText }: { searchText: string }) => { +const SearchSuggestions = ({ + searchText, + isInputFocused, +}: { + searchText: string + isInputFocused: boolean +}) => { const navigate = useNavigate() const [debouncedSearchText, setDebouncedSearchText] = useState('') @@ -64,7 +71,8 @@ const SearchSuggestions = ({ searchText }: { searchText: string }) => { return ( <AnimatePresence> - {searchText.length > 0 && + {isInputFocused && + searchText.length > 0 && suggestionsArray.length > 0 && !clickedSearchText && searchText === debouncedSearchText && ( @@ -118,6 +126,8 @@ const SearchSuggestions = ({ searchText }: { searchText: string }) => { const SearchBox = () => { const navigate = useNavigate() const [searchText, setSearchText] = useState('') + const [isFocused, setIsFocused] = useState(false) + const { t } = useTranslation() return ( <div className='relative'> @@ -134,7 +144,7 @@ const SearchBox = () => { > <Icon name='search' className='mr-2.5 h-7 w-7' /> <input - placeholder='Search' + placeholder={t`search.search`} className={cx( 'flex-shrink bg-transparent font-medium placeholder:text-white/40 dark:text-white/80', css` @@ -143,6 +153,8 @@ const SearchBox = () => { } ` )} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} value={searchText} onChange={e => setSearchText(e.target.value)} onKeyDown={e => { @@ -153,7 +165,7 @@ const SearchBox = () => { /> </div> - <SearchSuggestions searchText={searchText} /> + <SearchSuggestions searchText={searchText} isInputFocused={isFocused} /> </div> ) } diff --git a/packages/web/components/New/Topbar/SettingsButton.tsx b/packages/web/components/Topbar/SettingsButton.tsx similarity index 100% rename from packages/web/components/New/Topbar/SettingsButton.tsx rename to packages/web/components/Topbar/SettingsButton.tsx diff --git a/packages/web/components/New/Topbar/TopbarDesktop.tsx b/packages/web/components/Topbar/TopbarDesktop.tsx similarity index 96% rename from packages/web/components/New/Topbar/TopbarDesktop.tsx rename to packages/web/components/Topbar/TopbarDesktop.tsx index a1b0828..1da02c2 100644 --- a/packages/web/components/New/Topbar/TopbarDesktop.tsx +++ b/packages/web/components/Topbar/TopbarDesktop.tsx @@ -30,6 +30,7 @@ const Background = () => { transition={{ ease }} className={cx( 'absolute inset-0 z-0 bg-contain bg-repeat-x', + window.env?.isElectron && 'rounded-t-24', css` background-image: url(${topbarBackground}); ` @@ -48,8 +49,7 @@ const TopbarDesktop = () => { '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; - `, - window.env?.isElectron && 'rounded-t-24' + ` )} > {/* Background */} diff --git a/packages/web/components/New/Topbar/TopbarMobile.tsx b/packages/web/components/Topbar/TopbarMobile.tsx similarity index 100% rename from packages/web/components/New/Topbar/TopbarMobile.tsx rename to packages/web/components/Topbar/TopbarMobile.tsx diff --git a/packages/web/components/New/TrackList.tsx b/packages/web/components/TrackList.tsx similarity index 96% rename from packages/web/components/New/TrackList.tsx rename to packages/web/components/TrackList.tsx index b7153e0..a3d381b 100644 --- a/packages/web/components/New/TrackList.tsx +++ b/packages/web/components/TrackList.tsx @@ -16,6 +16,7 @@ const Actions = ({ track }: { track: Track }) => { const { data: likedTracksIDs } = useUserLikedTracksIDs() const likeATrack = useMutationLikeATrack() + // 当右键菜单开启时,让按钮组在鼠标移走了后也能继续显示 const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) const menu = useSnapshot(contextMenus) useEffect(() => { @@ -51,7 +52,7 @@ const Actions = ({ track }: { track: Track }) => { {/* Add to playlist */} <button className={cx( - 'opacity-0 transition-opacity group-hover:opacity-100', + 'transition-opacity group-hover:opacity-100', isContextMenuOpen ? 'opacity-100' : 'opacity-0' )} > @@ -163,7 +164,7 @@ const TrackList = ({ {/* Track duration */} <div className='hidden text-right lg:block'> - {formatDuration(track.dt, 'en', 'hh:mm:ss')} + {formatDuration(track.dt, 'en-US', 'hh:mm:ss')} </div> </div> ))} diff --git a/packages/web/components/New/TrackListHeader/Actions.tsx b/packages/web/components/TrackListHeader/Actions.tsx similarity index 93% rename from packages/web/components/New/TrackListHeader/Actions.tsx rename to packages/web/components/TrackListHeader/Actions.tsx index c95e6b6..f3d8868 100644 --- a/packages/web/components/New/TrackListHeader/Actions.tsx +++ b/packages/web/components/TrackListHeader/Actions.tsx @@ -1,7 +1,8 @@ import { openContextMenu } from '@/web/states/contextMenus' import { cx } from '@emotion/css' +import { useTranslation } from 'react-i18next' import { useParams } from 'react-router-dom' -import Icon from '../../Icon' +import Icon from '../Icon' const Actions = ({ onPlay, @@ -15,6 +16,8 @@ const Actions = ({ onLike?: () => void }) => { const params = useParams() + const { t } = useTranslation() + return ( <div className='mt-11 flex items-end justify-between lg:mt-4 lg:justify-start'> <div className='flex items-end'> @@ -62,7 +65,7 @@ const Actions = ({ isLoading ? 'bg-white/10 text-transparent' : 'bg-brand-700 text-white' )} > - Play + {t`player.play`} </button> </div> ) diff --git a/packages/web/components/New/TrackListHeader/Cover.tsx b/packages/web/components/TrackListHeader/Cover.tsx similarity index 94% rename from packages/web/components/New/TrackListHeader/Cover.tsx rename to packages/web/components/TrackListHeader/Cover.tsx index aa6914c..46b13e1 100644 --- a/packages/web/components/New/TrackListHeader/Cover.tsx +++ b/packages/web/components/TrackListHeader/Cover.tsx @@ -1,5 +1,5 @@ import { resizeImage } from '@/web/utils/common' -import Image from '@/web/components/New/Image' +import Image from '@/web/components/Image' import { memo, useEffect } from 'react' import uiStates from '@/web/states/uiStates' import VideoCover from './VideoCover' diff --git a/packages/web/components/New/TrackListHeader/Info.tsx b/packages/web/components/TrackListHeader/Info.tsx similarity index 100% rename from packages/web/components/New/TrackListHeader/Info.tsx rename to packages/web/components/TrackListHeader/Info.tsx diff --git a/packages/web/components/New/TrackListHeader/TrackListHeader.stories.tsx b/packages/web/components/TrackListHeader/TrackListHeader.stories.tsx similarity index 100% rename from packages/web/components/New/TrackListHeader/TrackListHeader.stories.tsx rename to packages/web/components/TrackListHeader/TrackListHeader.stories.tsx diff --git a/packages/web/components/New/TrackListHeader/TrackListHeader.tsx b/packages/web/components/TrackListHeader/TrackListHeader.tsx similarity index 100% rename from packages/web/components/New/TrackListHeader/TrackListHeader.tsx rename to packages/web/components/TrackListHeader/TrackListHeader.tsx diff --git a/packages/web/components/New/TrackListHeader/VideoCover.tsx b/packages/web/components/TrackListHeader/VideoCover.tsx similarity index 100% rename from packages/web/components/New/TrackListHeader/VideoCover.tsx rename to packages/web/components/TrackListHeader/VideoCover.tsx diff --git a/packages/web/components/New/TrackListHeader/index.tsx b/packages/web/components/TrackListHeader/index.tsx similarity index 100% rename from packages/web/components/New/TrackListHeader/index.tsx rename to packages/web/components/TrackListHeader/index.tsx diff --git a/packages/web/components/TracksAlbum.tsx b/packages/web/components/TracksAlbum.tsx deleted file mode 100644 index 2d7d7b4..0000000 --- a/packages/web/components/TracksAlbum.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { memo, useCallback, useMemo } from 'react' -import ArtistInline from '@/web/components/ArtistsInline' -import Skeleton from '@/web/components/Skeleton' -import Icon from '@/web/components/Icon' -import useUserLikedTracksIDs, { - useMutationLikeATrack, -} from '@/web/api/hooks/useUserLikedTracksIDs' -import player from '@/web/states/player' -import { formatDuration } from '@/web/utils/common' -import { State as PlayerState } from '@/web/utils/player' -import { cx } from '@emotion/css' -import { useSnapshot } from 'valtio' - -const PlayOrPauseButtonInTrack = memo( - ({ isHighlight, trackID }: { isHighlight: boolean; trackID: number }) => { - const playerSnapshot = useSnapshot(player) - const isPlaying = useMemo( - () => playerSnapshot.state === PlayerState.Playing, - [playerSnapshot.state] - ) - - const onClick = () => { - isHighlight ? player.playOrPause() : player.playTrack(trackID) - } - - return ( - <div - onClick={onClick} - className={cx( - 'btn-pressed-animation -ml-1 self-center', - !isHighlight && 'hidden group-hover:block' - )} - > - <Icon - className='h-5 w-5 text-brand-500' - name={isPlaying && isHighlight ? 'pause' : 'play'} - /> - </div> - ) - } -) -PlayOrPauseButtonInTrack.displayName = 'PlayOrPauseButtonInTrack' - -const Track = memo( - ({ - track, - isLiked = false, - isSkeleton = false, - isHighlight = false, - onClick, - }: { - track: Track - isLiked?: boolean - isSkeleton?: boolean - isHighlight?: boolean - onClick: (e: React.MouseEvent<HTMLElement>, trackID: number) => void - }) => { - const subtitle = useMemo( - () => track.tns?.at(0) ?? track.alia?.at(0), - [track.alia, track.tns] - ) - - const mutationLikeATrack = useMutationLikeATrack() - - return ( - <div - onClick={e => onClick(e, track.id)} - className={cx( - 'group grid w-full rounded-xl after:scale-[.98] after:rounded-xl', - 'grid-cols-12 py-2.5 px-4', - !isSkeleton && { - 'btn-hover-animation after:bg-gray-100 dark:after:bg-white/10': - !isHighlight, - 'bg-brand-50 dark:bg-gray-800': isHighlight, - } - )} - > - {/* Track name and number */} - <div className='col-span-6 grid grid-cols-[2rem_auto] pr-8'> - {/* Track number */} - {isSkeleton ? ( - <Skeleton className='h-6.5 w-6.5 -translate-x-1'></Skeleton> - ) : ( - !isHighlight && ( - <div - className={cx( - 'self-center group-hover:hidden', - isHighlight && 'text-brand-500', - !isHighlight && 'text-gray-500 dark:text-gray-400' - )} - > - {track.no} - </div> - ) - )} - - {/* Play or pause button for playing track */} - {!isSkeleton && ( - <PlayOrPauseButtonInTrack - isHighlight={isHighlight} - trackID={track.id} - /> - )} - - {/* Track name */} - <div className='flex'> - {isSkeleton ? ( - <Skeleton className='text-lg'> - PLACEHOLDER123456789012345 - </Skeleton> - ) : ( - <div - className={cx( - 'line-clamp-1 break-all text-lg font-semibold', - isHighlight ? 'text-brand-500' : 'text-black dark:text-white' - )} - > - <span className='flex items-center'> - {track.name} - {track.mark === 1318912 && ( - <Icon - name='explicit' - className='ml-1.5 mt-[2px] h-4 w-4 text-gray-300 dark:text-gray-500' - /> - )} - {subtitle && ( - <span - className={cx( - 'ml-1', - isHighlight ? 'text-brand-500/[.8]' : 'text-gray-400' - )} - > - ({subtitle}) - </span> - )} - </span> - </div> - )} - </div> - </div> - - {/* Artists */} - <div className='col-span-4 flex items-center'> - {isSkeleton ? ( - <Skeleton>PLACEHOLDER1234</Skeleton> - ) : ( - <ArtistInline - className={ - isHighlight - ? 'text-brand-500' - : 'text-gray-600 dark:text-gray-400' - } - artists={track.ar} - /> - )} - </div> - - {/* Actions & Track duration */} - <div className='col-span-2 flex items-center justify-end'> - {/* Like button */} - {!isSkeleton && ( - <button - onClick={() => track?.id && mutationLikeATrack.mutate(track.id)} - className={cx( - 'mr-5 cursor-default transition duration-300 hover:scale-[1.2]', - isLiked - ? 'text-brand-500 opacity-100' - : 'text-gray-600 opacity-0 dark:text-gray-400', - !isSkeleton && 'group-hover:opacity-100' - )} - > - <Icon - name={isLiked ? 'heart' : 'heart-outline'} - className='h-5 w-5' - /> - </button> - )} - - {/* Track duration */} - {isSkeleton ? ( - <Skeleton>0:00</Skeleton> - ) : ( - <div - className={cx( - 'min-w-[2.5rem] text-right', - isHighlight - ? 'text-brand-500' - : 'text-gray-600 dark:text-gray-400' - )} - > - {formatDuration(track.dt, 'en', 'hh:mm:ss')} - </div> - )} - </div> - </div> - ) - } -) -Track.displayName = 'Track' - -const TracksAlbum = ({ - tracks, - isSkeleton = false, - onTrackDoubleClick, -}: { - tracks: Track[] - isSkeleton?: boolean - onTrackDoubleClick?: (trackID: number) => void -}) => { - // Fake data when isSkeleton is true - const skeletonTracks: Track[] = new Array(1).fill({}) - - // Liked songs ids - const { data: userLikedSongs } = useUserLikedTracksIDs() - - const handleClick = useCallback( - (e: React.MouseEvent<HTMLElement>, trackID: number) => { - if (e.detail === 2) onTrackDoubleClick?.(trackID) - }, - [onTrackDoubleClick] - ) - - const { track } = useSnapshot(player) - - return ( - <div className='grid w-full'> - {/* Tracks table header */} - <div className='mx-4 mt-10 mb-2 grid grid-cols-12 border-b border-gray-100 py-2.5 text-sm text-gray-400 dark:border-gray-800 dark:text-gray-500'> - <div className='col-span-6 grid grid-cols-[2rem_auto]'> - <div>#</div> - <div>标题</div> - </div> - <div className='col-span-4'>艺人</div> - <div className='col-span-2 justify-self-end'>时长</div> - </div> - - {/* Tracks */} - {isSkeleton - ? skeletonTracks.map((track, index) => ( - <Track - key={index} - track={track} - onClick={() => null} - isSkeleton={true} - /> - )) - : tracks.map(track => ( - <Track - key={track.id} - track={track} - onClick={handleClick} - isLiked={userLikedSongs?.ids?.includes(track.id) ?? false} - isSkeleton={false} - isHighlight={track.id === track?.id} - /> - ))} - </div> - ) -} - -export default TracksAlbum diff --git a/packages/web/components/TracksGrid.tsx b/packages/web/components/TracksGrid.tsx deleted file mode 100644 index e392042..0000000 --- a/packages/web/components/TracksGrid.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import ArtistInline from '@/web/components/ArtistsInline' -import Skeleton from '@/web/components/Skeleton' -import player from '@/web/states/player' -import { resizeImage } from '@/web/utils/common' -import Icon from './Icon' -import { cx } from '@emotion/css' -import { useMemo } from 'react' -import { useSnapshot } from 'valtio' - -const Track = ({ - track, - isSkeleton = false, - isHighlight = false, - onClick, -}: { - track: Track - isSkeleton?: boolean - isHighlight?: boolean - onClick: (e: React.MouseEvent<HTMLElement>, trackID: number) => void -}) => { - return ( - <div - onClick={e => onClick(e, track.id)} - className={cx( - 'group grid w-full rounded-xl after:scale-[.98] after:rounded-xl ', - 'grid-cols-1 py-1.5 px-2', - !isSkeleton && { - 'btn-hover-animation after:bg-gray-100 dark:after:bg-white/10': - !isHighlight, - 'bg-brand-50 dark:bg-gray-800': isHighlight, - } - )} - > - <div className='grid grid-cols-[3rem_auto] items-center'> - {/* Cover */} - <div> - {isSkeleton ? ( - <Skeleton className='mr-4 h-9 w-9 rounded-md border border-gray-100' /> - ) : ( - <img - src={resizeImage(track.al.picUrl, 'xs')} - className='box-content h-9 w-9 rounded-md border border-black border-opacity-[.03]' - /> - )} - </div> - - {/* Track name & Artists */} - <div className='flex flex-col justify-center'> - {isSkeleton ? ( - <Skeleton className='text-base '>PLACEHOLDER12345</Skeleton> - ) : ( - <div - className={cx( - 'line-clamp-1 break-all text-base font-semibold ', - isHighlight ? 'text-brand-500' : 'text-black dark:text-white' - )} - > - {track.name} - </div> - )} - - <div className='text-xs text-gray-500 dark:text-gray-400'> - {isSkeleton ? ( - <Skeleton className='w-2/3 translate-y-px'>PLACE</Skeleton> - ) : ( - <span className='flex items-center'> - {track.mark === 1318912 && ( - <Icon - name='explicit' - className={cx( - 'mr-1 h-3 w-3', - isHighlight - ? 'text-brand-500' - : 'text-gray-300 dark:text-gray-500' - )} - /> - )} - <ArtistInline - artists={track.ar} - disableLink={true} - className={ - isHighlight - ? 'text-brand-500' - : 'text-gray-600 dark:text-gray-400' - } - /> - </span> - )} - </div> - </div> - </div> - </div> - ) -} - -const TrackGrid = ({ - tracks, - isSkeleton = false, - onTrackDoubleClick, - cols = 2, -}: { - tracks: Track[] - isSkeleton?: boolean - onTrackDoubleClick?: (trackID: number) => void - cols?: number -}) => { - const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => { - if (e.detail === 2) onTrackDoubleClick?.(trackID) - } - - const playerSnapshot = useSnapshot(player) - const playingTrack = useMemo( - () => playerSnapshot.track, - [playerSnapshot.track] - ) - - return ( - <div - className='grid gap-x-2' - style={{ - gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`, - }} - > - {tracks.map((track, index) => ( - <Track - onClick={handleClick} - key={track.id} - track={track} - isSkeleton={isSkeleton} - isHighlight={track.id === playingTrack?.id} - /> - ))} - </div> - ) -} - -export default TrackGrid diff --git a/packages/web/components/TracksList.tsx b/packages/web/components/TracksList.tsx deleted file mode 100644 index cae7e16..0000000 --- a/packages/web/components/TracksList.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import { memo, useMemo } from 'react' -import { NavLink } from 'react-router-dom' -import ArtistInline from '@/web/components/ArtistsInline' -import Skeleton from '@/web/components/Skeleton' -import Icon from '@/web/components/Icon' -import useUserLikedTracksIDs, { - useMutationLikeATrack, -} from '@/web/api/hooks/useUserLikedTracksIDs' -import { formatDuration, resizeImage } from '@/web/utils/common' -import player from '@/web/states/player' -import { cx } from '@emotion/css' -import { useSnapshot } from 'valtio' - -const Track = memo( - ({ - track, - isLiked = false, - isSkeleton = false, - isHighlight = false, - onClick, - }: { - track: Track - isLiked?: boolean - isSkeleton?: boolean - isHighlight?: boolean - onClick: (e: React.MouseEvent<HTMLElement>, trackID: number) => void - }) => { - const subtitle = useMemo( - () => track.tns?.at(0) ?? track.alia?.at(0), - [track.alia, track.tns] - ) - const mutationLikeATrack = useMutationLikeATrack() - - return ( - <div - onClick={e => onClick(e, track.id)} - className={cx( - 'group grid w-full rounded-xl after:scale-[.98] after:rounded-xl ', - 'grid-cols-12 p-2 pr-4', - !isSkeleton && - !isHighlight && - 'btn-hover-animation after:bg-gray-100 dark:after:bg-white/10', - !isSkeleton && isHighlight && 'bg-brand-50 dark:bg-gray-800' - )} - > - {/* Track info */} - <div className='col-span-6 grid grid-cols-[4.2rem_auto] pr-8'> - {/* Cover */} - <div> - {isSkeleton ? ( - <Skeleton className='mr-4 h-12 w-12 rounded-md border border-gray-100 dark:border-gray-800' /> - ) : ( - <img - src={resizeImage(track.al.picUrl, 'xs')} - className='box-content h-12 w-12 rounded-md border border-black border-opacity-[.03]' - /> - )} - </div> - - {/* Track name & Artists */} - <div className='flex flex-col justify-center'> - {isSkeleton ? ( - <Skeleton className='text-lg'>PLACEHOLDER12345</Skeleton> - ) : ( - <div - className={cx( - 'line-clamp-1 break-all text-lg font-semibold', - isHighlight ? 'text-brand-500' : 'text-black dark:text-white' - )} - > - <span>{track.name}</span> - {subtitle && ( - <span - title={subtitle} - className={cx( - 'ml-1', - isHighlight ? 'text-brand-500/[.8]' : 'text-gray-400' - )} - > - ({subtitle}) - </span> - )} - </div> - )} - - <div - className={cx( - 'text-sm', - isHighlight - ? 'text-brand-500' - : 'text-gray-600 dark:text-gray-400' - )} - > - {isSkeleton ? ( - <Skeleton className='w-2/3 translate-y-px'>PLACE</Skeleton> - ) : ( - <span className='inline-flex items-center'> - {track.mark === 1318912 && ( - <Icon - name='explicit' - className='mr-1 h-3.5 w-3.5 text-gray-300 dark:text-gray-500' - /> - )} - <ArtistInline artists={track.ar} /> - </span> - )} - </div> - </div> - </div> - - {/* Album name */} - <div className='col-span-4 flex items-center text-gray-600 dark:text-gray-400'> - {isSkeleton ? ( - <Skeleton>PLACEHOLDER1234567890</Skeleton> - ) : ( - <> - <NavLink - to={`/album/${track.al.id}`} - className={cx( - 'hover:underline', - isHighlight && 'text-brand-500' - )} - > - {track.al.name} - </NavLink> - <span className='flex-grow'></span> - </> - )} - </div> - - {/* Actions & Track duration */} - <div className='col-span-2 flex items-center justify-end'> - {/* Like button */} - {!isSkeleton && ( - <button - onClick={() => track?.id && mutationLikeATrack.mutate(track.id)} - className={cx( - 'mr-5 cursor-default transition duration-300 hover:scale-[1.2]', - !isLiked && 'text-gray-600 opacity-0 dark:text-gray-400', - isLiked && 'text-brand-500 opacity-100', - !isSkeleton && 'group-hover:opacity-100' - )} - > - <Icon - name={isLiked ? 'heart' : 'heart-outline'} - className='h-5 w-5' - /> - </button> - )} - - {/* Track duration */} - {isSkeleton ? ( - <Skeleton>0:00</Skeleton> - ) : ( - <div - className={cx( - 'min-w-[2.5rem] text-right', - isHighlight - ? 'text-brand-500' - : 'text-gray-600 dark:text-gray-400' - )} - > - {formatDuration(track.dt, 'en', 'hh:mm:ss')} - </div> - )} - </div> - </div> - ) - } -) -Track.displayName = 'Track' - -const TracksList = memo( - ({ - tracks, - isSkeleton = false, - onTrackDoubleClick, - }: { - tracks: Track[] - isSkeleton?: boolean - onTrackDoubleClick?: (trackID: number) => void - }) => { - // Fake data when isLoading is true - const skeletonTracks: Track[] = new Array(12).fill({}) - - // Liked songs ids - const { data: userLikedSongs } = useUserLikedTracksIDs() - - const handleClick = (e: React.MouseEvent<HTMLElement>, trackID: number) => { - if (e.detail === 2) onTrackDoubleClick?.(trackID) - } - - const playerSnapshot = useSnapshot(player) - const playingTrack = useMemo( - () => playerSnapshot.track, - [playerSnapshot.track] - ) - - return ( - <> - {/* Tracks table header */} - <div className='ml-2 mr-4 mt-10 mb-2 grid grid-cols-12 border-b border-gray-100 py-2.5 text-sm text-gray-400 dark:border-gray-800 dark:text-gray-500'> - <div className='col-span-6 grid grid-cols-[4.2rem_auto]'> - <div></div> - <div>标题</div> - </div> - <div className='col-span-4'>专辑</div> - <div className='col-span-2 justify-self-end'>时长</div> - </div> - - <div className='grid w-full'> - {/* Tracks */} - {isSkeleton - ? skeletonTracks.map((track, index) => ( - <Track - key={index} - track={track} - onClick={() => null} - isSkeleton={true} - /> - )) - : tracks.map(track => ( - <Track - onClick={handleClick} - key={track.id} - track={track} - isLiked={userLikedSongs?.ids?.includes(track.id) ?? false} - isSkeleton={false} - isHighlight={track.id === playingTrack?.id} - /> - ))} - </div> - </> - ) - } -) -TracksList.displayName = 'TracksList' - -export default TracksList diff --git a/packages/web/components/New/TrafficLight.tsx b/packages/web/components/TrafficLight.tsx similarity index 100% rename from packages/web/components/New/TrafficLight.tsx rename to packages/web/components/TrafficLight.tsx diff --git a/packages/web/components/VideoCover.tsx b/packages/web/components/VideoCover.tsx new file mode 100644 index 0000000..fdb941b --- /dev/null +++ b/packages/web/components/VideoCover.tsx @@ -0,0 +1,65 @@ +import { useEffect, useRef } from 'react' +import Hls from 'hls.js' +import { injectGlobal } from '@emotion/css' +import { isIOS, isSafari } from '@/web/utils/common' +import { motion } from 'framer-motion' + +injectGlobal` + .plyr__video-wrapper, + .plyr--video { + background-color: transparent !important; + } +` + +const VideoCover = ({ + source, + onPlay, +}: { + source?: string + onPlay?: () => void +}) => { + const ref = useRef<HTMLVideoElement>(null) + const hls = useRef<Hls>(new Hls()) + + useEffect(() => { + if (source && Hls.isSupported()) { + const video = document.querySelector('#video-cover') as HTMLVideoElement + hls.current.loadSource(source) + hls.current.attachMedia(video) + } + }, [source]) + + return ( + <motion.div + initial={{ opacity: isIOS ? 1 : 0 }} + animate={{ opacity: 1 }} + transition={{ duration: 0.6 }} + className='absolute inset-0' + > + {isSafari ? ( + <video + src={source} + className='h-full w-full' + autoPlay + loop + muted + playsInline + onPlay={() => onPlay?.()} + ></video> + ) : ( + <div className='aspect-square'> + <video + id='video-cover' + ref={ref} + autoPlay + muted + loop + onPlay={() => onPlay?.()} + /> + </div> + )} + </motion.div> + ) +} + +export default VideoCover diff --git a/packages/web/components/New/Wave.tsx b/packages/web/components/Wave.tsx similarity index 100% rename from packages/web/components/New/Wave.tsx rename to packages/web/components/Wave.tsx diff --git a/packages/web/hooks/useAppleMusicArtist.ts b/packages/web/hooks/useAppleMusicArtist.ts index 295a7f4..72e016f 100644 --- a/packages/web/hooks/useAppleMusicArtist.ts +++ b/packages/web/hooks/useAppleMusicArtist.ts @@ -12,6 +12,16 @@ export default function useAppleMusicArtist(props: { ['useAppleMusicArtist', props], async () => { if (!id || !name) return + + const cache = await window.ipcRenderer?.invoke(IpcChannels.GetApiCache, { + api: APIs.AppleMusicArtist, + query: { + id, + }, + }) + + if (cache) return cache + return window.ipcRenderer?.invoke(IpcChannels.GetArtistFromAppleMusic, { id, name, @@ -21,13 +31,6 @@ export default function useAppleMusicArtist(props: { enabled: !!id && !!name, refetchOnWindowFocus: false, refetchInterval: false, - initialData: (): AppleMusicArtist => - window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, { - api: APIs.AppleMusicArtist, - query: { - id, - }, - }), } ) } diff --git a/packages/web/i18n/i18n.ts b/packages/web/i18n/i18n.ts new file mode 100644 index 0000000..baca6fd --- /dev/null +++ b/packages/web/i18n/i18n.ts @@ -0,0 +1,43 @@ +import i18next from 'i18next' +import { initReactI18next } from 'react-i18next' +import zhCN from './locales/zh-cn.json' +import enUS from './locales/en-us.json' +import { subscribe } from 'valtio' +import settings from '../states/settings' + +export const supportedLanguages = ['zh-CN', 'en-US'] as const + +export const getLanguage = () => { + // Get language from settings + try { + const settings = JSON.parse(localStorage.getItem('settings') || '{}') + if (supportedLanguages.includes(settings.language)) { + return settings.language + } + } catch (e) { + // ignore + } + + // Get language from browser + if (navigator.language.startsWith('zh-')) { + return 'zh-CN' + } + + // Fallback to English + return 'en-US' +} + +i18next.use(initReactI18next).init({ + resources: { + 'en-US': { translation: enUS }, + 'zh-CN': { translation: zhCN }, + }, + lng: getLanguage(), + // lng: 'zh-CN', + fallbackLng: 'en-US', + interpolation: { + escapeValue: false, + }, +}) + +export default i18next diff --git a/packages/web/i18n/locales/en-us.json b/packages/web/i18n/locales/en-us.json new file mode 100644 index 0000000..9d670ab --- /dev/null +++ b/packages/web/i18n/locales/en-us.json @@ -0,0 +1,94 @@ +{ + "common": { + "artist_one": "Artist", + "artist_other": "Artists", + "track_one": "Track", + "track_other": "Tracks", + "track-with-count_one": "{{count}} Track", + "track-with-count_other": "{{count}} Tracks", + "album_one": "Album", + "album_other": "Albums", + "album-with-count_one": "{{count}} Album", + "album-with-count_other": "{{count}} Albums", + "playlist_one": "Playlist", + "playlist_other": "Playlists", + "video_one": "Video", + "video_other": "Videos", + "video-with-count_one": "{{count}} Video", + "video-with-count_other": "{{count}} Videos" + }, + "navigation": { + "goBack": "Go back", + "goForward": "Go forward" + }, + "auth": { + "login": "Login", + "logout": "Logout", + "use-phone-or-email": "Use Phone or Email", + "scan-qr-code": "Scan QR Code", + "or": "or", + "login-with-netease-qr": "Login with NetEase QR", + "password": "Password", + "email": "Email", + "phone": "Phone" + }, + "player": { + "play": "Play", + "pause": "Pause", + "next": "Next", + "previous": "Previous", + "enable-shuffle": "Enable shuffle", + "disable-shuffle": "Disable shuffle", + "enable-repeat": "Enable repeat", + "enable-repeat-one": "Enable repeat one", + "disable-repeat": "Disable repeat", + "mini-mode": "Mini Mode", + "exit-mini-mode": "Exit Mini Mode", + "queue": "Queue" + }, + "toasts": { + "copied": "Copied", + "add-to-liked-tracks": "Added to liked tracks", + "added-to-library": "Added to library", + "added-to-playlist": "Added to playlist", + "remove-from-liked-tracks": "Removed from liked tracks", + "removed-from-library": "Removed from library", + "removed-from-playlist": "Removed from playlist" + }, + "search": { + "search": "Search" + }, + "my": { + "xxxs-liked-tracks": "{{nickname}}'s Liked Tracks", + "playNow": "Play Now", + "recently-listened": "RECENTLY LISTENED" + }, + "settings": { + "settings": "Settings" + }, + "context-menu": { + "share": "Share", + "copy-netease-link": "Copy Netease Link", + "add-to-playlist": "Add to playlist", + "add-to-liked-tracks": "Add to Liked Tracks", + "go-to-album": "Go to album", + "go-to-artist": "Go to artist", + "add-to-queue": "Add to Queue", + "follow": "Follow", + "unfollow": "Unfollow", + "followed": "Followed", + "unfollowed": "Unfollowed" + }, + "toast": {}, + "artist": { + "listen": "Listen", + "latest-releases": "Latest Releases", + "popular": "Popular" + }, + "menu-bar": { + "discover": "DISCOVER", + "explore": "EXPLORE", + "lyrics": "LYRICS", + "my-music": "MY MUSIC" + } +} diff --git a/packages/web/i18n/locales/zh-cn.json b/packages/web/i18n/locales/zh-cn.json new file mode 100644 index 0000000..1832a74 --- /dev/null +++ b/packages/web/i18n/locales/zh-cn.json @@ -0,0 +1,94 @@ +{ + "common": { + "album_one": "专辑", + "album_other": "专辑", + "album-with-count_one": "{{count}} 张专辑", + "album-with-count_other": "{{count}} 张专辑", + "artist_one": "艺人", + "artist_other": "艺人", + "playlist_one": "歌单", + "playlist_other": "歌单", + "track_one": "歌曲", + "track_other": "歌曲", + "track-with-count_one": "{{count}} 首歌曲", + "track-with-count_other": "{{count}} 首歌曲", + "video_one": "视频", + "video_other": "视频", + "video-with-count_one": "{{count}} 个视频", + "video-with-count_other": "{{count}} 个视频" + }, + "navigation": { + "goBack": "返回", + "goForward": "前进" + }, + "auth": { + "login": "登录", + "logout": "退出登录", + "scan-qr-code": "扫码登录", + "use-phone-or-email": "使用手机或邮箱登录", + "or": "或者", + "password": "密码", + "email": "邮箱", + "phone": "手机", + "login-with-netease-qr": "扫描二维码登录" + }, + "player": { + "play": "播放", + "pause": "暂停", + "next": "下一首", + "previous": "上一首", + "enable-shuffle": "开启随机播放", + "disable-shuffle": "关闭随机播放", + "enable-repeat": "开启列表循环", + "enable-repeat-one": "开启单曲循环", + "disable-repeat": "关闭循环", + "mini-mode": "Mini模式", + "exit-mini-mode": "退出Mini模式", + "queue": "播放列表" + }, + "toasts": { + "copied": "已复制", + "add-to-liked-tracks": "已添加到喜欢的歌曲", + "added-to-library": "已添加到音乐库", + "added-to-playlist": "已添加到歌单", + "remove-from-liked-tracks": "已从喜欢的歌曲中移除", + "removed-from-library": "已从音乐库中移除", + "removed-from-playlist": "已从歌单中移除" + }, + "search": { + "search": "搜索" + }, + "my": { + "xxxs-liked-tracks": "{{nickname}}喜欢的音乐", + "playNow": "立即播放", + "recently-listened": "最近播放" + }, + "settings": { + "settings": "设置" + }, + "context-menu": { + "share": "分享", + "copy-netease-link": "复制网易云链接", + "add-to-playlist": "收藏到歌单", + "add-to-liked-tracks": "收藏到喜欢的音乐", + "go-to-album": "查看专辑", + "go-to-artist": "查看艺人", + "add-to-queue": "下一首播放", + "unfollow": "取消关注", + "follow": "关注", + "followed": "已关注", + "unfollowed": "已取消关注" + }, + "toast": {}, + "artist": { + "listen": "播放", + "latest-releases": "最新发行", + "popular": "热门歌曲" + }, + "menu-bar": { + "discover": "发现", + "explore": "浏览", + "lyrics": "歌词", + "my-music": "我的" + } +} diff --git a/packages/web/i18n/react-i18next.d.ts b/packages/web/i18n/react-i18next.d.ts new file mode 100644 index 0000000..a71d7e6 --- /dev/null +++ b/packages/web/i18n/react-i18next.d.ts @@ -0,0 +1,11 @@ +import 'react-i18next' +import enUS from './locales/en-us.json' + +declare module 'react-i18next' { + interface CustomTypeOptions { + resources: { + 'en-US': typeof enUS + 'zh-CN': typeof enUS + } + } +} diff --git a/packages/web/index.html b/packages/web/index.html index 5245877..a57fd08 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -13,7 +13,9 @@ </head> <body> - <div id="root"></div> + <div id="root"> + <div id="placeholder" style="background-color: #000; position: fixed; inset: 0; border-radius: 24px;"></div> + </div> <script type="module" src="/main.tsx"></script> </body> diff --git a/packages/web/main.tsx b/packages/web/main.tsx index 8a16c58..83348b7 100644 --- a/packages/web/main.tsx +++ b/packages/web/main.tsx @@ -14,13 +14,14 @@ import { BrowserTracing } from '@sentry/tracing' import 'virtual:svg-icons-register' import './styles/global.css' import './styles/accentColor.css' -import App from './AppNew' +import App from './App' import pkg from '../../package.json' import ReactGA from 'react-ga4' import { ipcRenderer } from './ipcRenderer' import { QueryClientProvider } from '@tanstack/react-query' import reactQueryClient from '@/web/utils/reactQueryClient' import React from 'react' +import './i18n/i18n' ReactGA.initialize('G-KMJJCFZDKF') diff --git a/packages/web/package.json b/packages/web/package.json index c1d08ec..be434a7 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -36,6 +36,7 @@ "framer-motion": "^6.5.1", "hls.js": "^1.2.0", "howler": "^2.2.3", + "i18next": "^21.9.1", "js-cookie": "^3.0.1", "lodash-es": "^4.17.21", "md5": "^2.3.0", @@ -45,6 +46,7 @@ "react-dom": "^18.2.0", "react-ga4": "^1.4.1", "react-hot-toast": "^2.3.0", + "react-i18next": "^11.18.4", "react-router-dom": "^6.3.0", "react-use": "^17.4.0", "react-use-measure": "^2.1.1", diff --git a/packages/web/pages/Album.tsx b/packages/web/pages/Album.tsx deleted file mode 100644 index 10ae011..0000000 --- a/packages/web/pages/Album.tsx +++ /dev/null @@ -1,361 +0,0 @@ -import dayjs from 'dayjs' -import { NavLink, useParams } from 'react-router-dom' -import Button, { Color as ButtonColor } from '@/web/components/Button' -import CoverRow, { Subtitle } from '@/web/components/CoverRow' -import Skeleton from '@/web/components/Skeleton' -import Icon from '@/web/components/Icon' -import TracksAlbum from '@/web/components/TracksAlbum' -import useAlbum from '@/web/api/hooks/useAlbum' -import useArtistAlbums from '@/web/api/hooks/useArtistAlbums' -import player from '@/web/states/player' -import { - Mode as PlayerMode, - State as PlayerState, - TrackListSourceType, -} from '@/web/utils/player' -import { - formatDate, - formatDuration, - resizeImage, - scrollToTop, -} from '@/web/utils/common' -import useTracks from '@/web/api/hooks/useTracks' -import useUserAlbums, { - useMutationLikeAAlbum, -} from '@/web/api/hooks/useUserAlbums' -import { useMemo, useState } from 'react' -import toast from 'react-hot-toast' -import { useSnapshot } from 'valtio' - -const PlayButton = ({ - album, - handlePlay, - isLoading, -}: { - album: Album | undefined - handlePlay: () => void - isLoading: boolean -}) => { - const playerSnapshot = useSnapshot(player) - const isThisAlbumPlaying = useMemo( - () => - playerSnapshot.mode === PlayerMode.TrackList && - playerSnapshot.trackListSource?.type === TrackListSourceType.Album && - playerSnapshot.trackListSource?.id === album?.id, - [ - playerSnapshot.mode, - playerSnapshot.trackListSource?.type, - playerSnapshot.trackListSource?.id, - album?.id, - ] - ) - - const isPlaying = - isThisAlbumPlaying && - [PlayerState.Playing, PlayerState.Loading].includes(playerSnapshot.state) - - const wrappedHandlePlay = () => { - if (isThisAlbumPlaying) { - player.playOrPause() - } else { - handlePlay() - } - } - - return ( - <Button onClick={wrappedHandlePlay} isSkelton={isLoading}> - <Icon - name={isPlaying ? 'pause' : 'play'} - className='mr-1 -ml-1 h-6 w-6' - /> - {isPlaying ? '暂停' : '播放'} - </Button> - ) -} - -const Header = ({ - album, - isLoading, - handlePlay, -}: { - album: Album | undefined - isLoading: boolean - handlePlay: () => void -}) => { - const coverUrl = resizeImage(album?.picUrl || '', 'lg') - - const albumDuration = useMemo(() => { - const duration = album?.songs?.reduce((acc, cur) => acc + cur.dt, 0) || 0 - return formatDuration(duration, 'zh-CN', 'hh[hr] mm[min]') - }, [album?.songs]) - - const [isCoverError, setCoverError] = useState( - coverUrl.includes('3132508627578625') - ) - - const { data: userAlbums } = useUserAlbums() - const isThisAlbumLiked = useMemo(() => { - if (!album) return false - return !!userAlbums?.data?.find(a => a.id === album.id) - }, [album, userAlbums?.data]) - const mutationLikeAAlbum = useMutationLikeAAlbum() - - return ( - <> - {/* Header background */} - <div className='absolute top-0 left-0 z-0 h-[24rem] w-full overflow-hidden'> - {coverUrl && !isCoverError && ( - <> - <img - src={coverUrl} - className='absolute -top-full w-full blur-[100px]' - /> - <img - src={coverUrl} - className='absolute -top-full w-full blur-[100px]' - /> - </> - )} - <div className='absolute top-0 h-full w-full bg-gradient-to-b from-white/[.85] to-white dark:from-black/50 dark:to-[#1d1d1d]'></div> - </div> - - <div className='grid grid-cols-[17rem_auto] items-center gap-9'> - {/* Cover */} - <div className='relative z-0 aspect-square self-start'> - {/* Neon shadow */} - {!isLoading && coverUrl && !isCoverError && ( - <div - className='absolute top-3.5 z-[-1] h-full w-full scale-x-[.92] scale-y-[.96] rounded-2xl bg-cover opacity-40 blur-lg filter' - style={{ - backgroundImage: `url("${coverUrl}")`, - }} - ></div> - )} - - {!isLoading && isCoverError ? ( - // Fallback cover - <div className='flex h-full w-full items-center justify-center rounded-2xl border border-black border-opacity-5 bg-gray-100 text-gray-300'> - <Icon name='music-note' className='h-1/2 w-1/2' /> - </div> - ) : ( - coverUrl && ( - <img - src={coverUrl} - className='w-full rounded-2xl border border-b-0 border-black border-opacity-5 dark:border-white dark:border-opacity-5' - onError={() => setCoverError(true)} - /> - ) - )} - {isLoading && <Skeleton className='h-full w-full rounded-2xl' />} - </div> - - {/* Info */} - <div className='z-10 flex h-full flex-col justify-between'> - {/* Name */} - {isLoading ? ( - <Skeleton className='w-3/4 text-6xl'>PLACEHOLDER</Skeleton> - ) : ( - <div className='text-6xl font-bold dark:text-white'> - {album?.name} - </div> - )} - - {/* Artist */} - {isLoading ? ( - <Skeleton className='mt-5 w-64 text-lg'>PLACEHOLDER</Skeleton> - ) : ( - <div className='mt-5 text-lg font-medium text-gray-800 dark:text-gray-300'> - Album ·{' '} - <NavLink - to={`/artist/${album?.artist.id}`} - className='cursor-default font-semibold hover:underline' - > - {album?.artist.name} - </NavLink> - </div> - )} - - {/* Release date & track count & album duration */} - {isLoading ? ( - <Skeleton className='w-72 translate-y-px text-sm'> - PLACEHOLDER - </Skeleton> - ) : ( - <div className='flex items-center text-sm text-gray-500 dark:text-gray-400'> - {album?.mark === 1056768 && ( - <Icon - name='explicit' - className='mt-px mr-1 h-4 w-4 text-gray-400 dark:text-gray-500' - /> - )} - {dayjs(album?.publishTime || 0).year()} · {album?.size} 首歌 ·{' '} - {albumDuration} - </div> - )} - - {/* Description */} - {isLoading ? ( - <Skeleton className='mt-5 min-h-[2.5rem] w-1/2 text-sm'> - PLACEHOLDER - </Skeleton> - ) : ( - <div className='line-clamp-2 mt-5 min-h-[2.5rem] text-sm text-gray-500 dark:text-gray-400'> - {album?.description} - </div> - )} - - {/* Buttons */} - <div className='mt-5 flex gap-4'> - <PlayButton {...{ album, handlePlay, isLoading }} /> - - <Button - color={ButtonColor.Gray} - iconColor={ - isThisAlbumLiked ? ButtonColor.Primary : ButtonColor.Gray - } - isSkelton={isLoading} - onClick={() => album?.id && mutationLikeAAlbum.mutate(album)} - > - <Icon - name={isThisAlbumLiked ? 'heart' : 'heart-outline'} - className='h-6 w-6' - /> - </Button> - - <Button - color={ButtonColor.Gray} - iconColor={ButtonColor.Gray} - isSkelton={isLoading} - onClick={() => toast('施工中...')} - > - <Icon name='more' className='h-6 w-6' /> - </Button> - </div> - </div> - </div> - </> - ) -} - -const MoreAlbum = ({ album }: { album: Album | undefined }) => { - // Fetch artist's albums - const { data: albums, isLoading } = useArtistAlbums({ - id: album?.artist.id ?? 0, - limit: 1000, - }) - - const filteredAlbums = useMemo((): Album[] => { - if (!albums) return [] - const allReleases = albums?.hotAlbums || [] - const filteredAlbums = allReleases.filter( - album => - ['专辑', 'EP/Single', 'EP'].includes(album.type) && album.size > 1 - ) - const singles = allReleases.filter(album => album.type === 'Single') - - const qualifiedAlbums = [...filteredAlbums, ...singles] - - const formatName = (name: string) => - name.toLowerCase().replace(/(\s|deluxe|edition|\(|\))/g, '') - - const uniqueAlbums: Album[] = [] - qualifiedAlbums.forEach(a => { - // 去除当前页面的专辑 - if (formatName(a.name) === formatName(album?.name ?? '')) return - - // 去除重复的专辑(包含 deluxe edition 的专辑会视为重复) - if ( - uniqueAlbums.findIndex(aa => { - return formatName(a.name) === formatName(aa.name) - }) !== -1 - ) { - return - } - - // 去除 remix 专辑 - if ( - a.name.toLowerCase().includes('remix)') || - a.name.toLowerCase().includes('remixes)') - ) { - return - } - - uniqueAlbums.push(a) - }) - - return uniqueAlbums.slice(0, 5) - }, [album?.name, albums]) - - return ( - <div> - <div className='my-5 h-px w-full bg-gray-100 dark:bg-gray-800'></div> - {!isLoading && albums?.hotAlbums?.length && ( - <div className='pl-px text-[1.375rem] font-semibold text-gray-800 dark:text-gray-100'> - More by{' '} - <NavLink - to={`/artist/${album?.artist?.id}`} - className='cursor-default hover:underline' - > - {album?.artist.name} - </NavLink> - </div> - )} - <div className='mt-3'> - <CoverRow - albums={ - filteredAlbums.length ? filteredAlbums : albums?.hotAlbums || [] - } - subtitle={Subtitle.TypeReleaseYear} - isSkeleton={isLoading} - rows={1} - navigateCallback={scrollToTop} - /> - </div> - </div> - ) -} - -const Album = () => { - const params = useParams() - const { data: album, isLoading } = useAlbum({ - id: Number(params.id) || 0, - }) - - const { data: tracks } = useTracks({ - ids: album?.songs?.map(track => track.id) ?? [], - }) - - const handlePlay = async (trackID: number | null = null) => { - if (!album?.album.id) { - toast('无法播放专辑,该专辑不存在') - return - } - await player.playAlbum(album.album.id, trackID) - } - - return ( - <div className='mt-10'> - <Header - album={album?.album} - isLoading={isLoading} - handlePlay={handlePlay} - /> - <TracksAlbum - tracks={tracks?.songs ?? album?.album.songs ?? []} - onTrackDoubleClick={handlePlay} - isSkeleton={isLoading} - /> - {album?.album && ( - <div className='mt-5 text-xs text-gray-400'> - <div> Released {formatDate(album.album.publishTime || 0, 'en')} </div> - {album.album.company && ( - <div className='mt-[2px]'>© {album.album.company} </div> - )} - </div> - )} - {!isLoading && <MoreAlbum album={album?.album} />} - </div> - ) -} - -export default Album diff --git a/packages/web/pages/New/Album/Album.tsx b/packages/web/pages/Album/Album.tsx similarity index 92% rename from packages/web/pages/New/Album/Album.tsx rename to packages/web/pages/Album/Album.tsx index 25fd73a..f4b483f 100644 --- a/packages/web/pages/New/Album/Album.tsx +++ b/packages/web/pages/Album/Album.tsx @@ -1,8 +1,8 @@ import useAlbum from '@/web/api/hooks/useAlbum' import useTracks from '@/web/api/hooks/useTracks' import { useParams } from 'react-router-dom' -import PageTransition from '@/web/components/New/PageTransition' -import TrackList from '@/web/components/New/TrackList' +import PageTransition from '@/web/components/PageTransition' +import TrackList from '@/web/components/TrackList' import player from '@/web/states/player' import toast from 'react-hot-toast' import { useCallback } from 'react' diff --git a/packages/web/pages/New/Album/Header.tsx b/packages/web/pages/Album/Header.tsx similarity index 85% rename from packages/web/pages/New/Album/Header.tsx rename to packages/web/pages/Album/Header.tsx index 52ec936..9e62b99 100644 --- a/packages/web/pages/New/Album/Header.tsx +++ b/packages/web/pages/Album/Header.tsx @@ -3,7 +3,7 @@ import useUserAlbums, { useMutationLikeAAlbum, } from '@/web/api/hooks/useUserAlbums' import Icon from '@/web/components/Icon' -import TrackListHeader from '@/web/components/New/TrackListHeader' +import TrackListHeader from '@/web/components/TrackListHeader' import useAppleMusicAlbum from '@/web/hooks/useAppleMusicAlbum' import useVideoCover from '@/web/hooks/useVideoCover' import player from '@/web/states/player' @@ -12,9 +12,12 @@ import dayjs from 'dayjs' import { useMemo } from 'react' import toast from 'react-hot-toast' import { useParams } from 'react-router-dom' +import { useTranslation } from 'react-i18next' const Header = () => { + const { t, i18n } = useTranslation() const params = useParams() + const { data: userLikedAlbums } = useUserAlbums() const { data: albumRaw, isLoading: isLoadingAlbum } = useAlbum({ @@ -51,7 +54,11 @@ const Header = () => { : albumFromApple?.attributes?.editorialNotes?.standard || album?.description const extraInfo = useMemo(() => { const duration = album?.songs?.reduce((acc, cur) => acc + cur.dt, 0) || 0 - const albumDuration = formatDuration(duration, 'en', 'hh[hr] mm[min]') + const albumDuration = formatDuration( + duration, + i18n.language, + 'hh[hr] mm[min]' + ) return ( <> {album?.mark === 1056768 && ( @@ -60,11 +67,12 @@ const Header = () => { className='mb-px mr-1 h-3 w-3 lg:h-3.5 lg:w-3.5' /> )}{' '} - {dayjs(album?.publishTime || 0).year()} · {album?.songs.length} tracks,{' '} + {dayjs(album?.publishTime || 0).year()} ·{' '} + {t('common.track-with-count', { count: album?.songs?.length })},{' '} {albumDuration} </> ) - }, [album]) + }, [album?.mark, album?.publishTime, album?.songs, i18n.language, t]) // For <Actions /> const isLiked = useMemo(() => { diff --git a/packages/web/pages/New/Album/MoreByArtist.tsx b/packages/web/pages/Album/MoreByArtist.tsx similarity index 71% rename from packages/web/pages/New/Album/MoreByArtist.tsx rename to packages/web/pages/Album/MoreByArtist.tsx index 6d39156..305b0c8 100644 --- a/packages/web/pages/New/Album/MoreByArtist.tsx +++ b/packages/web/pages/Album/MoreByArtist.tsx @@ -1,15 +1,7 @@ -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 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 { NavLink } from 'react-router-dom' import useArtistAlbums from '@/web/api/hooks/useArtistAlbums' -import { css, cx } from '@emotion/css' -import CoverRow from '@/web/components/New/CoverRow' +import { cx } from '@emotion/css' +import CoverRow from '@/web/components/CoverRow' import { useMemo } from 'react' const MoreByArtist = ({ album }: { album?: Album }) => { @@ -69,13 +61,21 @@ const MoreByArtist = ({ album }: { album?: Album }) => { {/* Title */} <div className='mx-2.5 mb-5 text-14 font-bold text-neutral-300 lg:mx-0'> - MORE BY{' '} - <NavLink - to={`/artist/${album?.artist.id}`} - className='transition duration-300 ease-in-out hover:text-neutral-100' - > - {album?.artist.name} - </NavLink> + {album?.artist.name ? ( + <> + MORE BY{' '} + <NavLink + to={`/artist/${album?.artist.id}`} + className='transition duration-300 ease-in-out hover:text-neutral-100' + > + {album.artist.name} + </NavLink> + </> + ) : ( + <span className='inline-block h-full rounded-full bg-white/10 text-transparent'> + MORE BY PLACEHOLDER + </span> + )} </div> <CoverRow diff --git a/packages/web/pages/New/Album/index.tsx b/packages/web/pages/Album/index.tsx similarity index 100% rename from packages/web/pages/New/Album/index.tsx rename to packages/web/pages/Album/index.tsx diff --git a/packages/web/pages/Artist.tsx b/packages/web/pages/Artist.tsx deleted file mode 100644 index 32a693f..0000000 --- a/packages/web/pages/Artist.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import Button, { Color as ButtonColor } from '@/web/components/Button' -import Icon from '@/web/components/Icon' -import Cover from '@/web/components/Cover' -import useArtist from '@/web/api/hooks/useArtist' -import useArtistAlbums from '@/web/api/hooks/useArtistAlbums' -import { resizeImage } from '@/web/utils/common' -import dayjs from 'dayjs' -import TracksGrid from '@/web/components/TracksGrid' -import CoverRow, { Subtitle } from '@/web/components/CoverRow' -import Skeleton from '@/web/components/Skeleton' -import useTracks from '@/web/api/hooks/useTracks' -import player from '@/web/states/player' -import { cx } from '@emotion/css' -import { useCallback, useMemo } from 'react' -import toast from 'react-hot-toast' -import { useNavigate, useParams } from 'react-router-dom' - -const Header = ({ artist }: { artist: Artist | undefined }) => { - const coverImage = resizeImage(artist?.img1v1Url || '', 'md') - - return ( - <> - <div className='absolute top-0 left-0 z-0 h-[24rem] w-full overflow-hidden'> - {coverImage && ( - <> - <img src={coverImage} className='absolute w-full blur-[100px]' /> - <img src={coverImage} className='absolute w-full blur-[100px]' /> - </> - )} - <div className='absolute top-0 h-full w-full bg-gradient-to-b from-white/[.85] to-white dark:from-black/50 dark:to-[#1d1d1d]'></div> - </div> - - <div className='relative mt-6 overflow-hidden rounded-2xl bg-gray-500/10 dark:bg-gray-800/20'> - <div className='flex h-[26rem] justify-center overflow-hidden'> - <img src={coverImage} className='aspect-square brightness-[.5]' /> - <img src={coverImage} className='aspect-square brightness-[.5]' /> - <img src={coverImage} /> - <img src={coverImage} className='aspect-square brightness-[.5]' /> - <img src={coverImage} className='aspect-square brightness-[.5]' /> - </div> - - <div className='absolute right-0 left-0 top-[18rem] h-32 bg-gradient-to-t from-[#222]/60 to-transparent'></div> - - <div className='absolute top-0 right-0 left-0 flex h-[26rem] items-end justify-between p-8 pb-6'> - <div className='text-7xl font-bold text-white'>{artist?.name}</div> - </div> - </div> - </> - ) -} - -const LatestRelease = ({ - album, - isLoading, -}: { - album: Album | undefined - isLoading: boolean -}) => { - const navigate = useNavigate() - const toAlbum = () => navigate(`/album/${album?.id}`) - return ( - <div> - <div className='mb-6 text-2xl font-semibold text-gray-800 dark:text-white'> - 最新发行 - </div> - <div className='flex-grow rounded-xl'> - {isLoading ? ( - <Skeleton className='aspect-square w-full rounded-xl'></Skeleton> - ) : ( - <Cover - imageUrl={resizeImage(album?.picUrl ?? '', 'md')} - showPlayButton={true} - onClick={toAlbum} - /> - )} - <div - onClick={toAlbum} - className='line-clamp-2 line-clamp-1 mt-2 font-semibold leading-tight decoration-gray-600 decoration-2 hover:underline dark:text-white dark:decoration-gray-200' - > - {album?.name} - </div> - <div className='text-[12px] text-gray-500 dark:text-gray-400'> - {album?.type} · {dayjs(album?.publishTime || 0).year()} - </div> - </div> - </div> - ) -} - -const PopularTracks = ({ - tracks, - isLoadingArtist, -}: { - tracks: Track[] | undefined - isLoadingArtist: boolean -}) => { - const { data: tracksWithExtraInfo } = useTracks({ - ids: tracks?.slice(0, 10)?.map(t => t.id) ?? [], - }) - - const handlePlay = useCallback( - (trackID: number | null = null) => { - if (!tracks?.length) { - toast('无法播放歌单') - return - } - player.playAList( - tracks.map(t => t.id), - trackID - ) - }, - [tracks] - ) - - return ( - <div> - <div className='mb-6 text-2xl font-semibold text-gray-800 dark:text-white'> - 热门歌曲 - </div> - <div className='rounded-xl'> - <TracksGrid - tracks={tracksWithExtraInfo?.songs ?? tracks?.slice(0, 10) ?? []} - isSkeleton={isLoadingArtist} - onTrackDoubleClick={handlePlay} - /> - </div> - </div> - ) -} - -const Artist = () => { - const params = useParams() - - const { data: artist, isLoading: isLoadingArtist } = useArtist({ - id: Number(params.id) || 0, - }) - - const { data: albumsRaw, isLoading: isLoadingAlbums } = useArtistAlbums({ - id: Number(params.id) || 0, - limit: 1000, - }) - - const albums = useMemo(() => { - if (!albumsRaw?.hotAlbums) return [] - const albums: Album[] = [] - albumsRaw.hotAlbums.forEach(album => { - if (album.type !== '专辑') return false - if (['混音版', '精选集', 'Remix'].includes(album.subType)) return false - - // No singles - if (album.size <= 1) return false - - // No remixes - if ( - /(\(|\[)(.*)(Remix|remix)(.*)(\)|\])/.test( - album.name.toLocaleLowerCase() - ) - ) { - return false - } - - // If have same name album only keep the Explicit version - const sameNameAlbumIndex = albums.findIndex(a => a.name === album.name) - if (sameNameAlbumIndex !== -1) { - if (album.mark === 1056768) albums[sameNameAlbumIndex] = album - return - } - - albums.push(album) - }) - return albums - }, [albumsRaw?.hotAlbums]) - - const singles = useMemo(() => { - if (!albumsRaw?.hotAlbums) return [] - const albumsIds = albums.map(album => album.id) - return albumsRaw.hotAlbums.filter( - album => albumsIds.includes(album.id) === false - ) - }, [albums, albumsRaw?.hotAlbums]) - - return ( - <div> - <Header artist={artist?.artist} /> - - <div - className={cx( - 'mt-12 px-2', - albumsRaw?.hotAlbums?.length !== 0 && - 'grid h-[20rem] grid-cols-[14rem,_auto] grid-rows-1 gap-16' - )} - > - {albumsRaw?.hotAlbums?.length !== 0 && ( - <LatestRelease - album={albumsRaw?.hotAlbums[0]} - isLoading={isLoadingAlbums} - /> - )} - - <PopularTracks - tracks={artist?.hotSongs} - isLoadingArtist={isLoadingArtist} - /> - </div> - - {/* Albums */} - {albums.length !== 0 && ( - <div className='mt-20 px-2'> - <div className='mb-6 text-2xl font-semibold text-gray-800 dark:text-white'> - 专辑 - </div> - <CoverRow - albums={albums.slice(0, 10)} - subtitle={Subtitle.TypeReleaseYear} - /> - </div> - )} - - {/* Singles/EP */} - {singles.length !== 0 && ( - <div className='mt-16 px-2'> - <div className='mb-6 text-2xl font-semibold text-gray-800 dark:text-white'> - 单曲和EP - </div> - <CoverRow - albums={singles.slice(0, 5)} - subtitle={Subtitle.TypeReleaseYear} - /> - </div> - )} - </div> - ) -} - -export default Artist diff --git a/packages/web/pages/New/Artist/Artist.tsx b/packages/web/pages/Artist/Artist.tsx similarity index 100% rename from packages/web/pages/New/Artist/Artist.tsx rename to packages/web/pages/Artist/Artist.tsx diff --git a/packages/web/pages/New/Artist/ArtistAlbums.tsx b/packages/web/pages/Artist/ArtistAlbums.tsx similarity index 88% rename from packages/web/pages/New/Artist/ArtistAlbums.tsx rename to packages/web/pages/Artist/ArtistAlbums.tsx index 6b065ca..8f28a13 100644 --- a/packages/web/pages/New/Artist/ArtistAlbums.tsx +++ b/packages/web/pages/Artist/ArtistAlbums.tsx @@ -1,10 +1,12 @@ import useArtistAlbums from '@/web/api/hooks/useArtistAlbums' -import CoverRow from '@/web/components/New/CoverRow' +import CoverRow from '@/web/components/CoverRow' import React from 'react' import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' import { useParams } from 'react-router-dom' const ArtistAlbum = () => { + const { t } = useTranslation() const params = useParams() const { data: albumsRaw, isLoading: isLoadingAlbums } = useArtistAlbums({ @@ -28,7 +30,7 @@ const ArtistAlbum = () => { return ( <div> <div className='mb-4 mt-11 text-12 font-medium uppercase text-neutral-300'> - Albums + {t`common.album_other`} </div> <div className='no-scrollbar flex gap-6 overflow-y-hidden overflow-x-scroll'> diff --git a/packages/web/pages/New/Artist/ArtistMVs.tsx b/packages/web/pages/Artist/ArtistMVs.tsx similarity index 88% rename from packages/web/pages/New/Artist/ArtistMVs.tsx rename to packages/web/pages/Artist/ArtistMVs.tsx index bfd7060..d4076b0 100644 --- a/packages/web/pages/New/Artist/ArtistMVs.tsx +++ b/packages/web/pages/Artist/ArtistMVs.tsx @@ -1,7 +1,9 @@ import { useNavigate, useParams } from 'react-router-dom' import useArtistMV from '@/web/api/hooks/useArtistMV' +import { useTranslation } from 'react-i18next' const ArtistMVs = () => { + const { t } = useTranslation() const params = useParams() const navigate = useNavigate() const { data: videos } = useArtistMV({ id: Number(params.id) || 0 }) @@ -9,7 +11,7 @@ const ArtistMVs = () => { return ( <div> <div className='mb-6 mt-10 text-12 font-medium uppercase text-neutral-300'> - MV + {t`common.video_other`} </div> <div className='grid grid-cols-3 gap-6'> diff --git a/packages/web/pages/New/Artist/FansAlsoLike.tsx b/packages/web/pages/Artist/FansAlsoLike.tsx similarity index 91% rename from packages/web/pages/New/Artist/FansAlsoLike.tsx rename to packages/web/pages/Artist/FansAlsoLike.tsx index bc49978..19fc8ca 100644 --- a/packages/web/pages/New/Artist/FansAlsoLike.tsx +++ b/packages/web/pages/Artist/FansAlsoLike.tsx @@ -1,4 +1,4 @@ -import ArtistRow from '@/web/components/New/ArtistRow' +import ArtistRow from '@/web/components/ArtistRow' import useSimilarArtists from '@/web/api/hooks/useSimilarArtists' import { useParams } from 'react-router-dom' diff --git a/packages/web/pages/New/Artist/Header/Actions.tsx b/packages/web/pages/Artist/Header/Actions.tsx similarity index 95% rename from packages/web/pages/New/Artist/Header/Actions.tsx rename to packages/web/pages/Artist/Header/Actions.tsx index c289eff..1cce241 100644 --- a/packages/web/pages/New/Artist/Header/Actions.tsx +++ b/packages/web/pages/Artist/Header/Actions.tsx @@ -6,9 +6,12 @@ import { openContextMenu } from '@/web/states/contextMenus' import player from '@/web/states/player' import { cx } from '@emotion/css' import toast from 'react-hot-toast' +import { useTranslation } from 'react-i18next' import { useParams } from 'react-router-dom' const Actions = ({ isLoading }: { isLoading: boolean }) => { + const { t } = useTranslation() + const { data: likedArtists } = useUserArtists() const params = useParams() const id = Number(params.id) || 0 @@ -62,7 +65,7 @@ const Actions = ({ isLoading }: { isLoading: boolean }) => { isLoading ? 'bg-white/10 text-transparent' : 'bg-brand-700 text-white' )} > - Listen + {t`artist.listen`} </button> </div> ) diff --git a/packages/web/pages/New/Artist/Header/ArtistInfo.tsx b/packages/web/pages/Artist/Header/ArtistInfo.tsx similarity index 87% rename from packages/web/pages/New/Artist/Header/ArtistInfo.tsx rename to packages/web/pages/Artist/Header/ArtistInfo.tsx index d8f1c1d..b407726 100644 --- a/packages/web/pages/New/Artist/Header/ArtistInfo.tsx +++ b/packages/web/pages/Artist/Header/ArtistInfo.tsx @@ -1,6 +1,7 @@ import useIsMobile from '@/web/hooks/useIsMobile' import useAppleMusicArtist from '@/web/hooks/useAppleMusicArtist' import { cx, css } from '@emotion/css' +import { useTranslation } from 'react-i18next' const ArtistInfo = ({ artist, @@ -9,6 +10,8 @@ const ArtistInfo = ({ artist?: Artist isLoading: boolean }) => { + const { t } = useTranslation() + const isMobile = useIsMobile() const { data: artistFromApple, isLoading: isLoadingArtistFromApple } = useAppleMusicArtist({ @@ -47,8 +50,9 @@ const ArtistInfo = ({ </div> ) : ( <div className='mt-1 text-12 font-medium text-white/40'> - {artist?.musicSize} Tracks · {artist?.albumSize} Albums ·{' '} - {artist?.mvSize} Videos + {t('common.track-with-count', { count: artist?.musicSize })} ·{' '} + {t('common.album-with-count', { count: artist?.albumSize })} ·{' '} + {t('common.video-with-count', { count: artist?.mvSize })} </div> )} diff --git a/packages/web/pages/Artist/Header/Cover.tsx b/packages/web/pages/Artist/Header/Cover.tsx new file mode 100644 index 0000000..d1805dd --- /dev/null +++ b/packages/web/pages/Artist/Header/Cover.tsx @@ -0,0 +1,59 @@ +import { isIOS, isSafari, resizeImage } from '@/web/utils/common' +import Image from '@/web/components/Image' +import { cx, css } from '@emotion/css' +import useAppleMusicArtist from '@/web/hooks/useAppleMusicArtist' +import { useEffect, useRef, useState } from 'react' +import Hls from 'hls.js' +import { motion } from 'framer-motion' +import uiStates from '@/web/states/uiStates' +import VideoCover from '@/web/components/VideoCover' + +const Cover = ({ artist }: { artist?: Artist }) => { + const { data: artistFromApple, isLoading: isLoadingArtistFromApple } = + useAppleMusicArtist({ + id: artist?.id, + name: artist?.name, + }) + + const video = + artistFromApple?.attributes?.editorialVideo?.motionArtistSquare1x1?.video + const cover = isLoadingArtistFromApple + ? '' + : artistFromApple?.attributes?.artwork?.url || artist?.img1v1Url + + useEffect(() => { + if (cover) uiStates.blurBackgroundImage = cover + }, [cover]) + + return ( + <> + <div + className={cx( + 'relative overflow-hidden lg:rounded-24', + css` + grid-area: cover; + ` + )} + > + <Image + className={cx( + 'aspect-square h-full w-full lg:z-10', + video ? 'opacity-0' : 'opacity-100' + )} + src={resizeImage( + isLoadingArtistFromApple + ? '' + : artistFromApple?.attributes?.artwork?.url || + artist?.img1v1Url || + '', + 'lg' + )} + /> + + {video && <VideoCover source={video} />} + </div> + </> + ) +} + +export default Cover diff --git a/packages/web/pages/New/Artist/Header/Header.tsx b/packages/web/pages/Artist/Header/Header.tsx similarity index 100% rename from packages/web/pages/New/Artist/Header/Header.tsx rename to packages/web/pages/Artist/Header/Header.tsx diff --git a/packages/web/pages/New/Artist/Header/LatestRelease.tsx b/packages/web/pages/Artist/Header/LatestRelease.tsx similarity index 95% rename from packages/web/pages/New/Artist/Header/LatestRelease.tsx rename to packages/web/pages/Artist/Header/LatestRelease.tsx index a3beb50..9e4b83f 100644 --- a/packages/web/pages/New/Artist/Header/LatestRelease.tsx +++ b/packages/web/pages/Artist/Header/LatestRelease.tsx @@ -2,11 +2,12 @@ import { resizeImage } from '@/web/utils/common' import dayjs from 'dayjs' import { cx, css } from '@emotion/css' import { useNavigate, useParams } from 'react-router-dom' -import Image from '@/web/components/New/Image' +import Image from '@/web/components/Image' import useArtistAlbums from '@/web/api/hooks/useArtistAlbums' import { useMemo } from 'react' import useArtistMV from '@/web/api/hooks/useArtistMV' import { motion } from 'framer-motion' +import { useTranslation } from 'react-i18next' const Album = ({ album }: { album?: Album }) => { const navigate = useNavigate() @@ -85,6 +86,8 @@ const Video = ({ video }: { video?: any }) => { } const LatestRelease = () => { + const { t } = useTranslation() + const params = useParams() const { data: albumsRaw, isLoading: isLoadingAlbums } = useArtistAlbums({ @@ -108,7 +111,7 @@ const LatestRelease = () => { className='mx-2.5 lg:mx-0' > <div className='mb-3 mt-7 text-14 font-bold text-neutral-300'> - Latest Releases + {t`artist.latest-releases`} </div> <Album album={album} /> diff --git a/packages/web/pages/New/Artist/Header/index.tsx b/packages/web/pages/Artist/Header/index.tsx similarity index 100% rename from packages/web/pages/New/Artist/Header/index.tsx rename to packages/web/pages/Artist/Header/index.tsx diff --git a/packages/web/pages/New/Artist/Popular.tsx b/packages/web/pages/Artist/Popular.tsx similarity index 93% rename from packages/web/pages/New/Artist/Popular.tsx rename to packages/web/pages/Artist/Popular.tsx index 1a53918..e4ae254 100644 --- a/packages/web/pages/New/Artist/Popular.tsx +++ b/packages/web/pages/Artist/Popular.tsx @@ -3,9 +3,10 @@ import player from '@/web/states/player' 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 Image from '@/web/components/Image' import useArtist from '@/web/api/hooks/useArtist' import { useParams } from 'react-router-dom' +import { useTranslation } from 'react-i18next' const Track = ({ track, @@ -52,6 +53,8 @@ const Track = ({ } const Popular = () => { + const { t } = useTranslation() + const params = useParams() const { data: artist, isLoading: isLoadingArtist } = useArtist({ id: Number(params.id) || 0, @@ -69,7 +72,7 @@ const Popular = () => { return ( <div> <div className='mb-4 text-12 font-medium uppercase text-neutral-300'> - Popular + {t`artist.popular`} </div> <div className='grid grid-cols-3 grid-rows-3 gap-4 overflow-hidden'> diff --git a/packages/web/pages/New/Artist/index.tsx b/packages/web/pages/Artist/index.tsx similarity index 100% rename from packages/web/pages/New/Artist/index.tsx rename to packages/web/pages/Artist/index.tsx diff --git a/packages/web/pages/New/Browse.tsx b/packages/web/pages/Browse.tsx similarity index 92% rename from packages/web/pages/New/Browse.tsx rename to packages/web/pages/Browse.tsx index 0a99971..a4efe7a 100644 --- a/packages/web/pages/New/Browse.tsx +++ b/packages/web/pages/Browse.tsx @@ -1,4 +1,4 @@ -import Tabs from '@/web/components/New/Tabs' +import Tabs from '@/web/components/Tabs' import { fetchDailyRecommendPlaylists, fetchRecommendedPlaylists, @@ -6,11 +6,11 @@ import { import { PlaylistApiNames } from '@/shared/api/Playlists' import { useState } from 'react' import { useQuery } from '@tanstack/react-query' -import CoverRowVirtual from '@/web/components/New/CoverRowVirtual' -import PageTransition from '@/web/components/New/PageTransition' +import CoverRowVirtual from '@/web/components/CoverRowVirtual' +import PageTransition from '@/web/components/PageTransition' import { playerWidth, topbarHeight } from '@/web/utils/const' import { cx, css } from '@emotion/css' -import CoverRow from '@/web/components/New/CoverRow' +import CoverRow from '@/web/components/CoverRow' import topbarBackground from '@/web/assets/images/topbar-background.png' const reactQueryOptions = { diff --git a/packages/web/pages/New/Discover.tsx b/packages/web/pages/Discover.tsx similarity index 96% rename from packages/web/pages/New/Discover.tsx rename to packages/web/pages/Discover.tsx index 44265eb..cdde859 100644 --- a/packages/web/pages/New/Discover.tsx +++ b/packages/web/pages/Discover.tsx @@ -1,5 +1,5 @@ -import CoverWall from '@/web/components/New/CoverWall' -import PageTransition from '@/web/components/New/PageTransition' +import CoverWall from '@/web/components/CoverWall' +import PageTransition from '@/web/components/PageTransition' import { fetchPlaylistWithReactQuery, fetchFromCache, diff --git a/packages/web/pages/Home.tsx b/packages/web/pages/Home.tsx deleted file mode 100644 index 5f08ffd..0000000 --- a/packages/web/pages/Home.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { - fetchRecommendedPlaylists, - fetchDailyRecommendPlaylists, -} from '@/web/api/playlist' -import CoverRow from '@/web/components/CoverRow' -import DailyTracksCard from '@/web/components/DailyTracksCard' -import FMCard from '@/web/components/FMCard' -import { PlaylistApiNames } from '@/shared/api/Playlists' -import { APIs } from '@/shared/CacheAPIs' -import { IpcChannels } from '@/shared/IpcChannels' -import { useQuery } from '@tanstack/react-query' - -export default function Home() { - const { - data: dailyRecommendPlaylists, - isLoading: isLoadingDailyRecommendPlaylists, - } = useQuery( - [PlaylistApiNames.FetchDailyRecommendPlaylists], - fetchDailyRecommendPlaylists, - { - retry: false, - placeholderData: () => - window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, { - api: APIs.RecommendResource, - }), - } - ) - - const { - data: recommendedPlaylists, - isLoading: isLoadingRecommendedPlaylists, - } = useQuery( - [PlaylistApiNames.FetchRecommendedPlaylists], - () => { - return fetchRecommendedPlaylists({}) - }, - { - placeholderData: () => - window.ipcRenderer?.sendSync(IpcChannels.GetApiCacheSync, { - api: APIs.Personalized, - }), - } - ) - - const playlists = [ - ...(dailyRecommendPlaylists?.recommend?.slice(1).slice(0, 8) ?? []), - ...(recommendedPlaylists?.result ?? []), - ].slice(0, 10) - - return ( - <div> - <CoverRow - title='推荐歌单' - playlists={playlists} - isSkeleton={ - isLoadingRecommendedPlaylists || isLoadingDailyRecommendPlaylists - } - /> - - <div className='mt-10 mb-4 text-[28px] font-bold text-black dark:text-white'> - For You - </div> - <div className='grid grid-cols-2 gap-6'> - <DailyTracksCard /> - <FMCard /> - </div> - </div> - ) -} diff --git a/packages/web/pages/Library.tsx b/packages/web/pages/Library.tsx deleted file mode 100644 index 659b3f3..0000000 --- a/packages/web/pages/Library.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import CoverRow, { Subtitle } from '@/web/components/CoverRow' -import Icon, { SvgName } from '@/web/components/Icon' -import useUserAlbums from '@/web/api/hooks/useUserAlbums' -import useLyric from '@/web/api/hooks/useLyric' -import usePlaylist from '@/web/api/hooks/usePlaylist' -import useUser from '@/web/api/hooks/useUser' -import useUserPlaylists from '@/web/api/hooks/useUserPlaylists' -import player from '@/web/states/player' -import { resizeImage } from '@/web/utils/common' -import { sample, chunk } from 'lodash-es' -import useUserArtists from '@/web/api/hooks/useUserArtists' -import { cx } from '@emotion/css' -import { useState, useEffect, useMemo, useCallback } from 'react' -import toast from 'react-hot-toast' -import { useNavigate } from 'react-router-dom' - -const LikedTracksCard = ({ className }: { className?: string }) => { - const navigate = useNavigate() - - const { data: playlists } = useUserPlaylists() - - const { data: likedSongsPlaylist } = usePlaylist({ - id: playlists?.playlist?.[0].id ?? 0, - }) - - // Lyric - const [trackID, setTrackID] = useState(0) - - useEffect(() => { - if (trackID === 0) { - setTrackID( - sample(likedSongsPlaylist?.playlist.trackIds?.map(t => t.id) ?? []) ?? 0 - ) - } - }, [likedSongsPlaylist?.playlist.trackIds, trackID]) - - const { data: lyric } = useLyric({ - id: trackID, - }) - - const lyricLines = useMemo(() => { - return ( - sample( - chunk( - lyric?.lrc.lyric - ?.split('\n') - ?.map(l => l.split(']').pop()?.trim()) - ?.filter( - l => - l && - !l.includes('作词') && - !l.includes('作曲') && - !l.includes('纯音乐,请欣赏') - ), - 3 - ) - ) ?? [] - ) - }, [lyric]) - - const handlePlay = useCallback( - (e: React.MouseEvent<HTMLButtonElement>) => { - e.stopPropagation() - if (!likedSongsPlaylist?.playlist.id) { - toast('无法播放歌单') - return - } - player.playPlaylist(likedSongsPlaylist.playlist.id) - }, - [likedSongsPlaylist?.playlist.id] - ) - - return ( - <div - onClick={() => - likedSongsPlaylist?.playlist.id && - navigate(`/playlist/${likedSongsPlaylist.playlist.id}`) - } - className={cx( - 'relative flex h-full w-full flex-col justify-between rounded-2xl bg-brand-50 py-5 px-6 text-brand-600 dark:bg-brand-600 dark:text-brand-50', - className - )} - > - <div className='text-sm'> - {lyricLines.map((line, index) => ( - <div key={`${index}-${line}`}>{line}</div> - ))} - </div> - <div> - <div className='text-2xl font-bold'>我喜欢的音乐</div> - <div className='mt-0.5 text-[15px]'> - {likedSongsPlaylist?.playlist.trackCount ?? 0} 首歌 - </div> - </div> - - <button - onClick={handlePlay} - className='btn-pressed-animation absolute bottom-6 right-6 grid h-11 w-11 cursor-default place-content-center rounded-full bg-brand-600 text-brand-50 shadow-lg dark:bg-white dark:text-brand-600' - > - <Icon name='play-fill' className='ml-0.5 h-6 w-6' /> - </button> - </div> - ) -} - -const OtherCard = ({ - name, - icon, - className, -}: { - name: string - icon: SvgName - className?: string -}) => { - return ( - <div - className={cx( - 'flex h-full w-full flex-col justify-between rounded-2xl bg-gray-100 text-lg font-bold dark:bg-gray-800 dark:text-gray-200', - className - )} - > - <Icon name={icon} className='ml-3 mt-3 h-12 w-12' /> - <span className='m-4'>{name}</span> - </div> - ) -} - -const Playlists = () => { - const { data: playlists } = useUserPlaylists() - return ( - <div> - <CoverRow - playlists={playlists?.playlist?.slice(1) ?? []} - subtitle={Subtitle.Creator} - /> - </div> - ) -} - -const Albums = () => { - const { data: albums } = useUserAlbums({ - limit: 1000, - }) - - return ( - <div> - <CoverRow albums={albums?.data ?? []} subtitle={Subtitle.Artist} /> - </div> - ) -} - -const Artists = () => { - const { data: artists } = useUserArtists() - - return ( - <div> - <CoverRow artists={artists?.data ?? []} subtitle={Subtitle.Artist} /> - </div> - ) -} - -const MVs = () => { - return <div>施工中</div> -} - -const Podcasts = () => { - return <div>施工中</div> -} -interface TabsType { - playlist: string - album: string - artist: string - mv: string - podcast: string -} - -const TabHeader = ({ - activeTab, - tabs, - setActiveTab, -}: { - activeTab: keyof TabsType - tabs: TabsType - setActiveTab: (tab: keyof TabsType) => void -}) => { - return ( - <div className='mt-10 flex text-lg dark:text-white'> - {Object.entries(tabs).map(([id, name]) => ( - <div - key={id} - onClick={() => setActiveTab(id as keyof TabsType)} - className={cx( - 'btn-pressed-animation mr-3 rounded-lg px-3.5 py-1.5 font-medium', - activeTab === id - ? 'bg-black/[.04] dark:bg-white/10' - : 'btn-hover-animation after:bg-black/[.04] dark:after:bg-white/10' - )} - > - {name} - </div> - ))} - </div> - ) -} - -const Tabs = () => { - const tabs = { - playlist: '全部歌单', - album: '专辑', - artist: '艺人', - mv: 'MV', - podcast: '播客', - } - - const [activeTab, setActiveTab] = useState<keyof TabsType>('playlist') - - return ( - <> - <TabHeader - activeTab={activeTab} - tabs={tabs} - setActiveTab={setActiveTab} - /> - <div className='mt-6'> - {activeTab === 'playlist' && <Playlists />} - {activeTab === 'album' && <Albums />} - {activeTab === 'artist' && <Artists />} - {activeTab === 'mv' && <MVs />} - {activeTab === 'podcast' && <Podcasts />} - </div> - </> - ) -} - -const Library = () => { - const { data: user } = useUser() - - const avatarUrl = useMemo( - () => resizeImage(user?.profile?.avatarUrl ?? '', 'sm'), - [user?.profile?.avatarUrl] - ) - - return ( - <div className='mt-8'> - <div className='flex items-center text-[2.625rem] font-semibold dark:text-white'> - <img src={avatarUrl} className='mr-3 mt-1 h-12 w-12 rounded-full' /> - {user?.profile?.nickname}的音乐库 - </div> - - <div className='mt-8 grid grid-cols-[2fr_1fr_1fr] grid-rows-2 gap-4'> - <LikedTracksCard className='row-span-2' /> - <OtherCard name='云盘' icon='fm' className='' /> - <OtherCard name='本地音乐' icon='music-note' className='' /> - <OtherCard name='最近播放' icon='playlist' className='' /> - <OtherCard name='听歌排行' icon='music-library' className='' /> - </div> - - <Tabs /> - </div> - ) -} - -export default Library diff --git a/packages/web/pages/Login.tsx b/packages/web/pages/Login.tsx deleted file mode 100644 index 833ba51..0000000 --- a/packages/web/pages/Login.tsx +++ /dev/null @@ -1,424 +0,0 @@ -import md5 from 'md5' -import QRCode from 'qrcode' -import { - checkLoginQrCodeStatus, - fetchLoginQrCodeKey, - loginWithEmail, - loginWithPhone, -} from '@/web/api/auth' -import Icon from '@/web/components/Icon' -import { state } from '@/web/store' -import { setCookies } from '@/web/utils/cookie' -import { useInterval } from 'react-use' -import { cx } from '@emotion/css' -import { useState, useMemo, useEffect } from 'react' -import toast from 'react-hot-toast' -import { useMutation, useQuery } from '@tanstack/react-query' -import { useNavigate } from 'react-router-dom' -import { useSnapshot } from 'valtio' - -enum Method { - QrCode = 'qrcode', - Email = 'email', - Phone = 'phone', -} - -const domParser = new DOMParser() - -// Shared components and methods -const EmailInput = ({ - email, - setEmail, -}: { - email: string - setEmail: (email: string) => void -}) => { - return ( - <div className='w-full'> - <div className='mb-1 text-sm font-medium text-gray-700 dark:text-gray-300'> - 邮箱 - </div> - <input - value={email} - onChange={e => setEmail(e.target.value)} - className='w-full rounded-md border border-gray-300 px-2 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white' - type='email' - /> - </div> - ) -} - -const PhoneInput = ({ - countryCode, - setCountryCode, - phone, - setPhone, -}: { - countryCode: string - setCountryCode: (code: string) => void - phone: string - setPhone: (phone: string) => void -}) => { - return ( - <div className='w-full'> - <div className='mb-1 text-sm font-medium text-gray-700 dark:text-gray-300'> - 手机 - </div> - <div className='flex w-full'> - <input - className={cx( - 'rounded-md rounded-r-none border border-r-0 border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white', - countryCode.length <= 3 && 'w-14', - countryCode.length == 4 && 'w-16', - countryCode.length >= 5 && 'w-20' - )} - type='text' - placeholder='+86' - value={countryCode} - onChange={e => setCountryCode(e.target.value)} - /> - <input - className='flex-grow rounded-md rounded-l-none border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white' - type='text' - value={phone} - onChange={e => setPhone(e.target.value)} - /> - </div> - </div> - ) -} - -const PasswordInput = ({ - password, - setPassword, -}: { - password: string - setPassword: (password: string) => void -}) => { - const [showPassword, setShowPassword] = useState(false) - return ( - <div className='mt-3 flex w-full flex-col'> - <div className='mb-1 text-sm font-medium text-gray-700 dark:text-gray-300'> - 密码 - </div> - <div className='flex w-full'> - <input - value={password} - onChange={e => setPassword(e.target.value)} - className='w-full rounded-md rounded-r-none border border-r-0 border-gray-300 px-2 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white' - type={showPassword ? 'text' : 'password'} - /> - <div className='flex items-center justify-center rounded-md rounded-l-none border border-l-0 border-gray-300 pr-1 dark:border-gray-600 dark:bg-gray-700'> - <button - onClick={() => setShowPassword(!showPassword)} - className='dark:hover-text-white cursor-default rounded-md p-1.5 text-gray-400 transition duration-300 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-600 dark:hover:text-white' - > - <Icon className='h-5 w-5' name={showPassword ? 'eye-off' : 'eye'} /> - </button> - </div> - </div> - </div> - ) -} - -const LoginButton = ({ - onClick, - disabled, -}: { - onClick: () => void - disabled: boolean -}) => { - // TODO: Add loading indicator - return ( - <button - onClick={onClick} - className={cx( - 'my-2 mt-6 flex w-full cursor-default items-center justify-center rounded-lg py-2 text-lg font-semibold transition duration-200', - !disabled && - 'bg-brand-100 text-brand-500 dark:bg-brand-600 dark:text-white', - disabled && - 'bg-brand-100 text-brand-300 dark:bg-brand-700 dark:text-white/50' - )} - > - 登录 - </button> - ) -} - -const OtherLoginMethods = ({ - method, - setMethod, -}: { - method: Method - setMethod: (method: Method) => void -}) => { - const otherLoginMethods: { - id: Method - name: string - }[] = [ - { - id: Method.QrCode, - name: '二维码', - }, - { - id: Method.Email, - name: '邮箱', - }, - { - id: Method.Phone, - name: '手机', - }, - ] - return ( - <> - <div className='mt-8 mb-4 flex w-full items-center'> - <span className='h-px flex-grow bg-gray-300 dark:bg-gray-700'></span> - <span className='mx-2 text-sm text-gray-400 '>or</span> - <span className='h-px flex-grow bg-gray-300 dark:bg-gray-700'></span> - </div> - <div className='flex gap-3'> - {otherLoginMethods.map( - ({ id, name }) => - method !== id && ( - <button - key={id} - onClick={() => setMethod(id)} - className='flex w-full cursor-default items-center justify-center rounded-lg bg-gray-100 py-2 text-gray-600 transition duration-300 hover:bg-gray-200 hover:text-gray-800 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500 dark:hover:text-gray-100' - > - <Icon className='mr-2 h-5 w-5' name={id} /> - <span>{name}</span> - </button> - ) - )} - </div> - </> - ) -} - -const saveCookie = (cookies: string) => { - setCookies(cookies) -} - -// Login with Email -const LoginWithEmail = () => { - const [password, setPassword] = useState('') - const [email, setEmail] = useState('') - const navigate = useNavigate() - - const doLogin = useMutation( - () => - loginWithEmail({ - email: email.trim(), - md5_password: md5(password.trim()), - }), - { - onSuccess: result => { - if (result?.code !== 200) { - toast(`Login failed: ${result.code}`) - return - } - saveCookie(result.cookie) - navigate(-1) - }, - onError: error => { - toast(`Login failed: ${error}`) - }, - } - ) - - const handleLogin = () => { - if (!email) { - toast.error('Please enter email') - return - } - if (!password) { - toast.error('Please enter password') - return - } - if ( - email.match( - /^[^\s@]+@(126|163|yeah|188|vip\.163|vip\.126)\.(com|net)$/ - ) == null - ) { - toast.error('Please use netease email') - return - } - - doLogin.mutate() - } - - return ( - <> - <EmailInput {...{ email, setEmail }} /> - <PasswordInput {...{ password, setPassword }} /> - <LoginButton onClick={handleLogin} disabled={doLogin.isLoading} /> - </> - ) -} - -// Login with Phone -const LoginWithPhone = () => { - const [password, setPassword] = useState('') - const [phone, setPhone] = useState('') - const countryCode = useSnapshot(state).uiStates.loginPhoneCountryCode - const setCountryCode = (countryCode: string) => { - state.uiStates.loginPhoneCountryCode = countryCode - } - const navigate = useNavigate() - - const doLogin = useMutation( - () => { - return loginWithPhone({ - countrycode: Number(countryCode.replace('+', '').trim()) || 86, - phone: phone.trim(), - md5_password: md5(password.trim()), - }) - }, - { - onSuccess: result => { - if (result?.code !== 200) { - toast(`Login failed: ${result.code}`) - return - } - saveCookie(result.cookie) - navigate(-1) - }, - onError: error => { - toast(`Login failed: ${error}`) - }, - } - ) - - const handleLogin = () => { - if (!countryCode || !Number(countryCode.replace('+', '').trim())) { - toast.error('Please enter country code') - return - } - if (!phone) { - toast.error('Please enter phone number') - return - } - if (!password) { - toast.error('Please enter password') - return - } - - doLogin.mutate() - } - - return ( - <> - <PhoneInput {...{ countryCode, setCountryCode, phone, setPhone }} /> - <PasswordInput {...{ password, setPassword }} /> - <LoginButton onClick={handleLogin} disabled={doLogin.isLoading} /> - </> - ) -} - -// Login with QRCode -const LoginWithQRCode = () => { - const [qrCodeMessage, setQrCodeMessage] = useState('打开网易云音乐,扫码登录') - const [qrCodeImage, setQrCodeImage] = useState('') - - const navigate = useNavigate() - - const { - data: key = { code: 200, data: { code: 200, unikey: 'Not Ready' } }, - status: keyStatus, - refetch: refetchKey, - } = useQuery( - ['qrCodeKey'], - async () => { - const result = await fetchLoginQrCodeKey() - if (result.data.code !== 200) { - throw Error(`Failed to fetch QR code key: ${result.data.code}`) - } - return result - }, - { - retry: true, - retryDelay: 500, - } - ) - - useInterval(async () => { - if (keyStatus !== 'success') return - const qrCodeStatus = await checkLoginQrCodeStatus({ key: key.data.unikey }) - switch (qrCodeStatus.code) { - case 800: - refetchKey() - break - case 801: - setQrCodeMessage('打开网易云音乐,扫码登录') - break - case 802: - setQrCodeMessage('等待确认') - break - case 803: - if (qrCodeStatus.cookie === undefined) { - toast('checkLoginQrCodeStatus returned 803 without cookie') - break - } - saveCookie(qrCodeStatus.cookie) - navigate(-1) - break - } - }, 1000) - - const qrCodeUrl = useMemo( - () => `https://music.163.com/login?codekey=${key.data.unikey}`, - [key] - ) - - useEffect(() => { - const updateImage = async () => { - const svg = await QRCode.toString(qrCodeUrl, { - margin: 0, - color: { - light: '#ffffff00', - }, - type: 'svg', - }) - const path = domParser - .parseFromString(svg, 'text/xml') - .getElementsByTagName('path')[0] - - setQrCodeImage(path?.getAttribute('d') ?? '') - } - updateImage() - }, [qrCodeUrl]) - - return ( - <div className='flex flex-col items-center justify-center'> - <div className='rounded-3xl border p-6 text-brand-500 dark:border-gray-700'> - <svg - xmlns='http://www.w3.org/2000/svg' - width='270' - height='270' - viewBox='0 0 37 37' - shapeRendering='crispEdges' - > - <path stroke='currentColor' d={qrCodeImage} /> - </svg> - </div> - <div className='mt-4 text-sm text-gray-500 dark:text-gray-200'> - {qrCodeMessage} - </div> - </div> - ) -} - -export default function Login() { - const [method, setMethod] = useState<Method>(Method.Phone) - - return ( - <div className='grid h-full place-content-center'> - <div className='w-80'> - {method === Method.Email && <LoginWithEmail />} - {method === Method.Phone && <LoginWithPhone />} - {method === Method.QrCode && <LoginWithQRCode />} - <OtherLoginMethods {...{ method, setMethod }} /> - </div> - </div> - ) -} diff --git a/packages/web/pages/New/Lyrics.tsx b/packages/web/pages/Lyrics.tsx similarity index 100% rename from packages/web/pages/New/Lyrics.tsx rename to packages/web/pages/Lyrics.tsx diff --git a/packages/web/pages/New/MV.tsx b/packages/web/pages/MV.tsx similarity index 96% rename from packages/web/pages/New/MV.tsx rename to packages/web/pages/MV.tsx index 40bdaa8..3049fb5 100644 --- a/packages/web/pages/New/MV.tsx +++ b/packages/web/pages/MV.tsx @@ -1,4 +1,4 @@ -import PageTransition from '@/web/components/New/PageTransition' +import PageTransition from '@/web/components/PageTransition' import useMV, { useMVUrl } from '@/web/api/hooks/useMV' import { useParams } from 'react-router-dom' import Plyr, { PlyrOptions, PlyrSource } from 'plyr-react' diff --git a/packages/web/pages/New/My/Collections.tsx b/packages/web/pages/My/Collections.tsx similarity index 86% rename from packages/web/pages/New/My/Collections.tsx rename to packages/web/pages/My/Collections.tsx index f9c7057..4e42a26 100644 --- a/packages/web/pages/New/My/Collections.tsx +++ b/packages/web/pages/My/Collections.tsx @@ -1,38 +1,20 @@ import { css, cx } from '@emotion/css' import useUserArtists from '@/web/api/hooks/useUserArtists' -import Tabs from '@/web/components/New/Tabs' +import Tabs from '@/web/components/Tabs' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import CoverRow from '@/web/components/New/CoverRow' +import CoverRow from '@/web/components/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 ArtistRow from '@/web/components/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 = [ - { - id: 'playlists', - name: 'Playlists', - }, - { - id: 'albums', - name: 'Albums', - }, - { - id: 'artists', - name: 'Artists', - }, - { - id: 'videos', - name: 'Videos', - }, -] +import { useTranslation } from 'react-i18next' const Albums = () => { const { data: albums } = useUserAlbums() @@ -54,6 +36,27 @@ const Artists = () => { } const CollectionTabs = ({ showBg }: { showBg: boolean }) => { + const { t } = useTranslation() + + const tabs = [ + { + id: 'playlists', + name: t`common.playlist_other`, + }, + { + id: 'albums', + name: t`common.album_other`, + }, + { + id: 'artists', + name: t`common.artist_other`, + }, + { + id: 'videos', + name: t`common.video_other`, + }, + ] + const { librarySelectedTab: selectedTab } = useSnapshot(uiStates) const setSelectedTab = ( id: 'playlists' | 'albums' | 'artists' | 'videos' diff --git a/packages/web/pages/New/My/My.tsx b/packages/web/pages/My/My.tsx similarity index 86% rename from packages/web/pages/New/My/My.tsx rename to packages/web/pages/My/My.tsx index ff79e2a..72a51f3 100644 --- a/packages/web/pages/New/My/My.tsx +++ b/packages/web/pages/My/My.tsx @@ -1,6 +1,6 @@ import { css, cx } from '@emotion/css' import PlayLikedSongsCard from './PlayLikedSongsCard' -import PageTransition from '@/web/components/New/PageTransition' +import PageTransition from '@/web/components/PageTransition' import RecentlyListened from './RecentlyListened' import Collections from './Collections' diff --git a/packages/web/pages/New/My/PlayLikedSongsCard.tsx b/packages/web/pages/My/PlayLikedSongsCard.tsx similarity index 92% rename from packages/web/pages/New/My/PlayLikedSongsCard.tsx rename to packages/web/pages/My/PlayLikedSongsCard.tsx index 7bbf82d..5736bfd 100644 --- a/packages/web/pages/New/My/PlayLikedSongsCard.tsx +++ b/packages/web/pages/My/PlayLikedSongsCard.tsx @@ -9,12 +9,14 @@ import toast from 'react-hot-toast' import { useNavigate } from 'react-router-dom' import Icon from '@/web/components/Icon' import { lyricParser } from '@/web/utils/lyric' -import Image from '@/web/components/New/Image' +import Image from '@/web/components/Image' import { resizeImage } from '@/web/utils/common' import { breakpoint as bp } from '@/web/utils/const' import useUser from '@/web/api/hooks/useUser' +import { useTranslation } from 'react-i18next' const Lyrics = ({ tracksIDs }: { tracksIDs: number[] }) => { + const { t } = useTranslation() const [id, setId] = useState(0) const { data: user } = useUser() @@ -49,7 +51,7 @@ const Lyrics = ({ tracksIDs }: { tracksIDs: number[] }) => { )} > <div className='mb-3.5 text-18 font-medium text-white/70'> - {user?.profile?.nickname}'S LIKED TRACKS + {t('my.xxxs-liked-tracks', { nickname: user?.profile?.nickname })} </div> {lyricLines.map((line, index) => ( <div @@ -87,6 +89,8 @@ const Covers = memo(({ tracks }: { tracks: Track[] }) => { Covers.displayName = 'Covers' const PlayLikedSongsCard = () => { + const { t } = useTranslation() + const navigate = useNavigate() const { data: playlists } = useUserPlaylists() @@ -98,11 +102,7 @@ const PlayLikedSongsCard = () => { const handlePlay = useCallback( (e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation() - if (!likedSongsPlaylist?.playlist.id) { - toast('无法播放歌单') - return - } - player.playPlaylist(likedSongsPlaylist.playlist.id) + player.playPlaylist(likedSongsPlaylist?.playlist.id) }, [likedSongsPlaylist?.playlist.id] ) @@ -139,7 +139,7 @@ const PlayLikedSongsCard = () => { onClick={handlePlay} className='rounded-full bg-brand-700 py-5 px-6 text-16 font-medium text-white' > - Play Now + {t`my.playNow`} </button> <button onClick={() => diff --git a/packages/web/pages/New/My/RecentlyListened.tsx b/packages/web/pages/My/RecentlyListened.tsx similarity index 82% rename from packages/web/pages/New/My/RecentlyListened.tsx rename to packages/web/pages/My/RecentlyListened.tsx index 5008450..80b97dc 100644 --- a/packages/web/pages/New/My/RecentlyListened.tsx +++ b/packages/web/pages/My/RecentlyListened.tsx @@ -1,9 +1,12 @@ import useUserListenedRecords from '@/web/api/hooks/useUserListenedRecords' import useArtists from '@/web/api/hooks/useArtists' import { useMemo } from 'react' -import ArtistRow from '@/web/components/New/ArtistRow' +import ArtistRow from '@/web/components/ArtistRow' +import { useTranslation } from 'react-i18next' const RecentlyListened = () => { + const { t } = useTranslation() + const { data: listenedRecords } = useUserListenedRecords({ type: 'week' }) const recentListenedArtistsIDs = useMemo(() => { const artists: { @@ -35,7 +38,11 @@ const RecentlyListened = () => { ) return ( - <ArtistRow artists={artist} placeholderRow={1} title='RECENTLY LISTENED' /> + <ArtistRow + artists={artist} + placeholderRow={1} + title={t`my.recently-listened`} + /> ) } diff --git a/packages/web/pages/New/My/index.tsx b/packages/web/pages/My/index.tsx similarity index 100% rename from packages/web/pages/New/My/index.tsx rename to packages/web/pages/My/index.tsx diff --git a/packages/web/pages/New/Artist/Header/Cover.tsx b/packages/web/pages/New/Artist/Header/Cover.tsx deleted file mode 100644 index fea0b69..0000000 --- a/packages/web/pages/New/Artist/Header/Cover.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { isIOS, isSafari, resizeImage } from '@/web/utils/common' -import Image from '@/web/components/New/Image' -import { cx, css } from '@emotion/css' -import useAppleMusicArtist from '@/web/hooks/useAppleMusicArtist' -import { useEffect, useRef } from 'react' -import Hls from 'hls.js' -import { motion } from 'framer-motion' -import uiStates from '@/web/states/uiStates' - -const VideoCover = ({ source }: { source?: string }) => { - const ref = useRef<HTMLVideoElement>(null) - const hls = useRef<Hls>(new Hls()) - - useEffect(() => { - if (source && Hls.isSupported()) { - const video = document.querySelector('#video-cover') as HTMLVideoElement - hls.current.loadSource(source) - hls.current.attachMedia(video) - } - }, [source]) - - return ( - <div className='z-10 aspect-square overflow-hidden rounded-24'> - <video - id='video-cover' - className='h-full w-full' - ref={ref} - autoPlay - muted - loop - /> - </div> - ) -} - -const Cover = ({ artist }: { artist?: Artist }) => { - const { data: artistFromApple, isLoading: isLoadingArtistFromApple } = - useAppleMusicArtist({ - id: artist?.id, - name: artist?.name, - }) - - const video = - artistFromApple?.attributes?.editorialVideo?.motionArtistSquare1x1?.video - const cover = isLoadingArtistFromApple - ? '' - : artistFromApple?.attributes?.artwork?.url || artist?.img1v1Url - - useEffect(() => { - if (cover) uiStates.blurBackgroundImage = cover - }, [cover]) - - return ( - <> - <div - className={cx( - 'relative', - css` - grid-area: cover; - ` - )} - > - <Image - className='aspect-square h-full w-full lg:z-10 lg:rounded-24' - src={resizeImage( - isLoadingArtistFromApple - ? '' - : artistFromApple?.attributes?.artwork?.url || - artist?.img1v1Url || - '', - 'lg' - )} - /> - - {video && ( - <motion.div - initial={{ opacity: isIOS ? 1 : 0 }} - animate={{ opacity: 1 }} - transition={{ duration: 0.6 }} - className='absolute inset-0 z-10 h-full w-full' - > - {isSafari ? ( - <video - src={video} - className='h-full w-full' - autoPlay - loop - muted - playsInline - ></video> - ) : ( - <VideoCover source={video} /> - )} - </motion.div> - )} - </div> - </> - ) -} - -export default Cover diff --git a/packages/web/pages/Playlist.tsx b/packages/web/pages/Playlist.tsx deleted file mode 100644 index 36e7cf4..0000000 --- a/packages/web/pages/Playlist.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import { memo, useCallback, useEffect, useMemo } from 'react' -import Button, { Color as ButtonColor } from '@/web/components/Button' -import Skeleton from '@/web/components/Skeleton' -import Icon from '@/web/components/Icon' -import TracksList from '@/web/components/TracksList' -import usePlaylist from '@/web/api/hooks/usePlaylist' -import useScroll from '@/web/hooks/useScroll' -import useTracksInfinite from '@/web/api/hooks/useTracksInfinite' -import player from '@/web/states/player' -import { formatDate, resizeImage } from '@/web/utils/common' -import useUserPlaylists, { - useMutationLikeAPlaylist, -} from '@/web/api/hooks/useUserPlaylists' -import useUser from '@/web/api/hooks/useUser' -import { - Mode as PlayerMode, - TrackListSourceType, - State as PlayerState, -} from '@/web/utils/player' -import toast from 'react-hot-toast' -import { useParams } from 'react-router-dom' -import { useSnapshot } from 'valtio' - -const PlayButton = ({ - playlist, - handlePlay, - isLoading, -}: { - playlist: Playlist | undefined - handlePlay: () => void - isLoading: boolean -}) => { - const playerSnapshot = useSnapshot(player) - const isThisPlaylistPlaying = useMemo( - () => - playerSnapshot.mode === PlayerMode.TrackList && - playerSnapshot.trackListSource?.type === TrackListSourceType.Playlist && - playerSnapshot.trackListSource?.id === playlist?.id, - [ - playerSnapshot.mode, - playerSnapshot.trackListSource?.id, - playerSnapshot.trackListSource?.type, - playlist?.id, - ] - ) - - const wrappedHandlePlay = () => { - if (isThisPlaylistPlaying) { - player.playOrPause() - } else { - handlePlay() - } - } - - const isPlaying = - isThisPlaylistPlaying && - [PlayerState.Playing, PlayerState.Loading].includes(playerSnapshot.state) - - return ( - <Button onClick={wrappedHandlePlay} isSkelton={isLoading}> - <Icon - name={isPlaying ? 'pause' : 'play'} - className='-ml-1 mr-1 h-6 w-6' - /> - {isPlaying ? '暂停' : '播放'} - </Button> - ) -} - -const Header = memo( - ({ - playlist, - isLoading, - handlePlay, - }: { - playlist: Playlist | undefined - isLoading: boolean - handlePlay: () => void - }) => { - const coverUrl = resizeImage(playlist?.coverImgUrl || '', 'lg') - - const mutationLikeAPlaylist = useMutationLikeAPlaylist() - const { data: userPlaylists } = useUserPlaylists() - - const isThisPlaylistLiked = useMemo(() => { - if (!playlist) return false - return !!userPlaylists?.playlist?.find(p => p.id === playlist.id) - }, [playlist, userPlaylists?.playlist]) - - const { data: user } = useUser() - const isThisPlaylistCreatedByCurrentUser = useMemo(() => { - if (!playlist || !user) return false - return playlist.creator.userId === user?.profile?.userId - }, [playlist, user]) - - return ( - <> - {/* Header background */} - <div className='absolute top-0 left-0 z-0 h-[24rem] w-full overflow-hidden'> - <img src={coverUrl} className='absolute top-0 w-full blur-[100px]' /> - <img src={coverUrl} className='absolute top-0 w-full blur-[100px]' /> - <div className='absolute top-0 h-full w-full bg-gradient-to-b from-white/[.85] to-white dark:from-black/50 dark:to-[#1d1d1d]'></div> - </div> - - <div className='grid grid-cols-[17rem_auto] items-center gap-9'> - {/* Cover */} - <div className='relative z-0 aspect-square self-start'> - {!isLoading && ( - <div - className='absolute top-3.5 z-[-1] h-full w-full scale-x-[.92] scale-y-[.96] rounded-2xl bg-cover opacity-40 blur-lg filter' - style={{ - backgroundImage: `url("${coverUrl}")`, - }} - ></div> - )} - - {!isLoading && ( - <img - src={coverUrl} - className='rounded-2xl border border-black border-opacity-5' - /> - )} - {isLoading && ( - <Skeleton v-else className='h-full w-full rounded-2xl' /> - )} - </div> - - {/* <!-- Playlist info --> */} - <div className='z-10 flex h-full flex-col justify-between'> - {/* <!-- Playlist name --> */} - {!isLoading && ( - <div className='text-4xl font-bold dark:text-white'> - {playlist?.name} - </div> - )} - {isLoading && ( - <Skeleton v-else className='w-3/4 text-4xl'> - PLACEHOLDER - </Skeleton> - )} - - {/* <!-- Playlist creator --> */} - {!isLoading && ( - <div className='mt-5 text-lg font-medium text-gray-800 dark:text-gray-300'> - 歌单 · <span>{playlist?.creator?.nickname}</span> - </div> - )} - {isLoading && ( - <Skeleton v-else className='mt-5 w-64 text-lg'> - PLACEHOLDER - </Skeleton> - )} - - {/* <!-- Playlist last update time & track count --> */} - {!isLoading && ( - <div className='text-sm text-gray-500 dark:text-gray-400'> - 更新于 {formatDate(playlist?.updateTime || 0, 'zh-CN')} ·{' '} - {playlist?.trackCount} 首歌 - </div> - )} - {isLoading && ( - <Skeleton v-else className='w-72 translate-y-px text-sm'> - PLACEHOLDER - </Skeleton> - )} - - {/* <!-- Playlist description --> */} - {!isLoading && ( - <div className='line-clamp-2 mt-5 min-h-[2.5rem] text-sm text-gray-500 dark:text-gray-400'> - {playlist?.description} - </div> - )} - {isLoading && ( - <Skeleton v-else className='mt-5 min-h-[2.5rem] w-1/2 text-sm'> - PLACEHOLDER - </Skeleton> - )} - - {/* <!-- Buttons --> */} - <div className='mt-5 flex gap-4'> - <PlayButton {...{ playlist, handlePlay, isLoading }} /> - - {!isThisPlaylistCreatedByCurrentUser && ( - <Button - color={ButtonColor.Gray} - iconColor={ - isThisPlaylistLiked ? ButtonColor.Primary : ButtonColor.Gray - } - isSkelton={isLoading} - onClick={() => - playlist?.id && mutationLikeAPlaylist.mutate(playlist) - } - > - <Icon - name={isThisPlaylistLiked ? 'heart' : 'heart-outline'} - className='h-6 w-6' - /> - </Button> - )} - - <Button - color={ButtonColor.Gray} - iconColor={ButtonColor.Gray} - isSkelton={isLoading} - onClick={() => toast('施工中...')} - > - <Icon name='more' className='h-6 w-6' /> - </Button> - </div> - </div> - </div> - </> - ) - } -) -Header.displayName = 'Header' - -const Tracks = memo( - ({ - playlist, - handlePlay, - isLoadingPlaylist, - }: { - playlist: Playlist | undefined - handlePlay: (trackID: number | null) => void - isLoadingPlaylist: boolean - }) => { - const { - data: tracksPages, - hasNextPage, - isLoading: isLoadingTracks, - isFetchingNextPage, - fetchNextPage, - } = useTracksInfinite({ - ids: playlist?.trackIds?.map(t => t.id) || [], - }) - - const scroll = useScroll(document.getElementById('mainContainer'), { - throttle: 500, - offset: { - bottom: 256, - }, - }) - - useEffect(() => { - if (!scroll.arrivedState.bottom || !hasNextPage || isFetchingNextPage) - return - fetchNextPage() - }, [ - fetchNextPage, - hasNextPage, - isFetchingNextPage, - scroll.arrivedState.bottom, - ]) - - const tracks = useMemo(() => { - if (!tracksPages) return [] - const allTracks: Track[] = [] - tracksPages.pages.forEach(page => allTracks.push(...(page?.songs ?? []))) - return allTracks - }, [tracksPages]) - - return ( - <> - {isLoadingPlaylist ? ( - <TracksList tracks={[]} isSkeleton={true} /> - ) : isLoadingTracks ? ( - <TracksList - tracks={playlist?.tracks ?? []} - onTrackDoubleClick={handlePlay} - /> - ) : ( - <TracksList tracks={tracks} onTrackDoubleClick={handlePlay} /> - )} - </> - ) - } -) -Tracks.displayName = 'Tracks' - -const Playlist = () => { - const params = useParams() - const { data: playlist, isLoading } = usePlaylist({ - id: Number(params.id) || 0, - }) - - const handlePlay = useCallback( - (trackID: number | null = null) => { - if (!playlist?.playlist?.id) { - toast('无法播放歌单') - return - } - player.playPlaylist(playlist.playlist.id, trackID) - }, - [playlist] - ) - - return ( - <div className='mt-10'> - <Header - playlist={playlist?.playlist} - isLoading={isLoading} - handlePlay={handlePlay} - /> - - <Tracks - playlist={playlist?.playlist} - handlePlay={handlePlay} - isLoadingPlaylist={isLoading} - /> - </div> - ) -} - -export default Playlist diff --git a/packages/web/pages/New/Playlist/Header.tsx b/packages/web/pages/Playlist/Header.tsx similarity index 96% rename from packages/web/pages/New/Playlist/Header.tsx rename to packages/web/pages/Playlist/Header.tsx index dbf380a..ead0df4 100644 --- a/packages/web/pages/New/Playlist/Header.tsx +++ b/packages/web/pages/Playlist/Header.tsx @@ -3,7 +3,7 @@ import useUser from '@/web/api/hooks/useUser' import useUserPlaylists, { useMutationLikeAPlaylist, } from '@/web/api/hooks/useUserPlaylists' -import TrackListHeader from '@/web/components/New/TrackListHeader' +import TrackListHeader from '@/web/components/TrackListHeader' import player from '@/web/states/player' import { formatDate } from '@/web/utils/common' import { useMemo } from 'react' diff --git a/packages/web/pages/New/Playlist/Playlist.tsx b/packages/web/pages/Playlist/Playlist.tsx similarity index 84% rename from packages/web/pages/New/Playlist/Playlist.tsx rename to packages/web/pages/Playlist/Playlist.tsx index 43a655e..5e43613 100644 --- a/packages/web/pages/New/Playlist/Playlist.tsx +++ b/packages/web/pages/Playlist/Playlist.tsx @@ -1,6 +1,6 @@ import { useParams } from 'react-router-dom' -import PageTransition from '@/web/components/New/PageTransition' -import TrackList from '@/web/components/New/TrackList' +import PageTransition from '@/web/components/PageTransition' +import TrackList from '@/web/components/TrackList' import player from '@/web/states/player' import usePlaylist from '@/web/api/hooks/usePlaylist' import Header from './Header' diff --git a/packages/web/pages/New/Playlist/index.tsx b/packages/web/pages/Playlist/index.tsx similarity index 100% rename from packages/web/pages/New/Playlist/index.tsx rename to packages/web/pages/Playlist/index.tsx diff --git a/packages/web/pages/Podcast.tsx b/packages/web/pages/Podcast.tsx deleted file mode 100644 index f82a44e..0000000 --- a/packages/web/pages/Podcast.tsx +++ /dev/null @@ -1,5 +0,0 @@ -const Podcast = () => { - return <div>施工中...</div> -} - -export default Podcast diff --git a/packages/web/pages/Search/Search.tsx b/packages/web/pages/Search/Search.tsx index 18f0631..29a97a7 100644 --- a/packages/web/pages/Search/Search.tsx +++ b/packages/web/pages/Search/Search.tsx @@ -1,6 +1,4 @@ import { multiMatchSearch, search } from '@/web/api/search' -import Cover from '@/web/components/Cover' -import TrackGrid from '@/web/components/TracksGrid' import player from '@/web/states/player' import { resizeImage } from '@/web/utils/common' import { SearchTypes, SearchApiNames } from '@/shared/api/Search' @@ -9,6 +7,8 @@ import { useMemo, useCallback } from 'react' import toast from 'react-hot-toast' import { useQuery } from '@tanstack/react-query' import { useNavigate, useParams } from 'react-router-dom' +import Image from '@/web/components/Image' +import { cx } from '@emotion/css' const Artists = ({ artists }: { artists: Artist[] }) => { const navigate = useNavigate() @@ -21,10 +21,9 @@ const Artists = ({ artists }: { artists: Artist[] }) => { className='btn-hover-animation flex items-center p-2.5 after:rounded-xl after:bg-gray-100 dark:after:bg-white/10' > <div className='mr-4 h-14 w-14'> - <Cover - imageUrl={resizeImage(artist.img1v1Url, 'xs')} - roundedClass='rounded-full' - showHover={false} + <img + src={resizeImage(artist.img1v1Url, 'xs')} + className='h-12 w-12 rounded-full' /> </div> <div> @@ -52,10 +51,9 @@ const Albums = ({ albums }: { albums: Album[] }) => { className='btn-hover-animation flex items-center p-2.5 after:rounded-xl after:bg-gray-100 dark:after:bg-white/10' > <div className='mr-4 h-14 w-14'> - <Cover - imageUrl={resizeImage(album.picUrl, 'xs')} - roundedClass='rounded-lg' - showHover={false} + <img + src={resizeImage(album.picUrl, 'xs')} + className='h-12 w-12 rounded-lg' /> </div> <div> @@ -72,6 +70,50 @@ const Albums = ({ albums }: { albums: Album[] }) => { ) } +const Track = ({ + track, + isPlaying, + onPlay, +}: { + track?: Track + isPlaying?: boolean + onPlay: (id: number) => void +}) => { + return ( + <div + className='flex items-center justify-between' + onClick={e => { + if (e.detail === 2 && track?.id) onPlay(track.id) + }} + > + {/* Cover */} + <Image + className='mr-4 aspect-square h-14 w-14 flex-shrink-0 rounded-12' + src={resizeImage(track?.al?.picUrl || '', 'sm')} + animation={false} + placeholder={false} + /> + + {/* Track info */} + <div className='mr-3 flex-grow'> + <div + className={cx( + 'line-clamp-1 text-16 font-medium ', + isPlaying + ? 'text-brand-700' + : 'text-neutral-700 dark:text-neutral-200' + )} + > + {track?.name} + </div> + <div className='line-clamp-1 mt-1 text-14 font-bold text-neutral-200 dark:text-neutral-700'> + {track?.ar.map(a => a.name).join(', ')} + </div> + </div> + </div> + ) +} + const Search = () => { const { keywords = '', type = 'all' } = useParams() @@ -145,10 +187,9 @@ const Search = () => { className='btn-hover-animation flex items-center p-3 after:rounded-xl after:bg-gray-100 dark:after:bg-white/10' > <div className='mr-6 h-24 w-24'> - <Cover - imageUrl={resizeImage(match.picUrl, 'xs')} - showHover={false} - roundedClass='rounded-full' + <img + src={resizeImage(match.picUrl, 'xs')} + className='h-12 w-12 rounded-full' /> </div> <div> @@ -184,11 +225,11 @@ const Search = () => { {searchResult?.result?.song?.songs && ( <div className='col-span-2'> <div className='mb-2 text-sm font-medium text-gray-400'>歌曲</div> - <TrackGrid - tracks={searchResult.result.song.songs} - cols={3} - onTrackDoubleClick={handlePlayTracks} - /> + <div className='mt-4 grid grid-cols-3 grid-rows-3 gap-5 gap-y-6 overflow-hidden pb-12'> + {searchResult.result.song.songs.map(track => ( + <Track key={track.id} track={track} onPlay={handlePlayTracks} /> + ))} + </div> </div> )} </div> diff --git a/packages/web/states/contextMenus.ts b/packages/web/states/contextMenus.ts index 6272742..dcf4c13 100644 --- a/packages/web/states/contextMenus.ts +++ b/packages/web/states/contextMenus.ts @@ -36,6 +36,11 @@ export const openContextMenu = ({ dataSourceID: ContextMenu['dataSourceID'] options?: ContextMenu['options'] }) => { + if (event.target === contextMenus.target) { + closeContextMenu() + return + } + const target = event.target as HTMLElement contextMenus.target = ref(target) @@ -48,8 +53,8 @@ export const openContextMenu = ({ } } -export const closeContextMenu = (event: MouseEvent) => { - if (event.target === contextMenus.target) { +export const closeContextMenu = (event?: MouseEvent) => { + if (event?.target === contextMenus.target) { return } assign(contextMenus, initContextMenu) diff --git a/packages/web/states/settings.ts b/packages/web/states/settings.ts index b1ad185..8d096cb 100644 --- a/packages/web/states/settings.ts +++ b/packages/web/states/settings.ts @@ -1,9 +1,11 @@ import { IpcChannels } from '@/shared/IpcChannels' import { merge } from 'lodash-es' import { proxy, subscribe } from 'valtio' +import i18n, { getLanguage, supportedLanguages } from '../i18n/i18n' interface Settings { accentColor: string + language: typeof supportedLanguages[number] unm: { enabled: boolean sources: Array< @@ -26,6 +28,7 @@ interface Settings { const initSettings: Settings = { accentColor: 'blue', + language: getLanguage(), unm: { enabled: true, sources: ['migu'], @@ -36,19 +39,24 @@ const initSettings: Settings = { } const STORAGE_KEY = 'settings' -const statesInStorageString = localStorage.getItem(STORAGE_KEY) + let statesInStorage = {} -if (statesInStorageString) { - try { - statesInStorage = JSON.parse(statesInStorageString) - } catch { - // ignore - } +try { + statesInStorage = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') +} catch { + // ignore } const settings = proxy<Settings>(merge(initSettings, statesInStorage)) subscribe(settings, () => { + if ( + settings.language !== i18n.language && + supportedLanguages.includes(settings.language) + ) { + i18n.changeLanguage(settings.language) + } + window.ipcRenderer?.send(IpcChannels.SyncSettings, settings) localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)) }) diff --git a/packages/web/states/uiStates.ts b/packages/web/states/uiStates.ts index 2aca117..e10a2ae 100644 --- a/packages/web/states/uiStates.ts +++ b/packages/web/states/uiStates.ts @@ -18,7 +18,11 @@ const initUIStates: UIStates = { librarySelectedTab: 'playlists', mobileShowPlayingNext: false, blurBackgroundImage: null, - fullscreen: window.ipcRenderer?.sendSync(IpcChannels.IsMaximized) || false, + fullscreen: false, } +window.ipcRenderer + ?.invoke(IpcChannels.IsMaximized) + .then(isMaximized => (initUIStates.fullscreen = !!isMaximized)) + export default proxy<UIStates>(initUIStates) diff --git a/packages/web/tailwind.config.js b/packages/web/tailwind.config.js index d3e9e9a..b256665 100644 --- a/packages/web/tailwind.config.js +++ b/packages/web/tailwind.config.js @@ -60,6 +60,18 @@ module.exports = { 700: '#393939', 800: '#1C1C1C', }, + gray: { + 50: '#14161A', + 100: '#14161A', + 200: '#14161A', + 300: '#14161A', + 400: '#14161A', + 500: '#14161A', + 600: '#14161A', + 700: '#14161A', + 800: '#14161A', + 900: '#0D0E10', + }, }, fontSize: { 12: ['0.75rem', fontSizeDefault], diff --git a/packages/web/test/utils/common.test.ts b/packages/web/test/utils/common.test.ts index 1b3e2b8..69cb05a 100644 --- a/packages/web/test/utils/common.test.ts +++ b/packages/web/test/utils/common.test.ts @@ -144,7 +144,7 @@ describe('getCoverColor', () => { test('hit cache', async () => { vi.stubGlobal('ipcRenderer', { sendSync: (channel: IpcChannels, ...args: any[]) => { - expect(channel).toBe(IpcChannels.GetApiCacheSync) + expect(channel).toBe(IpcChannels.GetApiCache) expect(args[0].api).toBe(APIs.CoverColor) expect(args[0].query).toEqual({ id: '109951165911363', @@ -168,7 +168,7 @@ describe('getCoverColor', () => { test('did not hit cache', async () => { vi.stubGlobal('ipcRenderer', { sendSync: (channel: IpcChannels, ...args: any[]) => { - expect(channel).toBe(IpcChannels.GetApiCacheSync) + expect(channel).toBe(IpcChannels.GetApiCache) expect(args[0].api).toBe(APIs.CoverColor) expect(args[0].query).toEqual({ id: '109951165911363', diff --git a/packages/web/utils/common.ts b/packages/web/utils/common.ts index 44db0bc..1ef58e0 100644 --- a/packages/web/utils/common.ts +++ b/packages/web/utils/common.ts @@ -4,6 +4,7 @@ import duration from 'dayjs/plugin/duration' import { APIs } from '@/shared/CacheAPIs' import { average } from 'color.js' import { colord } from 'colord' +import { supportedLanguages } from '../i18n/i18n' /** * @description 调整网易云和苹果音乐封面图片大小 @@ -71,7 +72,7 @@ export function formatDate( */ export function formatDuration( milliseconds: number, - locale: 'en' | 'zh-TW' | 'zh-CN' = 'zh-CN', + locale: typeof supportedLanguages[number] = 'zh-CN', format: 'hh:mm:ss' | 'hh[hr] mm[min]' = 'hh:mm:ss' ): string { dayjs.extend(duration) @@ -87,7 +88,7 @@ export function formatDuration( : `${mins}:${seconds}` } else { const units = { - en: { + 'en-US': { hours: 'hr', mins: 'min', }, @@ -99,7 +100,7 @@ export function formatDuration( hours: '小時', mins: '分鐘', }, - } + } as const return hours !== '0' ? `${hours} ${units[locale].hours}${ @@ -129,15 +130,15 @@ export async function getCoverColor(coverUrl: string) { return } - const colorFromCache = window.ipcRenderer?.sendSync( - IpcChannels.GetApiCacheSync, + const colorFromCache: string | undefined = await window.ipcRenderer?.invoke( + IpcChannels.GetApiCache, { api: APIs.CoverColor, query: { id, }, } - ) as string | undefined + ) return colorFromCache || calcCoverColor(coverUrl) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff22a12..b1ddd0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,18 +7,17 @@ importers: cross-env: ^7.0.3 eslint: ^8.21.0 prettier: ^2.7.1 - turbo: ^1.4.2 + turbo: ^1.6.1 typescript: ^4.7.4 devDependencies: cross-env: 7.0.3 eslint: 8.21.0 prettier: 2.7.1 - turbo: 1.4.2 + turbo: 1.6.1 typescript: 4.7.4 packages/desktop: specifiers: - '@electron/universal': 1.3.0 '@sentry/electron': ^3.0.7 '@types/better-sqlite3': ^7.6.0 '@types/cookie-parser': ^1.4.3 @@ -26,8 +25,6 @@ importers: '@types/express-fileupload': ^1.2.3 '@typescript-eslint/eslint-plugin': ^5.32.0 '@typescript-eslint/parser': ^5.32.0 - '@unblockneteasemusic/rust-napi': ^0.3.0 - '@vitejs/plugin-react': ^2.0.0 '@vitest/ui': ^0.20.3 NeteaseCloudMusicApi: ^4.6.7 axios: ^0.27.2 @@ -58,13 +55,13 @@ importers: picocolors: ^1.0.0 prettier: '*' pretty-bytes: ^6.0.0 + type-fest: ^3.0.0 typescript: '*' vitest: ^0.20.3 wait-on: ^6.0.1 zx: ^7.0.8 dependencies: '@sentry/electron': 3.0.7 - '@unblockneteasemusic/rust-napi': 0.3.0 NeteaseCloudMusicApi: 4.6.7 better-sqlite3: 7.6.2 change-case: 4.1.2 @@ -77,16 +74,15 @@ importers: express: 4.18.1 fast-folder-size: 1.7.0 pretty-bytes: 6.0.0 + type-fest: 3.0.0 zx: 7.0.8 devDependencies: - '@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.3 '@typescript-eslint/eslint-plugin': 5.32.0_iosr3hrei2tubxveewluhu5lhy '@typescript-eslint/parser': 5.32.0_qugx7qdu5zevzvxaiqyxfiwquq - '@vitejs/plugin-react': 2.0.0 '@vitest/ui': 0.20.3 axios: 0.27.2 cross-env: 7.0.3 @@ -109,6 +105,37 @@ importers: vitest: 0.20.3_@vitest+ui@0.20.3 wait-on: 6.0.1 + packages/server: + specifiers: + '@fastify/autoload': ^5.0.0 + '@fastify/sensible': ^4.1.0 + '@types/node': ^18.0.0 + '@types/tap': ^15.0.5 + axios: ^0.27.2 + concurrently: ^7.0.0 + fastify: ^4.0.0 + fastify-cli: ^4.4.0 + fastify-plugin: ^3.0.0 + fastify-tsconfig: ^1.0.1 + tap: ^16.1.0 + ts-node: ^10.4.0 + typescript: ^4.5.4 + dependencies: + '@fastify/autoload': 5.3.1 + '@fastify/sensible': 4.1.0 + axios: 0.27.2 + fastify: 4.5.3 + fastify-cli: 4.4.0 + fastify-plugin: 3.0.1 + devDependencies: + '@types/node': 18.6.4 + '@types/tap': 15.0.7 + concurrently: 7.3.0 + fastify-tsconfig: 1.0.1 + tap: 16.3.0_6oasmw356qmm23djlsjgkwvrtm + ts-node: 10.9.1_hn66opzbaneygq52jmwjxha6su + typescript: 4.7.4 + packages/web: specifiers: '@emotion/css': ^11.10.0 @@ -151,6 +178,7 @@ importers: framer-motion: ^6.5.1 hls.js: ^1.2.0 howler: ^2.2.3 + i18next: ^21.9.1 js-cookie: ^3.0.1 jsdom: ^20.0.0 lodash-es: ^4.17.21 @@ -165,6 +193,7 @@ importers: react-dom: ^18.2.0 react-ga4: ^1.4.1 react-hot-toast: ^2.3.0 + react-i18next: ^11.18.4 react-router-dom: ^6.3.0 react-use: ^17.4.0 react-use-measure: ^2.1.1 @@ -192,6 +221,7 @@ importers: framer-motion: 6.5.1_biqbaboplfbrettd7655fr4n2y hls.js: 1.2.0 howler: 2.2.3 + i18next: 21.9.1 js-cookie: 3.0.1 lodash-es: 4.17.21 md5: 2.3.0 @@ -201,6 +231,7 @@ importers: react-dom: 18.2.0_react@18.2.0 react-ga4: 1.4.1 react-hot-toast: 2.3.0_biqbaboplfbrettd7655fr4n2y + react-i18next: 11.18.4_4sidbwfhen5r7txudrvpua6nty react-router-dom: 6.3.0_biqbaboplfbrettd7655fr4n2y react-use: 17.4.0_biqbaboplfbrettd7655fr4n2y react-use-measure: 2.1.1_biqbaboplfbrettd7655fr4n2y @@ -241,7 +272,7 @@ importers: prettier-plugin-tailwindcss: 0.1.13_prettier@2.7.1 rollup-plugin-visualizer: 5.7.1 storybook-tailwind-dark-mode: 1.0.12_biqbaboplfbrettd7655fr4n2y - tailwindcss: 3.1.8 + tailwindcss: 3.1.8_postcss@8.4.15 typescript: 4.7.4 vite: 3.0.4 vite-plugin-pwa: 0.12.3_vite@3.0.4 @@ -2616,6 +2647,13 @@ packages: dev: true optional: true + /@cspotcode/source-map-support/0.8.1: + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + dev: true + /@design-systems/utils/2.12.0_bb2bxwco6ptpubzwpazr52qf6i: resolution: {integrity: sha512-Y/d2Zzr+JJfN6u1gbuBUb1ufBuLMJJRZQk+dRmw8GaTpqKx5uf7cGUYGTwN02dIb3I+Tf+cW8jcGBTRiFxdYFg==} peerDependencies: @@ -2728,21 +2766,6 @@ packages: - supports-color dev: true - /@electron/universal/1.3.0: - resolution: {integrity: sha512-6SAIlMZZRj1qpe3z3qhMWf3fmqhAdzferiQ5kpspCI9sH1GjkzRXY0RLaz0ktHtYonOj9XMpXNkhDy7QQagQEg==} - engines: {node: '>=8.6'} - dependencies: - '@malept/cross-spawn-promise': 1.1.1 - asar: 3.2.0 - debug: 4.3.4 - dir-compare: 2.4.0 - fs-extra: 9.1.0 - minimatch: 3.1.2 - plist: 3.0.6 - transitivePeerDependencies: - - supports-color - dev: true - /@emotion/babel-plugin/11.10.0: resolution: {integrity: sha512-xVnpDAAbtxL1dsuSelU5A7BnY/lftws0wUexNJZTPsvX/1tM4GZJbclgODhvW4E+NH7E5VFcH0bBn30NvniPJA==} peerDependencies: @@ -2859,6 +2882,47 @@ packages: - supports-color dev: true + /@fastify/ajv-compiler/3.2.0: + resolution: {integrity: sha512-JrqgKmZoh1AJojDZk699DupQ9+tz5gSy7/w+5DrkXy5whM5IcqdV3SjG5qnOqgVJT1nPtUMDY0xYus2j6vwJiw==} + dependencies: + ajv: 8.11.0 + ajv-formats: 2.1.1 + fast-uri: 2.1.0 + dev: false + + /@fastify/autoload/5.3.1: + resolution: {integrity: sha512-2flvHB5wYMrXa/ZrnwxejajNVB1PgWnnZv+9NbqNfk4pYhnsHqjjVl+4JnAUGVZqhckP8Xw6t+jj78QvR9hh0Q==} + dependencies: + pkg-up: 3.1.0 + dev: false + + /@fastify/deepmerge/1.1.0: + resolution: {integrity: sha512-E8Hfdvs1bG6u0N4vN5Nty6JONUfTdOciyD5rn8KnEsLKIenvOVcr210BQR9t34PRkNyjqnMLGk3e0BsaxRdL+g==} + dev: false + + /@fastify/error/3.0.0: + resolution: {integrity: sha512-dPRyT40GiHRzSCll3/Jn2nPe25+E1VXc9tDwRAIKwFCxd5Np5wzgz1tmooWG3sV0qKgrBibihVoCna2ru4SEFg==} + dev: false + + /@fastify/fast-json-stringify-compiler/4.1.0: + resolution: {integrity: sha512-cTKBV2J9+u6VaKDhX7HepSfPSzw+F+TSd+k0wzifj4rG+4E5PjSFJCk19P8R6tr/72cuzgGd+mbB3jFT6lvAgw==} + dependencies: + fast-json-stringify: 5.2.0 + dev: false + + /@fastify/sensible/4.1.0: + resolution: {integrity: sha512-8TBlmCK055y6WO9jZlndmceB9x8NyNcLEbnJtdu44zelfmY1ebBMSB7MOqyMteyDvpSMq3CVaPknBu35d9FRlA==} + engines: {node: '>=14.0.0'} + dependencies: + fast-deep-equal: 3.1.3 + fastify-plugin: 3.0.1 + forwarded: 0.2.0 + http-errors: 2.0.0 + ms: 2.1.3 + type-is: 1.6.18 + vary: 1.1.2 + dev: false + /@gar/promisify/1.1.3: resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} dev: true @@ -3023,6 +3087,13 @@ packages: '@jridgewell/sourcemap-codec': 1.4.14 dev: true + /@jridgewell/trace-mapping/0.3.9: + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.0 + '@jridgewell/sourcemap-codec': 1.4.14 + dev: true + /@leichtgewicht/ip-codec/2.0.4: resolution: {integrity: sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==} dev: false @@ -5020,6 +5091,22 @@ packages: engines: {node: '>=10.13.0'} dev: true + /@tsconfig/node10/1.0.9: + resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} + dev: true + + /@tsconfig/node12/1.0.11: + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + dev: true + + /@tsconfig/node14/1.0.3: + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + dev: true + + /@tsconfig/node16/1.0.3: + resolution: {integrity: sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==} + dev: true + /@types/aria-query/4.2.2: resolution: {integrity: sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==} dev: true @@ -5133,6 +5220,7 @@ packages: /@types/glob/7.2.0: resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} + requiresBuild: true dependencies: '@types/minimatch': 3.0.5 '@types/node': 18.6.4 @@ -5353,6 +5441,12 @@ packages: '@types/node': 18.6.4 dev: true + /@types/tap/15.0.7: + resolution: {integrity: sha512-TTMajw4gxQfFgYbhXhy/Tb2OiNcwS+4oP/9yp1/GdU0pFJo3wtnkYhRgmQy39ksh+rnoa0VrPHJ4Tuv2cLNQ5A==} + dependencies: + '@types/node': 18.6.4 + dev: true + /@types/tapable/1.0.8: resolution: {integrity: sha512-ipixuVrh2OdNmauvtT51o3d8z12p6LtFW9in7U79der/kwejjdNchQC5UMn5u/KxNoM7VHHOs/l8KS8uHxhODQ==} dev: true @@ -5555,142 +5649,6 @@ packages: eslint-visitor-keys: 3.3.0 dev: true - /@unblockneteasemusic/rust-napi-android-arm-eabi/0.3.0: - resolution: {integrity: sha512-PSgb5j8sSs4gGUBy3FcPvQTsLlCc6HknyyK5Ax7caUMEzGK/oTFhpwkldulidc+eLh5mRCuNeKq9NvdMZuYp6A==} - engines: {node: '>= 10'} - cpu: [arm] - os: [android] - requiresBuild: true - dev: false - optional: true - - /@unblockneteasemusic/rust-napi-android-arm64/0.3.0: - resolution: {integrity: sha512-0Zi8QgIxDYTEWaOTbDYIPoL0ECfsQXUd0N43HBjMnFsgAkc/FViNAFaKN1SkowrZNQoXYM/5I928Ea18Bv2ivQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [android] - requiresBuild: true - dev: false - optional: true - - /@unblockneteasemusic/rust-napi-darwin-arm64/0.3.0: - resolution: {integrity: sha512-EByiGPy6GUk6bpZFZ+gdnalaomWjztOGuj8Ei7XExGJbl5gHw9ab+eh5v73frricGWpyz25SpSq4Nta8BDaoxw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: false - optional: true - - /@unblockneteasemusic/rust-napi-darwin-x64/0.3.0: - resolution: {integrity: sha512-5CYwFCtpU+cxdPsDmqVyaPQ2ZzmZQ1OdOT2zQFfhWR6ru8px/8PhlYSaO+/T9d5z/OkXU6T8so8yBUHyhOBJng==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: false - optional: true - - /@unblockneteasemusic/rust-napi-freebsd-x64/0.3.0: - resolution: {integrity: sha512-Ndh5UUVDpkqWvNJtkTQX3BloxQcWahnixvS6MtJ2orSem0GOTxB1AaVwguofF3DDA2MdIWEogmMNbML+YfGq1A==} - engines: {node: '>= 10'} - cpu: [x64] - os: [freebsd] - requiresBuild: true - dev: false - optional: true - - /@unblockneteasemusic/rust-napi-linux-arm-gnueabihf/0.3.0: - resolution: {integrity: sha512-DJGg8KozC/bUETGkR9+frcEnytNDCBaROIDM1OjgsqXlILMAStK8fAxhNhXHRvrHQcgVL2xZ7NXQayvSwJSRsw==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /@unblockneteasemusic/rust-napi-linux-arm64-gnu/0.3.0: - resolution: {integrity: sha512-Xi5oo+E4RGMCPI2S5rtd7Hb1K86R89D+tptCwoZDC4xAoQ6Tjn7/psL1hpnBI5dx76YG/PLELjrdlV8UwUV2Mg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /@unblockneteasemusic/rust-napi-linux-arm64-musl/0.3.0: - resolution: {integrity: sha512-ZiF/bQbaIr+d+8JVxQ2pwvevrWAF3l4q7PzKytV/z0mzZo70ZOpfseZkQ08puAfzzDhAmRls0gTsDX1ktvuNPA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /@unblockneteasemusic/rust-napi-linux-x64-gnu/0.3.0: - resolution: {integrity: sha512-fyWDKdcUGdJCGlCOJ7nPIuWEhEI3J2GvptVegYnE6XEIfLkvbPOYVmPylI/Gw3pVwZ2dvU0V2hPjqcfgUqzJGg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /@unblockneteasemusic/rust-napi-linux-x64-musl/0.3.0: - resolution: {integrity: sha512-NQ/axCrjzBbzgPjxY88PN/b9oQeryCOjpCwXD90fMJXuV8llUA4BsTILxVneiPwt2VI4fCvth1O5BMsNfeFAWw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /@unblockneteasemusic/rust-napi-win32-arm64-msvc/0.3.0: - resolution: {integrity: sha512-TvAMHneV9cB2HULnMQfOnTgd7p+E4L+MtG2I5foHD3h0IeLC8+fPvYzs/HIKhfZCBWcHR5hfoQ/102V6uHFxJg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - requiresBuild: true - dev: false - optional: true - - /@unblockneteasemusic/rust-napi-win32-ia32-msvc/0.3.0: - resolution: {integrity: sha512-x68DuYHZOHDIlzcG9HbTbpABiSwP9Uc3GK0WuCXl03HTOMKYjT+OJ0aA8HtTyeXMe+IpSJPpg2UIOT3XqaCaeg==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - requiresBuild: true - dev: false - optional: true - - /@unblockneteasemusic/rust-napi-win32-x64-msvc/0.3.0: - resolution: {integrity: sha512-ACmTzPih91FmVt87BIdGhiKVbjKHNawRj5vgHcFlPX6KQ8NrxSigXXNs0/JWWDvAK9BLpOmeCtieXHnMdv+YQQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: false - optional: true - - /@unblockneteasemusic/rust-napi/0.3.0: - resolution: {integrity: sha512-UA20K1T72XkFFoaY7D+XNqlX4zakrGDsrIsChs92e4qqhzu2mPJWNYV/cS2xOWFUbeFve84Hz7Kwl/OocjTDpQ==} - engines: {node: '>= 10'} - optionalDependencies: - '@unblockneteasemusic/rust-napi-android-arm-eabi': 0.3.0 - '@unblockneteasemusic/rust-napi-android-arm64': 0.3.0 - '@unblockneteasemusic/rust-napi-darwin-arm64': 0.3.0 - '@unblockneteasemusic/rust-napi-darwin-x64': 0.3.0 - '@unblockneteasemusic/rust-napi-freebsd-x64': 0.3.0 - '@unblockneteasemusic/rust-napi-linux-arm-gnueabihf': 0.3.0 - '@unblockneteasemusic/rust-napi-linux-arm64-gnu': 0.3.0 - '@unblockneteasemusic/rust-napi-linux-arm64-musl': 0.3.0 - '@unblockneteasemusic/rust-napi-linux-x64-gnu': 0.3.0 - '@unblockneteasemusic/rust-napi-linux-x64-musl': 0.3.0 - '@unblockneteasemusic/rust-napi-win32-arm64-msvc': 0.3.0 - '@unblockneteasemusic/rust-napi-win32-ia32-msvc': 0.3.0 - '@unblockneteasemusic/rust-napi-win32-x64-msvc': 0.3.0 - dev: false - /@virtuoso.dev/react-urx/0.2.13_react@18.2.0: resolution: {integrity: sha512-MY0ugBDjFb5Xt8v2HY7MKcRGqw/3gTpMlLXId2EwQvYJoC8sP7nnXjAxcBtTB50KTZhO0SbzsFimaZ7pSdApwA==} engines: {node: '>=10'} @@ -5721,23 +5679,6 @@ packages: - supports-color dev: true - /@vitejs/plugin-react/2.0.0: - resolution: {integrity: sha512-zHkRR+X4zqEPNBbKV2FvWSxK7Q6crjMBVIAYroSU8Nbb4M3E5x4qOiLoqJBHtXgr27kfednXjkwr3lr8jS6Wrw==} - engines: {node: '>=14.18.0'} - peerDependencies: - vite: ^3.0.0 - dependencies: - '@babel/core': 7.18.10 - '@babel/plugin-transform-react-jsx': 7.18.10_@babel+core@7.18.10 - '@babel/plugin-transform-react-jsx-development': 7.18.6_@babel+core@7.18.10 - '@babel/plugin-transform-react-jsx-self': 7.18.6_@babel+core@7.18.10 - '@babel/plugin-transform-react-jsx-source': 7.18.6_@babel+core@7.18.10 - magic-string: 0.26.2 - react-refresh: 0.14.0 - transitivePeerDependencies: - - supports-color - dev: true - /@vitejs/plugin-react/2.0.0_vite@3.0.4: resolution: {integrity: sha512-zHkRR+X4zqEPNBbKV2FvWSxK7Q6crjMBVIAYroSU8Nbb4M3E5x4qOiLoqJBHtXgr27kfednXjkwr3lr8jS6Wrw==} engines: {node: '>=14.18.0'} @@ -6035,6 +5976,17 @@ packages: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} dev: true + /abort-controller/3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + dependencies: + event-target-shim: 5.0.1 + dev: false + + /abstract-logging/2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + dev: false + /accepts/1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -6089,7 +6041,6 @@ packages: /acorn-walk/8.2.0: resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==} engines: {node: '>=0.4.0'} - dev: false /acorn/6.4.2: resolution: {integrity: sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==} @@ -6306,7 +6257,6 @@ packages: dependencies: normalize-path: 3.0.0 picomatch: 2.3.1 - dev: true /app-builder-bin/4.0.0: resolution: {integrity: sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==} @@ -6350,6 +6300,13 @@ packages: resolution: {integrity: sha512-jlpIfsOoNoafl92Sz//64uQHGSyMrD2vYG5d8o2a4qGvyNCvXur7bzIsWtAC/6flI2RYAp3kv8rsfBtaLm7w0g==} dev: true + /append-transform/2.0.0: + resolution: {integrity: sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==} + engines: {node: '>=8'} + dependencies: + default-require-extensions: 3.0.0 + dev: true + /aproba/1.2.0: resolution: {integrity: sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==} dev: true @@ -6358,6 +6315,9 @@ packages: resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} dev: true + /archy/1.0.0: + resolution: {integrity: sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==} + /are-we-there-yet/2.0.0: resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} engines: {node: '>=10'} @@ -6374,6 +6334,10 @@ packages: readable-stream: 3.6.0 dev: true + /arg/4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + dev: true + /arg/5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} dev: true @@ -6576,6 +6540,11 @@ packages: engines: {node: '>=0.12.0'} dev: true + /async-hook-domain/2.0.4: + resolution: {integrity: sha512-14LjCmlK1PK8eDtTezR6WX8TMaYNIzBIsd2D1sGoGjgx0BuNMMoSdk7i/drlbtamy0AWv9yv2tkB+ASdmeqFIw==} + engines: {node: '>=10'} + dev: true + /async/2.6.4: resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} dependencies: @@ -6600,6 +6569,11 @@ packages: hasBin: true dev: true + /atomic-sleep/1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + dev: false + /atomically/1.7.0: resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} engines: {node: '>=10.12.0'} @@ -6634,6 +6608,16 @@ packages: postcss-value-parser: 4.2.0 dev: true + /avvio/8.2.0: + resolution: {integrity: sha512-bbCQdg7bpEv6kGH41RO/3B2/GMMmJSo2iBK+X8AWN9mujtfUipMDfIjsgHCfpnKqoGEQrrmCDKSa5OQ19+fDmg==} + dependencies: + archy: 1.0.0 + debug: 4.3.4 + fastq: 1.13.0 + transitivePeerDependencies: + - supports-color + dev: false + /axios/0.24.0: resolution: {integrity: sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==} dependencies: @@ -6879,7 +6863,6 @@ packages: /binary-extensions/2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} - dev: true /binary/0.3.0: resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} @@ -6888,6 +6871,11 @@ packages: chainsaw: 0.1.0 dev: false + /bind-obj-methods/3.0.0: + resolution: {integrity: sha512-nLEaaz3/sEzNSyPWRsN9HNsqwk1AUyECtGj+XwGdIi3xABnEqecvXtIJ0wehQXuuER5uZ/5fTs2usONgYjG+iw==} + engines: {node: '>=10'} + dev: true + /bindings/1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} dependencies: @@ -6988,7 +6976,6 @@ packages: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} dependencies: balanced-match: 1.0.2 - dev: true /braces/2.3.2: resolution: {integrity: sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==} @@ -7356,6 +7343,16 @@ packages: responselike: 2.0.1 dev: true + /caching-transform/4.0.0: + resolution: {integrity: sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==} + engines: {node: '>=8'} + dependencies: + hasha: 5.2.2 + make-dir: 3.1.0 + package-hash: 4.0.0 + write-file-atomic: 3.0.3 + dev: true + /call-bind/1.0.2: resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} dependencies: @@ -7505,7 +7502,6 @@ packages: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 - dev: true /chalk/5.0.1: resolution: {integrity: sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==} @@ -7583,7 +7579,6 @@ packages: readdirp: 3.6.0 optionalDependencies: fsevents: 2.3.2 - dev: true /chownr/1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -7703,7 +7698,6 @@ packages: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 6.2.0 - dev: false /cliui/7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -7737,6 +7731,10 @@ packages: engines: {node: '>=0.8'} dev: true + /close-with-grace/1.1.0: + resolution: {integrity: sha512-6cCp71Y5tKw1o9sGVBOa9OwY4vJ+YoLpFcWiTt9YCBhYlcQi0z68EiiN9mJ6/401Za6TZ5YOZg012IHHZt15lw==} + dev: false + /clsx/1.1.0: resolution: {integrity: sha512-3avwM37fSK5oP6M5rQ9CNe99lwxhXDOeSWVPAOYF6OazUTgZCMb0yWlJpmdD74REy1gkEaFiub2ULv4fq9GUhA==} engines: {node: '>=6'} @@ -7784,6 +7782,10 @@ packages: resolution: {integrity: sha512-Uqbg+J445nc1TKn4FoDPS6ZZqAvEDnwrH42yo8B40JSOgSLxMZ/gt3h4nmCtPLQeXhjJJkqBx7SCY35WnIixaQ==} dev: false + /colorette/2.0.19: + resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==} + dev: false + /colors/1.0.3: resolution: {integrity: sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==} engines: {node: '>=0.1.90'} @@ -7830,6 +7832,13 @@ packages: engines: {node: '>= 10'} dev: true + /commist/2.0.0: + resolution: {integrity: sha512-FOz6gc7VRNFV+SYjGv0ZPfgG1afGqJfKg2/aVtiDP6kF1ZPpo7NvUOGHuVNQ2YgMoCVglEUo0O3WzcD63yczVA==} + dependencies: + leven: 3.1.0 + minimist: 1.2.6 + dev: false + /common-path-prefix/3.0.0: resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==} dev: true @@ -7899,6 +7908,22 @@ packages: typedarray: 0.0.6 dev: true + /concurrently/7.3.0: + resolution: {integrity: sha512-IiDwm+8DOcFEInca494A8V402tNTQlJaYq78RF2rijOrKEk/AOHTxhN4U1cp7GYKYX5Q6Ymh1dLTBlzIMN0ikA==} + engines: {node: ^12.20.0 || ^14.13.0 || >=16.0.0} + hasBin: true + dependencies: + chalk: 4.1.2 + date-fns: 2.29.2 + lodash: 4.17.21 + rxjs: 7.5.6 + shell-quote: 1.7.3 + spawn-command: 0.0.2-1 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.5.1 + dev: true + /conf/10.2.0: resolution: {integrity: sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==} engines: {node: '>=12'} @@ -8137,6 +8162,10 @@ packages: sha.js: 2.4.11 dev: true + /create-require/1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + dev: true + /cross-env/7.0.3: resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} @@ -8328,6 +8357,15 @@ packages: whatwg-url: 11.0.0 dev: true + /date-fns/2.29.2: + resolution: {integrity: sha512-0VNbwmWJDS/G3ySwFSJA3ayhbURMTJLtwM2DTxf9CWondCnh6DTNlO9JgRSq6ibf4eD0lfMJNBxUdEAHHix+bA==} + engines: {node: '>=0.11'} + dev: true + + /dateformat/4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dev: false + /dayjs/1.11.4: resolution: {integrity: sha512-Zj/lPM5hOvQ1Bf7uAvewDaUcsJoI6JmNqmHhHl3nyumwe0XHwt8sWdOVAPACJzCebL8gQCi+K49w7iKWnGwX9g==} dev: false @@ -8447,6 +8485,13 @@ packages: dev: true optional: true + /default-require-extensions/3.0.0: + resolution: {integrity: sha512-ek6DpXq/SCpvjhpFsLFRVtIxJCRw6fUR42lYMVZuUMK7n8eMz4Uh5clckdBjEpLhn/gEBZo7hDJnJcwdKLKQjg==} + engines: {node: '>=8'} + dependencies: + strip-bom: 4.0.0 + dev: true + /defaults/1.0.3: resolution: {integrity: sha512-s82itHOnYrN0Ib8r+z7laQz3sdE+4FP3d9Q7VLO7U+KRT+CR0GsWuyHxzdAY82I7cXv0G/twrqomTJLOssO5HA==} dependencies: @@ -8600,6 +8645,11 @@ packages: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} dev: true + /diff/4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + dev: true + /diffie-hellman/5.0.3: resolution: {integrity: sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==} dependencies: @@ -8810,7 +8860,6 @@ packages: /dotenv/16.0.1: resolution: {integrity: sha512-1K6hR6wtk2FviQ4kEiSjFiH5rpzEVi8WW0x96aztHVMhEspNpc4DVOUTEHtEva5VThQ8IaBX1Pe4gSzpVVUsKQ==} engines: {node: '>=12'} - dev: true /dotenv/8.6.0: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} @@ -9155,7 +9204,6 @@ packages: /es6-error/4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} dev: true - optional: true /es6-shim/0.35.6: resolution: {integrity: sha512-EmTr31wppcaIAgblChZiuN/l9Y7DPyw8Xtbg7fIVngn6zMW+IEBJDJngeKC3x6wr0V/vcA2wqeFnaw1bFJbDdA==} @@ -9365,6 +9413,11 @@ packages: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} + /escape-string-regexp/2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + dev: true + /escape-string-regexp/4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -9661,6 +9714,15 @@ packages: through: 2.3.8 dev: false + /event-target-shim/5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + dev: false + + /events-to-array/1.1.2: + resolution: {integrity: sha512-inRWzRY7nG+aXZxBzEqYKB3HPgwflZRopAjDCHv0whhRx+MTUr1ei0ICZUypdyE0HRm4L2d5VEcIqLD6yl+BFA==} + dev: true + /events/3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -9821,6 +9883,10 @@ packages: dev: true optional: true + /fast-copy/2.1.3: + resolution: {integrity: sha512-LDzYKNTHhD+XOp8wGMuCkY4eTxFZOOycmpwLBiuF3r3OjOmZnURRD8t2dUAbmKuXGbo/MGggwbSjcBdp8QT0+g==} + dev: false + /fast-deep-equal/3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -9864,17 +9930,95 @@ packages: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} dev: true + /fast-json-stringify/5.2.0: + resolution: {integrity: sha512-u5jtrcAK9RINW15iuDKnsuuhqmqre4AmDMp3crRTjUMdAuHMpQUt3IfoMm5wlJm59b74PcajqOl3SjgnC5FPmw==} + dependencies: + '@fastify/deepmerge': 1.1.0 + ajv: 8.11.0 + ajv-formats: 2.1.1 + fast-deep-equal: 3.1.3 + fast-uri: 2.1.0 + rfdc: 1.3.0 + dev: false + /fast-levenshtein/2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + /fast-redact/3.1.2: + resolution: {integrity: sha512-+0em+Iya9fKGfEQGcd62Yv6onjBmmhV1uh86XVfOU8VwAe6kaFdQCWI9s0/Nnugx5Vd9tdbZ7e6gE2tR9dzXdw==} + engines: {node: '>=6'} + dev: false + + /fast-safe-stringify/2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + dev: false + /fast-shallow-equal/1.0.0: resolution: {integrity: sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==} dev: false + /fast-uri/2.1.0: + resolution: {integrity: sha512-qKRta6N7BWEFVlyonVY/V+BMLgFqktCUV0QjT259ekAIlbVrMaFnFLxJ4s/JPl4tou56S1BzPufI60bLe29fHA==} + dev: false + /fastest-stable-stringify/2.0.2: resolution: {integrity: sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==} dev: false + /fastify-cli/4.4.0: + resolution: {integrity: sha512-+tYGwKqGSNKH55Q8iroDir0JBLQIwMqUe+Z3/pk4vp7bC340QS/zLI27Cdlb26hcNCuYvjH1keCW9fTVlmMh0Q==} + engines: {node: '>= 10'} + hasBin: true + dependencies: + chalk: 4.1.2 + chokidar: 3.5.3 + close-with-grace: 1.1.0 + commist: 2.0.0 + dotenv: 16.0.1 + fastify: 4.5.3 + generify: 4.2.0 + help-me: 2.0.1 + is-docker: 2.2.1 + make-promises-safe: 5.1.0 + pino-pretty: 8.1.0 + pkg-up: 3.1.0 + resolve-from: 5.0.0 + semver: 7.3.7 + yargs-parser: 20.2.9 + transitivePeerDependencies: + - supports-color + dev: false + + /fastify-plugin/3.0.1: + resolution: {integrity: sha512-qKcDXmuZadJqdTm6vlCqioEbyewF60b/0LOFCcYN1B6BIZGlYJumWWOYs70SFYLDAH4YqdE1cxH/RKMG7rFxgA==} + dev: false + + /fastify-tsconfig/1.0.1: + resolution: {integrity: sha512-BXkTG3JYcjJb3xX5R5FcE9ciscV/h7YtmnkiSaNAONd1g6ooMSN/4GWfhA8hnS6SRZFYBBxsn8719Mj9lbCOtA==} + engines: {node: '>=10.4.0'} + dev: true + + /fastify/4.5.3: + resolution: {integrity: sha512-Q8Zvkmg7GnioMCDX1jT2Q7iRqjywlnDZ1735D2Ipf7ashCM/3/bqPKv2Jo1ZF2iDExct2eP1C/tdhcj0GG/OuQ==} + dependencies: + '@fastify/ajv-compiler': 3.2.0 + '@fastify/error': 3.0.0 + '@fastify/fast-json-stringify-compiler': 4.1.0 + abstract-logging: 2.0.1 + avvio: 8.2.0 + find-my-way: 7.0.1 + light-my-request: 5.5.1 + pino: 8.4.2 + process-warning: 2.0.0 + proxy-addr: 2.0.7 + rfdc: 1.3.0 + secure-json-parse: 2.5.0 + semver: 7.3.7 + tiny-lru: 8.0.2 + transitivePeerDependencies: + - supports-color + dev: false + /fastq/1.13.0: resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==} dependencies: @@ -10003,6 +10147,14 @@ packages: pkg-dir: 4.2.0 dev: true + /find-my-way/7.0.1: + resolution: {integrity: sha512-w05SaOPg54KqBof/RDA+75n1R48V7ZZNPL3nR17jJJs5dgZpR3ivfrMWOyx7BVFQgCLhYRG05hfgFCohYvSUXA==} + engines: {node: '>=14'} + dependencies: + fast-deep-equal: 3.1.3 + safe-regex2: 2.0.0 + dev: false + /find-root/1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} dev: false @@ -10037,6 +10189,10 @@ packages: path-exists: 4.0.0 dev: true + /findit/2.0.0: + resolution: {integrity: sha512-ENZS237/Hr8bjczn5eKuBohLgaD0JyUd0arxretR1f9RO46vZHA1b2y0VorgGV3WaOT3c+78P8h7v4JGJ1i/rg==} + dev: true + /flat-cache/3.0.4: resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -10223,10 +10379,18 @@ packages: readable-stream: 2.3.7 dev: true + /fromentries/1.3.2: + resolution: {integrity: sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==} + dev: true + /fs-constants/1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} dev: false + /fs-exists-cached/1.0.0: + resolution: {integrity: sha512-kSxoARUDn4F2RPXX48UXnaFKwVU7Ivd/6qpzZL29MCDmr9sTvybv4gFCp+qaI4fM9m0z9fgz/yJvi56GAz+BZg==} + dev: true + /fs-extra/10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -10316,6 +10480,10 @@ packages: /function-bind/1.1.1: resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + /function-loop/2.0.1: + resolution: {integrity: sha512-ktIR+O6i/4h+j/ZhZJNdzeI4i9lEPeEK6UPR2EVyTVBqOwcU3Za9xYKLH64ZR9HmcROyRrOkizNyjjtWJzDDkQ==} + dev: true + /function.prototype.name/1.1.5: resolution: {integrity: sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==} engines: {node: '>= 0.4'} @@ -10363,6 +10531,16 @@ packages: wide-align: 1.1.5 dev: true + /generify/4.2.0: + resolution: {integrity: sha512-b4cVhbPfbgbCZtK0dcUc1lASitXGEAIqukV5DDAyWm25fomWnV+C+a1yXvqikcRZXHN2j0pSDyj3cTfzq8pC7Q==} + hasBin: true + dependencies: + isbinaryfile: 4.0.10 + pump: 3.0.0 + split2: 3.2.2 + walker: 1.0.8 + dev: false + /gensync/1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -10524,7 +10702,6 @@ packages: inherits: 2.0.4 minimatch: 5.1.0 once: 1.4.0 - dev: true /global-agent/3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -10718,7 +10895,6 @@ packages: /has-flag/4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - dev: true /has-glob/1.0.0: resolution: {integrity: sha512-D+8A457fBShSEI3tFCj65PAbT++5sKiFtdCdOam0gnfBgw9D277OERk+HM9qYJXmdVLZ/znez10SqHN0BBQ50g==} @@ -10806,6 +10982,14 @@ packages: minimalistic-assert: 1.0.1 dev: true + /hasha/5.2.2: + resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==} + engines: {node: '>=8'} + dependencies: + is-stream: 2.0.1 + type-fest: 0.8.1 + dev: true + /hast-to-hyperscript/9.0.1: resolution: {integrity: sha512-zQgLKqF+O2F72S1aa4y2ivxzSlko3MAvxkwG8ehGmNiqd98BIN3JM1rAJPmplEyLmGLO2QZYJtIneOSZ2YbJuA==} dependencies: @@ -10880,6 +11064,20 @@ packages: tslib: 2.4.0 dev: false + /help-me/2.0.1: + resolution: {integrity: sha512-M0zuH7YG7t6xeLDllblPQkBfuI4MVz1teOJ+JCKBAiOzVXy3FmseeQ87Zt50fM1hTX9627iO/P1oWEwvykGlPQ==} + dependencies: + glob: 7.2.3 + readable-stream: 3.6.0 + dev: false + + /help-me/4.0.1: + resolution: {integrity: sha512-PLv01Z+OhEPKj2QPYB4kjoCUkopYNPUK3EROlaPIf5bib752fZ+VCvGDAoA+FXo/OwCyLEA4D2e0mX8+Zhcplw==} + dependencies: + glob: 8.0.3 + readable-stream: 3.6.0 + dev: false + /hey-listen/1.0.8: resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==} dev: false @@ -10952,6 +11150,12 @@ packages: terser: 4.8.1 dev: true + /html-parse-stringify/3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + dependencies: + void-elements: 3.1.0 + dev: false + /html-tags/3.2.0: resolution: {integrity: sha512-vy7ClnArOZwCnqZgvv+ddgHgJiAFXe3Ge9ML5/mBctVJoUoYPCdxVucOywjDARn6CVoh3dRSFdPHy2sX80L0Wg==} engines: {node: '>=8'} @@ -11080,6 +11284,12 @@ packages: resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==} dev: false + /i18next/21.9.1: + resolution: {integrity: sha512-ITbDrAjbRR73spZAiu6+ex5WNlHRr1mY+acDi2ioTHuUiviJqSz269Le1xHAf0QaQ6GgIHResUhQNcxGwa/PhA==} + dependencies: + '@babel/runtime': 7.18.9 + dev: false + /iconv-corefoundation/1.1.7: resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} engines: {node: ^8.11.2 || >=10} @@ -11303,7 +11513,6 @@ packages: engines: {node: '>=8'} dependencies: binary-extensions: 2.2.0 - dev: true /is-boolean-object/1.1.2: resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} @@ -11392,7 +11601,6 @@ packages: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} hasBin: true - dev: true /is-dom/1.1.0: resolution: {integrity: sha512-u82f6mvhYxRPKpw8V1N0W8ce1xXwOrQtgGcxl6UCL5zBmZu3is/18K0rR7uFCnMDuAsS/3W54mGL4vsaFUQlEQ==} @@ -11681,7 +11889,6 @@ packages: /isbinaryfile/4.0.10: resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} engines: {node: '>= 8.0.0'} - dev: true /isexe/2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -11716,6 +11923,25 @@ packages: engines: {node: '>=8'} dev: true + /istanbul-lib-hook/3.0.0: + resolution: {integrity: sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==} + engines: {node: '>=8'} + dependencies: + append-transform: 2.0.0 + dev: true + + /istanbul-lib-instrument/4.0.3: + resolution: {integrity: sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==} + engines: {node: '>=8'} + dependencies: + '@babel/core': 7.18.10 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.0 + semver: 6.3.0 + transitivePeerDependencies: + - supports-color + dev: true + /istanbul-lib-instrument/5.2.0: resolution: {integrity: sha512-6Lthe1hqXHBNsqvgDzGO6l03XNeu3CrG4RqQ1KM9+l5+jNGpEJfIELx1NS3SEHmJQA8np/u+E4EPRKRiu6m19A==} engines: {node: '>=8'} @@ -11729,6 +11955,18 @@ packages: - supports-color dev: true + /istanbul-lib-processinfo/2.0.3: + resolution: {integrity: sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==} + engines: {node: '>=8'} + dependencies: + archy: 1.0.0 + cross-spawn: 7.0.3 + istanbul-lib-coverage: 3.2.0 + p-map: 3.0.0 + rimraf: 3.0.2 + uuid: 8.3.2 + dev: true + /istanbul-lib-report/3.0.0: resolution: {integrity: sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==} engines: {node: '>=8'} @@ -11738,6 +11976,17 @@ packages: supports-color: 7.2.0 dev: true + /istanbul-lib-source-maps/4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + dependencies: + debug: 4.3.4 + istanbul-lib-coverage: 3.2.0 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + dev: true + /istanbul-reports/3.1.5: resolution: {integrity: sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==} engines: {node: '>=8'} @@ -11757,6 +12006,13 @@ packages: iterate-iterator: 1.0.2 dev: true + /jackspeak/1.4.1: + resolution: {integrity: sha512-npN8f+M4+IQ8xD3CcWi3U62VQwKlT3Tj4GxbdT/fYTmeogD9eBF9OFdpoFG/VPNoshRjPUijdkp/p2XrzUHaVg==} + engines: {node: '>=8'} + dependencies: + cliui: 7.0.4 + dev: true + /jake/10.8.5: resolution: {integrity: sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==} engines: {node: '>=10'} @@ -11852,6 +12108,11 @@ packages: '@sideway/pinpoint': 2.0.0 dev: true + /joycon/3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + dev: false + /js-base64/2.6.4: resolution: {integrity: sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==} dev: true @@ -12104,7 +12365,6 @@ packages: /leven/3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} - dev: true /levn/0.3.0: resolution: {integrity: sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==} @@ -12121,12 +12381,39 @@ packages: type-check: 0.4.0 dev: true + /libtap/1.4.0: + resolution: {integrity: sha512-STLFynswQ2A6W14JkabgGetBNk6INL1REgJ9UeNKw5llXroC2cGLgKTqavv0sl8OLVztLLipVKMcQ7yeUcqpmg==} + engines: {node: '>=10'} + dependencies: + async-hook-domain: 2.0.4 + bind-obj-methods: 3.0.0 + diff: 4.0.2 + function-loop: 2.0.1 + minipass: 3.3.4 + own-or: 1.0.0 + own-or-env: 1.0.2 + signal-exit: 3.0.7 + stack-utils: 2.0.5 + tap-parser: 11.0.1 + tap-yaml: 1.0.0 + tcompare: 5.0.7 + trivial-deferred: 1.0.1 + dev: true + /lie/3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} dependencies: immediate: 3.0.6 dev: true + /light-my-request/5.5.1: + resolution: {integrity: sha512-Zd4oZjF7axSyc5rYQsbB0qsgY4LFFviZSbEywxf7Vi5UE3y3c7tYF/GeheQjBNYY+pQ55BF8UGGJTjneoxOS1w==} + dependencies: + cookie: 0.5.0 + process-warning: 2.0.0 + set-cookie-parser: 2.5.1 + dev: false + /lilconfig/2.0.6: resolution: {integrity: sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==} engines: {node: '>=10'} @@ -12216,6 +12503,10 @@ packages: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} dev: true + /lodash.flattendeep/4.4.0: + resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==} + dev: true + /lodash.merge/4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true @@ -12352,6 +12643,10 @@ packages: semver: 6.3.0 dev: true + /make-error/1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + dev: true + /make-fetch-happen/10.2.0: resolution: {integrity: sha512-OnEfCLofQVJ5zgKwGk55GaqosqKjaR6khQlJY3dBAA+hM25Bc5CmX5rKUfVut+rYA3uidA7zb7AvcglU87rPRg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -12377,11 +12672,14 @@ packages: - supports-color dev: true + /make-promises-safe/5.1.0: + resolution: {integrity: sha512-AfdZ49rtyhQR/6cqVKGoH7y4ql7XkS5HJI1lZm0/5N6CQosy1eYbBJ/qbhkKHzo17UH7M918Bysf6XB9f3kS1g==} + dev: false + /makeerror/1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} dependencies: tmpl: 1.0.5 - dev: true /map-cache/0.2.2: resolution: {integrity: sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==} @@ -12711,7 +13009,6 @@ packages: engines: {node: '>=10'} dependencies: brace-expansion: 2.0.1 - dev: true /minimist-options/4.1.0: resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} @@ -13075,6 +13372,13 @@ packages: vm-browserify: 1.1.2 dev: true + /node-preload/0.2.1: + resolution: {integrity: sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==} + engines: {node: '>=8'} + dependencies: + process-on-spawn: 1.0.0 + dev: true + /node-releases/2.0.6: resolution: {integrity: sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==} dev: true @@ -13129,7 +13433,6 @@ packages: /normalize-path/3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - dev: true /normalize-range/0.1.2: resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} @@ -13202,6 +13505,42 @@ packages: resolution: {integrity: sha512-JYOWTeFoS0Z93587vRJgASD5Ut11fYl5NyihP3KrYBvMe1FRRs6RN7m20SA/16GM4P6hTnZjT+UmDOt38UeXNg==} dev: true + /nyc/15.1.0: + resolution: {integrity: sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==} + engines: {node: '>=8.9'} + hasBin: true + dependencies: + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + caching-transform: 4.0.0 + convert-source-map: 1.8.0 + decamelize: 1.2.0 + find-cache-dir: 3.3.2 + find-up: 4.1.0 + foreground-child: 2.0.0 + get-package-type: 0.1.0 + glob: 7.2.3 + istanbul-lib-coverage: 3.2.0 + istanbul-lib-hook: 3.0.0 + istanbul-lib-instrument: 4.0.3 + istanbul-lib-processinfo: 2.0.3 + istanbul-lib-report: 3.0.0 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.1.5 + make-dir: 3.1.0 + node-preload: 0.2.1 + p-map: 3.0.0 + process-on-spawn: 1.0.0 + resolve-from: 5.0.0 + rimraf: 3.0.2 + signal-exit: 3.0.7 + spawn-wrap: 2.0.0 + test-exclude: 6.0.0 + yargs: 15.4.1 + transitivePeerDependencies: + - supports-color + dev: true + /object-assign/4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -13301,6 +13640,14 @@ packages: resolution: {integrity: sha512-eJJDYkhJFFbBBAxeh8xW+weHlkI28n2ZdQV/J/DNfWfSKlGEf2xcfAbZTv3riEXHAhL9SVOTs2pRmXiSTf78xg==} dev: true + /on-exit-leak-free/1.0.0: + resolution: {integrity: sha512-Ve8ubhrXRdnuCJ5bQSQpP3uaV43K1PMcOfSRC1pqHgRZommXCgsXwh08jVC5NpjwScE23BPDwDvVg4cov3mwjw==} + dev: false + + /on-exit-leak-free/2.1.0: + resolution: {integrity: sha512-VuCaZZAjReZ3vUwgOB8LxAosIurDiAW0s13rI1YwmaP++jvcxP77AWoQvenZebpCA2m8WC1/EosPYPMjnRAp/w==} + dev: false + /on-finished/2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -13352,6 +13699,11 @@ packages: is-wsl: 2.2.0 dev: true + /opener/1.5.2: + resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} + hasBin: true + dev: true + /optionator/0.8.3: resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} engines: {node: '>= 0.8.0'} @@ -13415,6 +13767,16 @@ packages: dev: true optional: true + /own-or-env/1.0.2: + resolution: {integrity: sha512-NQ7v0fliWtK7Lkb+WdFqe6ky9XAzYmlkXthQrBbzlYbmFKoAYbDDcwmOm6q8kOuwSRXW8bdL5ORksploUJmWgw==} + dependencies: + own-or: 1.0.0 + dev: true + + /own-or/1.0.0: + resolution: {integrity: sha512-NfZr5+Tdf6MB8UI9GLvKRs4cXY8/yB0w3xtt84xFdWy8hkGjn+JFc60VhzS/hFRfbyxFcGYMTjnF4Me+RbbqrA==} + dev: true + /p-all/2.1.0: resolution: {integrity: sha512-HbZxz5FONzz/z2gJfk6bFca0BCiSRF8jU3yCsWOen/vR6lZjfPOu/e7L3uFzTW1i0H8TlC3vqQstEJPQL4/uLA==} engines: {node: '>=6'} @@ -13539,6 +13901,16 @@ packages: netmask: 2.0.2 dev: false + /package-hash/4.0.0: + resolution: {integrity: sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==} + engines: {node: '>=8'} + dependencies: + graceful-fs: 4.2.10 + hasha: 5.2.2 + lodash.flattendeep: 4.4.0 + release-zalgo: 1.0.0 + dev: true + /package-json/6.5.0: resolution: {integrity: sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==} engines: {node: '>=8'} @@ -13781,6 +14153,54 @@ packages: dev: true optional: true + /pino-abstract-transport/1.0.0: + resolution: {integrity: sha512-c7vo5OpW4wIS42hUVcT5REsL8ZljsUfBjqV/e2sFxmFEFZiq1XLUp5EYLtuDH6PEHq9W1egWqRbnLUP5FuZmOA==} + dependencies: + readable-stream: 4.1.0 + split2: 4.1.0 + dev: false + + /pino-pretty/8.1.0: + resolution: {integrity: sha512-oKfI8qKXR2a3haHs/X8iB6QSnWLqoOGAjwxIAXem4+XOGIGNw7IKpozId1uE7j89Rj46HIfWnGbAgmQmr8+yRw==} + hasBin: true + dependencies: + colorette: 2.0.19 + dateformat: 4.6.3 + fast-copy: 2.1.3 + fast-safe-stringify: 2.1.1 + help-me: 4.0.1 + joycon: 3.1.1 + minimist: 1.2.6 + on-exit-leak-free: 1.0.0 + pino-abstract-transport: 1.0.0 + pump: 3.0.0 + readable-stream: 4.1.0 + secure-json-parse: 2.5.0 + sonic-boom: 3.2.0 + strip-json-comments: 3.1.1 + dev: false + + /pino-std-serializers/6.0.0: + resolution: {integrity: sha512-mMMOwSKrmyl+Y12Ri2xhH1lbzQxwwpuru9VjyJpgFIH4asSj88F2csdMwN6+M5g1Ll4rmsYghHLQJw81tgZ7LQ==} + dev: false + + /pino/8.4.2: + resolution: {integrity: sha512-PlXDeGhJZfAuVay+wtlS02s5j8uisQveZExYdAm9MwwxUQSz9R7Q78XtjM2tTa4sa5KJmygimZjZxXXuHgV6ew==} + hasBin: true + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.1.2 + on-exit-leak-free: 2.1.0 + pino-abstract-transport: 1.0.0 + pino-std-serializers: 6.0.0 + process-warning: 2.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.3.1 + sonic-boom: 3.2.0 + thread-stream: 2.1.0 + dev: false + /pirates/4.0.5: resolution: {integrity: sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==} engines: {node: '>= 6'} @@ -14167,6 +14587,17 @@ packages: /process-nextick-args/2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + /process-on-spawn/1.0.0: + resolution: {integrity: sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==} + engines: {node: '>=8'} + dependencies: + fromentries: 1.3.2 + dev: true + + /process-warning/2.0.0: + resolution: {integrity: sha512-+MmoAXoUX+VTHAlwns0h+kFUWFs/3FZy+ZuchkgjyOu3oioLAo2LB5aCfKPh2+P9O18i3m43tUEv3YqttSy0Ww==} + dev: false + /process/0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} @@ -14403,6 +14834,10 @@ packages: /queue-microtask/1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + /quick-format-unescaped/4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + dev: false + /quick-lru/5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} @@ -14560,6 +14995,26 @@ packages: - csstype dev: false + /react-i18next/11.18.4_4sidbwfhen5r7txudrvpua6nty: + resolution: {integrity: sha512-gK/AylAQC5DvCD5YLNCHW4PNzpCfrWIyVAXbSMl+/5QXzlDP8VdBoqE2s2niGHB+zIXwBV9hRXbDrVuupbgHcg==} + peerDependencies: + i18next: '>= 19.0.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + dependencies: + '@babel/runtime': 7.18.9 + html-parse-stringify: 3.0.1 + i18next: 21.9.1 + react: 18.2.0 + react-dom: 18.2.0_react@18.2.0 + dev: false + /react-inspector/5.1.1_react@18.2.0: resolution: {integrity: sha512-GURDaYzoLbW8pMGXwYPDBIv6nqei4kK7LPRZ9q9HCZF54wqXz/dnylBp/kfE9XmekBhHvLDdcYeyIwSrvtOiWg==} peerDependencies: @@ -14784,6 +15239,13 @@ packages: string_decoder: 1.3.0 util-deprecate: 1.0.2 + /readable-stream/4.1.0: + resolution: {integrity: sha512-sVisi3+P2lJ2t0BPbpK629j8wRW06yKGJUcaLAGXPAUhyUxVJm7VsCTit1PFgT4JHUDMrGNR+ZjSKpzGaRF3zw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + abort-controller: 3.0.0 + dev: false + /readable-web-to-node-stream/3.0.2: resolution: {integrity: sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==} engines: {node: '>=8'} @@ -14807,7 +15269,11 @@ packages: engines: {node: '>=8.10.0'} dependencies: picomatch: 2.3.1 - dev: true + + /real-require/0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + dev: false /redent/1.0.0: resolution: {integrity: sha512-qtW5hKzGQZqKoh6JNSD+4lfitfPKGz42e6QwiRmPM5mmKtR0N41AbJRYu0xJi7nhOJ4WDgRkKvAk6tw4WIwR4g==} @@ -14910,6 +15376,13 @@ packages: engines: {node: '>= 0.10'} dev: true + /release-zalgo/1.0.0: + resolution: {integrity: sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==} + engines: {node: '>=4'} + dependencies: + es6-error: 4.1.1 + dev: true + /remark-external-links/8.0.0: resolution: {integrity: sha512-5vPSX0kHoSsqtdftSHhIYofVINC8qmp0nctkeU9YoJwV3YfiBRiI6cbFRJ0oI/1F9xS+bopXG0m2KS8VFscuKA==} dependencies: @@ -15020,7 +15493,6 @@ packages: /require-main-filename/2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} - dev: false /resize-observer-polyfill/1.5.1: resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} @@ -15037,7 +15509,6 @@ packages: /resolve-from/5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} - dev: true /resolve-url/0.2.1: resolution: {integrity: sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==} @@ -15094,6 +15565,11 @@ packages: engines: {node: '>=0.12'} dev: true + /ret/0.2.2: + resolution: {integrity: sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==} + engines: {node: '>=4'} + dev: false + /retry/0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -15103,6 +15579,10 @@ packages: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + /rfdc/1.3.0: + resolution: {integrity: sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==} + dev: false + /rimraf/2.7.1: resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} hasBin: true @@ -15216,6 +15696,17 @@ packages: ret: 0.1.15 dev: true + /safe-regex2/2.0.0: + resolution: {integrity: sha512-PaUSFsUaNNuKwkBijoAPHAK6/eM6VirvyPWlZ7BAQy4D+hCvh4B6lIG+nPdhbFfIbP+gTGBcrdsOaUs0F+ZBOQ==} + dependencies: + ret: 0.2.2 + dev: false + + /safe-stable-stringify/2.3.1: + resolution: {integrity: sha512-kYBSfT+troD9cDA85VDnHZ1rpHC50O0g1e6WlGHVCz/g+JS+9WKLj+XwFYyR8UbrZN8ll9HUpDAAddY58MGisg==} + engines: {node: '>=10'} + dev: false + /safer-buffer/2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -15300,6 +15791,10 @@ packages: engines: {node: '>=0.10.0'} dev: false + /secure-json-parse/2.5.0: + resolution: {integrity: sha512-ZQruFgZnIWH+WyO9t5rWt4ZEGqCKPwhiw+YbzTwpmT9elgLrLcfuyUiSnwwjUiVy9r4VM3urtbNF1xmEh9IL2w==} + dev: false + /semver-compare/1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} dev: true @@ -15413,6 +15908,10 @@ packages: /set-blocking/2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + /set-cookie-parser/2.5.1: + resolution: {integrity: sha512-1jeBGaKNGdEq4FgIrORu/N570dwoPYio8lSoYLWmX7sQ//0JY08Xh9o5pBcgmHQ/MbsYp/aZnOe1s1lIsbLprQ==} + dev: false + /set-harmonic-interval/1.0.1: resolution: {integrity: sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==} engines: {node: '>=6.9'} @@ -15473,6 +15972,10 @@ packages: engines: {node: '>=8'} dev: true + /shell-quote/1.7.3: + resolution: {integrity: sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==} + dev: true + /side-channel/1.0.4: resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} dependencies: @@ -15606,6 +16109,12 @@ packages: ip: 2.0.0 smart-buffer: 4.2.0 + /sonic-boom/3.2.0: + resolution: {integrity: sha512-SbbZ+Kqj/XIunvIAgUZRlqd6CGQYq71tRRbXR92Za8J/R3Yh4Av+TWENiSiEgnlwckYLyP0YZQWVfyNC0dzLaA==} + dependencies: + atomic-sleep: 1.0.0 + dev: false + /source-list-map/2.0.1: resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==} dev: true @@ -15669,6 +16178,22 @@ packages: resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==} dev: true + /spawn-command/0.0.2-1: + resolution: {integrity: sha512-n98l9E2RMSJ9ON1AKisHzz7V42VDiBQGY6PB1BwRglz99wpVsSuGzQ+jOi6lFXBGVTCrRpltvjm+/XA+tpeJrg==} + dev: true + + /spawn-wrap/2.0.0: + resolution: {integrity: sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==} + engines: {node: '>=8'} + dependencies: + foreground-child: 2.0.0 + is-windows: 1.0.2 + make-dir: 3.1.0 + rimraf: 3.0.2 + signal-exit: 3.0.7 + which: 2.0.2 + dev: true + /spdx-correct/3.1.1: resolution: {integrity: sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==} dependencies: @@ -15704,6 +16229,17 @@ packages: through: 2.3.8 dev: false + /split2/3.2.2: + resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==} + dependencies: + readable-stream: 3.6.0 + dev: false + + /split2/4.1.0: + resolution: {integrity: sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==} + engines: {node: '>= 10.x'} + dev: false + /sprintf-js/1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} dev: true @@ -15744,6 +16280,13 @@ packages: stackframe: 1.3.4 dev: false + /stack-utils/2.0.5: + resolution: {integrity: sha512-xrQcmYhOsn/1kX+Vraq+7j4oE2j/6BFscZ0etmYg81xuM8Gq0022Pxb8+IqgOFUIaxHs0KaSb7T1+OegiNrNFA==} + engines: {node: '>=10'} + dependencies: + escape-string-regexp: 2.0.0 + dev: true + /stackframe/1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} @@ -15956,6 +16499,11 @@ packages: dev: true optional: true + /strip-bom/4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + dev: true + /strip-comments/2.0.1: resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} engines: {node: '>=10'} @@ -16001,7 +16549,6 @@ packages: /strip-json-comments/3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - dev: true /strtok3/6.3.0: resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==} @@ -16084,7 +16631,6 @@ packages: engines: {node: '>=8'} dependencies: has-flag: 4.0.0 - dev: true /supports-color/8.1.1: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} @@ -16160,10 +16706,12 @@ packages: resolution: {integrity: sha512-k8uzYIkIVwmT+TcglpdN50pS2y1BDcUnBPK9iJeGu0Pl1lOI8pD6wtzgw91Pjpe+RxtTncw32tLxs/R0yNL2Mg==} dev: true - /tailwindcss/3.1.8: + /tailwindcss/3.1.8_postcss@8.4.15: resolution: {integrity: sha512-YSneUCZSFDYMwk+TGq8qYFdCA3yfBRdBlS7txSq0LUmzyeqRe3a8fBQzbz9M3WS/iFT4BNf/nmw9mEzrnSaC0g==} engines: {node: '>=12.13.0'} hasBin: true + peerDependencies: + postcss: ^8.0.9 dependencies: arg: 5.0.2 chokidar: 3.5.3 @@ -16191,6 +16739,91 @@ packages: - ts-node dev: true + /tap-mocha-reporter/5.0.3: + resolution: {integrity: sha512-6zlGkaV4J+XMRFkN0X+yuw6xHbE9jyCZ3WUKfw4KxMyRGOpYSRuuQTRJyWX88WWuLdVTuFbxzwXhXuS2XE6o0g==} + engines: {node: '>= 8'} + hasBin: true + dependencies: + color-support: 1.1.3 + debug: 4.3.4 + diff: 4.0.2 + escape-string-regexp: 2.0.0 + glob: 7.2.3 + tap-parser: 11.0.1 + tap-yaml: 1.0.0 + unicode-length: 2.1.0 + transitivePeerDependencies: + - supports-color + dev: true + + /tap-parser/11.0.1: + resolution: {integrity: sha512-5ow0oyFOnXVSALYdidMX94u0GEjIlgc/BPFYLx0yRh9hb8+cFGNJqJzDJlUqbLOwx8+NBrIbxCWkIQi7555c0w==} + engines: {node: '>= 8'} + hasBin: true + dependencies: + events-to-array: 1.1.2 + minipass: 3.3.4 + tap-yaml: 1.0.0 + dev: true + + /tap-yaml/1.0.0: + resolution: {integrity: sha512-Rxbx4EnrWkYk0/ztcm5u3/VznbyFJpyXO12dDBHKWiDVxy7O2Qw6MRrwO5H6Ww0U5YhRY/4C/VzWmFPhBQc4qQ==} + dependencies: + yaml: 1.10.2 + dev: true + + /tap/16.3.0_6oasmw356qmm23djlsjgkwvrtm: + resolution: {integrity: sha512-J9GffPUAbX6FnWbQ/jj7ktzd9nnDFP1fH44OzidqOmxUfZ1hPLMOvpS99LnDiP0H2mO8GY3kGN5XoY0xIKbNFA==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + coveralls: ^3.1.1 + flow-remove-types: '>=2.112.0' + ts-node: '>=8.5.2' + typescript: '>=3.7.2' + peerDependenciesMeta: + coveralls: + optional: true + flow-remove-types: + optional: true + ts-node: + optional: true + typescript: + optional: true + dependencies: + chokidar: 3.5.3 + findit: 2.0.0 + foreground-child: 2.0.0 + fs-exists-cached: 1.0.0 + glob: 7.2.3 + isexe: 2.0.0 + istanbul-lib-processinfo: 2.0.3 + jackspeak: 1.4.1 + libtap: 1.4.0 + minipass: 3.3.4 + mkdirp: 1.0.4 + nyc: 15.1.0 + opener: 1.5.2 + rimraf: 3.0.2 + signal-exit: 3.0.7 + source-map-support: 0.5.21 + tap-mocha-reporter: 5.0.3 + tap-parser: 11.0.1 + tap-yaml: 1.0.0 + tcompare: 5.0.7 + ts-node: 10.9.1_hn66opzbaneygq52jmwjxha6su + typescript: 4.7.4 + which: 2.0.2 + transitivePeerDependencies: + - supports-color + dev: true + bundledDependencies: + - ink + - treport + - '@types/react' + - '@isaacs/import-jsx' + - react + /tapable/1.1.3: resolution: {integrity: sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==} engines: {node: '>=6'} @@ -16233,6 +16866,13 @@ packages: yallist: 4.0.0 dev: true + /tcompare/5.0.7: + resolution: {integrity: sha512-d9iddt6YYGgyxJw5bjsN7UJUO1kGOtjSlNy/4PoGYAjQS5pAT/hzIoLf1bZCw+uUxRmZJh7Yy1aA7xKVRT9B4w==} + engines: {node: '>=10'} + dependencies: + diff: 4.0.2 + dev: true + /telejson/6.0.8: resolution: {integrity: sha512-nerNXi+j8NK1QEfBHtZUN/aLdDcyupA//9kAboYLrtzZlPLpUfqbVGWb9zz91f/mIjRbAYhbgtnJHY8I1b5MBg==} dependencies: @@ -16376,6 +17016,12 @@ packages: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true + /thread-stream/2.1.0: + resolution: {integrity: sha512-5+Pf2Ya31CsZyIPYYkhINzdTZ3guL+jHq7D8lkBybgGcSQIKDbid3NJku3SpCKeE/gACWAccDA/rH2B6doC5aA==} + dependencies: + real-require: 0.2.0 + dev: false + /throttle-debounce/3.0.1: resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==} engines: {node: '>=10'} @@ -16403,6 +17049,11 @@ packages: setimmediate: 1.0.5 dev: true + /tiny-lru/8.0.2: + resolution: {integrity: sha512-ApGvZ6vVvTNdsmt676grvCkUCGwzG9IqXma5Z07xJgiC5L7akUMof5U8G2JTI9Rz/ovtVhJBlY6mNhEvtjzOIg==} + engines: {node: '>=6'} + dev: false + /tinypool/0.2.4: resolution: {integrity: sha512-Vs3rhkUH6Qq1t5bqtb816oT+HeJTXfwt2cbPH17sWHIYKTotQIFPk3tf2fgqRrVyMDVOc1EnPgzIxfIulXVzwQ==} engines: {node: '>=14.0.0'} @@ -16428,7 +17079,6 @@ packages: /tmpl/1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} - dev: true /to-arraybuffer/1.0.1: resolution: {integrity: sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==} @@ -16528,6 +17178,11 @@ packages: resolution: {integrity: sha512-kdf4JKs8lbARxWdp7RKdNzoJBhGUcIalSYibuGyHJbmk40pOysQ0+QPvlkCOICOivDWU2IJo2rkrxyTK2AH4fw==} dev: true + /tree-kill/1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + dev: true + /trim-newlines/1.0.0: resolution: {integrity: sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw==} engines: {node: '>=0.10.0'} @@ -16547,6 +17202,10 @@ packages: resolution: {integrity: sha512-YzQV+TZg4AxpKxaTHK3c3D+kRDCGVEE7LemdlQZoQXn0iennk10RsIoY6ikzAqJTc9Xjl9C1/waHom/J86ziAQ==} dev: true + /trivial-deferred/1.0.1: + resolution: {integrity: sha512-dagAKX7vaesNNAwOc9Np9C2mJ+7YopF4lk+jE2JML9ta4kZ91Y6UruJNH65bLRYoUROD8EY+Pmi44qQWwXR7sw==} + dev: true + /trough/1.0.5: resolution: {integrity: sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==} dev: true @@ -16566,6 +17225,37 @@ packages: resolution: {integrity: sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==} dev: false + /ts-node/10.9.1_hn66opzbaneygq52jmwjxha6su: + resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.9 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.3 + '@types/node': 18.6.4 + acorn: 8.8.0 + acorn-walk: 8.2.0 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 4.7.4 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + dev: true + /ts-pnp/1.2.0_typescript@4.7.4: resolution: {integrity: sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==} engines: {node: '>=6'} @@ -16608,137 +17298,65 @@ packages: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} - /turbo-android-arm64/1.4.2: - resolution: {integrity: sha512-h6PorJ+muKDQE3wETwrkx3NpqypAxjIFLP3b8RQaAoNvxYa1JTSC71VMtYxMbwuDk58A1KGbXLDteR4by8Lqew==} - cpu: [arm64] - os: [android] - requiresBuild: true - dev: true - optional: true - - /turbo-darwin-64/1.4.2: - resolution: {integrity: sha512-HrXRwx+5FuKeR4r2ea2mWO5dImzxG7z987t4xZWytEWJ0gEujln1z1cjwotYU1l2pq8slJS8W3q9Qv3JRMrSkQ==} + /turbo-darwin-64/1.6.1: + resolution: {integrity: sha512-xsItJ/hmnd6R8V60cCe0RAZQjO+En/LVXVkZhiw0Fyfxoo+iKcAA4sVeWkaL+cg5sQd5UWlWfD1EOKbHDjVb9Q==} cpu: [x64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-darwin-arm64/1.4.2: - resolution: {integrity: sha512-/qkMqq1hdbM/I0gchx08/ZZucgnQVp6gd03tHHYnyG20z8a39f38MbX7+I5aLRpa3pQzBk8RgNx0o7G1T+KvzA==} + /turbo-darwin-arm64/1.6.1: + resolution: {integrity: sha512-wRfAJWCLYB29IGTx6sF6QvexK/89AbAgnfYA5yVcuUJT+xz2/zLeGcOODQBCnP4rB+vX5ipXLY0XjkLGl+z6fA==} cpu: [arm64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-freebsd-64/1.4.2: - resolution: {integrity: sha512-bZcjR7GxpuE/0qz/aKg4gWDa+6eiuoV0cRnqCJ/rae14/iSmBt0MsMa+lUH5gZUFj581Dj8fQRoBeE+EOau5CA==} - cpu: [x64] - os: [freebsd] - requiresBuild: true - dev: true - optional: true - - /turbo-freebsd-arm64/1.4.2: - resolution: {integrity: sha512-dDx++7AELGAHuaMQjzNiKjSPu/xdDelUtRjWOzJWmwXzrgJlwNgQ93p+LYEA7LBWVZ8a32fpBE/VDait0alIJw==} - cpu: [arm64] - os: [freebsd] - requiresBuild: true - dev: true - optional: true - - /turbo-linux-32/1.4.2: - resolution: {integrity: sha512-AAxsEYhgv6x4UXwCoiRe+iL2pd+ArsJQDMgJGsJn+Tb09ca6+i1rTgdOTgcCSyvRwbXt0LYzqXN9zp6FwG6VHQ==} - cpu: [ia32] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /turbo-linux-64/1.4.2: - resolution: {integrity: sha512-t7gGxp1ILmGwzymcf72Lw4Ca916Bi9j2C4xPnJ2CAqxMWQOJKCRvyOuUHC/uy1kFDjR2yszxMb+ZJL6P3nccfA==} + /turbo-linux-64/1.6.1: + resolution: {integrity: sha512-NZ88muC3hHbWW/cBgl9DFFbyzDcFVvZHQBXKTwVA8l2yLOOvesX+aQ2Knr4Pxu9Kb0F3t6ABsOSf8SbI7CpJsg==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-linux-arm/1.4.2: - resolution: {integrity: sha512-6Rri//bX3wPMa8D0sSie05Xuze+6jTUDt4qsKp+JoQVanUKkKmRaVDpyV4WuFfjDbC5iP4ocN20FeaXenMFxTA==} - cpu: [arm] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /turbo-linux-arm64/1.4.2: - resolution: {integrity: sha512-V7eBFUOrIvLvjrc81UE8C+NfqBRKADyKrrbKD9hMG5beE/piZZNGoHUwuEgLgsykCSfHjn1sQCidocVztuTMAw==} + /turbo-linux-arm64/1.6.1: + resolution: {integrity: sha512-HDgx+0ozqMpoDBOSzWz43nYMDp/+giEz8+vmLOB6mTQU/9IlZQVwachzwkqLRsJyBUhYALBlWGcuRWO3KqXMmg==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-linux-mips64le/1.4.2: - resolution: {integrity: sha512-C0JpzpwyvhW5ChWr6S7ulUd8a+1SBLe2mLBepTWaXTh/5+sFDU/AMwPNkhpfV5o0gEtz03UPm7Y4G6dqU3UCAQ==} - cpu: [mipsel] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /turbo-linux-ppc64le/1.4.2: - resolution: {integrity: sha512-ipxyQCj3NXq/2V6a4lKoNDt8CjcufICgHAvOhAUmoAxz4kIEKRvA/75xsPpt2Ih/a4fWGxsbWFK6oA+ac0l3jQ==} - cpu: [ppc64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /turbo-windows-32/1.4.2: - resolution: {integrity: sha512-pCvsh8mPSw6ZFABQqeJzfercnsOwCj1LpbWTPW1QftDij6zw1OWU4jWU529Ub1f+GMBej+xm/ufJhT+8A3NhwA==} - cpu: [ia32] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /turbo-windows-64/1.4.2: - resolution: {integrity: sha512-GnvM8uGOA6idUloDDxiEgUpIx5o5ZJL0ARL7f+6r/vfwA+qlSe/F+4hDCmT3+Xkg7/7zDZix5ViKVoTDMBP/Ig==} + /turbo-windows-64/1.6.1: + resolution: {integrity: sha512-jnR0V0YBlFJKEoAeq0GQFLmZ1UNl6vh+RHTHX546+o5jKcE6nfp9oTOEwtR0PLutiuxxDDm6roAc+9mSfycffw==} cpu: [x64] os: [win32] requiresBuild: true dev: true optional: true - /turbo-windows-arm64/1.4.2: - resolution: {integrity: sha512-2mCPiDnMLY924+M07mMo464cjOr0EITuIkK67IBm3EeEwSlunOmQk+LRc/Jq/Zx6Zuzp5XPZ2fZVvmmSFfogpQ==} + /turbo-windows-arm64/1.6.1: + resolution: {integrity: sha512-vOqw/iPgLjkwpni2vNFK9YO19lN9QZ8JG8v1unvL09/rnXyKpHygrYECj+efJptEVJKBG2xLIauJYmZ/2LV1Uw==} cpu: [arm64] os: [win32] requiresBuild: true dev: true optional: true - /turbo/1.4.2: - resolution: {integrity: sha512-ry1vUs5oHCIM+Sef8HED2XsbL28YAeclCrOtDp9zbZZMUX1r5s01COqOJjFJZfDiv2zSlUge9IIQXprM8BfrtA==} + /turbo/1.6.1: + resolution: {integrity: sha512-CkcJo17cbwfTzmxtxJo2AbbeVqaz1yQotBUqVwZDdcrVSNKci2nvw+JHJ3sy/z9YY9xOJmoRaZifbkja3UXUWA==} hasBin: true requiresBuild: true optionalDependencies: - turbo-android-arm64: 1.4.2 - turbo-darwin-64: 1.4.2 - turbo-darwin-arm64: 1.4.2 - turbo-freebsd-64: 1.4.2 - turbo-freebsd-arm64: 1.4.2 - turbo-linux-32: 1.4.2 - turbo-linux-64: 1.4.2 - turbo-linux-arm: 1.4.2 - turbo-linux-arm64: 1.4.2 - turbo-linux-mips64le: 1.4.2 - turbo-linux-ppc64le: 1.4.2 - turbo-windows-32: 1.4.2 - turbo-windows-64: 1.4.2 - turbo-windows-arm64: 1.4.2 + turbo-darwin-64: 1.6.1 + turbo-darwin-arm64: 1.6.1 + turbo-linux-64: 1.6.1 + turbo-linux-arm64: 1.6.1 + turbo-windows-64: 1.6.1 + turbo-windows-arm64: 1.6.1 dev: true /type-check/0.3.2: @@ -16795,6 +17413,11 @@ packages: engines: {node: '>=12.20'} dev: false + /type-fest/3.0.0: + resolution: {integrity: sha512-MINvUN5ug9u+0hJDzSZNSnuKXI8M4F5Yvb6SQZ2CYqe7SgKXKOosEcU5R7tRgo85I6eAVBbkVF7TCvB4AUK2xQ==} + engines: {node: '>=14.16'} + dev: false + /type-is/1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -16851,6 +17474,12 @@ packages: engines: {node: '>=4'} dev: true + /unicode-length/2.1.0: + resolution: {integrity: sha512-4bV582zTV9Q02RXBxSUMiuN/KHo5w4aTojuKTNT96DIKps/SIawFp7cS5Mu25VuY1AioGXrmYyzKZUzh8OqoUw==} + dependencies: + punycode: 2.1.1 + dev: true + /unicode-match-property-ecmascript/2.0.0: resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} engines: {node: '>=4'} @@ -16872,6 +17501,7 @@ packages: /unified/9.2.0: resolution: {integrity: sha512-vx2Z0vY+a3YoTj8+pttM3tiJHCwY5UFbYdiWrwBEbHmK8pvsPj2rtAX2BFfgXen8T39CJWblWRDT4L5WGXtDdg==} dependencies: + '@types/unist': 2.0.6 bail: 1.0.5 extend: 3.0.2 is-buffer: 2.0.5 @@ -17159,6 +17789,15 @@ packages: hasBin: true dev: true + /uuid/8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + dev: true + + /v8-compile-cache-lib/3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + dev: true + /v8-compile-cache/2.3.0: resolution: {integrity: sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==} dev: true @@ -17405,6 +18044,11 @@ packages: acorn-walk: 8.2.0 dev: false + /void-elements/3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + dev: false + /w3c-hr-time/1.0.2: resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==} dependencies: @@ -17436,7 +18080,6 @@ packages: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} dependencies: makeerror: 1.0.12 - dev: true /watchpack-chokidar2/2.0.1: resolution: {integrity: sha512-nCFfBIPKr5Sh61s4LPpy1Wtfi0HE8isJ3d2Yb5/Ppw2P2B/3eVSEBjKfN0fmHJSK14+31KwMKmcrzs2GM4P0Ww==} @@ -17683,7 +18326,6 @@ packages: /which-module/2.0.0: resolution: {integrity: sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==} - dev: false /which/1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} @@ -17885,7 +18527,6 @@ packages: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 - dev: false /wrap-ansi/7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} @@ -18001,12 +18642,10 @@ packages: dependencies: camelcase: 5.3.1 decamelize: 1.2.0 - dev: false /yargs-parser/20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} - dev: true /yargs-parser/21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} @@ -18027,7 +18666,6 @@ packages: which-module: 2.0.0 y18n: 4.0.3 yargs-parser: 18.1.3 - dev: false /yargs/16.2.0: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} @@ -18061,6 +18699,11 @@ packages: fd-slicer: 1.1.0 dev: true + /yn/3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + dev: true + /yocto-queue/0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} diff --git a/turbo.json b/turbo.json index 2fac94e..a7d5fed 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,5 @@ { "$schema": "https://turborepo.org/schema.json", - "baseBranch": "origin/react", "pipeline": { "post-install": { "cache": false @@ -10,15 +9,14 @@ }, "build": { "dependsOn": ["^build"], - "outputs": ["dist/**"] + "outputs": ["dist/**"], + "cache": false }, "pack": { - "dependsOn": ["build"], "outputs": ["release/**"], "cache": false }, "pack:test": { - "dependsOn": ["build"], "outputs": ["release/**"], "cache": false },