Browse Source

Завершил основные критические моменты done

Vladimir 2 years ago
parent
commit
5b78efbc22
10 changed files with 1170 additions and 0 deletions
  1. 127 0
      src/AdPage.js
  2. 22 0
      src/Header.js
  3. 32 0
      src/Login.js
  4. 42 0
      src/Main.js
  5. 71 0
      src/MainPage.js
  6. 116 0
      src/NewAd.js
  7. 37 0
      src/PasswordChange.js
  8. 92 0
      src/Profile.js
  9. 287 0
      src/Reducers.js
  10. 344 0
      src/index.scss

+ 127 - 0
src/AdPage.js

@@ -0,0 +1,127 @@
+import {connect}                                    from "react-redux";
+import {Link}                                       from 'react-router-dom';
+import {backendURL, actionAdById, actionNewComment} from "./Reducers";
+import {useState}                                   from "react";
+
+let AdImg = function({images}) {
+    return (
+        <div className="ad__img-container">
+            <ul className="ad__list">
+                {images && images.length != 0 ? 
+                images.map(item => <li>
+                                        <img className="ad__img"
+                                             src={item.url ?
+                                                `${backendURL}/${item.url}` :
+                                                `${backendURL}/images/96ecc4298f6ffd0b0aaa957318fbeae5`} />
+                                  </li>) :
+                <li><img className="ad__img" src={`${backendURL}/images/96ecc4298f6ffd0b0aaa957318fbeae5`} /></li>}
+            </ul>
+        </div>
+    );
+};
+
+let AdOwner = function({owner}) {
+    return (
+        <div className="ad__owner-container">
+            <span>Владелец</span>
+            <Link to={`/user/${owner?._id}`}>
+                <img className="ad__owner-img" 
+                     src={owner?.avatar ?
+                          `${backendURL}/${owner?.avatar.url}` :
+                          `${backendURL}/images/0c0a079786ed0b108acc21296ec2959f`} />
+                {owner?.login}
+            </Link>
+            <Link to={`/user-ad/${owner?._id}`}>Другие объявления автора</Link>
+        </div>
+    );
+};
+
+let AdTags = function({tags}) {
+    return (
+        <ul className="ad__tags-list">
+            {tags.map(text => <li>{text}</li>)}
+        </ul>
+    );
+};
+
+let AdInfo = function({address, createdAt, description, price, tags, title}) {
+    return (
+        <div className="ad__info-container">
+            <span>Опубликовано: {(new Date(+createdAt)).toLocaleString()}</span>
+            <h3>{title}</h3>
+            <span>Цена: {price}</span>
+            {tags && <AdTags  tags={tags}/>}
+            <p>Описание:<br/>{description}</p>
+        </div>
+    );
+};
+
+let Comment = function({comment: {createdAt, owner, text, _id}}) {
+    return (
+        <li>
+            <Link to={`/user/${owner?._id}`}>
+                <img className="ad__comment-owner-img" 
+                        src={owner?.avatar ?
+                            `${backendURL}/${owner?.avatar.url}` :
+                            `${backendURL}/images/0c0a079786ed0b108acc21296ec2959f`} />
+                <span>{owner?.login}</span><br/>
+            </Link>
+            <p>{text}</p>
+            <span>{(new Date(+createdAt)).toLocaleString()}</span>
+        </li>
+    );
+};
+
+let AdComments = function({comments}) {
+    return (
+        <div className="ad__comments-container">
+            <h3>Комментарии</h3>
+            <ul>
+                {comments ? 
+                    comments.map(comment => <Comment comment={comment} />) :
+                    <p>К этому объявлению пока нет комментариев, станьте первым</p>} 
+            </ul>
+        </div>
+    );
+};
+
+let AdNewComment = function({newComment, adId, answerToId}) {
+    const [text, setText] = useState("");
+
+    return (
+        <div className="ad_new-comment-container">
+            <textarea placeholder="Напишите ваш комментарий"
+                      onChange={evt => setText(evt.target.value)} />
+            <button onClick={() => newComment(text, adId, answerToId)}>Отправить</button>
+        </div>
+    );
+};
+
+const CAdNewComment = connect(null, {newComment: actionNewComment})(AdNewComment);
+
+let Advertisement = function({ad: {address, comments, createdAt, description, images, owner, price, tags, title, _id}}) {
+    return (
+        <section className="ad">
+            <h2 className="visually-hidden">Объявление</h2>
+            <AdImg images={images} />
+            <AdOwner owner={owner} />
+            <AdInfo address={address} createdAt={createdAt} description={description} price={price} tags={tags} title={title} />
+            <AdComments comments={comments}/>
+            <CAdNewComment adId={_id} answerToId={comments && comments[comments?.length - 1]?._id} />
+        </section>
+    );
+};
+
+const CAdvertisement = connect(state => ({ad: state.promise?.adById?.payload || {}}))(Advertisement);
+
+let AdPage = function({match: {params: {_id}}, adById}) {
+    adById(_id);
+
+    return (
+        <CAdvertisement />
+    );
+};
+
+const CAdPage = connect(null, {adById: actionAdById})(AdPage);
+
+export default CAdPage;

