Alyona Brytvina 2 سال پیش
والد
کامیت
e1a9f5fae5

+ 56 - 0
.eslintrc.js

@@ -0,0 +1,56 @@
+module.exports = {
+  env: {
+    browser: true,
+    es2021: true,
+  },
+  extends: [
+    'plugin:react/recommended',
+    'airbnb',
+    'eslint:recommended',
+  ],
+  parserOptions: {
+    ecmaFeatures: {
+      jsx: true,
+    },
+    ecmaVersion: 12,
+    sourceType: 'module',
+  },
+  ignorePatterns: ['node_modules/'],
+  plugins: [
+    'react',
+  ],
+  rules: {
+    'react/jsx-no-useless-fragment': 'off',
+    'react/require-default-props': 'off',
+    'react/jsx-props-no-spreading': 'off',
+    'react/jsx-tag-spacing': 'off',
+    'react/prop-types': 'off',
+    'react/function-component-definition': 'off',
+    'react/jsx-filename-extension': [1, { extensions: ['.jsx', '.js'] }],
+    'react/button-has-type': 'off',
+    'default-param-last': 'off',
+    'object-curly-spacing': 'off',
+    'no-underscore-dangle': 'off',
+    'no-case-declarations': 'off',
+    'no-unused-expressions': 'off',
+    'no-confusing-arrow': 'off',
+    'no-use-before-define': 'off',
+    'no-return-await': 'off',
+    'no-shadow': 'off',
+    'no-static-element-interactions': 'off',
+    'no-unused-vars': 'off',
+    'no-plusplus': 'off',
+    'no-nested-ternary': 'off',
+    'arrow-parens': 'off',
+    'max-len': 'off',
+    'quote-props': 'off',
+    'eslint quote-props': 'off',
+    'import/extensions': 'off',
+    'import/no-unresolved': 'off',
+    'import/prefer-default-export': 'off',
+    'jsx-a11y/no-static-element-interactions': 'off',
+    'jsx-a11y/click-events-have-key-events': 'off',
+    'jsx-a11y/label-has-associated-control': 'off',
+    'jsx-a11y/anchor-is-valid': 'off',
+  },
+};

+ 1 - 0
.gitignore

@@ -1,6 +1,7 @@
 # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
 
 # dependencies
+/.idea
 /node_modules
 /.pnp
 .pnp.js

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 882 - 63
package-lock.json


+ 16 - 0
package.json

@@ -3,12 +3,19 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
+    "@emotion/react": "^11.7.1",
+    "@emotion/styled": "^11.6.0",
+    "@mui/icons-material": "^5.2.5",
+    "@mui/material": "^5.2.6",
     "@testing-library/jest-dom": "^5.16.1",
     "@testing-library/react": "^12.1.2",
     "@testing-library/user-event": "^13.5.0",
     "react": "^17.0.2",
     "react-dom": "^17.0.2",
+    "react-redux": "^7.2.6",
+    "react-router-dom": "^5.3.0",
     "react-scripts": "5.0.0",
+    "redux": "^4.1.2",
     "web-vitals": "^2.1.2"
   },
   "scripts": {
@@ -34,5 +41,14 @@
       "last 1 firefox version",
       "last 1 safari version"
     ]
+  },
+  "devDependencies": {
+    "eslint": "^7.11.0",
+    "eslint-config-airbnb": "^19.0.0",
+    "eslint-plugin-import": "^2.25.3",
+    "eslint-plugin-jsx-a11y": "^6.5.1",
+    "eslint-plugin-react": "^7.28.0",
+    "eslint-plugin-react-hooks": "^4.3.0",
+    "sass": "^1.45.1"
   }
 }

+ 0 - 38
src/App.css

@@ -1,38 +0,0 @@
-.App {
-  text-align: center;
-}
-
-.App-logo {
-  height: 40vmin;
-  pointer-events: none;
-}
-
-@media (prefers-reduced-motion: no-preference) {
-  .App-logo {
-    animation: App-logo-spin infinite 20s linear;
-  }
-}
-
-.App-header {
-  background-color: #282c34;
-  min-height: 100vh;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  font-size: calc(10px + 2vmin);
-  color: white;
-}
-
-.App-link {
-  color: #61dafb;
-}
-
-@keyframes App-logo-spin {
-  from {
-    transform: rotate(0deg);
-  }
-  to {
-    transform: rotate(360deg);
-  }
-}

+ 0 - 25
src/App.js

