├── .env
├── .eslintrc.json
├── .gitignore
├── .npmrc
├── README.md
├── docker-compose.yml
├── jsconfig.json
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── prisma
├── schema.prisma
└── seed.ts
├── public
├── next.svg
└── vercel.svg
├── src
├── api
│ └── config.js
├── app
│ ├── HomeLayout.style.js
│ ├── ReduxProvider.jsx
│ ├── favicon.ico
│ ├── favorite
│ │ ├── layout.jsx
│ │ └── page.jsx
│ ├── globals.css
│ ├── home
│ │ ├── components
│ │ │ ├── header.jsx
│ │ │ ├── miniPlayer
│ │ │ │ ├── index.jsx
│ │ │ │ └── style.js
│ │ │ ├── normalPlayer
│ │ │ │ └── index.jsx
│ │ │ ├── playList
│ │ │ │ ├── index.jsx
│ │ │ │ └── style.js
│ │ │ ├── player
│ │ │ │ └── index.jsx
│ │ │ └── recommendList
│ │ │ │ ├── index.jsx
│ │ │ │ └── style.js
│ │ ├── layout.jsx
│ │ ├── page.jsx
│ │ ├── rank
│ │ │ ├── [id]
│ │ │ │ ├── page.jsx
│ │ │ │ └── style.js
│ │ │ ├── components
│ │ │ │ └── main.jsx
│ │ │ ├── page.jsx
│ │ │ └── style.js
│ │ ├── recommend
│ │ │ └── main.jsx
│ │ ├── singer
│ │ │ ├── [id]
│ │ │ │ ├── page.jsx
│ │ │ │ └── style.js
│ │ │ ├── components
│ │ │ │ ├── horizen-item.jsx
│ │ │ │ ├── main.jsx
│ │ │ │ ├── songList.jsx
│ │ │ │ └── top-header.jsx
│ │ │ ├── page.jsx
│ │ │ └── style.js
│ │ └── style.js
│ ├── layout.jsx
│ ├── mine
│ │ └── page.jsx
│ ├── page.jsx
│ ├── providers.jsx
│ └── registry.jsx
├── assets
│ ├── global-style.js
│ └── music.png
├── components
│ ├── confirm
│ │ └── index.jsx
│ ├── header
│ │ └── index.jsx
│ ├── loading-v2
│ │ └── index.jsx
│ ├── loading
│ │ └── index.jsx
│ ├── progress-circle
│ │ └── index.jsx
│ ├── scroll
│ │ └── index.jsx
│ ├── slider
│ │ ├── index.jsx
│ │ └── style.js
│ ├── svg-icon
│ │ └── index.jsx
│ └── toast
│ │ └── index.jsx
├── hooks
│ ├── useGetAlbums.jsx
│ ├── useGetSingerSongs.jsx
│ └── useSingerMutation.jsx
├── lib
│ └── db.ts
├── store
│ ├── index.js
│ └── slices
│ │ ├── singers.js
│ │ └── songs.js
└── utils
│ └── index.js
├── tailwind.config.js
└── yarn.lock
/.env:
--------------------------------------------------------------------------------
1 | # Environment variables declared in this file are automatically made available to Prisma.
2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
3 |
4 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings
6 |
7 | DATABASE_URL="postgresql://username:password@localhost:5432/mydb?schema=public"
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 | /postgres
9 | /prisma/migrations
10 |
11 | # testing
12 | /coverage
13 |
14 | # next.js
15 | /.next/
16 | /out/
17 |
18 | # production
19 | /build
20 |
21 | # misc
22 | .DS_Store
23 | *.pem
24 |
25 | # debug
26 | npm-debug.log*
27 | yarn-debug.log*
28 | yarn-error.log*
29 |
30 | # local env files
31 | .env*.local
32 |
33 | # vercel
34 | .vercel
35 |
36 | # typescript
37 | *.tsbuildinfo
38 | next-env.d.ts
39 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | public-hoist-pattern[]=*@nextui-org/*
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | ## 项目简介
3 |
4 | **Nextjs** + **App Router**路由模式 + 客户端**SWR**仿**网易云音乐**的WebApp项目实战
5 |
6 |
7 | ## 本地运行
8 |
9 | run the development server:
10 |
11 | ```bash
12 | npm install
13 | # or
14 | pnpm install
15 |
16 |
17 | npm run dev
18 | # or
19 | pnpm dev
20 | ```
21 |
22 |
23 | ## 歌曲源数据
24 |
25 | ### 精力旺盛的法子
26 |
27 | path:/src/api/config.js
28 |
29 | ```js
30 | export const API_BASE_URL = "http://localhost:3100";
31 | ```
32 |
33 |
34 | 小伙伴本地学习的时候需要本地运行一个拿歌曲数据的服务
35 |
36 | 进入[react-cloud-music](https://github.com/sanyuan0704/react-cloud-music) 项目 ,NeteaseCloudMusicApi目录下安装包,启动服务
37 |
38 |
39 |
40 | 然后**API_BASE_URL**放你启动的**服务地址**就行了
41 |
42 |
43 | ### 懒人必备的法子
44 |
45 | 自己本地mock点假数据,搞那么费事干嘛呢~~~
46 |
47 |
48 | ## Prisma
49 |
50 | 项目集成了**Prisma** + **Postgresql**可以从数据库中获取数据展示在页面上
51 |
52 | 不过需要小伙伴本地搭建个数据库
53 |
54 | 前端部分:
55 |
56 | 
57 |
58 |
59 | ## 🙏🙏🙏 在线求Star
60 |
61 | 如果您觉得这个项目还不错, 可以在 Github 上面帮我点个star, 支持一下作者
62 |
63 | ## 效果展示
64 | 
65 |
66 | 
67 |
68 | 
69 |
70 | 
71 |
72 | 
73 |
74 |
75 |
76 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | # docker-compose.yml
2 | version: "3.1"
3 | services:
4 | db:
5 | image: postgres
6 | volumes:
7 | - ./postgres:/var/lib/postgresql/data
8 | restart: always
9 | ports:
10 | - 5432:5432
11 | environment:
12 | - POSTGRES_USER=cluonote
13 | - POSTGRES_PASSWORD=EASYlife.520
14 |
15 | adminer:
16 | image: adminer
17 | restart: always
18 | ports:
19 | - 8080:8080
20 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "paths": {
4 | "@/*": ["./src/*"]
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | compiler: {
4 | styledComponents: true,
5 | },
6 | reactStrictMode: false,
7 | };
8 |
9 | export default nextConfig;
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "my-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@nextui-org/react": "^2.4.2",
13 | "@prisma/client": "^5.17.0",
14 | "@reduxjs/toolkit": "^2.2.3",
15 | "better-scroll": "^2.5.1",
16 | "framer-motion": "^11.2.10",
17 | "next": "14.2.3",
18 | "react": "^18",
19 | "react-dom": "^18",
20 | "react-lazyload": "^3.2.1",
21 | "react-redux": "^9.1.2",
22 | "react-transition-group": "^4.2.1",
23 | "redux": "^5.0.1",
24 | "styled-components": "6.1.10",
25 | "swiper": "^8.1.5",
26 | "swr": "^2.2.5"
27 | },
28 | "devDependencies": {
29 | "@types/node": "^20.14.12",
30 | "eslint": "^8",
31 | "eslint-config-next": "14.2.3",
32 | "postcss": "^8",
33 | "prisma": "^5.17.0",
34 | "tailwindcss": "^3.4.1",
35 | "ts-node": "^10.9.2",
36 | "typescript": "^5.5.4"
37 | },
38 | "prisma": {
39 | "seed": "ts-node prisma/seed.ts"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
6 |
7 | generator client {
8 | provider = "prisma-client-js"
9 | }
10 |
11 | datasource db {
12 | provider = "postgresql"
13 | url = env("DATABASE_URL")
14 | }
15 |
16 | model Video {
17 | id Int @id @default(autoincrement())
18 | title String @unique
19 | desc String?
20 | pic String
21 | authorId Int
22 | author User? @relation(fields: [authorId], references: [id])
23 | categoryId Int
24 | category Category? @relation(fields: [categoryId], references: [id])
25 | level Int @default(1)
26 | createdAt DateTime @default(now())
27 | updatedAt DateTime @updatedAt
28 | chapter Chapter[]
29 | }
30 |
31 | model Category {
32 | id Int @id @default(autoincrement())
33 | name String @unique
34 | video Video[]
35 | }
36 |
37 | model Chapter {
38 | id Int @id @default(autoincrement())
39 | title String
40 | cover String
41 | url String
42 | videoId Int
43 | video Video? @relation(fields: [videoId], references: [id])
44 | }
45 |
46 | model User {
47 | id Int @id @default(autoincrement())
48 | avatar String
49 | name String
50 | video Video[]
51 | }
52 |
--------------------------------------------------------------------------------
/prisma/seed.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 | // js用 const { PrismaClient } = require("@prisma/client");
3 | // 初始化 Prisma Client
4 | const prisma = new PrismaClient();
5 |
6 | async function main() {
7 | //在此编写 Prisma Client 查询
8 | const user = await prisma.user.create({
9 | data: {
10 | name: "小马",
11 | avatar:
12 | "https://p3-passport.byteimg.com/img/user-avatar/585e1491713363bc8f67d06c485e8260~100x100.awebp",
13 | },
14 | });
15 | console.log(user);
16 | }
17 |
18 | main()
19 | .catch((e) => {
20 | console.error(e);
21 | process.exit(1);
22 | })
23 | .finally(async () => {
24 | // 关闭 Prisma Client
25 | await prisma.$disconnect();
26 | });
27 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/api/config.js:
--------------------------------------------------------------------------------
1 | export const API_BASE_URL = "http://localhost:3100";
2 |
3 | //歌手种类
4 | export const categoryTypes = [
5 | {
6 | name: "华语男",
7 | key: "1001",
8 | },
9 | {
10 | name: "华语女",
11 | key: "1002",
12 | },
13 | {
14 | name: "华语组合",
15 | key: "1003",
16 | },
17 | {
18 | name: "欧美男",
19 | key: "2001",
20 | },
21 | {
22 | name: "欧美女",
23 | key: "2002",
24 | },
25 | {
26 | name: "欧美组合",
27 | key: "2003",
28 | },
29 | {
30 | name: "日本男",
31 | key: "6001",
32 | },
33 | {
34 | name: "日本女",
35 | key: "6002",
36 | },
37 | {
38 | name: "日本组合",
39 | key: "6003",
40 | },
41 | {
42 | name: "韩国男",
43 | key: "7001",
44 | },
45 | {
46 | name: "韩国女",
47 | key: "7002",
48 | },
49 | {
50 | name: "韩国组合",
51 | key: "7003",
52 | },
53 | {
54 | name: "其他男歌手",
55 | key: "4001",
56 | },
57 | {
58 | name: "其他女歌手",
59 | key: "4002",
60 | },
61 | {
62 | name: "其他组合",
63 | key: "4003",
64 | },
65 | ];
66 |
67 | //歌手首字母
68 | export const alphaTypes = [
69 | {
70 | key: "A",
71 | name: "A",
72 | },
73 | {
74 | key: "B",
75 | name: "B",
76 | },
77 | {
78 | key: "C",
79 | name: "C",
80 | },
81 | {
82 | key: "D",
83 | name: "D",
84 | },
85 | {
86 | key: "E",
87 | name: "E",
88 | },
89 | {
90 | key: "F",
91 | name: "F",
92 | },
93 | {
94 | key: "G",
95 | name: "G",
96 | },
97 | {
98 | key: "H",
99 | name: "H",
100 | },
101 | {
102 | key: "I",
103 | name: "I",
104 | },
105 | {
106 | key: "J",
107 | name: "J",
108 | },
109 | {
110 | key: "K",
111 | name: "K",
112 | },
113 | {
114 | key: "L",
115 | name: "L",
116 | },
117 | {
118 | key: "M",
119 | name: "M",
120 | },
121 | {
122 | key: "N",
123 | name: "N",
124 | },
125 | {
126 | key: "O",
127 | name: "O",
128 | },
129 | {
130 | key: "P",
131 | name: "P",
132 | },
133 | {
134 | key: "Q",
135 | name: "Q",
136 | },
137 | {
138 | key: "R",
139 | name: "R",
140 | },
141 | {
142 | key: "S",
143 | name: "S",
144 | },
145 | {
146 | key: "T",
147 | name: "T",
148 | },
149 | {
150 | key: "U",
151 | name: "U",
152 | },
153 | {
154 | key: "V",
155 | name: "V",
156 | },
157 | {
158 | key: "W",
159 | name: "W",
160 | },
161 | {
162 | key: "X",
163 | name: "X",
164 | },
165 | {
166 | key: "Y",
167 | name: "Y",
168 | },
169 | {
170 | key: "Z",
171 | name: "Z",
172 | },
173 | ];
174 |
175 | //排行榜编号
176 | export const RankTypes = {
177 | 0: "云音乐新歌榜",
178 | 1: "云音乐热歌榜",
179 | 2: "网易原创歌曲榜",
180 | 3: "云音乐飙升榜",
181 | 4: "云音乐国电榜",
182 | 5: "UK排行榜周榜",
183 | 6: "美国Billboard周榜",
184 | 7: "KTV唛榜",
185 | 8: "iTunes榜",
186 | 9: "Hit FM Top榜",
187 | 10: "日本Oricon周榜",
188 | 11: "韩国Melon排行榜周榜",
189 | 12: "韩国Mnet排行榜周榜",
190 | 13: "韩国Melon原声周榜",
191 | 14: "中国TOP排行榜(港台榜)",
192 | 15: "中国TOP排行榜(内地榜)",
193 | 16: "香港电台中文歌曲龙虎榜",
194 | 17: "华语金曲榜",
195 | 18: "中国嘻哈榜",
196 | 19: "法国 NRJ Vos Hits 周榜",
197 | 20: "台湾Hito排行榜",
198 | 21: "Beatport全球电子舞曲榜",
199 | 22: "云音乐ACG音乐榜",
200 | 23: "江小白YOLO云音乐说唱榜",
201 | };
202 |
203 | //歌单一页限定歌曲数量
204 | export const ONE_PAGE_COUNT = 50;
205 |
206 | //顶部的高度
207 | export const HEADER_HEIGHT = 45;
208 |
209 | //播放模式
210 | export const playMode = {
211 | sequence: 0,
212 | loop: 1,
213 | random: 2,
214 | };
215 |
216 | // 倍速播放配置
217 | export const list = [
218 | {
219 | key: 0.75,
220 | name: "x0.75",
221 | },
222 | {
223 | key: 1,
224 | name: "x1",
225 | },
226 | {
227 | key: 1.25,
228 | name: "x1.25",
229 | },
230 | {
231 | key: 1.5,
232 | name: "x1.5",
233 | },
234 | {
235 | key: 2,
236 | name: "x2",
237 | },
238 | ];
239 |
--------------------------------------------------------------------------------
/src/app/HomeLayout.style.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import style from "../assets/global-style";
3 |
4 | export const Top = styled.div`
5 | display: flex;
6 | flex-direction: row;
7 | justify-content: space-between;
8 | padding: 5px 10px;
9 | background: ${style["theme-color"]};
10 | & > span {
11 | line-height: 40px;
12 | color: #f1f1f1;
13 | font-size: 20px;
14 | &.iconfont {
15 | font-size: 25px;
16 | }
17 | }
18 | `;
19 |
20 | export const Tab = styled.div`
21 | height: 44px;
22 | display: flex;
23 | flex-direction: row;
24 | justify-content: space-around;
25 | background: ${style["theme-color"]};
26 | a {
27 | flex: 1;
28 | padding: 2px 0;
29 | font-size: 14px;
30 | color: #e4e4e4;
31 | &.selected {
32 | span {
33 | padding: 3px 0;
34 | font-weight: 700;
35 | color: #f1f1f1;
36 | border-bottom: 2px solid #f1f1f1;
37 | }
38 | }
39 | }
40 | `;
41 |
42 | export const TabItem = styled.div`
43 | height: 100%;
44 | display: flex;
45 | flex-direction: row;
46 | justify-content: center;
47 | align-items: center;
48 | .selected {
49 | padding: 3px 0;
50 | font-weight: 700;
51 | color: #f1f1f1;
52 | border-bottom: 2px solid #f1f1f1;
53 | }
54 | `;
55 |
--------------------------------------------------------------------------------
/src/app/ReduxProvider.jsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 | import { Provider } from 'react-redux'
5 | import store from '../store'
6 |
7 | const ReduxProvider = ({ children }) => {
8 | return {children}
9 | }
10 |
11 | export default ReduxProvider
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mrcluo/react-nextjs/89286f12ec6bab5b731b7cb81dabff83d593550f/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/favorite/layout.jsx:
--------------------------------------------------------------------------------
1 | export default function FavoriteLayout({ children }) {
2 | return (
3 |
4 |
共享的头部
5 | {children}
6 |
7 | );
8 | }
9 |
--------------------------------------------------------------------------------
/src/app/favorite/page.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useSelector } from "react-redux";
3 | export default function Home() {
4 | return (
5 |
6 |
Favorite Page
7 |
{useSelector((state) => state.singers.contact)}
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --foreground-rgb: 0, 0, 0;
7 | --background-start-rgb: 214, 219, 220;
8 | --background-end-rgb: 255, 255, 255;
9 | }
10 |
11 | @media (prefers-color-scheme: dark) {
12 | :root {
13 | --foreground-rgb: 255, 255, 255;
14 | --background-start-rgb: 0, 0, 0;
15 | --background-end-rgb: 0, 0, 0;
16 | }
17 | }
18 |
19 | body {}
20 |
21 | @layer utilities {
22 | .text-balance {
23 | text-wrap: balance;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/app/home/components/header.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React from "react";
3 | import Link from "next/link";
4 | import { usePathname } from "next/navigation";
5 | import SvgIcon from "@/components/svg-icon";
6 | import { Top, Tab, TabItem } from "../../HomeLayout.style";
7 |
8 | function Header() {
9 | const pathName = usePathname();
10 | const pathCheck = (path) => {
11 | const reg = new RegExp(`/(${path})$`);
12 | return reg.test(pathName) ? "selected" : "";
13 | };
14 | return (
15 |
16 |
17 | alert("用户中心正在开发中,敬请期待:)")}
20 | >
21 |
22 |
23 | 云音悦
24 | alert("用户中心正在开发中,敬请期待:)")}
27 | >
28 |
29 |
30 |
31 |
32 |
33 |
34 | 推荐
35 |
36 |
37 |
38 |
39 | 歌手
40 |
41 |
42 |
43 |
44 | 排行榜
45 |
46 |
47 |
48 |
49 | );
50 | }
51 |
52 | export default React.memo(Header);
53 |
--------------------------------------------------------------------------------
/src/app/home/components/miniPlayer/index.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useRef, useCallback } from "react";
4 | import { CSSTransition } from "react-transition-group";
5 | import ProgressCircle from "@/components/progress-circle";
6 | import SvgIcon from "@/components/svg-icon";
7 | import { getName } from "@/utils";
8 | import { MiniPlayerContainer } from "./style";
9 | function MiniPlayer(props) {
10 | const { full, song, playing, percent } = props;
11 | const { clickPlaying, togglePlayList } = props;
12 |
13 | const miniPlayerRef = useRef();
14 | const miniWrapperRef = useRef();
15 | const miniImageRef = useRef();
16 |
17 | const handleTogglePlayList = useCallback(
18 | (e) => {
19 | togglePlayList(true);
20 | e.stopPropagation();
21 | },
22 | [togglePlayList]
23 | );
24 |
25 | return (
26 | {
31 | miniPlayerRef.current.style.display = "flex";
32 | }}
33 | onExited={() => {
34 | miniPlayerRef.current.style.display = "none";
35 | }}
36 | >
37 |
38 |
39 |
40 |

48 |
49 |
50 |
51 |
{song.name}
52 |
{getName(song.ar)}
53 |
54 |
55 |
56 | {playing ? (
57 | clickPlaying(e, false)}
60 | >
61 |
62 |
63 | ) : (
64 | clickPlaying(e, true)}
67 | >
68 |
69 |
70 | )}
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | );
79 | }
80 | export default React.memo(MiniPlayer);
81 |
--------------------------------------------------------------------------------
/src/app/home/components/miniPlayer/style.js:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from "styled-components";
2 | import style from "@/assets/global-style";
3 |
4 | const rotate = keyframes`
5 | 0%{
6 | transform: rotate(0);
7 | }
8 | 100%{
9 | transform: rotate(360deg);
10 | }
11 | `;
12 |
13 | export const MiniPlayerContainer = styled.div`
14 | display: flex;
15 | align-items: center;
16 | position: fixed;
17 | left: 0;
18 | bottom: 0;
19 | z-index: 1000;
20 | width: 100%;
21 | height: 60px;
22 | background: ${style["highlight-background-color"]};
23 | &.mini-enter {
24 | transform: translate3d(0, 100%, 0);
25 | }
26 | &.mini-enter-active {
27 | transform: translate3d(0, 0, 0);
28 | transition: all 0.4s;
29 | }
30 | &.mini-exit-active {
31 | transform: translate3d(0, 100%, 0);
32 | transition: all 0.4s;
33 | }
34 | .icon {
35 | flex: 0 0 40px;
36 | width: 40px;
37 | height: 40px;
38 | padding: 0 10px 0 20px;
39 | box-sizing: content-box;
40 | .imgWrapper {
41 | width: 100%;
42 | height: 100%;
43 | img {
44 | max-width: max-content;
45 | border-radius: 50%;
46 | &.play {
47 | animation: ${rotate} 10s infinite;
48 | &.pause {
49 | animation-play-state: paused;
50 | }
51 | }
52 | }
53 | }
54 | }
55 | .text {
56 | display: flex;
57 | flex-direction: column;
58 | justify-content: center;
59 | flex: 1;
60 | line-height: 20px;
61 | overflow: hidden;
62 | .name {
63 | margin-bottom: 2px;
64 | font-size: ${style["font-size-m"]};
65 | color: ${style["font-color-desc"]};
66 | ${style.noWrap()}
67 | }
68 | .desc {
69 | font-size: ${style["font-size-s"]};
70 | color: ${style["font-color-desc-v2"]};
71 | ${style.noWrap()}
72 | }
73 | }
74 | .control {
75 | flex: 0 0 30px;
76 | padding: 0 10px;
77 | .iconfont,
78 | .icon-playlist {
79 | font-size: 30px;
80 | color: ${style["theme-color"]};
81 | }
82 | .icon-mini {
83 | font-size: 16px;
84 | position: absolute;
85 | left: 8px;
86 | top: 8px;
87 | &.icon-play {
88 | left: 9px;
89 | }
90 | }
91 | }
92 | `;
93 |
--------------------------------------------------------------------------------
/src/app/home/components/normalPlayer/index.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | export default function NormalPlayer() {
4 | return 213
;
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/home/components/playList/index.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useRef, useState, useCallback } from "react";
4 | import { CSSTransition } from "react-transition-group";
5 | import { useSelector, useDispatch } from "react-redux";
6 | import Scroll from "@/components/scroll";
7 | import Confirm from "@/components/confirm";
8 | import SvgIcon from "@/components/svg-icon";
9 | import { shuffle, findIndex, getName } from "@/utils";
10 | import { playMode } from "@/api/config";
11 | import {
12 | changeShowPlayList,
13 | changePlayList,
14 | changeCurrentIndex,
15 | changePlayMode,
16 | } from "@/store/slices/songs";
17 | import {
18 | PlayListWrapper,
19 | ListHeader,
20 | ListContent,
21 | ScrollWrapper,
22 | } from "./style";
23 | function PlayList() {
24 | const dispatch = useDispatch();
25 | const _states = useSelector((state) => state.songs);
26 | let {
27 | mode,
28 | sequencePlayList = [],
29 | currentIndex,
30 | playList = [],
31 | currentSong = {},
32 | showPlayList,
33 | } = _states;
34 |
35 | const listContentRef = useRef();
36 | const listWrapperRef = useRef();
37 | const playListRef = useRef();
38 | const confirmRef = useRef();
39 |
40 | const [isShow, setIsShow] = useState(false);
41 |
42 | const onEnterCB = useCallback(() => {
43 | setIsShow(true);
44 | listWrapperRef.current.style["transform"] = `translate3d(0, 100%, 0)`;
45 | }, []);
46 |
47 | const onEnteringCB = useCallback(() => {
48 | listWrapperRef.current.style["transition"] = "all 0.3s";
49 | listWrapperRef.current.style["transform"] = `translate3d(0, 0, 0)`;
50 | }, []);
51 |
52 | const onExitCB = useCallback(() => {
53 | listWrapperRef.current.style["transform"] = `translate3d(0, 0px, 0)`;
54 | }, []);
55 |
56 | const onExitingCB = useCallback(() => {
57 | listWrapperRef.current.style["transition"] = "all 0.3s";
58 | listWrapperRef.current.style["transform"] = `translate3d(0px, 100%, 0px)`;
59 | }, []);
60 |
61 | const onExitedCB = useCallback(() => {
62 | setIsShow(false);
63 | listWrapperRef.current.style["transform"] = `translate3d(0px, 100%, 0px)`;
64 | }, []);
65 |
66 | const changeMode = (e) => {
67 | let newMode = (mode + 1) % 3;
68 | if (newMode === 0) {
69 | dispatch(changePlayList(sequencePlayList));
70 | let index = findIndex(currentSong, sequencePlayList);
71 | dispatch(changeCurrentIndex(index));
72 | } else if (newMode === 1) {
73 | dispatch(changePlayList(sequencePlayList));
74 | } else if (newMode === 2) {
75 | let newList = shuffle(sequencePlayList);
76 | let index = findIndex(currentSong, newList);
77 | dispatch(changePlayList(newList));
78 | dispatch(changeCurrentIndex(index));
79 | }
80 | dispatch(changePlayMode(newMode));
81 | };
82 |
83 | const getPlayMode = () => {
84 | let content, text;
85 | if (mode === playMode.sequence) {
86 | content = "icon-inturn";
87 | text = "顺序播放";
88 | } else if (mode === playMode.loop) {
89 | content = "icon-danquxunhuan";
90 | text = "单曲循环";
91 | } else {
92 | content = "icon-suijibofang";
93 | text = "随机播放";
94 | }
95 | return (
96 | changeMode(e)}>
97 |
98 | {text}
99 |
100 | );
101 | };
102 |
103 | const togglePlayListDispatch = (status) => {
104 | dispatch(changeShowPlayList(status));
105 | };
106 |
107 | const handleShowClear = () => {
108 | confirmRef.current.show();
109 | };
110 |
111 | const handleConfirmClear = () => {
112 | // clearDispatch();
113 | // // 修复清空播放列表后点击同样的歌曲,播放器不出现的bug
114 | // clearPreSong();
115 | };
116 |
117 | const getFavoriteIcon = (item) => {
118 | return ;
119 | };
120 |
121 | const getCurrentIcon = (item) => {
122 | const current = currentSong.id === item.id;
123 | return current ? (
124 |
125 | ) : (
126 |
127 | );
128 | };
129 |
130 | const handleScroll = (pos) => {};
131 |
132 | const handleChangeCurrentIndex = (index) => {
133 | if (currentIndex === index) return;
134 | dispatch(changeCurrentIndex(index));
135 | };
136 |
137 | const handleDeleteSong = (e, song) => {
138 | e.stopPropagation();
139 | // deleteSongDispatch(song);
140 | };
141 |
142 | return (
143 |
153 | togglePlayListDispatch(false)}
157 | >
158 | e.stopPropagation()}
162 | >
163 |
164 |
165 | {getPlayMode()}
166 | {/*
167 |
168 | */}
169 |
170 |
171 |
172 | handleScroll(pos)}
177 | bounceTop={false}
178 | >
179 |
180 | {playList.map((item, index) => {
181 | return (
182 | handleChangeCurrentIndex(index)}
186 | >
187 | {getCurrentIcon(item)}
188 |
189 | {item.name} - {getName(item.ar)}
190 |
191 | {/* {getFavoriteIcon(item)} */}
192 | {/* handleDeleteSong(e, item)}
195 | >
196 |
197 | */}
198 |
199 | );
200 | })}
201 |
202 |
203 |
204 |
205 |
212 |
213 |
214 | );
215 | }
216 | export default React.memo(PlayList);
217 |
--------------------------------------------------------------------------------
/src/app/home/components/playList/style.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import style from "@/assets/global-style";
3 |
4 | export const PlayListWrapper = styled.div`
5 | position: fixed;
6 | left: 0;
7 | right: 0;
8 | top: 0;
9 | bottom: 0;
10 | z-index: 1000;
11 | background-color: ${style["background-color-shadow"]};
12 | &.list-fade-enter {
13 | opacity: 0;
14 | }
15 | &.list-fade-enter-active {
16 | opacity: 1;
17 | transition: all 0.3s;
18 | }
19 | &.list-fade-exit {
20 | opacity: 1;
21 | }
22 | &.list-fade-exit-active {
23 | opacity: 0;
24 | transition: all 0.3s;
25 | }
26 | .list_wrapper {
27 | position: absolute;
28 | left: 0;
29 | bottom: 0;
30 | width: 100%;
31 | opacity: 1;
32 | border-radius: 10px 10px 0 0;
33 | background-color: ${style["highlight-background-color"]};
34 | transform: translate3d(0, 0, 0);
35 | .list_close {
36 | text-align: center;
37 | line-height: 50px;
38 | background: ${style["background-color"]};
39 | font-size: ${style["font-size-l"]};
40 | color: ${style["font-color-desc"]};
41 | }
42 | }
43 | `;
44 |
45 | export const ListHeader = styled.div`
46 | position: relative;
47 | padding: 20px 30px 10px 20px;
48 | .title {
49 | display: flex;
50 | align-items: center;
51 | > div {
52 | flex: 1;
53 | .text {
54 | flex: 1;
55 | font-size: ${style["font-size-m"]};
56 | color: ${style["font-color-desc"]};
57 | }
58 | }
59 | .iconfont {
60 | margin-right: 10px;
61 | font-size: ${style["font-size-ll"]};
62 | color: ${style["theme-color"]};
63 | }
64 |
65 | .clear {
66 | ${style.extendClick()}
67 | font-size: ${style["font-size-l"]};
68 | }
69 | .change-mode {
70 | display: flex;
71 | }
72 | }
73 | `;
74 | export const ScrollWrapper = styled.div`
75 | height: 400px;
76 | overflow: hidden;
77 | `;
78 |
79 | export const ListContent = styled.div`
80 | .item {
81 | display: flex;
82 | align-items: center;
83 | height: 40px;
84 | padding: 0 30px 0 20px;
85 | overflow: hidden;
86 | &.list-enter,
87 | &.list-exit-done {
88 | height: 0;
89 | }
90 | &.list-enter-active,
91 | &.list-leave-active {
92 | transition: all 0.1s;
93 | }
94 | .current {
95 | flex: 0 0 20px;
96 | width: 20px;
97 | font-size: ${style["font-size-s"]};
98 | color: ${style["theme-color"]};
99 | }
100 | .text {
101 | flex: 1;
102 | ${style.noWrap()}
103 | font-size: ${style["font-size-m"]};
104 | color: ${style["font-color-desc-v2"]};
105 | .icon-favorite {
106 | color: ${style["theme-color"]};
107 | }
108 | }
109 | .like {
110 | ${style.extendClick()}
111 | margin-right: 15px;
112 | font-size: ${style["font-size-m"]};
113 | color: ${style["theme-color"]};
114 | }
115 | .delete {
116 | ${style.extendClick()}
117 | font-size: ${style["font-size-s"]};
118 | color: ${style["theme-color"]};
119 | }
120 | }
121 | `;
122 |
--------------------------------------------------------------------------------
/src/app/home/components/player/index.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useRef, useState, useEffect } from "react";
4 | import { useSelector, useDispatch } from "react-redux";
5 | import { isEmptyObject, getSongUrl } from "@/utils";
6 | import Toast from "@/components/toast";
7 | import {
8 | changeCurrentSong,
9 | changePlaying,
10 | changeShowPlayList,
11 | } from "@/store/slices/songs";
12 | import MiniPlayer from "../miniPlayer";
13 | import PlayList from "../playList";
14 |
15 | function Player() {
16 | const dispatch = useDispatch();
17 | const _states = useSelector((state) => state.songs);
18 | let {
19 | speed,
20 | playing,
21 | currentIndex,
22 | playList = [],
23 | currentSong = {},
24 | } = _states;
25 |
26 | const audioRef = useRef();
27 | const toastRef = useRef();
28 |
29 | const [currentTime, setCurrentTime] = useState(0);
30 | const [duration, setDuration] = useState(0);
31 | const [preSong, setPreSong] = useState({});
32 | const [modeText, setModeText] = useState("");
33 |
34 | let percent = isNaN(currentTime / duration) ? 0 : currentTime / duration;
35 |
36 | useEffect(() => {
37 | if (
38 | !playList.length ||
39 | currentIndex === -1 ||
40 | !playList[currentIndex] ||
41 | playList[currentIndex].id === preSong.id
42 | )
43 | return;
44 | let current = playList[currentIndex];
45 | dispatch(changeCurrentSong(current));
46 | setPreSong(current); // 保存当前歌曲
47 | audioRef.current.src = getSongUrl(current.id);
48 | audioRef.current.autoplay = true;
49 | audioRef.current.playbackRate = speed;
50 | dispatch(changePlaying(true));
51 | setCurrentTime(0);
52 | setDuration((current.dt / 1000) | 0); // 设置歌曲时长
53 | }, [currentIndex, playList]);
54 |
55 | useEffect(() => {
56 | playing ? audioRef.current.play() : audioRef.current.pause();
57 | }, [playing]);
58 | const clickPlaying = (e, state) => {
59 | e.stopPropagation();
60 | dispatch(changePlaying(state));
61 | };
62 |
63 | const togglePlayListDispatch = (data) => {
64 | dispatch(changeShowPlayList(data));
65 | };
66 |
67 | const updateTime = (e) => {
68 | setCurrentTime(e.target.currentTime);
69 | };
70 |
71 | const handleEnd = () => {};
72 | const handleError = () => {};
73 |
74 | return (
75 |
76 | {isEmptyObject(currentSong) ? null : (
77 |
84 | )}
85 |
86 |
87 |
93 |
94 |
95 | );
96 | }
97 | export default React.memo(Player);
98 |
--------------------------------------------------------------------------------
/src/app/home/components/recommendList/index.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 | import LazyLoad from "react-lazyload";
5 | import Link from "next/link";
6 | import SvgIcon from "@/components/svg-icon";
7 | import musicImg from "@/assets/music.png";
8 | import { ListWrapper, ListItem, List } from "./style";
9 |
10 | function RecommendList(props) {
11 | return (
12 |
13 | 推荐歌单
14 |
15 | {props.recommendList.map((item) => {
16 | return (
17 |
18 |
19 |
20 |
21 |
29 | }
30 | >
31 |

37 |
38 |
39 |
40 |
41 | {Math.floor(item.playCount / 10000)}万
42 |
43 |
44 |
45 | {item.name}
46 |
47 |
48 | );
49 | })}
50 |
51 |
52 | );
53 | }
54 |
55 | export default React.memo(RecommendList);
56 |
--------------------------------------------------------------------------------
/src/app/home/components/recommendList/style.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import style from "@/assets/global-style";
3 |
4 | export const ListWrapper = styled.div`
5 | max-width: 100%;
6 | .title {
7 | font-weight: 700;
8 | padding-left: 6px;
9 | font-size: 14px;
10 | line-height: 60px;
11 | color: ${style["font-color"]};
12 | }
13 | `;
14 | export const List = styled.div`
15 | width: 100%;
16 | display: flex;
17 | flex-direction: row;
18 | flex-wrap: wrap;
19 | justify-content: space-around;
20 | &:after {
21 | content: "";
22 | flex: auto;
23 | }
24 | `;
25 |
26 | export const ListItem = styled.div`
27 | position: relative;
28 | width: 32%;
29 | .decorate {
30 | z-index: 1;
31 | position: absolute;
32 | top: 0;
33 | width: 100%;
34 | height: 35px;
35 | border-radius: 3px;
36 | background: linear-gradient(hsla(0, 0%, 43%, 0.4), hsla(0, 0%, 100%, 0));
37 | }
38 | .img_wrapper {
39 | position: relative;
40 | height: 0;
41 | padding-bottom: 100%;
42 | .play_count {
43 | z-index: 1;
44 | display: flex;
45 | position: absolute;
46 | right: 2px;
47 | top: 2px;
48 | font-size: ${style["font-size-s"]};
49 | line-height: 15px;
50 | color: ${style["font-color-light"]};
51 | .play {
52 | vertical-align: top;
53 | }
54 | }
55 | img {
56 | position: absolute;
57 | width: 100%;
58 | height: 100%;
59 | border-radius: 3px;
60 | }
61 | }
62 | .desc {
63 | overflow: hidden;
64 | margin-top: 2px;
65 | padding: 0 2px;
66 | height: 50px;
67 | text-align: left;
68 | font-size: ${style["font-size-s"]};
69 | line-height: 1.4;
70 | color: ${style["font-color-desc"]};
71 | }
72 | `;
73 |
--------------------------------------------------------------------------------
/src/app/home/layout.jsx:
--------------------------------------------------------------------------------
1 | import Header from "./components/header";
2 | import Player from "./components/player";
3 |
4 | export const metadata = {
5 | title: "Create Next App",
6 | description: "Generated by create next app",
7 | };
8 |
9 | export default function HomeLayout({ children }) {
10 | return (
11 |
12 |
13 | {children}
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/home/page.jsx:
--------------------------------------------------------------------------------
1 | import { API_BASE_URL } from "@/api/config";
2 | import RecommendMain from "./recommend/main";
3 | import { Content } from "./style";
4 | export default async function Recommend(props) {
5 | const { songsCount = 0 } = props;
6 | const getData = async () => {
7 | const res = await fetch(`${API_BASE_URL}/banner`);
8 | if (!res.ok) {
9 | throw new Error("Failed to fetch data");
10 | }
11 | return res.json();
12 | };
13 | const getrecommendData = async () => {
14 | const res = await fetch(`${API_BASE_URL}/personalized`);
15 | if (!res.ok) {
16 | throw new Error("Failed to fetch data");
17 | }
18 | return res.json();
19 | };
20 | const bannerList = await getData();
21 | const recommendList = await getrecommendData();
22 | return (
23 |
24 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/home/rank/[id]/page.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React, { useEffect, useState, useCallback, useRef } from "react";
3 | import { useParams, useRouter } from "next/navigation";
4 | import { CSSTransition } from "react-transition-group";
5 | import Loading from "@/components/loading";
6 | import Scroll from "@/components/scroll";
7 | import SvgIcon from "@/components/svg-icon";
8 | import useGetAlbums from "@/hooks/useGetAlbums";
9 | import Header from "@/components/header";
10 | import style from "@/assets/global-style";
11 | import SongsList from "../../singer/components/songList";
12 | import { EnterLoading } from "../../singer/style";
13 | import { Container, SongListWrapper, TopDesc } from "./style";
14 |
15 | function Rank() {
16 | const route = useParams();
17 | const router = useRouter();
18 | const [showStatus, setShowStatus] = useState(true);
19 | const { data = {}, isLoading } = useGetAlbums(route?.id);
20 | const ImgWrapperRef = useRef();
21 | const SongScrollWrapperRef = useRef();
22 | const ScrollRef = useRef();
23 | const HeaderRef = useRef();
24 | const InitialHeight = useRef(0);
25 |
26 | const OFFSET = 5;
27 | const HEADER_HEIGHT = 45;
28 |
29 | useEffect(() => {
30 | let h = ImgWrapperRef.current.offsetHeight;
31 | InitialHeight.current = h;
32 | SongScrollWrapperRef.current.style.top = `${h - OFFSET}px`;
33 | ScrollRef.current.refresh();
34 | }, []);
35 |
36 | const setShowStatusFalse = useCallback(() => {
37 | setShowStatus(false);
38 | }, []);
39 |
40 | const handleScroll = (pos) => {
41 | let height = InitialHeight.current;
42 | const newY = pos.y;
43 | const headerDOM = HeaderRef.current;
44 | const minScrollY = -(height - OFFSET) + HEADER_HEIGHT;
45 | const percent = Math.abs(pos.y / minScrollY);
46 | if (newY < minScrollY) {
47 | //防止溢出的歌单内容遮住Header
48 | headerDOM.style.zIndex = 100;
49 | headerDOM.style.backgroundColor = style["theme-color"];
50 | headerDOM.style.opacity = Math.min(1, percent - 1);
51 | } else {
52 | headerDOM.style.backgroundColor = "";
53 | headerDOM.style.opacity = 1;
54 | }
55 | };
56 |
57 | const renderTopDesc = () => {
58 | return (
59 |
60 |
63 |
64 |
65 |

66 |
67 |
68 |
69 | {Math.floor(data?.playlist?.subscribedCount / 1000) / 10}万
70 |
71 |
72 |
73 |
74 |
{data?.playlist?.name}
75 |
76 |
77 |

78 |
79 |
{data?.playlist?.creator.nickname}
80 |
81 |
82 |
83 | );
84 | };
85 |
86 | return (
87 | router.back()}
94 | >
95 |
96 |
97 | {renderTopDesc()}
98 |
102 |
108 |
109 |
110 |
111 | {isLoading ? (
112 |
113 |
114 |
115 | ) : null}
116 |
117 |
118 | );
119 | }
120 |
121 | export default Rank;
122 |
--------------------------------------------------------------------------------
/src/app/home/rank/[id]/style.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import style from "@/assets/global-style";
3 |
4 | export const Container = styled.div`
5 | position: fixed;
6 | top: 0;
7 | left: 0;
8 | right: 0;
9 | bottom: ${(props) => (props.play > 0 ? "60px" : 0)};
10 | width: 100%;
11 | z-index: 100;
12 | overflow: hidden;
13 | background: #f2f3f4;
14 | transform-origin: right bottom;
15 | &.fly-enter,
16 | &.fly-appear {
17 | transform: rotateZ(30deg) translate3d(100%, 0, 0);
18 | }
19 | &.fly-enter-active,
20 | &.fly-appear-active {
21 | transition: transform 0.3s;
22 | transform: rotateZ(0deg) translate3d(0, 0, 0);
23 | }
24 | &.fly-exit {
25 | transform: rotateZ(0deg) translate3d(0, 0, 0);
26 | }
27 | &.fly-exit-active {
28 | transition: transform 0.3s;
29 | transform: rotateZ(30deg) translate3d(100%, 0, 0);
30 | }
31 | `;
32 |
33 | export const ImgWrapper = styled.div`
34 | position: relative;
35 | width: 100%;
36 | height: 0;
37 | padding-top: 75%;
38 | transform-origin: top;
39 | background: url(${(props) => props?.bgurl});
40 | background-size: cover;
41 | z-index: 50;
42 | .filter {
43 | position: absolute;
44 | top: 0;
45 | left: 0;
46 | width: 100%;
47 | height: 100%;
48 | /* : blur(20px); */
49 | background: rgba(7, 17, 27, 0.3);
50 | }
51 | `;
52 | export const CollectButton = styled.div`
53 | position: fixed;
54 | left: 0;
55 | right: 0;
56 | margin: auto;
57 | box-sizing: border-box;
58 | width: 120px;
59 | height: 40px;
60 | margin-top: -55px;
61 | z-index: 50;
62 | background: ${style["theme-color"]};
63 | color: ${style["font-color-light"]};
64 | border-radius: 20px;
65 | text-align: center;
66 | font-size: 0;
67 | line-height: 40px;
68 | .iconfont {
69 | display: inline-block;
70 | margin-right: 10px;
71 | font-size: 12px;
72 | vertical-align: 1px;
73 | }
74 | .text {
75 | display: inline-block;
76 | font-size: 14px;
77 | letter-spacing: 5px;
78 | }
79 | `;
80 |
81 | export const SongListWrapper = styled.div`
82 | position: absolute;
83 | z-index: 50;
84 | top: 0;
85 | left: 0;
86 | bottom: ${(props) => (props.play ? "60px" : 0)};
87 | right: 0;
88 | > div {
89 | position: absolute;
90 | left: 0;
91 | width: 100%;
92 | overflow: visible;
93 | }
94 | `;
95 | export const BgLayer = styled.div`
96 | position: absolute;
97 | top: 0;
98 | bottom: 0;
99 | width: 100%;
100 | background: white;
101 | border-radius: 10px;
102 | z-index: 50;
103 | `;
104 |
105 | export const TopDesc = styled.div`
106 | background-size: 100%;
107 | padding: 5px 20px;
108 | display: flex;
109 | justify-content: space-around;
110 | align-items: center;
111 | box-sizing: border-box;
112 | width: 100%;
113 | height: 225px;
114 | position: relative;
115 | .background {
116 | z-index: -1;
117 | background: url(${(props) => props.background}) left top no-repeat;
118 | background: contain;
119 | background-position: 0 0;
120 | background-size: 100% 100%;
121 | position: absolute;
122 | width: 100%;
123 | height: 100%;
124 | filter: blur(20px);
125 | .filter {
126 | position: absolute;
127 | z-index: 10;
128 | top: 0;
129 | left: 0;
130 | width: 100%;
131 | height: 100%;
132 | background: rgba(7, 17, 27, 0.2);
133 | }
134 | }
135 | .img_wrapper {
136 | width: 120px;
137 | height: 120px;
138 | position: relative;
139 | .decorate {
140 | position: absolute;
141 | top: 0;
142 | width: 100%;
143 | height: 35px;
144 | border-radius: 3px;
145 | background: linear-gradient(hsla(0, 0%, 43%, 0.4), hsla(0, 0%, 100%, 0));
146 | }
147 | .play_count {
148 | position: absolute;
149 | right: 2px;
150 | top: 2px;
151 | font-size: ${style["font-size-s"]};
152 | line-height: 15px;
153 | color: ${style["font-color-light"]};
154 | display: flex;
155 | padding-right: 10px;
156 | padding-top: 10px;
157 | .svg-class {
158 | margin-right: 4px;
159 | }
160 | .play {
161 | vertical-align: top;
162 | }
163 | }
164 | img {
165 | width: 120px;
166 | height: 120px;
167 | border-radius: 3px;
168 | }
169 | }
170 | .desc_wrapper {
171 | flex: 1;
172 | display: flex;
173 | flex-direction: column;
174 | justify-content: space-around;
175 | height: 120px;
176 | padding: 0 10px;
177 | .title {
178 | max-height: 70px;
179 | overflow: hidden;
180 | text-overflow: ellipsis;
181 | color: ${style["font-color-light"]};
182 | font-weight: 700;
183 | line-height: 1.5;
184 | font-size: ${style["font-size-l"]};
185 | }
186 | .person {
187 | display: flex;
188 | .avatar {
189 | width: 20px;
190 | height: 20px;
191 | margin-right: 5px;
192 | img {
193 | width: 100%;
194 | height: 100%;
195 | border-radius: 50%;
196 | }
197 | }
198 | .name {
199 | line-height: 20px;
200 | font-size: ${style["font-size-m"]};
201 | color: ${style["font-color-desc-v2"]};
202 | }
203 | }
204 | }
205 | `;
206 |
--------------------------------------------------------------------------------
/src/app/home/rank/components/main.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 | import Link from "next/link";
5 | import Scroll from "@/components/scroll";
6 | import Loading from "@/components/loading";
7 | import { filterIndex } from "@/utils";
8 | import { EnterLoading } from "../../singer/style";
9 | import { List, ListItem, SongList, Container } from "../style";
10 | function Rank(props) {
11 | const { rankList, loading = true } = props;
12 |
13 | const renderSongList = (list) => {
14 | return list.length ? (
15 |
16 | {list.map((item, index) => {
17 | return (
18 |
19 | {index + 1}. {item.first} - {item.second}
20 |
21 | );
22 | })}
23 |
24 | ) : null;
25 | };
26 |
27 | const renderRankList = (list, global) => {
28 | return (
29 |
30 | {list.map((item, index) => {
31 | return (
32 |
36 |
37 |
38 |

39 |
40 |
41 | {item.updateFrequency}
42 |
43 |
44 | {renderSongList(item.tracks)}
45 |
46 |
47 | );
48 | })}
49 |
50 | );
51 | };
52 |
53 | let globalStartIndex = filterIndex(rankList);
54 | let officialList = rankList.slice(0, globalStartIndex);
55 | let globalList = rankList.slice(globalStartIndex);
56 | return (
57 |
58 |
59 |
60 |
官方榜
61 | {renderRankList(officialList)}
62 | 全球榜
63 | {renderRankList(globalList, true)}
64 | {loading ? (
65 |
66 |
67 |
68 | ) : null}
69 |
70 |
71 |
72 | );
73 | }
74 |
75 | export default React.memo(Rank);
76 |
--------------------------------------------------------------------------------
/src/app/home/rank/page.jsx:
--------------------------------------------------------------------------------
1 | import { API_BASE_URL } from "@/api/config";
2 | import Main from "./components/main";
3 |
4 | export default async function Rank() {
5 | const getData = async () => {
6 | const res = await fetch(`${API_BASE_URL}/toplist/detail`);
7 | if (!res.ok) {
8 | throw new Error("Failed to fetch data");
9 | }
10 | return res.json();
11 | };
12 | const data = await getData();
13 | return ;
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/home/rank/style.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import style from "@/assets/global-style";
3 |
4 | // Props中的globalRank和tracks.length均代表是否为全球榜
5 |
6 | export const Container = styled.div`
7 | position: fixed;
8 | top: 90px;
9 | bottom: ${(props) => (props.play > 0 ? "60px" : 0)};
10 | width: 100%;
11 | .offical,
12 | .global {
13 | margin: 10px 5px;
14 | padding-top: 15px;
15 | font-weight: 700;
16 | font-size: ${style["font-size-m"]};
17 | color: ${style["font-color-desc"]};
18 | }
19 | `;
20 |
21 | export const List = styled.ul`
22 | margin-top: 10px;
23 | padding: 0 5px;
24 | display: ${(props) => (props.globalrank ? "flex" : "")};
25 | flex-direction: row;
26 | justify-content: space-between;
27 | flex-wrap: wrap;
28 | &::after {
29 | content: "";
30 | display: block;
31 | width: 32vw;
32 | }
33 | `;
34 | export const ListItem = styled.li`
35 | display: ${(props) => (props.tracks.length ? "flex" : "")};
36 | padding: 3px 0;
37 | border-bottom: 1px solid ${style["border-color"]};
38 | .img_wrapper {
39 | width: ${(props) => (props.tracks.length ? "27vw" : "32vw")};
40 | height: ${(props) => (props.tracks.length ? "27vw" : "32vw")};
41 | border-radius: 3px;
42 | position: relative;
43 | .decorate {
44 | position: absolute;
45 | bottom: 0;
46 | width: 100%;
47 | height: 35px;
48 | border-radius: 3px;
49 | background: linear-gradient(hsla(0, 0%, 100%, 0), hsla(0, 0%, 43%, 0.4));
50 | }
51 | img {
52 | width: 100%;
53 | height: 100%;
54 | border-radius: 3px;
55 | }
56 | .update_frequecy {
57 | position: absolute;
58 | left: 7px;
59 | bottom: 7px;
60 | font-size: ${style["font-size-ss"]};
61 | color: ${style["font-color-light"]};
62 | }
63 | }
64 | `;
65 | export const SongList = styled.ul`
66 | flex: 1;
67 | display: flex;
68 | flex-direction: column;
69 | justify-content: space-around;
70 | padding: 10px 10px;
71 | > li {
72 | font-size: ${style["font-size-s"]};
73 | color: grey;
74 | }
75 | `;
76 |
--------------------------------------------------------------------------------
/src/app/home/recommend/main.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React, { useEffect, useState } from "react";
3 | import { forceCheck } from "react-lazyload";
4 | import Slider from "@/components/slider";
5 | import Scroll from "@/components/scroll";
6 | import Loading from "@/components/loading";
7 | import { EnterLoading } from "../singer/style";
8 | import RecommendList from "../components/recommendList";
9 | export default function Main(props) {
10 | const { bannerList, recommendList } = props;
11 | const [enterLoading, setEnterLoading] = useState(true);
12 |
13 | useEffect(() => {
14 | setEnterLoading(false);
15 | }, recommendList);
16 | const pullUp = () => {
17 | console.log("到底部了");
18 | };
19 | const pullDown = () => {
20 | console.log("到顶部了");
21 | };
22 |
23 | return (
24 | <>
25 |
32 |
33 |
34 |
35 |
36 |
37 | {enterLoading ? (
38 |
39 |
40 |
41 | ) : null}
42 | >
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/app/home/singer/[id]/page.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React, { useEffect, useState, useCallback, useRef } from "react";
3 | import { useParams, useRouter } from "next/navigation";
4 | import { CSSTransition } from "react-transition-group";
5 | import Loading from "@/components/loading";
6 | import Scroll from "@/components/scroll";
7 | import useGetSingerSongs from "@/hooks/useGetSingerSongs";
8 | import Header from "@/components/header";
9 | import SongsList from "../components/songList";
10 | import { Container, CollectButton, SongListWrapper, ImgWrapper } from "./style";
11 | import { EnterLoading } from "../style";
12 |
13 | function Singer() {
14 | const route = useParams();
15 | const router = useRouter();
16 | const [showStatus, setShowStatus] = useState(true);
17 | const { data = {}, isLoading } = useGetSingerSongs(route?.id);
18 | const ImgWrapperRef = useRef();
19 | const SongScrollWrapperRef = useRef();
20 | const ScrollRef = useRef();
21 | const HeaderRef = useRef();
22 | const InitialHeight = useRef(0);
23 |
24 | const OFFSET = 5;
25 | const HEADER_HEIGHT = 45;
26 |
27 | useEffect(() => {
28 | let h = ImgWrapperRef.current.offsetHeight;
29 | InitialHeight.current = h;
30 | SongScrollWrapperRef.current.style.top = `${h - OFFSET}px`;
31 | ScrollRef.current.refresh();
32 | }, []);
33 |
34 | const setShowStatusFalse = useCallback(() => {
35 | setShowStatus(false);
36 | }, []);
37 |
38 | const handleScroll = (pos) => {
39 | let height = InitialHeight.current;
40 | const newY = pos.y;
41 | const imageDOM = ImgWrapperRef.current;
42 | const headerDOM = HeaderRef.current;
43 | const minScrollY = -(height - OFFSET) + HEADER_HEIGHT;
44 |
45 | const percent = Math.abs(newY / height);
46 | //说明: 在歌手页的布局中,歌单列表其实是没有自己的背景的,layerDOM其实是起一个遮罩的作用,给歌单内容提供白色背景
47 | //因此在处理的过程中,随着内容的滚动,遮罩也跟着移动
48 | if (newY > 0) {
49 | //处理往下拉的情况,效果:图片放大,按钮跟着偏移
50 | imageDOM.style["transform"] = `scale(${1 + percent})`;
51 | } else if (newY >= minScrollY) {
52 | //往上滑动,但是还没超过Header部分
53 | imageDOM.style.paddingTop = "75%";
54 | imageDOM.style.height = 0;
55 | imageDOM.style.zIndex = -1;
56 | } else if (newY < minScrollY) {
57 | //防止溢出的歌单内容遮住Header
58 | headerDOM.style.zIndex = 100;
59 | //此时图片高度与Header一致
60 | imageDOM.style.height = `${HEADER_HEIGHT}px`;
61 | imageDOM.style.paddingTop = 0;
62 | imageDOM.style.zIndex = 99;
63 | }
64 | };
65 |
66 | return (
67 | router.back()}
74 | >
75 |
76 |
81 |
82 |
83 |
84 |
88 |
94 |
95 |
96 |
97 | {isLoading ? (
98 |
99 |
100 |
101 | ) : null}
102 |
103 |
104 | );
105 | }
106 |
107 | export default Singer;
108 |
--------------------------------------------------------------------------------
/src/app/home/singer/[id]/style.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import style from "@/assets/global-style";
3 |
4 | export const Container = styled.div`
5 | position: fixed;
6 | top: 0;
7 | left: 0;
8 | right: 0;
9 | bottom: ${(props) => (props.play > 0 ? "60px" : 0)};
10 | width: 100%;
11 | z-index: 100;
12 | overflow: hidden;
13 | background: #f2f3f4;
14 | transform-origin: right bottom;
15 | &.fly-enter,
16 | &.fly-appear {
17 | transform: rotateZ(30deg) translate3d(100%, 0, 0);
18 | }
19 | &.fly-enter-active,
20 | &.fly-appear-active {
21 | transition: transform 0.3s;
22 | transform: rotateZ(0deg) translate3d(0, 0, 0);
23 | }
24 | &.fly-exit {
25 | transform: rotateZ(0deg) translate3d(0, 0, 0);
26 | }
27 | &.fly-exit-active {
28 | transition: transform 0.3s;
29 | transform: rotateZ(30deg) translate3d(100%, 0, 0);
30 | }
31 | `;
32 |
33 | export const ImgWrapper = styled.div`
34 | position: relative;
35 | width: 100%;
36 | height: 0;
37 | padding-top: 75%;
38 | transform-origin: top;
39 | background: url(${(props) => props?.bgurl});
40 | background-size: cover;
41 | z-index: 50;
42 | .filter {
43 | position: absolute;
44 | top: 0;
45 | left: 0;
46 | width: 100%;
47 | height: 100%;
48 | /* : blur(20px); */
49 | background: rgba(7, 17, 27, 0.3);
50 | }
51 | `;
52 | export const CollectButton = styled.div`
53 | position: fixed;
54 | left: 0;
55 | right: 0;
56 | margin: auto;
57 | box-sizing: border-box;
58 | width: 120px;
59 | height: 40px;
60 | margin-top: -55px;
61 | z-index: 50;
62 | background: ${style["theme-color"]};
63 | color: ${style["font-color-light"]};
64 | border-radius: 20px;
65 | text-align: center;
66 | font-size: 0;
67 | line-height: 40px;
68 | .iconfont {
69 | display: inline-block;
70 | margin-right: 10px;
71 | font-size: 12px;
72 | vertical-align: 1px;
73 | }
74 | .text {
75 | display: inline-block;
76 | font-size: 14px;
77 | letter-spacing: 5px;
78 | }
79 | `;
80 |
81 | export const SongListWrapper = styled.div`
82 | position: absolute;
83 | z-index: 50;
84 | top: 0;
85 | left: 0;
86 | bottom: ${(props) => (props.play ? "60px" : 0)};
87 | right: 0;
88 | > div {
89 | position: absolute;
90 | left: 0;
91 | width: 100%;
92 | overflow: visible;
93 | }
94 | `;
95 | export const BgLayer = styled.div`
96 | position: absolute;
97 | top: 0;
98 | bottom: 0;
99 | width: 100%;
100 | background: white;
101 | border-radius: 10px;
102 | z-index: 50;
103 | `;
104 |
--------------------------------------------------------------------------------
/src/app/home/singer/components/horizen-item.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef, useEffect, memo } from "react";
2 | import styled from "styled-components";
3 | import Scroll from "@/components/scroll";
4 | import style from "@/assets/global-style";
5 |
6 | //样式部分
7 | const List = styled.div`
8 | display: flex;
9 | align-items: center;
10 | height: 30px;
11 | justify-content: center;
12 | overflow: hidden;
13 | > span:first-of-type {
14 | display: block;
15 | flex: 0 0 auto;
16 | padding: 5px 0;
17 | color: grey;
18 | font-size: ${style["font-size-m"]};
19 | vertical-align: middle;
20 | }
21 | `;
22 | const ListItem = styled.span`
23 | flex: 0 0 auto;
24 | font-size: ${style["font-size-m"]};
25 | padding: 0 5px;
26 | border-radius: 10px;
27 | &.selected {
28 | color: ${style["theme-color"]};
29 | border: 1px solid ${style["theme-color"]};
30 | opacity: 0.8;
31 | }
32 | `;
33 |
34 | function Horizen(props) {
35 | const [refreshCategoryScroll, setRefreshCategoryScroll] = useState(false);
36 | const Category = useRef(null);
37 | const { list, oldVal, title } = props;
38 | const { handleClick } = props;
39 |
40 | useEffect(() => {
41 | let categoryDOM = Category.current;
42 | let tagElems = categoryDOM.querySelectorAll("span");
43 | let totalWidth = 0;
44 | Array.from(tagElems).forEach((ele) => {
45 | totalWidth += ele.offsetWidth;
46 | });
47 | totalWidth += 2;
48 | categoryDOM.style.width = `${totalWidth}px`;
49 | setRefreshCategoryScroll(true);
50 | }, [refreshCategoryScroll]);
51 |
52 | const clickHandle = (item) => {
53 | handleClick(item.key);
54 | };
55 | return (
56 |
57 |
58 |
59 | {title}
60 | {list.map((item) => {
61 | return (
62 | clickHandle(item)}
66 | >
67 | {item.name}
68 |
69 | );
70 | })}
71 |
72 |
73 |
74 | );
75 | }
76 |
77 | export default memo(Horizen);
78 |
--------------------------------------------------------------------------------
/src/app/home/singer/components/main.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { memo, useRef, useEffect, useState } from "react";
4 | import { useSelector, useDispatch } from "react-redux";
5 | import LazyLoad, { forceCheck } from "react-lazyload";
6 | import Link from "next/link";
7 | import Scroll from "@/components/scroll";
8 | import musicImg from "@/assets/music.png";
9 | import Loading from "@/components/loading";
10 | import {
11 | changeSingerList,
12 | changePullDownLoading,
13 | changePullUpLoading,
14 | } from "@/store/slices/singers";
15 | import useSingerMutation from "@/hooks/useSingerMutation";
16 | import { ListContainer, List, ListItem, EnterLoading } from "../style";
17 |
18 | function Main(props) {
19 | const scrollRef = useRef(null);
20 | const data = useSelector((state) => state.singers.singerDes);
21 | const {
22 | songsCount = 0,
23 | singerList,
24 | pullUpLoading,
25 | pullDownLoading,
26 | category,
27 | alpha,
28 | listOffset,
29 | } = data;
30 | const { initSingerList } = props;
31 | const dispatch = useDispatch();
32 | const { changeSinger, loadMoreSinger } = useSingerMutation();
33 | const [enterLoading, setEnterLoading] = useState(true);
34 |
35 | useEffect(() => {
36 | dispatch(changeSingerList(initSingerList));
37 | setEnterLoading(false);
38 | }, [initSingerList]);
39 |
40 | const pullUp = () => {
41 | console.log("到底部了");
42 | dispatch(changePullUpLoading(true));
43 | loadMoreSinger(category, alpha, listOffset);
44 | };
45 | const pullDown = () => {
46 | console.log("到顶部了");
47 | dispatch(changePullDownLoading(true));
48 | changeSinger(category, alpha, 0);
49 | };
50 |
51 | const renderSingerList = () => {
52 | return (
53 |
54 | {singerList.map((item, index) => {
55 | return (
56 |
60 |
61 |
62 |
70 | }
71 | >
72 |

78 |
79 |
80 | {item.name}
81 |
82 |
83 | );
84 | })}
85 |
86 | );
87 | };
88 | return (
89 | <>
90 |
91 |
101 | {renderSingerList()}
102 |
103 |
104 | {enterLoading ? (
105 |
106 |
107 |
108 | ) : null}
109 | >
110 | );
111 | }
112 |
113 | export default memo(Main);
114 |
--------------------------------------------------------------------------------
/src/app/home/singer/components/songList.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useDispatch } from "react-redux";
3 | import SvgIcon from "@/components/svg-icon";
4 | import { getName } from "@/utils";
5 | import {
6 | changeCurrentIndex,
7 | changePlayList,
8 | changeSequencePlayList,
9 | } from "@/store/slices/songs";
10 | import { SongList, SongItem } from "../style";
11 |
12 | function SongsList(props) {
13 | const { songs = [] } = props;
14 | const dispatch = useDispatch();
15 |
16 | const selectItem = (e, index) => {
17 | dispatch(changeCurrentIndex(index));
18 | dispatch(changePlayList(songs));
19 | dispatch(changeSequencePlayList(songs));
20 | // musicAnimation(e.nativeEvent.clientX, e.nativeEvent.clientY);
21 | };
22 | const renderSongList = () => {
23 | return (
24 |
25 | {songs.map((item, i) => {
26 | return (
27 | selectItem(e, i)}>
28 | {i + 1}
29 |
30 | {item.name}
31 |
32 | {item.ar ? getName(item.ar) : getName(item.artists)} -{" "}
33 | {item.al ? item.al.name : item.album.name}
34 |
35 |
36 |
37 | );
38 | })}
39 |
40 | );
41 | };
42 | return (
43 |
44 |
45 |
selectItem(e, 0)}>
46 |
47 |
48 | 播放全部 (共{songs.length}首)
49 |
50 |
51 |
52 | {renderSongList()}
53 |
54 | );
55 | }
56 |
57 | export default React.memo(SongsList);
58 |
--------------------------------------------------------------------------------
/src/app/home/singer/components/top-header.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { memo } from "react";
4 | import { useSelector, useDispatch } from "react-redux";
5 | import { categoryTypes, alphaTypes } from "@/api/config";
6 | import useSingerMutation from "@/hooks/useSingerMutation";
7 | import {
8 | changeCategory,
9 | changeAlpha,
10 | changeEnterLoading,
11 | } from "@/store/slices/singers";
12 | import { NavContainer } from "../style";
13 | import Horizen from "./horizen-item";
14 | function TopHeader() {
15 | const data = useSelector((state) => state.singers.singerDes);
16 | const dispatch = useDispatch();
17 | const { category, alpha } = data;
18 | const { changeSinger } = useSingerMutation();
19 |
20 | const handleUpdateCategory = async (newVal) => {
21 | if (category === newVal) return;
22 | dispatch(changeEnterLoading(true));
23 | dispatch(changeCategory(newVal));
24 | changeSinger(newVal, alpha, 0);
25 | // scrollRef.current.refresh();
26 | };
27 |
28 | const handleUpdateAlpha = (newVal) => {
29 | if (alpha === newVal) return;
30 | dispatch(changeEnterLoading(true));
31 | dispatch(changeAlpha(newVal));
32 | changeSinger(category, newVal, 0);
33 | // scrollRef.current.refresh();
34 | };
35 | return (
36 |
37 | handleUpdateCategory(v)}
41 | oldVal={category}
42 | >
43 | handleUpdateAlpha(v)}
47 | oldVal={alpha}
48 | >
49 |
50 | );
51 | }
52 | export default memo(TopHeader);
53 |
--------------------------------------------------------------------------------
/src/app/home/singer/page.jsx:
--------------------------------------------------------------------------------
1 | import { API_BASE_URL } from "@/api/config";
2 | import TopHeader from "./components/top-header";
3 | import Main from "./components/main";
4 |
5 | export default async function Singer() {
6 | const getData = async (count = 1) => {
7 | const res = await fetch(`${API_BASE_URL}/top/artists?offset=${count}`);
8 | if (!res.ok) {
9 | throw new Error("Failed to fetch data");
10 | }
11 | return res.json();
12 | };
13 | const data = await getData();
14 | return (
15 |
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/home/singer/style.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import style from "@/assets/global-style";
3 |
4 | export const NavContainer = styled.div`
5 | box-sizing: border-box;
6 | position: fixed;
7 | top: 95px;
8 | width: 100%;
9 | padding: 5px;
10 | overflow: hidden;
11 | display: flex;
12 | flex-direction: column;
13 | `;
14 |
15 | export const ListContainer = styled.div`
16 | position: fixed;
17 | top: 160px;
18 | left: 0;
19 | bottom: ${(props) => (props.play ? "60px" : 0)};
20 | overflow: hidden;
21 | width: 100%;
22 | `;
23 |
24 | export const List = styled.div`
25 | display: flex;
26 | margin: auto;
27 | flex-direction: column;
28 | overflow: hidden;
29 | .title {
30 | margin: 10px 0 10px 10px;
31 | color: ${style["font-color-desc"]};
32 | font-size: ${style["font-size-s"]};
33 | }
34 | `;
35 | export const ListItem = styled.div`
36 | box-sizing: border-box;
37 | display: flex;
38 | flex-direction: row;
39 | margin: 0 5px;
40 | padding: 5px 0;
41 | align-items: center;
42 | border-bottom: 1px solid ${style["border-color"]};
43 | .img_wrapper {
44 | margin-right: 20px;
45 | img {
46 | border-radius: 3px;
47 | width: 50px;
48 | height: 50px;
49 | }
50 | }
51 | .name {
52 | font-size: ${style["font-size-m"]};
53 | color: ${style["font-color-desc"]};
54 | font-weight: 500;
55 | }
56 | `;
57 | export const EnterLoading = styled.div`
58 | position: fixed;
59 | left: 0;
60 | right: 0;
61 | top: 0;
62 | bottom: 0;
63 | width: 100px;
64 | height: 100px;
65 | margin: auto;
66 | `;
67 |
68 | export const SongList = styled.div`
69 | border-radius: 10px;
70 | background: #fff;
71 | ${(props) =>
72 | props.showBackground
73 | ? `background: ${style["highlight-background-color"]}`
74 | : ""};
75 | .first_line {
76 | box-sizing: border-box;
77 | padding: 10px 0;
78 | margin-left: 10px;
79 | position: relative;
80 | justify-content: space-between;
81 | border-bottom: 1px solid ${style["border-color"]};
82 | .play_all {
83 | display: flex;
84 | align-items: center;
85 | line-height: 24px;
86 | color: ${style["font-color-desc"]};
87 | .svg-class {
88 | margin-right: 10px;
89 | }
90 | .iconfont {
91 | font-size: 24px;
92 | margin-right: 10px;
93 | vertical-align: top;
94 | }
95 | .sum {
96 | font-size: ${style["font-size-s"]};
97 | color: ${style["font-color-desc-v2"]};
98 | }
99 | > span {
100 | vertical-align: top;
101 | }
102 | }
103 | .add_list,
104 | .isCollected {
105 | display: flex;
106 | align-items: center;
107 | position: absolute;
108 | right: 0;
109 | top: 0;
110 | bottom: 0;
111 | width: 130px;
112 | line-height: 34px;
113 | background: ${style["theme-color"]};
114 | color: ${style["font-color-light"]};
115 | font-size: 0;
116 | border-radius: 3px;
117 | vertical-align: top;
118 | .iconfont {
119 | vertical-align: top;
120 | font-size: 10px;
121 | margin: 0 5px 0 10px;
122 | }
123 | span {
124 | font-size: 14px;
125 | line-height: 34px;
126 | }
127 | }
128 | .isCollected {
129 | display: flex;
130 | background: ${style["background-color"]};
131 | color: ${style["font-color-desc"]};
132 | }
133 | }
134 | `;
135 | export const SongItem = styled.ul`
136 | > li {
137 | display: flex;
138 | height: 60px;
139 | align-items: center;
140 | .index {
141 | flex-basis: 60px;
142 | width: 60px;
143 | height: 60px;
144 | line-height: 60px;
145 | text-align: center;
146 | }
147 | .info {
148 | box-sizing: border-box;
149 | flex: 1;
150 | display: flex;
151 | height: 100%;
152 | padding: 5px 0;
153 | flex-direction: column;
154 | justify-content: space-around;
155 | border-bottom: 1px solid ${style["border-color"]};
156 | ${style.noWrap()}
157 | >span {
158 | ${style.noWrap()}
159 | }
160 | > span:first-child {
161 | color: ${style["font-color-desc"]};
162 | }
163 | > span:last-child {
164 | font-size: ${style["font-size-s"]};
165 | color: #bba8a8;
166 | }
167 | }
168 | }
169 | `;
170 |
--------------------------------------------------------------------------------
/src/app/home/style.js:
--------------------------------------------------------------------------------
1 | "use client";
2 | import styled from "styled-components";
3 |
4 | export const Content = styled.div`
5 | position: fixed;
6 | top: 94px;
7 | left: 0;
8 | bottom: ${(props) => (props.play > 0 ? "60px" : 0)};
9 | width: 100%;
10 | `;
11 |
--------------------------------------------------------------------------------
/src/app/layout.jsx:
--------------------------------------------------------------------------------
1 | import "./globals.css";
2 | import ReduxProvider from "./ReduxProvider";
3 | import StyledComponentsRegistry from "./registry";
4 | import { Providers } from "./providers";
5 |
6 | export default function RootLayout({ children }) {
7 | return (
8 | // suppressHydrationWarning用于在客户端与服务器端渲染(SSR)内容不一致时,抑制 hydration 过程中的警告。
9 |
10 |
11 |
15 |
16 |
17 |
18 |
19 | {children}
20 |
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/app/mine/page.jsx:
--------------------------------------------------------------------------------
1 | import prisma from "@/lib/db";
2 |
3 | export default async function Mine() {
4 | const data = await prisma.user.findMany();
5 | console.log(222222, data);
6 | return (
7 |
8 |
Mine Page
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/src/app/page.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React, { useEffect } from "react";
3 | import { useRouter } from "next/navigation";
4 | function Home() {
5 | const router = useRouter();
6 | useEffect(() => {
7 | router.push("/home");
8 | }, []);
9 | return 开屏动画
;
10 | }
11 |
12 | export default React.memo(Home);
13 |
--------------------------------------------------------------------------------
/src/app/providers.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | // 集成nextui
3 |
4 | import { NextUIProvider } from "@nextui-org/react";
5 |
6 | export function Providers({ children }) {
7 | return {children};
8 | }
9 |
--------------------------------------------------------------------------------
/src/app/registry.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useState } from "react";
4 | import { useServerInsertedHTML } from "next/navigation";
5 | import { ServerStyleSheet, StyleSheetManager } from "styled-components";
6 |
7 | export default function StyledComponentsRegistry({ children }) {
8 | // Only create stylesheet once with lazy initial state
9 | // x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
10 | const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());
11 |
12 | useServerInsertedHTML(() => {
13 | const styles = styledComponentsStyleSheet.getStyleElement();
14 | styledComponentsStyleSheet.instance.clearTag();
15 | return <>{styles}>;
16 | });
17 |
18 | if (typeof window !== "undefined") return <>{children}>;
19 |
20 | return (
21 |
22 | {children}
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/assets/global-style.js:
--------------------------------------------------------------------------------
1 | const extendClick = () => {
2 | return `
3 | position: relative;
4 | &:before{
5 | content: '';
6 | position: absolute;
7 | top: -10px; bottom: -10px; left: -10px; right: -10px;
8 | };
9 | `;
10 | };
11 |
12 | const noWrap = () => {
13 | return `
14 | text-overflow: ellipsis;
15 | overflow: hidden;
16 | white-space: nowrap;
17 | `;
18 | };
19 |
20 | const bgFull = () => {
21 | return `
22 | background-position: 50%;
23 | background-size: contain;
24 | background-repeat: no-repeat;
25 | `
26 | };
27 |
28 | export default {
29 | "theme-color": "#d44439",
30 | "theme-color-shadow": "rgba(212, 68, 57, .5)",
31 | "font-color-light": "#f1f1f1",
32 | "font-color-light-shadow": "rgba(241, 241, 241, 0.6)",//略淡
33 | "font-color-desc": "#2E3030",
34 | "font-color-desc-v2": "#bba8a8", //略淡
35 | "font-size-ss": "10px",
36 | "font-size-s": "12px",
37 | "font-size-m": "14px",
38 | "font-size-l": "16px",
39 | "font-size-ll": "18px",
40 | "border-color": "#e4e4e4",
41 | "border-color-v2": "rgba(228, 228, 228, 0.1)",
42 | "background-color": "#f2f3f4",
43 | "background-color-shadow": "rgba(0, 0, 0, 0.3)",
44 | "highlight-background-color": "#fff",
45 | "official-red": "#E82001",
46 | extendClick,
47 | noWrap,
48 | bgFull
49 | };
50 |
--------------------------------------------------------------------------------
/src/assets/music.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mrcluo/react-nextjs/89286f12ec6bab5b731b7cb81dabff83d593550f/src/assets/music.png
--------------------------------------------------------------------------------
/src/components/confirm/index.jsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef, useImperativeHandle, useState } from "react";
2 | import { CSSTransition } from "react-transition-group";
3 | import styled, { keyframes } from "styled-components";
4 | import style from "@/assets/global-style";
5 |
6 | const confirmFadeIn = keyframes`
7 | 0%{
8 | opacity: 0;
9 | }
10 | 100%{
11 | opacity: 1;
12 | }
13 | `;
14 | const confirmZoom = keyframes`
15 | 0%{
16 | transform: scale(0);
17 | }
18 | 50%{
19 | transform: scale(1.1);
20 | }
21 | 100%{
22 | transform: scale(1);
23 | }
24 | `;
25 |
26 | const ConfirmWrapper = styled.div`
27 | position: fixed;
28 | left: 0;
29 | right: 0;
30 | top: 0;
31 | bottom: 0;
32 | z-index: 1000;
33 | background: ${style["background-color-shadow"]};
34 | &.confirm-fade-enter-active {
35 | animation: ${confirmFadeIn} 0.3s;
36 | .confirm_content {
37 | animation: ${confirmZoom} 0.3s;
38 | }
39 | }
40 | > div {
41 | position: absolute;
42 | top: 50%;
43 | left: 50%;
44 | transform: translate3d(-50%, -50%, 0);
45 | z-index: 100;
46 | .confirm_content {
47 | width: 270px;
48 | border-radius: 13px;
49 | background: ${style["highlight-background-color"]};
50 | .text {
51 | padding: 19px 15px;
52 | line-height: 22px;
53 | text-align: center;
54 | font-size: ${style["font-size-l"]};
55 | color: ${style["font-color-desc-v2"]};
56 | }
57 | .operate {
58 | display: flex;
59 | align-items: center;
60 | text-align: center;
61 | font-size: ${style["font-size-l"]};
62 | .operate_btn {
63 | flex: 1;
64 | line-height: 22px;
65 | padding: 10px 0;
66 | border-top: 1px solid ${style["border-color"]};
67 | color: ${style["font-color-desc"]};
68 | &.left {
69 | border-right: 1px solid ${style["border-color"]};
70 | }
71 | }
72 | }
73 | }
74 | }
75 | `;
76 |
77 | const Confirm = (props, ref) => {
78 | const [show, setShow] = useState(false);
79 | const { text, cancelBtnText, confirmBtnText } = props;
80 |
81 | const { handleConfirm } = props;
82 |
83 | useImperativeHandle(ref, () => ({
84 | show() {
85 | setShow(true);
86 | },
87 | }));
88 | // style={{display: show ? "block": "none"}}
89 | return (
90 |
96 | e.stopPropagation()}
99 | >
100 |
101 |
102 |
{text}
103 |
104 |
setShow(false)}>
105 | {cancelBtnText}
106 |
107 |
{
110 | setShow(false);
111 | handleConfirm();
112 | }}
113 | >
114 | {confirmBtnText}
115 |
116 |
117 |
118 |
119 |
120 |
121 | );
122 | };
123 |
124 | export default React.memo(forwardRef(Confirm));
125 |
--------------------------------------------------------------------------------
/src/components/header/index.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import style from "@/assets/global-style";
4 | import SvgIcon from "@/components/svg-icon";
5 | const HeaderContainer = styled.div`
6 | position: fixed;
7 | padding: 5px 10px;
8 | padding-top: 0;
9 | height: 40px;
10 | width: 100%;
11 | z-index: 100;
12 | display: flex;
13 | line-height: 40px;
14 | color: ${style["font-color-light"]};
15 | .back {
16 | margin-right: 5px;
17 | font-size: 20px;
18 | width: 20px;
19 | height: 40px;
20 | }
21 | > h1 {
22 | font-size: ${style["font-size-l"]};
23 | font-weight: 700;
24 | }
25 | `;
26 | function Header(props, ref) {
27 | const { handleClick, title } = props;
28 | return (
29 |
30 |
31 |
32 |
33 | {title}
34 |
35 | );
36 | }
37 | export default React.memo(React.forwardRef(Header));
38 |
--------------------------------------------------------------------------------
/src/components/loading-v2/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled, {keyframes} from 'styled-components';
3 | import style from '../../assets/global-style'
4 |
5 | const dance = keyframes`
6 | 0%, 40%, 100%{
7 | transform: scaleY(0.4);
8 | transform-origin: center 100%;
9 | }
10 | 20%{
11 | transform: scaleY(1);
12 | }
13 | `
14 | const Loading = styled.div`
15 | height: 10px;
16 | width: 100%;
17 | margin: auto;
18 | text-align: center;
19 | font-size: 10px;
20 | >div{
21 | display: inline-block;
22 | background-color: ${style["theme-color"]};
23 | height: 100%;
24 | width: 1px;
25 | margin-right:2px;
26 | animation: ${dance} 1s infinite;
27 | }
28 | >div:nth-child(2) {
29 | animation-delay: -0.4s;
30 | }
31 | >div:nth-child(3) {
32 | animation-delay: -0.6s;
33 | }
34 | >div:nth-child(4) {
35 | animation-delay: -0.5s;
36 | }
37 | >div:nth-child(5) {
38 | animation-delay: -0.2s;
39 | }
40 |
41 | `
42 |
43 | function LoadingV2() {
44 | return (
45 |
46 |
47 |
48 |
49 |
50 |
51 | 拼命加载中...
52 |
53 | );
54 | }
55 |
56 | export default React.memo(LoadingV2);
--------------------------------------------------------------------------------
/src/components/loading/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled, { keyframes } from 'styled-components';
3 | import style from '../../assets/global-style';
4 |
5 | const loading = keyframes`
6 | 0%, 100% {
7 | transform: scale(0.0);
8 | }
9 | 50% {
10 | transform: scale(1.0);
11 | }
12 | `
13 | const LoadingWrapper = styled.div`
14 | >div {
15 | position: absolute;
16 | top: 0; left: 0; right: 0; bottom: 0;
17 | margin: auto;
18 | width: 60px;
19 | height: 60px;
20 | opacity: 0.6;
21 | border-radius: 50%;
22 | background-color: ${style["theme-color"]};
23 | animation: ${loading} 1.4s infinite ease-in;
24 | }
25 | >div:nth-child(2) {
26 | animation-delay: -0.7s;
27 | }
28 | `
29 |
30 | function Loading() {
31 | return (
32 |
33 |
34 |
35 |
36 | );
37 | }
38 |
39 | export default React.memo(Loading);
--------------------------------------------------------------------------------
/src/components/progress-circle/index.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import style from "@/assets/global-style";
4 |
5 | const CircleWrapper = styled.div`
6 | position: relative;
7 | circle {
8 | stroke-width: 8px;
9 | transform-origin: center;
10 | &.progress-background {
11 | transform: scale(0.9);
12 | stroke: ${style["theme-color-shadow"]};
13 | }
14 | &.progress-bar {
15 | transform: scale(0.9) rotate(-90deg);
16 | stroke: ${style["theme-color"]};
17 | }
18 | }
19 | `;
20 |
21 | function ProgressCircle(props) {
22 | const { radius, percent } = props;
23 | //整个背景的周长
24 | const dashArray = Math.PI * 100;
25 | //没有高亮的部分,剩下高亮的就是进度
26 | const dashOffset = (1 - percent) * dashArray;
27 |
28 | return (
29 |
30 |
54 | {props.children}
55 |
56 | );
57 | }
58 |
59 | export default React.memo(ProgressCircle);
60 |
--------------------------------------------------------------------------------
/src/components/scroll/index.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React, {
3 | forwardRef,
4 | useState,
5 | useEffect,
6 | useRef,
7 | useImperativeHandle,
8 | useMemo,
9 | } from "react";
10 | import BScroll from "better-scroll";
11 | import styled from "styled-components";
12 | import Loading from "@/components/loading";
13 | import Loading2 from "@/components/loading-v2";
14 | import { debounce } from "@/utils";
15 |
16 | const ScrollContainer = styled.div`
17 | width: 100%;
18 | height: 100%;
19 | overflow: hidden;
20 | `;
21 |
22 | const PullUpLoading = styled.div`
23 | position: absolute;
24 | left: 0;
25 | right: 0;
26 | bottom: 5px;
27 | width: 60px;
28 | height: 60px;
29 | margin: auto;
30 | z-index: 100;
31 | `;
32 |
33 | export const PullDownLoading = styled.div`
34 | position: absolute;
35 | left: 0;
36 | right: 0;
37 | top: 0px;
38 | height: 30px;
39 | margin: auto;
40 | z-index: 100;
41 | `;
42 |
43 | const Scroll = forwardRef((props, ref) => {
44 | const [bScroll, setBScroll] = useState();
45 |
46 | const scrollContaninerRef = useRef();
47 |
48 | const { direction, refresh, pullUpLoading, pullDownLoading } = props;
49 |
50 | const { onScroll, pullUp, pullDown } = props;
51 |
52 | let pullUpDebounce = useMemo(() => {
53 | return debounce(pullUp, 500);
54 | }, [pullUp]);
55 |
56 | let pullDownDebounce = useMemo(() => {
57 | return debounce(pullDown, 500);
58 | }, [pullDown]);
59 |
60 | useEffect(() => {
61 | const scroll = new BScroll(scrollContaninerRef.current, {
62 | scrollX: direction === "horizental",
63 | scrollY: direction === "vertical",
64 | probeType: 3,
65 | click: true,
66 | bounce: {
67 | top: true, // 是否支持向上吸顶
68 | bottom: true, // 是否支持向下吸顶
69 | },
70 | });
71 | setBScroll(scroll);
72 | return () => {
73 | setBScroll(null);
74 | };
75 | // eslint-disable-next-line
76 | }, []);
77 |
78 | useEffect(() => {
79 | if (!bScroll || !onScroll) return;
80 | bScroll.on("scroll", onScroll);
81 | return () => {
82 | bScroll.off("scroll", onScroll);
83 | };
84 | }, [bScroll, onScroll]);
85 |
86 | useEffect(() => {
87 | if (!bScroll || !pullUp) return;
88 | const handlePullUp = () => {
89 | //判断是否滑动到了底部
90 | if (bScroll.y <= bScroll.maxScrollY + 100) {
91 | pullUpDebounce();
92 | }
93 | };
94 | bScroll.on("scrollEnd", handlePullUp);
95 | return () => {
96 | bScroll.off("scrollEnd", handlePullUp);
97 | };
98 | }, [pullUp, pullUpDebounce, bScroll]);
99 |
100 | useEffect(() => {
101 | if (!bScroll || !pullDown) return;
102 | const handlePullDown = (pos) => {
103 | //判断用户的下拉动作
104 | if (pos.y > 50) {
105 | pullDownDebounce();
106 | }
107 | };
108 | bScroll.on("touchEnd", handlePullDown);
109 | return () => {
110 | bScroll.off("touchEnd", handlePullDown);
111 | };
112 | }, [pullDown, pullDownDebounce, bScroll]);
113 |
114 | useEffect(() => {
115 | if (refresh && bScroll) {
116 | bScroll.refresh();
117 | }
118 | });
119 |
120 | useImperativeHandle(ref, () => ({
121 | refresh() {
122 | if (bScroll) {
123 | bScroll.refresh();
124 | bScroll.scrollTo(0, 0);
125 | }
126 | },
127 | getBScroll() {
128 | if (bScroll) {
129 | return bScroll;
130 | }
131 | },
132 | }));
133 | const PullUpdisplayStyle = pullUpLoading
134 | ? { display: "" }
135 | : { display: "none" };
136 | const PullDowndisplayStyle = pullDownLoading
137 | ? { display: "" }
138 | : { display: "none" };
139 | return (
140 |
141 | {props.children}
142 | {/* 滑到底部加载动画 */}
143 |
144 |
145 |
146 | {/* 顶部下拉刷新动画 */}
147 |
148 |
149 |
150 |
151 | );
152 | });
153 |
154 | export default Scroll;
155 |
--------------------------------------------------------------------------------
/src/components/slider/index.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useEffect, useState } from "react";
4 | import { SliderContainer } from "./style";
5 | import "swiper/css";
6 | import "swiper/css/bundle";
7 | import Swiper, { Navigation, Pagination } from "swiper";
8 | function Slider(props) {
9 | const { bannerList } = props;
10 | const [swiperDOM, setSwiperDOM] = useState(null);
11 |
12 | useEffect(() => {
13 | if (bannerList.length && !swiperDOM) {
14 | Swiper.use([Navigation, Pagination]);
15 | const swiperDOM = new Swiper(".slider-container", {
16 | loop: true,
17 | autoplay: {
18 | delay: 3000,
19 | disableOnInteraction: false,
20 | },
21 | pagination: {
22 | el: ".swiper-pagination",
23 | type: "bullets",
24 | },
25 | });
26 | setSwiperDOM(swiperDOM);
27 | }
28 | }, [bannerList.length]);
29 | return (
30 |
31 |
32 |
33 |
34 | {bannerList.map((slider) => {
35 | return (
36 |
37 |
38 |

44 |
45 |
46 | );
47 | })}
48 |
49 |
50 |
51 |
52 | );
53 | }
54 |
55 | export default React.memo(Slider);
56 |
--------------------------------------------------------------------------------
/src/components/slider/style.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import style from "../../assets/global-style";
3 |
4 | export const SliderContainer = styled.div`
5 | position: relative;
6 | box-sizing: border-box;
7 | width: 100%;
8 | height: 100%;
9 | margin: auto;
10 | background: white;
11 | .before {
12 | position: absolute;
13 | top: -300px;
14 | height: 400px;
15 | width: 100%;
16 | background: ${style["theme-color"]};
17 | z-index: 1;
18 | }
19 | .slider-container {
20 | position: relative;
21 | width: 98%;
22 | height: 160px;
23 | overflow: hidden;
24 | margin: auto;
25 | border-radius: 6px;
26 | .slider-nav {
27 | position: absolute;
28 | display: block;
29 | width: 100%;
30 | height: 100%;
31 | }
32 | .swiper-pagination-bullet-active {
33 | background: ${style["theme-color"]};
34 | }
35 | }
36 | `;
37 |
--------------------------------------------------------------------------------
/src/components/svg-icon/index.jsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const SliderContainer = styled.span`
4 | .svg-class {
5 | width: 1em;
6 | height: 1em;
7 | vertical-align: -0.15em;
8 | fill: currentColor;
9 | overflow: hidden;
10 | }
11 | `;
12 |
13 | function SvgIcon(props) {
14 | const { iconClass = "", fill = "", svgClass = "" } = props;
15 | return (
16 |
17 |
20 |
21 | );
22 | }
23 |
24 | export default SvgIcon;
25 |
--------------------------------------------------------------------------------
/src/components/toast/index.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useImperativeHandle, forwardRef } from "react";
2 | import styled from "styled-components";
3 | import { CSSTransition } from "react-transition-group";
4 | import style from "@/assets/global-style";
5 |
6 | const ToastWrapper = styled.div`
7 | position: fixed;
8 | bottom: 0;
9 | z-index: 1000;
10 | width: 100%;
11 | height: 50px;
12 | /* background: ${style["highlight-background-color"]}; */
13 | &.drop-enter {
14 | opacity: 0;
15 | transform: translate3d(0, 100%, 0);
16 | }
17 | &.drop-enter-active {
18 | opacity: 1;
19 | transition: all 0.3s;
20 | transform: translate3d(0, 0, 0);
21 | }
22 | &.drop-exit-active {
23 | opacity: 0;
24 | transition: all 0.3s;
25 | transform: translate3d(0, 100%, 0);
26 | }
27 | .text {
28 | line-height: 50px;
29 | text-align: center;
30 | color: #fff;
31 | font-size: ${style["font-size-l"]};
32 | }
33 | `;
34 |
35 | const Toast = (props, ref) => {
36 | const [show, setShow] = useState(false);
37 | const [timer, setTimer] = useState("");
38 | const { text } = props;
39 |
40 | useImperativeHandle(ref, () => ({
41 | show() {
42 | if (timer) clearTimeout(timer);
43 | setShow(true);
44 | setTimer(
45 | setTimeout(() => {
46 | setShow(false);
47 | }, 3000)
48 | );
49 | },
50 | }));
51 | return (
52 |
53 |
54 | {text}
55 |
56 |
57 | );
58 | };
59 |
60 | export default React.memo(forwardRef(Toast));
61 |
--------------------------------------------------------------------------------
/src/hooks/useGetAlbums.jsx:
--------------------------------------------------------------------------------
1 | import useSWR from "swr";
2 | import { API_BASE_URL } from "@/api/config";
3 | export default function useGetAlbums(id) {
4 | const fetcher = (url) => fetch(url).then((res) => res.json());
5 | const { data, error, isLoading } = useSWR(
6 | `${API_BASE_URL}/playlist/detail?id=${id}`,
7 | fetcher
8 | );
9 | return {
10 | data,
11 | isLoading,
12 | isError: error,
13 | };
14 | }
15 |
--------------------------------------------------------------------------------
/src/hooks/useGetSingerSongs.jsx:
--------------------------------------------------------------------------------
1 | import useSWR from "swr";
2 | import { API_BASE_URL } from "@/api/config";
3 | export default function useGetSingerSongs(id) {
4 | const fetcher = (url) => fetch(url).then((res) => res.json());
5 | const { data, error, isLoading } = useSWR(
6 | `${API_BASE_URL}/artists?id=${id}`,
7 | fetcher
8 | );
9 | return {
10 | data,
11 | isLoading,
12 | isError: error,
13 | };
14 | }
15 |
--------------------------------------------------------------------------------
/src/hooks/useSingerMutation.jsx:
--------------------------------------------------------------------------------
1 | import useSWRMutation from "swr/mutation";
2 | import { useSelector, useDispatch } from "react-redux";
3 | import { API_BASE_URL } from "@/api/config";
4 | import {
5 | changeSingerList,
6 | changePullUpLoading,
7 | changePullDownLoading,
8 | changeEnterLoading,
9 | changeListOffset,
10 | } from "@/store/slices/singers";
11 | export default function useSingerMutation() {
12 | const data = useSelector((state) => state.singers.singerDes);
13 | const { singerList } = data;
14 | const dispatch = useDispatch();
15 | const fetcher = (url, { arg }) => {
16 | url += `?cat=${arg.category}&initial=${arg.alpha.toLowerCase()}&offset=${
17 | arg.offset
18 | }`;
19 | return fetch(url).then((res) => res.json());
20 | };
21 | const { trigger, isMutating } = useSWRMutation(
22 | `${API_BASE_URL}/artist/list`,
23 | fetcher
24 | );
25 |
26 | const changeSinger = async (category = "", alpha = "", offset = "") => {
27 | const data = await trigger({
28 | category,
29 | alpha,
30 | offset,
31 | });
32 | let _data = data?.artists || [];
33 | dispatch(changeSingerList(_data));
34 | dispatch(changePullDownLoading(false));
35 | dispatch(changeEnterLoading(false));
36 | dispatch(changeListOffset(_data.length));
37 | };
38 |
39 | const loadMoreSinger = async (category = "", alpha = "", offset = "") => {
40 | const data = await trigger({
41 | category,
42 | alpha,
43 | offset,
44 | });
45 | let _data = data?.artists || [];
46 | _data = [...singerList, ..._data];
47 | dispatch(changeSingerList(_data));
48 | dispatch(changePullUpLoading(false));
49 | dispatch(changeListOffset(_data.length));
50 | };
51 | // isMutating: 远程数据变更是否正在进行
52 | return { changeSinger, loadMoreSinger, isMutating };
53 | }
54 |
--------------------------------------------------------------------------------
/src/lib/db.ts:
--------------------------------------------------------------------------------
1 | // 不想用require 所以文件后缀为.ts
2 | import { PrismaClient } from "@prisma/client";
3 |
4 | const prismaClientSingleton = () => {
5 | return new PrismaClient()
6 | }
7 |
8 | declare const globalThis: {
9 | prismaGlobal: ReturnType;
10 | } & typeof global;
11 |
12 | // 单例模式,避免dev环境频繁创建 new PrismaClient()
13 | const prisma = globalThis?.prismaGlobal ?? prismaClientSingleton()
14 |
15 | if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma
16 |
17 | export default prisma
18 |
19 | // let prisma: PrismaClient;
20 |
21 | // if (process.env.NODE_ENV === "production") {
22 | // prisma = new PrismaClient();
23 | // } else {
24 | // if (!global.prisma) {
25 | // global.prisma = new PrismaClient();
26 | // }
27 | // prisma = global.prisma;
28 | // }
29 | // export default prisma;
30 |
--------------------------------------------------------------------------------
/src/store/index.js:
--------------------------------------------------------------------------------
1 | import { configureStore } from "@reduxjs/toolkit";
2 | import singers from "./slices/singers";
3 | import songs from "./slices/songs";
4 |
5 | const store = configureStore({
6 | reducer: {
7 | singers,
8 | songs,
9 | },
10 | });
11 | export default store;
12 |
--------------------------------------------------------------------------------
/src/store/slices/singers.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 |
3 | export const SingersSlice = createSlice({
4 | name: "singers",
5 | initialState: {
6 | contact: "111",
7 | value: 0,
8 | singerDes: {
9 | category: "",
10 | alpha: "",
11 | singerList: [],
12 | enterLoading: false,
13 | pullUpLoading: false,
14 | pullDownLoading: false,
15 | listOffset: 0, // 请求列表的偏移不是page,是个数
16 | },
17 | },
18 | reducers: {
19 | increment: (state) => {
20 | // Redux Toolkit 允许我们在 reducers 写 "可变" 逻辑。它
21 | // 并不是真正的改变状态值,因为它使用了 Immer 库
22 | // 可以检测到“草稿状态“ 的变化并且基于这些变化生产全新的
23 | // 不可变的状态
24 | state.value += 1;
25 | },
26 | setContact: (state, action) => {
27 | state.contact = action.payload;
28 | },
29 | changeCategory: (state, action) => {
30 | state.singerDes = { ...state.singerDes, category: action.payload };
31 | },
32 | changeAlpha: (state, action) => {
33 | state.singerDes = { ...state.singerDes, alpha: action.payload };
34 | },
35 | changeSingerList: (state, action) => {
36 | state.singerDes = { ...state.singerDes, singerList: action.payload };
37 | },
38 | changePullUpLoading: (state, action) => {
39 | state.singerDes = { ...state.singerDes, pullUpLoading: action.payload };
40 | },
41 | changePullDownLoading: (state, action) => {
42 | state.singerDes = { ...state.singerDes, pullDownLoading: action.payload };
43 | },
44 | changeEnterLoading: (state, action) => {
45 | state.singerDes = { ...state.singerDes, enterLoading: action.payload };
46 | },
47 | changeListOffset: (state, action) => {
48 | state.singerDes = { ...state.singerDes, listOffset: action.payload };
49 | },
50 | },
51 | });
52 | // 每个 case reducer 函数会生成对应的 Action creators
53 | export const {
54 | increment,
55 | setContact,
56 | changeCategory,
57 | changeAlpha,
58 | changeSingerList,
59 | changePullUpLoading,
60 | changePullDownLoading,
61 | changeEnterLoading,
62 | changeListOffset,
63 | } = SingersSlice.actions;
64 | export default SingersSlice.reducer;
65 |
--------------------------------------------------------------------------------
/src/store/slices/songs.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 | import { playMode } from "@/api/config";
3 |
4 | export const SongsSlice = createSlice({
5 | name: "songs",
6 | initialState: {
7 | playing: false,
8 | playList: [],
9 | sequencePlayList: [],
10 | mode: playMode.sequence,
11 | currentIndex: -1,
12 | showPlayList: false,
13 | currentSong: {},
14 | speed: 1,
15 | },
16 | reducers: {
17 | changeCurrentIndex: (state, action) => {
18 | state.currentIndex = action.payload;
19 | },
20 | changePlaying: (state, action) => {
21 | state.playing = action.payload;
22 | },
23 | changeShowPlayList: (state, action) => {
24 | state.showPlayList = action.payload;
25 | },
26 | changeSequencePlayList: (state, action) => {
27 | state.sequencePlayList = action.payload;
28 | },
29 | changePlayList: (state, action) => {
30 | state.playList = action.payload;
31 | },
32 | changeCurrentSong: (state, action) => {
33 | state.currentSong = action.payload;
34 | },
35 | changePlayMode: (state, action) => {
36 | state.mode = action.payload;
37 | },
38 | },
39 | });
40 | // 每个 case reducer 函数会生成对应的 Action creators
41 | export const {
42 | changeCurrentSong,
43 | changePlayList,
44 | changeShowPlayList,
45 | changePlaying,
46 | changeCurrentIndex,
47 | changeSequencePlayList,
48 | changePlayMode,
49 | } = SongsSlice.actions;
50 | export default SongsSlice.reducer;
51 |
--------------------------------------------------------------------------------
/src/utils/index.js:
--------------------------------------------------------------------------------
1 | export const debounce = (func, delay) => {
2 | let timer;
3 | return function (...args) {
4 | if (timer) {
5 | clearTimeout(timer);
6 | }
7 | timer = setTimeout(() => {
8 | func.apply(this, args);
9 | clearTimeout(timer);
10 | }, delay);
11 | };
12 | };
13 |
14 | //处理歌手列表拼接歌手名字
15 | export const getName = (list) => {
16 | let str = "";
17 | list.map((item, index) => {
18 | str += index === 0 ? item.name : "/" + item.name;
19 | return item;
20 | });
21 | return str;
22 | };
23 |
24 | //判断一个对象是否为空对象
25 | export const isEmptyObject = (obj) => !obj || Object.keys(obj).length === 0;
26 |
27 | function getRandomInt(min, max) {
28 | return Math.floor(Math.random() * (max - min + 1) + min);
29 | }
30 |
31 | // 随机算法
32 | export function shuffle(arr) {
33 | let new_arr = [];
34 | arr.forEach((item) => {
35 | new_arr.push(item);
36 | });
37 | for (let i = 0; i < new_arr.length; i++) {
38 | let j = getRandomInt(0, i);
39 | let t = new_arr[i];
40 | new_arr[i] = new_arr[j];
41 | new_arr[j] = t;
42 | }
43 | return new_arr;
44 | }
45 |
46 | // 找到当前的歌曲索引
47 | export const findIndex = (song, list) => {
48 | return list.findIndex((item) => {
49 | return song.id === item.id;
50 | });
51 | };
52 |
53 | //拼接出歌曲的url链接
54 | export const getSongUrl = (id) => {
55 | return `https://music.163.com/song/media/outer/url?id=${id}.mp3`;
56 | };
57 | //除去手机号码的空格符号
58 |
59 | export const trimPhone = (val) => val.replace(/(^\s+)|(\s+$)|\s+/g, "");
60 |
61 | //处理数据,找出第一个没有歌名的排行榜的索引
62 | export const filterIndex = (rankList) => {
63 | for (let i = 0; i < rankList.length - 1; i++) {
64 | if (rankList[i].tracks.length && !rankList[i + 1].tracks.length) {
65 | return i + 1;
66 | }
67 | }
68 | };
69 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 |
3 | import { nextui } from "@nextui-org/react";
4 | const config = {
5 | content: [
6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
9 | "./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}",
10 | ],
11 | theme: {
12 | extend: {
13 | backgroundImage: {
14 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
15 | "gradient-conic":
16 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
17 | },
18 | },
19 | },
20 | darkMode: "class",
21 | plugins: [nextui()],
22 | };
23 | export default config;
24 |
--------------------------------------------------------------------------------