+ 22 - 0
src/Header.js

@@ -0,0 +1,22 @@
+import {Link} from 'react-router-dom';
+
+let Header = function() {
+    return (
+        <header className="header">
+            <div className="header__logo">Типо логотип</div>
+            <ul className="header__nav">
+                <li>
+                    <Link className="header__link" to="/">Главная</Link>
+                </li>
+                <li>
+                    <Link className="header__link" to="/login">Вход в профиль</Link>
+                </li>
+                <li>
+                    <Link className="header__link" to="/new-ad">Подать объявление</Link>
+                </li>
+            </ul>
+        </header>
+    );
+};
+
+export default Header;

+ 32 - 0
src/Login.js

@@ -0,0 +1,32 @@
+import React, {useState} from "react";
+import {Link}            from 'react-router-dom';
+import {connect}         from "react-redux";
+import {actionFullLogin} from "./Reducers";
+
+let Login = function({loginFeil, fullLogin}) {
+    const [login, setLogin] = useState("");
+    const [password, setPassword] = useState("");
+
+    return (
+        <section className="login">
+            <h2>Форма логина</h2>
+            <label>Введите ваш логин<br/>
+                <input type="text"
+                    placeholder="Введите ваш логин"
+                    onChange={evt => setLogin(evt.target.value)} />
+            </label>
+            <label>Введите ваш пароль<br/>
+                <input type="password"
+                    placeholder="Введите ваш пароль"
+                    onChange={evt => setPassword(evt.target.value)} />
+            </label>
+            <Link to="/password-change">Забыли пароль ?</Link>
+            {loginFeil && <p>Не верный логин или пароль</p>}
+            <button onClick={() => fullLogin(login, password)}>Войти</button>
+        </section>
+    );
+};
+
+const CLogin = connect(state => ({loginFeil: state.auth?.error}), {fullLogin: actionFullLogin})(Login)
+
+export default CLogin;

+ 42 - 0
src/Main.js

@@ -0,0 +1,42 @@
+import {Route, Switch, Redirect} from 'react-router-dom';
+import CMainPage                 from "./MainPage";
+import CLogin                    from "./Login";
+import CPasswordChange           from "./PasswordChange";
+import CAdPage                   from "./AdPage";
+import CNewAd                    from "./NewAd";
+import CProfile                  from "./Profile";
+import { connect }               from 'react-redux';
+
+const ProtectedRoute = function({roles=[], fallback="/login", component, auth, ...routeProps}) {
+    const WrapperComponent = (renderProps) => {
+        const C = component;
+        if(!roles.filter(item => item.includes([auth])).length) {
+            return <Redirect to={fallback} />
+        };
+        return <C {...renderProps} />
+    }
+    return <Route {...routeProps} component={WrapperComponent}/>
+};
+
+const CProtectedRoute = connect(state => ({auth: state.auth?.payload?.sub?.acl[1] || "anon"}))(ProtectedRoute);
+
+let Main = function({authId}) {
+    return (
+        <main className="main">
+            <h1 className="visually-hidden">Сервис для объявлений по купле/продажи товаров и услуг</h1>
+            <Switch>
+                <Route path="/"                exact component={CMainPage} />
+                <Route path="/user-ad/:_id"    exact component={CMainPage} />
+                <Route path="/password-change" exact component={CPasswordChange} />
+                <CProtectedRoute roles={["anon"]} fallback={`/user/${authId}`} path="/login" component={CLogin} />
+                <CProtectedRoute roles={["user"]} fallback="/login" path="/ad/:_id" component={CAdPage} />
+                <CProtectedRoute roles={["user"]} fallback="/login" path="/new-ad" component={CNewAd} />
+                <CProtectedRoute roles={["user"]} fallback="/login" path="/user/:_id" component={CProfile} />
+            </Switch>
+        </main>
+    );
+};
+
+const CMain = connect(state => ({authId: state.auth?.payload?.sub?.acl[0]}))(Main);
+
+export default CMain;