@@ -1,25 +0,0 @@
-import logo from './logo.svg';
-import './App.css';
-
-function App() {
-  return (
-    <div className="App">
-      <header className="App-header">
-        <img src={logo} className="App-logo" alt="logo" />
-        <p>
-          Edit <code>src/App.js</code> and save to reload.
-        </p>
-        <a
-          className="App-link"
-          href="https://reactjs.org"
-          target="_blank"
-          rel="noopener noreferrer"
-        >
-          Learn React
-        </a>
-      </header>
-    </div>
-  );
-}
-
-export default App;

+ 0 - 8
src/App.test.js

@@ -1,8 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import App from './App';
-
-test('renders learn react link', () => {
-  render(<App />);
-  const linkElement = screen.getByText(/learn react/i);
-  expect(linkElement).toBeInTheDocument();
-});

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 9 - 0
src/assets/svgs/Vector.svg


+ 22 - 0
src/components/App/App.jsx

@@ -0,0 +1,22 @@
+import React from 'react';
+import './App.scss';
+import { BrowserRouter, Route, Switch } from 'react-router-dom';
+import { Provider } from 'react-redux';
+import { Header } from '../Header/Header';
+import { MainPage } from '../../pages/MainPage/MainPage';
+import { LoginPage } from '../../pages/LoginPage/LoginPage';
+import { PlaylistsPage } from '../../pages/PlaylistsPage/PlaylistsPage';
+import store from '../../store/store';
+
+export const App = () => (
+  <Provider store={store}>
+    <BrowserRouter>
+      <Header/>
+      <Switch>
+        <Route exact path="/" component={MainPage}/>
+        <Route exact path="/playlists" component={PlaylistsPage}/>
+        <Route exact path="/login" component={LoginPage}/>
+      </Switch>
+    </BrowserRouter>
+  </Provider>
+);

+ 9 - 0
src/components/App/App.scss

@@ -0,0 +1,9 @@
+* {
+  padding: 0;
+  margin: 0;
+}
+
+a {
+  text-decoration: none;
+  color: white;
+}

+ 24 - 0
src/components/Header/Header.jsx

@@ -0,0 +1,24 @@
+import React from 'react';
+import './Header.scss';
+import {
+  AppBar, Toolbar, Box, Button,
+} from '@mui/material';
+import { Link } from 'react-router-dom';
+import { ReactComponent as Vector } from '../../assets/svgs/Vector.svg';
+
+export const Header = () => (
+  <AppBar position="relative">
+    <Toolbar>
+      <Vector className="logo"/>
+      <Link to="/">
+        <Button variant="secondary">Main</Button>
+      </Link>
+      <Link to="/playlists">
+        <Button variant="secondary">Playlists</Button>
+      </Link>
+      <Link to="/login">
+        <Button variant="secondary">Login</Button>
+      </Link>
+    </Toolbar>
+  </AppBar>
+);

+ 4 - 0
src/components/Header/Header.scss

@@ -0,0 +1,4 @@
+.logo {
+  height: 45px;
+  width: 45px;
+}

+ 0 - 0
src/components/Player/Player.jsx


+ 91 - 0
src/components/TrackList/TrackList.jsx

@@ -0,0 +1,91 @@
+import React, { useEffect, useState } from 'react';
+import {
+  IconButton, List, ListItem, Typography,
+  Slider, Stack,
+} from '@mui/material';
+import { PlayCircle, VolumeUp } from '@mui/icons-material';
+import { useDispatch, useSelector } from 'react-redux';
+import {
+  actionPause, actionPlay, actionSetAudio, actionSetCurrentTrackId,
+} from '../../store/types/playerTypes';
+
+export const TrackList = ({trackList}) => {
+  const dispatch = useDispatch();
+  const playerState = useSelector(state => state.player);
+  const [volume, setVolume] = useState(0);
+  console.log(playerState);
+
+  const onVolumeChange = (id, value) => {
+    if (playerState.currentPlayingTrackId === id) {
+      setVolume(value / 100);
+      playerState.audio.volume = volume;
+    }
+  };
+  useEffect(() => {
+    if (playerState.trackList.length === 0) {
+      return;
+    }
+    if (playerState.isPlaying) {
+      if (playerState.audio === null) {
+        const {url} = playerState.trackList.find(track => track._id === playerState.currentPlayingTrackId);
+        const audio = new Audio(url);
+        audio.play();
+        dispatch(actionSetAudio(audio));
+      } else {
+        playerState.audio.play();
+      }
+    } else {
+      playerState.audio.pause();
+    }
+  }, [playerState.isPlaying]);
+
+  useEffect(() => {
+    if (playerState.audio !== null) {
+      const {url} = playerState.trackList.find(track => track._id === playerState.currentPlayingTrackId);
+      const audio = new Audio(url);
+      audio.play();
+      dispatch(actionSetAudio(audio));
+    }
+  }, [playerState.currentPlayingTrackId]);
+
+  const togglePlayPause = (id) => {
+    if (playerState.currentPlayingTrackId !== null && playerState.currentPlayingTrackId !== id) {
+      playerState.audio.pause();
+      dispatch(actionSetCurrentTrackId(id));
+    } else if (playerState.isPlaying === false) {
+      dispatch(actionPlay({
+        trackList, id,
+      }));
+    } else {
+      dispatch(actionPause());
+    }
+  };
+
+  return (
+    <List>
+      {trackList.map(track => (
+        <ListItem key={track._id}>
+          <IconButton onClick={() => togglePlayPause(track._id)}>
+            <PlayCircle/>
+          </IconButton>
+          <Typography>{track.originalFileName}</Typography>
+          <Stack
+            spacing={2}
+            direction="row"
+            sx={
+              {ml: 2, width: 200}
+            }
+          >
+            <VolumeUp/>
+            <Slider
+              max={100}
+              arial-label="Volume"
+              onChange={(e) => onVolumeChange(track._id, e.target.value)}
+              value={volume * 100}
+            />
+          </Stack>
+        </ListItem>
+      ))}
+    </List>
+  );
+};