+ 71 - 0
src/MainPage.js

@@ -0,0 +1,71 @@
+import {backendURL, 
+       actionEnndlessScrollAdFind, 
+       actionEndlessScrollClear} from "./Reducers";
+import {connect}                 from "react-redux";
+import {Link}                    from 'react-router-dom';
+import {useEffect}               from "react";
+
+let AdItem = function({adItem: {createdAt, images, price, title, _id}}) {
+    return (
+        <li>
+            <Link to={`/ad/${_id}`}>
+                <img className="main-page__img"
+                    src={images && images[0]?.url ? 
+                    `${backendURL}/${images[0]?.url}` :
+                    `${backendURL}/images/96ecc4298f6ffd0b0aaa957318fbeae5`}/>
+
+                <h3>{title ? title : "Title"}</h3>
+                <div>
+                    <span>Цена: {price}</span><br/>
+                    <span>{(new Date(+createdAt)).toLocaleString()}</span>
+                </div>
+            </Link>
+        </li>
+    );
+};
+
+let AdList = function({ArrAd}) {
+    return (
+        <ul className="main-page__list">
+            {ArrAd.map(adItem => <AdItem adItem={adItem} />)}
+        </ul>
+    );
+};
+
+const CAdList = connect(state => ({ArrAd: state.scroll?.arr || []}))(AdList);
+
+let MainPage = function({match: {params: {_id}}, userLogin, adFind, adClear}) {
+    let checkScrollBottom = function() {
+        if ((window.innerHeight + window.pageYOffset) >= document.body.offsetHeight) {
+            adFind(_id, 10);
+        };
+    };
+
+    useEffect(() => {
+        window.addEventListener("scroll", checkScrollBottom);
+
+        if(userLogin) {
+            adFind(_id, 10);
+        };
+
+        return () => {
+            window.removeEventListener("scroll", checkScrollBottom);
+            adClear();
+        };
+    });
+
+    return (
+        <section className="main-page">
+            <h2>Главная страница</h2>
+            <input placeholder="Поиск" />
+            {!!userLogin ? <CAdList /> :
+                           <p>Для того что бы увидеть объявления нужно залогинится </p>}
+        </section>
+    );
+};
+
+const CMainPage = connect(state => ({userLogin: state.auth?.payload}), 
+                                    {adFind: actionEnndlessScrollAdFind,
+                                     adClear: actionEndlessScrollClear})(MainPage);
+
+export default CMainPage;

+ 116 - 0
src/NewAd.js