+ 1 - 0
src/constants/index.js

@@ -0,0 +1 @@
+export const BACKEND_URL = 'http://player.asmer.fs.a-level.com.ua';

+ 0 - 13
src/index.css

@@ -1,13 +0,0 @@
-body {
-  margin: 0;
-  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
-    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
-    sans-serif;
-  -webkit-font-smoothing: antialiased;
-  -moz-osx-font-smoothing: grayscale;
-}
-
-code {
-  font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
-    monospace;
-}

+ 5 - 12
src/index.js

@@ -1,17 +1,10 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
-import './index.css';
-import App from './App';
-import reportWebVitals from './reportWebVitals';
+import { App } from './components/App/App';
+
+localStorage.authToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOnsiaWQiOiI2MWM2MWYwZWU5NDcyOTMzYTY3ODVlZmEiLCJsb2dpbiI6Imxlc2hhIiwiYWNsIjpbIjYxYzYxZjBlZTk0NzI5MzNhNjc4NWVmYSIsInVzZXIiXX0sImlhdCI6MTY0MDM3NDY3Mn0.51jdHqISRb19LqMYoEmlnLKWXi76r8b9nYwl2j4YLfg';
 
 ReactDOM.render(
-  <React.StrictMode>
-    <App />
-  </React.StrictMode>,
-  document.getElementById('root')
+  <App />,
+  document.getElementById('root'),
 );
-
-// If you want to start measuring performance in your app, pass a function
-// to log results (for example: reportWebVitals(console.log))
-// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
-reportWebVitals();

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 1
src/logo.svg


+ 7 - 0
src/pages/LoginPage/LoginPage.jsx

@@ -0,0 +1,7 @@
+import React from 'react';
+
+export const LoginPage = () => (
+  <div>
+    LoginPage
+  </div>
+);

+ 48 - 0
src/pages/MainPage/MainPage.jsx

@@ -0,0 +1,48 @@
+import React, { useEffect, useState } from 'react';
+import { TrackList } from '../../components/TrackList/TrackList';
+import { BACKEND_URL } from '../../constants';
+import { getGql } from '../../utils/getGql';
+
+// const trackList = [
+//   {
+//     _id: 1,
+//     url: 'https://cdn.drivemusic.club/dl/online/92VXJSQQDxV6Y6mnyWyxLg/1640753197/download_music/novogodnie_pesni/abba-happy-new-year.mp3',
+//     originalFileName: 'ABBA - Happy New Year!',
+//   },
+//   {
+//     _id: 2,
+//     url: 'https://cdn.drivemusic.club/dl/online/oT6fMW-T0FqmX3gYYp9Ijg/1640753313/download_music/2021/12/oksana-kovalevskaja-happy-end.mp3',
+//     originalFileName: 'Оксана Ковалевская - Happy End',
+//   },
+//   {
+//     _id: 3,
+//     url: 'https://cdn.drivemusic.club/dl/online/fA4XCWzbjt9Rox1rfhELbA/1640753313/download_music/2021/12/khleb-novogodnjaja.mp3',
+//     originalFileName: 'Новогодняя - ХЛЕБ',
+//   },
+//   {
+//     _id: 4,
+//     url: 'https://cdn.drivemusic.club/dl/online/cUrD6jj6Npb7dUu8IR0MLA/1640835038/download_music/2014/05/nico-vinz-am-i-wrong.mp3',
+//     originalFileName: 'Am I Wrong - Nico & Vinz',
+//   },
+// ];
+
+export const MainPage = () => {
+  const [tracks, setTracks] = useState([]);
+
+  useEffect(() => {
+    const gql = getGql(`${BACKEND_URL}/graphql`);
+    gql(`
+      query allTracks {
+        TrackFind(query: "[{}]") {
+         _id url originalFileName
+        }
+      }
+  `).then(data => setTracks(data.map(track => ({...track, url: `${BACKEND_URL}/${track.url}`}))));
+  }, []);
+
+  return (
+    <div>
+      <TrackList trackList={tracks}/>
+    </div>
+  );
+};