@@ -0,0 +1,116 @@
+import {connect}                                          from "react-redux";
+import {useDropzone}                                      from 'react-dropzone';
+import React, {useState, useEffect}                       from "react";
+import {backendURL, actionUploadFiles, actionSaveAdState} from "./Reducers";
+import {sortableContainer, sortableElement}               from 'react-sortable-hoc';
+import {arrayMoveImmutable}                               from 'array-move';
+import Select                                             from 'react-select'
+
+function Dropzone({uploadFiles}) {
+    const {acceptedFiles, getRootProps, getInputProps} = useDropzone();
+
+    useEffect(() => {
+        if(acceptedFiles.length) {
+            uploadFiles(acceptedFiles)
+        }
+    }, [acceptedFiles])
+
+    return (
+        <section className="container">
+            <div {...getRootProps({className: 'dropzone'})}>
+                <input name="photo" {...getInputProps()} />
+                <p>Drag 'n' drop some files here, or click to select files</p>
+            </div>
+        </section>
+    );
+};
+
+const CDropzone = connect(null, {uploadFiles: actionUploadFiles})(Dropzone);
+
+const SortableContainer = sortableContainer(({children}) => {
+    return (
+        <ul className="new-ad__sort-list">{children}</ul>
+    );
+});
+
+const SortableItem = sortableElement(({url}) => {
+    return (
+      <li>
+        <img className="new-ad__sort-img" src={`${backendURL}/${url}`} />
+      </li>
+    );
+  });
+
+let NewAd = function({uploadFiles, onSave}) {
+    const [state, setState] = useState({images: []});
+
+    useEffect(() => {
+        if(uploadFiles?.status == "FULFILLED") {
+            setState({...state, images: [...state.images, ...uploadFiles?.payload]});
+        };
+    }, [uploadFiles]);
+
+    let onSortEnd = ({oldIndex, newIndex}) => {
+        setState(({images}) => {
+            return {...state, images: arrayMoveImmutable(images, oldIndex, newIndex)}
+        });
+    };
+
+    const selectTags = [
+        { value: 'Детский мир', label: 'Детский мир' },
+        { value: 'Недвижимость', label: 'Недвижимость' },
+        { value: 'Авто', label: 'Авто' },
+        { value: 'Запчасти для транспорта', label: 'Запчасти для транспорта' },
+        { value: 'Работа', label: 'Работа' },
+        { value: 'Животные', label: 'Животные' },
+        { value: 'Дом и сад', label: 'Дом и сад' },
+        { value: 'Электроника', label: 'Электроника' },
+        { value: 'Хобби, отдых и спорт', label: 'Хобби, отдых и спорт' },
+        { value: 'Обмен', label: 'Обмен' },
+        { value: 'Медицина', label: 'Медицина' }
+    ]
+
+    return (
+        <section className="new-ad">
+            <h2 className="new-ad__title">Создать объявление</h2>
+            <div className="new-ad__dropzone-container">
+                <CDropzone />
+                <SortableContainer onSortEnd={onSortEnd}>
+                    {state.images.map((item, index) => <SortableItem index={index} url={item.url} />)}
+                </SortableContainer>
+            </div>
+            <div className="new-ad__ad-info-container">
+                <label>Оглавление<br/>
+                    <input type="text"
+                           placeholder="Введите оглавление"
+                           onChange={(evt) => setState({...state, title: evt.target.value})} />
+                </label><br/>
+                <label>Описание<br/>
+                    <textarea placeholder="Введите описание"
+                              onChange={(evt) => setState({...state, description: evt.target.value})} />
+                </label><br/>
+                <label>Выберите один или несколько категорий которые больше всего подходят вашему объявлению
+                    <Select isMulti
+                            name="tags"
+                            options={selectTags}
+                            onChange={(tags) => setState({...state, tags: tags.map(tag => tag.label)})} />
+                </label><br/>
+                <label>Введите ваш адрес
+                    <input type="text"
+                           placeholder="Введите ваш адрес"
+                           onChange={(evt) => setState({...state, address: evt.target.value})} />
+                </label><br/>
+                <label>Цена:
+                    <input type="number"
+                           onChange={(evt) => setState({...state, price: +evt.target.value})} />
+                </label>
+                <button onClick={() => onSave(state)}>Создать</button>
+            </div>
+        </section>
+    );
+};
+
+const CNewAd = connect(state => ({uploadFiles: state.promise?.uploadFiles}),
+                                 {onSave: actionSaveAdState})(NewAd);
+
+export default CNewAd;

+ 37 - 0
src/PasswordChange.js

@@ -0,0 +1,37 @@
+import {useState}             from "react";
+import {connect}              from "react-redux";
+import {actionChangePassword} from "./Reducers";
+
+let PasswordChange = function({changeFeil, changePassword}) {
+    const [login, setLogin]             = useState("");
+    const [password, setPassword]       = useState("");
+    const [newPassword, setNewPassword] = useState("");
+
+    return (
+        <section className="password-change">
+            <h2>Форма смены пароля</h2>
+            <label>Введите ваш логин<br/>
+                <input type="text"
+                       placeholder="Введите ваш логин"
+                       onChange={evt => setLogin(evt.target.value)} />
+            </label>
+            <label>Введите старый пароль<br/>
+                <input type="password"
+                       placeholder="Введите старый пароль"
+                       onChange={evt => setPassword(evt.target.value)} />
+            </label>
+            <label>Введите новый пароль<br/>
+                <input type="password"
+                       placeholder="Введите новый пароль"
+                       onChange={evt => setNewPassword(evt.target.value)} />
+            </label>
+            {changeFeil && <p>Не верный логин или пароль</p>}
+            <button onClick={() => changePassword(login, password, newPassword)}>Сменить пароль</button>
+        </section>
+    );
+};
+
+const CPasswordChange = connect(state => ({changeFeil: state.auth?.error}),
+                                          {changePassword: actionChangePassword})(PasswordChange)
+
+export default CPasswordChange;

+ 92 - 0
src/Profile.js

@@ -0,0 +1,92 @@
+import {useState}                                          from "react";
+import {connect}                                           from "react-redux";
+import {Redirect} from 'react-router-dom';
+import {backendURL, actionAboutUserById, actionAuthLogout} from "./Reducers";
+
+let UserInfo = function({user: {addresses, createdAt, login, nick, phones}}) {
+    return (
+        <div>
+            <h3>Инфо</h3>
+            <table>
+                <tr>
+                    <td>Логин:</td>
+                    <td>{login}</td>
+                </tr>
+                <tr>
+                    <td>Никнейм:</td>
+                    <td>{nick || "Отсутствует"}</td>
+                </tr>
+                <tr>
+                    <td>Телефон:</td>
+                    <td>{phones || "Отсутствует"}</td>
+                </tr>
+                <tr>
+                    <td>Адрес:</td>
+                    <td>{addresses || "Отсутствует"}</td>
+                </tr>
+                <tr>
+                    <td>Зарегистрирован:</td>
+                    <td>{(new Date(+createdAt)).toLocaleString()}</td>
+                </tr>
+            </table>
+        </div>
+    );
+};
+
+let Ad = function({}) {
+    return (
+        <div>
+            <h3>Объявления</h3>
+        </div>
+    );
+};
+
+let Message = function({}) {
+    return (
+        <div>
+            <h3>Сообщения</h3>
+        </div>
+    );
+};
+
+let ProfilePage = function({user, owner: {sub: {id}}, logout}) {
+    const [show, setShow] = useState({info: true, ad: false, message: false});
+
+    return (
+        <section className="user-profile">
+            <h2>{user?._id == id ? "Мой профиль" : `Профиль пользователя ${user?.login}`}</h2>
+            <div className="user-profile__head">
+                <img className="user-profile__avatar"
+                     src={user?.avatar ?
+                         `${backendURL}/${user?.avatar?.url}` :
+                         `${backendURL}/images/96ecc4298f6ffd0b0aaa957318fbeae5`} />
+
+                {user?._id == id && <button>Сменить аватар</button>}
+                {user?._id == id && <button onClick={() => logout()}>Выйти из личного кабинета</button>}
+            </div>
+            <ul className="user-profile__list">
+                <li><button onClick={() => setShow({info: true, ad: false, message: false})}>Общая информация</button></li>
+                <li><button onClick={() => setShow({info: false, ad: true, message: false})}>Объявления</button></li>
+                {user?._id == id && <li><button onClick={() => setShow({info: false, ad: false, message: true})}>Мои сообщения</button></li>}
+            </ul>
+            {show.info && <UserInfo user={user} />}
+            {show.ad && <Ad />}
+            {show.message && <Message />}
+        </section>
+    );
+};
+
+const CProfilePage = connect(state => ({user: state.promise?.aboutUserById?.payload || {},
+                                        owner: state.auth?.payload}), 
+                                       {logout: actionAuthLogout})(ProfilePage);
+
+let Profile = function({match: {params: {_id}}, aboutUser}) {
+    aboutUser(_id);
+    return (
+        <CProfilePage userId={_id} />
+    );
+};
+
+const CProfile = connect(null, {aboutUser: actionAboutUserById})(Profile);
+
+export default CProfile;

+ 287 - 0
src/Reducers.js