+ 7 - 0
src/pages/PlaylistsPage/PlaylistsPage.jsx

@@ -0,0 +1,7 @@
+import React from 'react';
+
+export const PlaylistsPage = () => (
+  <div>
+    PlaylistsPage
+  </div>
+);

+ 0 - 13
src/reportWebVitals.js

@@ -1,13 +0,0 @@
-const reportWebVitals = onPerfEntry => {
-  if (onPerfEntry && onPerfEntry instanceof Function) {
-    import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
-      getCLS(onPerfEntry);
-      getFID(onPerfEntry);
-      getFCP(onPerfEntry);
-      getLCP(onPerfEntry);
-      getTTFB(onPerfEntry);
-    });
-  }
-};
-
-export default reportWebVitals;

+ 0 - 5
src/setupTests.js

@@ -1,5 +0,0 @@
-// jest-dom adds custom jest matchers for asserting on DOM nodes.
-// allows you to do things like:
-// expect(element).toHaveTextContent(/react/i)
-// learn more: https://github.com/testing-library/jest-dom
-import '@testing-library/jest-dom';

+ 40 - 0
src/store/reducers/playerReducer.js

@@ -0,0 +1,40 @@
+import types from '../types/playerTypes';
+
+const initialState = {
+  trackList: [],
+  isPlaying: false,
+  duration: 0,
+  currentTime: 0,
+  audio: null,
+  currentPlayingTrackId: null,
+};
+
+export function playerReducer(state = initialState, action) {
+  switch (action.type) {
+    case types.PLAY:
+      const trackPlay = action.payload.trackList.find(track => track._id === action.payload.id);
+      return {
+        ...state,
+        isPlaying: true,
+        trackList: action.payload.trackList,
+        currentPlayingTrackId: trackPlay._id,
+      };
+    case types.PAUSE:
+      return {
+        ...state,
+        isPlaying: false,
+      };
+    case types.SET_AUDIO:
+      return {
+        ...state,
+        audio: action.payload,
+      };
+    case types.SET_CURRENT_TRACK_ID:
+      return {
+        ...state,
+        currentPlayingTrackId: action.payload,
+      };
+    default:
+      return state;
+  }
+}

+ 10 - 0
src/store/store.js

@@ -0,0 +1,10 @@
+import { combineReducers, createStore } from 'redux';
+import { playerReducer } from './reducers/playerReducer';
+
+const rootReducer = combineReducers({
+  player: playerReducer,
+});
+
+const store = createStore(rootReducer);
+
+export default store;

+ 13 - 0
src/store/types/playerTypes.js

@@ -0,0 +1,13 @@
+const types = {
+  PAUSE: 'PAUSE',
+  PLAY: 'PLAY',
+  SET_AUDIO: 'SET_AUDIO',
+  SET_CURRENT_TRACK_ID: 'SET_CURRENT_TRACK_ID',
+};
+
+export const actionPause = (payload) => ({type: types.PAUSE, payload});
+export const actionPlay = (payload) => ({type: types.PLAY, payload});
+export const actionSetAudio = (payload) => ({type: types.SET_AUDIO, payload});
+export const actionSetCurrentTrackId = (payload) => ({type: types.SET_CURRENT_TRACK_ID, payload});
+
+export default types;

+ 17 - 0
src/utils/getGql.js

@@ -0,0 +1,17 @@
+export const getGql = url => (query, variables = {}) => fetch(url, {
+  method: 'POST',
+  headers: {
+    'Content-Type': 'application/json',
+    ...(localStorage.authToken ? {'Authorization': `Bearer ${localStorage.authToken}`}
+      : {}),
+  },
+  body: JSON.stringify({query, variables}),
+})
+  .then(res => res.json())
+  .then(data => {
+    if (data.errors && !data.data) {
+      throw new Error(JSON.stringify(data.errors));
+    }
+
+    return data.data[Object.keys(data.data)[0]];
+  });