@@ -0,0 +1,287 @@
+import {createStore, combineReducers, applyMiddleware} from "redux";
+import thunk                                           from "redux-thunk";
+
+const getGQL = function(url) {
+    return async function(query, variables) {
+        const res = await fetch(url, {
+            method: "POST",
+            headers: {
+                "Content-Type": "application/json",
+                ...(localStorage.authToken ? { "Authorization": "Bearer " + localStorage.authToken } : {})
+            },
+            body: JSON.stringify({ query, variables })
+        });
+        const data = await res.json();
+        if (data.data) {
+            return Object.values(data.data)[0];
+        }
+        else {
+            throw new Error(JSON.stringify(data.errors));
+        };
+    };
+};
+
+const backendURL = 'http://marketplace.asmer.fs.a-level.com.ua';
+
+const gql = getGQL(backendURL + '/graphql');
+
+const promiseReducer = function(state={}, {type, name, status, payload, error}) {
+    if (type == 'PROMISE'){
+        return {
+            ...state,
+            [name]:{status, payload, error}
+        }
+    }
+
+    return state;
+};
+
+const actionPending             = name => ({type: "PROMISE", name, status: 'PENDING'});
+const actionFulfilled = (name,payload) => ({type: "PROMISE", name, status: 'FULFILLED', payload});
+const actionRejected  = (name,error)   => ({type: "PROMISE", name, status: 'REJECTED', error});
+const actionPromise = function(name, promise) {
+    return async dispatch => {
+        dispatch(actionPending(name));
+        try {
+            let payload = await promise
+            dispatch(actionFulfilled(name, payload))
+            return payload
+        }
+        catch(error){
+            dispatch(actionRejected(name, error))
+        };
+    };
+};
+
+let jwtDecode = function(token) {
+    let payloadInBase64;
+    let payloadInJson;
+    let payload;
+
+    try {
+        payloadInBase64 = token.split(".")[1];
+        payloadInJson = atob(payloadInBase64);
+        payload = JSON.parse(payloadInJson);
+
+        return payload;
+    }
+    catch(err) {
+
+    }
+};
+  
+const authReducer = function(state, {type, token}) {
+    let payload;
+
+    if (state == undefined) {
+        if(localStorage.authToken) {
+            type = "AUTH_LOGIN";
+            token = localStorage.authToken;
+        } else {
+            type = "AUTH_LOGOUT";
+        };
+    };
+    if (type == "AUTH_LOGIN") {
+        payload = jwtDecode(token);
+
+        if(payload) {
+            localStorage.authToken = token;
+            return {
+                token: token,
+                payload: payload
+            }
+        } else {
+            return {
+                error: true
+            }
+        }
+
+    };
+    if (type == "AUTH_LOGOUT") {
+        localStorage.removeItem("authToken");
+
+        return {};
+    };
+
+    return state || {};
+};
+
+const actionAuthLogin  = token => ({type: "AUTH_LOGIN", token});
+const actionAuthLogout = () => ({type: "AUTH_LOGOUT"});
+const actionFullLogin = function(login, password) {
+  return async dispatch => {
+    let token = await gql(`query userLogin($login: String!, $password: String!) {
+        login(login: $login, password: $password)
+      }`, {
+          "login": login,
+          "password": password
+      });
+    dispatch(actionAuthLogin(token));
+  };
+};
+
+const actionFullRegister = function(login, password) {
+  return async dispatch => {
+    await dispatch(actionPromise("userRegister", gql(`mutation userRegister($login: String!, $password: String!) {
+      createUser(user: {login: $login, password: $password}) {
+        _id login
+      }
+    }`,
+    {
+      "login": login,
+      "password": password
+    })));
+    dispatch(actionFullLogin(login, password));
+  };
+};
+
+const actionChangePassword = function(login, password, newPassword) {
+    return async dispatch => {
+        await dispatch(actionPromise("userChangPassword", gql(`mutation userChangePassword($login: String!, $password: String!, $newPassword: String!) {
+            changePassword(login: $login, password: $password, newPassword: $newPassword) {
+              _id login
+            }
+          }`, {
+              "login": login,
+              "password": password,
+              "newPassword": newPassword
+          })));
+          dispatch(actionFullLogin(login, newPassword));
+    };
+};
+
+const actionAdById = function(_id) {
+    return actionPromise("adById", gql(`query adFindById($query: String) {
+      AdFindOne(query: $query) {
+        _id createdAt title description address price tags
+        owner {
+          _id login 
+          avatar {
+            _id url
+          }
+        }
+        images {
+          _id url
+        }
+        comments {
+          _id text createdAt
+          owner {
+            _id login
+            avatar {
+              _id url
+            }
+          }
+        }
+      }
+    }`, {
+        query: JSON.stringify([{"_id": _id}])
+      }));
+};
+
+const actionNewComment = function(text, adId, answerToId) {
+  return async dispatch => {
+    await dispatch(actionPromise("newComment", gql(`mutation newComment($text: String, $adId: ID, $answerToId: ID) {
+      CommentUpsert(comment: {text: $text, ad: {_id: $adId}, answerTo: {_id: $answerToId}}) {
+        _id
+      }
+    }`, {
+      text: text,
+      adId: adId,
+      answerToId: answerToId
+    })));
+    dispatch(actionAdById(adId))
+  };
+};
+
+const uploadFile = async function(file) {
+  let fd = new FormData();
+
+  fd.append("photo", file);
+
+  const res = await fetch(`${backendURL}/upload`, {
+    method: "POST",
+    headers: localStorage.authToken ? { Authorization: 'Bearer ' + localStorage.authToken } : {},
+    body: fd
+  });
+
+  return res.json();
+};
+
+const actionUploadFiles = function(files) {
+  return actionPromise("uploadFiles", Promise.all(files.map(file => uploadFile(file))));
+};
+
+const actionSaveAdState = function(state) {
+  return actionPromise("newAd", gql(`mutation newAd($ad: AdInput) {
+    AdUpsert(ad: $ad) {
+      _id
+    }
+  }`, {
+    ad: {...state, images: state.images.map(imag => ({_id: imag._id}))}
+  }));
+};
+
+const endlessScrollReducer = function(state={skip: 0, arr: []}, {type, arr, limit}) {
+  if(type == "add") {
+    return {skip: state.skip + limit,
+            arr: [...state.arr, ...arr]}
+  };
+  if(type == "clear") {
+    return {skip: 0, arr: []}
+  }
+  return state;
+};
+
+const actionEndlessScrollAdd = (arr, limit)  => ({type: "add", arr, limit});
+const actionEndlessScrollClear = () => ({type: "clear"});
+const actionEnndlessScrollAdFind = function(_id, limit) {
+  return async (dispatch, getState) => {
+    let result = await gql(`query userAdFindById($query: String) {
+      AdFind(query: $query) {
+        _id title price createdAt images {
+          _id url
+        }
+      }
+    }`, {
+      query: JSON.stringify([{"___owner": _id},
+                             {"sort": [{"_id": -1}],
+                             "skip": [getState().scroll?.skip],
+                             "limit": [limit]}])
+    });
+    dispatch(actionEndlessScrollAdd(result, limit));
+  };
+};
+
+const actionAboutUserById = function(_id) {
+  return actionPromise("aboutUserById", gql(`query aboutUser($query:String) {
+    UserFindOne(query: $query) {
+      _id createdAt login nick phones addresses
+      avatar {
+        _id url
+      }
+      incomings {
+        _id createdAt text
+        image {
+          _id url
+        }
+        to {
+          _id login
+        }
+        owner {
+          _id login
+          avatar {
+            _id url
+          }
+        }
+      }
+    }
+  }`, {
+    query: JSON.stringify([{"___owner": _id}])
+  }))
+};
+
+const store = createStore(combineReducers({auth: authReducer, promise: promiseReducer, scroll: endlessScrollReducer}), applyMiddleware(thunk));
+
+store.subscribe(() => console.log(store.getState()));
+
+export {store, backendURL, actionFullLogin, actionAuthLogout, actionFullRegister, actionChangePassword, actionEnndlessScrollAdFind, actionEndlessScrollClear, actionAdById, actionNewComment, actionUploadFiles, actionSaveAdState, actionAboutUserById};

+ 344 - 0
src/index.scss

@@ -0,0 +1,344 @@
+/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
+
+html {
+    line-height: 1.15; /* 1 */
+    -webkit-text-size-adjust: 100%; /* 2 */
+}
+
+body {
+    margin: 0;
+}
+
+main {
+    display: block;
+}
+
+h1 {
+    font-size: 2em;
+    margin: 0.67em 0;
+}
+
+hr {
+    box-sizing: content-box; /* 1 */
+    height: 0; /* 1 */
+    overflow: visible; /* 2 */
+}
+
+pre {
+    font-family: monospace, monospace; /* 1 */
+    font-size: 1em; /* 2 */
+}
+
+a {
+    background-color: transparent;
+}
+
+abbr[title] {
+    border-bottom: none; /* 1 */
+    text-decoration: underline; /* 2 */
+    text-decoration: underline dotted; /* 2 */
+}
+
+b,
+strong {
+    font-weight: bolder;
+}
+
+code,
+kbd,
+samp {
+    font-family: monospace, monospace; /* 1 */
+    font-size: 1em; /* 2 */
+}
+
+small {
+    font-size: 80%;
+}
+
+sub,
+sup {
+    font-size: 75%;
+    line-height: 0;
+    position: relative;
+    vertical-align: baseline;
+}
+
+sub {
+    bottom: -0.25em;
+}
+
+sup {
+    top: -0.5em;
+}
+
+img {
+    border-style: none;
+}
+
+button,
+input,
+optgroup,
+select,
+textarea {
+    font-family: inherit; /* 1 */
+    font-size: 100%; /* 1 */
+    line-height: 1.15; /* 1 */
+    margin: 0; /* 2 */
+}
+
+button,
+input { /* 1 */
+    overflow: visible;
+}
+
+button,
+select { /* 1 */
+    text-transform: none;
+}
+
+button,
+    [type="button"],
+    [type="reset"],
+    [type="submit"] {
+    -webkit-appearance: button;
+}
+
+button::-moz-focus-inner,
+[type="button"]::-moz-focus-inner,
+[type="reset"]::-moz-focus-inner,
+[type="submit"]::-moz-focus-inner {
+    border-style: none;
+    padding: 0;
+}
+
+button:-moz-focusring,
+[type="button"]:-moz-focusring,
+[type="reset"]:-moz-focusring,
+[type="submit"]:-moz-focusring {
+    outline: 1px dotted ButtonText;
+}
+
+fieldset {
+    padding: 0.35em 0.75em 0.625em;
+}
+
+legend {
+    box-sizing: border-box; /* 1 */
+    color: inherit; /* 2 */
+    display: table; /* 1 */
+    max-width: 100%; /* 1 */
+    padding: 0; /* 3 */
+    white-space: normal; /* 1 */
+}
+
+progress {
+    vertical-align: baseline;
+}
+
+textarea {
+    overflow: auto;
+}
+
+[type="checkbox"],
+[type="radio"] {
+    box-sizing: border-box; /* 1 */
+    padding: 0; /* 2 */
+}
+
+[type="number"]::-webkit-inner-spin-button,
+[type="number"]::-webkit-outer-spin-button {
+    height: auto;
+}
+
+[type="search"] {
+    -webkit-appearance: textfield; /* 1 */
+    outline-offset: -2px; /* 2 */
+}
+
+[type="search"]::-webkit-search-decoration {
+    -webkit-appearance: none;
+}
+
+::-webkit-file-upload-button {
+    -webkit-appearance: button; /* 1 */
+    font: inherit; /* 2 */
+}
+
+details {
+    display: block;
+}
+
+summary {
+    display: list-item;
+}
+
+template {
+    display: none;
+}
+
+[hidden] {
+    display: none;
+}
+
+.visually-hidden:not(:focus):not(:active), 
+input[type=checkbox].visually-hidden, 
+input[type=radio].visually-hidden {
+    position: absolute;
+    width: 1px;
+    height: 1px;
+    margin: -1px;
+    border: 0;
+    padding: 0;
+    white-space: nowrap;
+    -webkit-clip-path: inset(100%);
+    clip-path: inset(100%);
+    clip: rect(0 0 0 0);
+    overflow: hidden;
+}
+
+#root {
+    font-family: 'Segoe UI', Tahoma, sans-serif;
+    background-color: #B681F5;
+}
+
+.header {
+    display: flex;
+    flex-wrap: nowrap;
+    justify-content: space-between;
+    align-items: center;
+    color: #fff;
+    background-color: #573E75;
+    padding: 10px 20px;
+    &__logo {
+        font-size: 30px;
+        font-weight: 600;
+    }
+    &__nav {
+        display: flex;
+        flex-wrap: wrap;
+        list-style: none;
+        margin: 0;
+        padding: 0;
+    }
+    &__link {
+        color: #fff;
+        text-decoration: none;
+        padding: 10px 15px;
+        transition: opacity 0.3s;
+        &:hover {
+          opacity: 0.4;  
+        }
+    }
+}
+
+.main {
+    display: flex;
+    flex-wrap: wrap;
+    background-color: #DFCDF7;
+    &__container {
+        width: 250px;
+        height: 400px;
+        margin: 0 auto;
+        margin-bottom: 20px;
+        background-color: #fff;
+    }
+}
+
+.main-page {
+    background-color: #fff;
+    margin: 0 auto;
+    &__list {
+        display: flex;
+        flex-wrap: wrap;
+        list-style: none;
+        margin: 0;
+        padding: 0;
+    }
+    &__img {
+        width: 250px;
+        vertical-align: bottom;
+    }
+}
+
+.login {
+    display: flex;
+    flex-wrap: wrap;
+    flex-direction: column;
+    background-color: #fff;
+    margin: 0 auto;
+}
+
+.password-change {
+    display: flex;
+    flex-wrap: wrap;
+    flex-direction: column;
+    background-color: #fff;
+    margin: 0 auto;
+}
+
+.ad {
+    display: flex;
+    flex-wrap: wrap;
+    width: 100%;
+    &__list {
+        list-style: none;
+        margin: 0;
+        padding: 0;
+    }
+    &__img-container {
+        flex-basis: 70%;
+    }
+    &__img {
+        width: 400px;
+        vertical-align: bottom;
+    }
+    &__owner-container {
+        flex-basis: 30%;
+    }
+    &__owner-img {
+        width: 100px;
+        border-radius: 50%;
+    }
+    &__info-container {
+        flex-basis: 100%;
+    }
+    &__comment-owner-img {
+        width: 50px;
+        border-radius: 50%;
+    }
+}
+
+.new-ad {
+    display: flex;
+    flex-wrap: wrap;
+    margin: 0 auto;
+    background-color: #fff;
+    &__title {
+        flex-basis: 100%;
+    }
+    &__dropzone-container {
+        flex-basis: 50%;
+    }
+    &__sort-list {
+        list-style: none;
+        margin: 0;
+        padding: 0;
+    }
+    &__sort-img {
+        width: 100px;
+    }
+    &__ad-info-container {
+        flex-basis: 50%;
+    }
+}
+
+.user-profile {
+    &__avatar {
+        width: 200px;
+    }
+    &__list {
+        list-style: none;
+        margin: 0;
+        padding: 0;
+    }
+}