Initial commit
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
build/
|
||||
.idea/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.26",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.12.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.12.1",
|
||||
"@fortawesome/react-fontawesome": "^0.1.8",
|
||||
"@sentry/browser": "^5.7.1",
|
||||
"@types/react-infinite-scroller": "^1.2.1",
|
||||
"axios": "^0.19.0",
|
||||
"bbcode-to-react": "^0.2.9",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-infinite-scroller": "^1.2.4",
|
||||
"react-loader-spinner": "^3.1.4",
|
||||
"react-recaptcha": "^2.3.10",
|
||||
"react-redux": "^7.1.3",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"redux": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "12.12.26",
|
||||
"@types/react": "16.9.19",
|
||||
"@types/react-dom": "^16.9.5",
|
||||
"@types/react-redux": "^7.1.7",
|
||||
"@types/react-router-dom": "^5.1.3",
|
||||
"node-sass": "^4.13.1",
|
||||
"redux-devtools-extension": "^2.13.8",
|
||||
"typescript": "^3.8.2",
|
||||
"react-scripts": "^3.3.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<title>Unmei</title>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
|
||||
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="Unmei">
|
||||
<meta name="application-name" content="Unmei">
|
||||
<meta name="theme-color" content="#0b0b1a">
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16x16.png">
|
||||
<link rel="shortcut icon" href="/static/icons/favicon.ico">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<main id="root"></main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"short_name": "Unmei",
|
||||
"name": "Unmei",
|
||||
"icons": [{
|
||||
"src": "/static/icons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}, {
|
||||
"src": "/static/icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}],
|
||||
"display": "fullscreen",
|
||||
"start_url": "/",
|
||||
"theme_color": "#0b0b1a",
|
||||
"background_color": "#121222"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /admin
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 304 B |
|
After Width: | Height: | Size: 402 B |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1,36 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
|
||||
export const http = axios.create({
|
||||
baseURL: process.env.NODE_ENV === "development" ? 'http://localhost:8080/v1' : 'https://api.unmei.nix13.pw/v1',
|
||||
withCredentials: true
|
||||
});
|
||||
|
||||
http.interceptors.response.use(undefined, (error: AxiosError) => {
|
||||
if(error.response) {
|
||||
if(error.response?.status === 429) {
|
||||
return new Promise(res => {
|
||||
setTimeout(res.bind(null, http.request(error.config)), 1000)
|
||||
});
|
||||
} else {
|
||||
if(error.response.data.error_data)
|
||||
return Promise.reject(error.response.data.error_data);
|
||||
}
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
const get = (url: string, data?: object) => http.get(url, data).then(r => r?.data?.data);
|
||||
const post = (url: string, data?: object) => http.post(url, data).then(r => r?.data.data);
|
||||
const put = (url: string, data?: object) => http.put(url, data).then(r => r?.data.data);
|
||||
const del = (url: string, data?: object) => http.delete(url, data).then(r => r?.data.data);
|
||||
|
||||
const TranslateStatus: { [id: string]: string } = {
|
||||
planned: 'Запланировано',
|
||||
completed: 'Пройдено',
|
||||
in_progress: 'Прохожу',
|
||||
dropped: 'Брошено',
|
||||
deferred: 'Отложено'
|
||||
};
|
||||
|
||||
export { get, post, put, del, TranslateStatus };
|
||||
@@ -0,0 +1,9 @@
|
||||
import {http} from "./api";
|
||||
|
||||
export const fetchChar = (id: number) =>
|
||||
http.get(`characters/${id}`)
|
||||
.then(res => res.data.data);
|
||||
|
||||
export const fetchCharNovels = (id: number) =>
|
||||
http.get(`characters/${id}/novels`)
|
||||
.then(res => res.data.data);
|
||||
@@ -0,0 +1,9 @@
|
||||
const errors: { [key: number]: string } = {
|
||||
1: 'Неверный логин и/или пароль!',
|
||||
2: 'Проверка ReCaptcha не пройдена!',
|
||||
5: 'Аккаунт уже активирован! Вы будете перенаправлены на главную через 3 секунды.',
|
||||
6: 'Токен истек!',
|
||||
100: 'Не найдено!'
|
||||
};
|
||||
|
||||
export default errors;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { get, post, put, del } from "./api";
|
||||
|
||||
|
||||
export const fetchNovels = (orderBy?: string) => get(orderBy ? `novels?sort=${orderBy}` : 'novels');
|
||||
export const fetchNovel = (id: number) => get(`novels/${id}`);
|
||||
export const fetchNovelCharacters = (id: number) => get(`novels/${id}/characters`);
|
||||
export const fetchNovelGenres = (id: number) => get(`novels/${id}/genres`);
|
||||
|
||||
export const fetchNovelComments = (id: number, offset=0, count=5) => get(`novels/${id}/comments?offset=${offset}&count=${count}`);
|
||||
export const postNovelComment = (id: number, text: string) => post(`novels/${id}/comments`, { text });
|
||||
|
||||
export const fetchUserNovel = (userId: number, novelId: number) => get(`users/${userId}/novels/${novelId}`);
|
||||
export const createUserNovel = (userId: number, novelId: number, status='planned') => post(`users/${userId}/novels`, { novel_id: Number(novelId), status });
|
||||
export const updateUserNovel = (userId: number, novelId: number, data: UserNovelType) => put(`users/${userId}/novels/${novelId}`, data);
|
||||
export const deleteUserNovel = (userId: number, novelId: number) => del(`users/${userId}/novels/${novelId}`);
|
||||
@@ -0,0 +1,10 @@
|
||||
import {get, post} from "./api";
|
||||
|
||||
export const fetchUser = (id: number) => get(`users/${id}`);
|
||||
export const fetchCurrentUser = () => get('users/current');
|
||||
export const fetchUserNovels = (id: number) => get(`users/${id}/novels`);
|
||||
|
||||
export const login = (login: string, password: string) => post('auth/login', { login, password });
|
||||
export const registerUser = (login: string, password: string, email: string, recaptcha: string) => post('auth/register', { login, password, email, recaptcha });
|
||||
export const activateAccount = (token: string) => post('auth/activate', { token });
|
||||
export const userLogout = () => post('auth/logout');
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from "react";
|
||||
import {activateAccount} from "../api/users";
|
||||
import {connect} from "react-redux";
|
||||
import {addNotification} from "../store/actions";
|
||||
import errors from "../api/errors";
|
||||
import Loading from "./Loading";
|
||||
|
||||
type Props = {
|
||||
history: { push: (path: string) => void }
|
||||
match: { params: { token: string } }
|
||||
addNotification: (notification: React.ReactNode) => void
|
||||
};
|
||||
|
||||
type State = {
|
||||
msg: string
|
||||
error: boolean
|
||||
};
|
||||
|
||||
class ActivateAccount extends React.Component<Props, State> {
|
||||
timeout: NodeJS.Timeout | undefined;
|
||||
state: State = {
|
||||
msg: '', error: false
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { token } = this.props.match.params;
|
||||
activateAccount(token).then(() => {
|
||||
const msg = 'Аккаунт успешно активирован! Вы будете перенаправлены на главную через 3 секунды.';
|
||||
this.setState({ msg });
|
||||
this.timeout = setTimeout(() => {
|
||||
this.props.history.push('/')
|
||||
}, 3000)
|
||||
}).catch((err: ApiError) => {
|
||||
const msg = errors[err.code];
|
||||
if(msg) {
|
||||
this.setState({ msg, error: true });
|
||||
} else {
|
||||
this.setState({ msg: 'Во время активации произошла ошибка! Сообщите об этом разработчику!', error: true });
|
||||
console.error(err.text);
|
||||
}
|
||||
if(err.code === 5) {
|
||||
this.timeout = setTimeout(() => {
|
||||
this.props.history.push('/')
|
||||
}, 3000)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if(this.timeout)
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { msg, error } = this.state;
|
||||
if(msg.length === 0) return <Loading/>;
|
||||
return (
|
||||
<div className={'Error'} style={{ background: error ? 'var(--error-bg-color)' : 'var(--success-bg-color)' }}>{msg}</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(null,
|
||||
dispatch => ({
|
||||
addNotification: (notification: React.ReactNode) => dispatch(addNotification(notification))
|
||||
})
|
||||
)(ActivateAccount);
|
||||
@@ -0,0 +1,113 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap')
|
||||
@import "../Constants"
|
||||
|
||||
html, body, #root
|
||||
width: 100%
|
||||
height: 100%
|
||||
|
||||
|
||||
body
|
||||
margin: 0
|
||||
padding: 0
|
||||
font-family: 'Roboto', sans-serif
|
||||
|
||||
--main-color: #{$mainColor}
|
||||
--sec-color: #{$secondaryColor}
|
||||
--link-color: #{$linkHoverColor}
|
||||
|
||||
--main-bg-color: #{$primaryBackgroundColor}
|
||||
--sec-bg-color: #{$secondaryBackgroundColor}
|
||||
--block-color: #{$blockBackgroundColor}
|
||||
--block-sec-color: #{$blockSecondaryBackgroundColor}
|
||||
--nav-bg-color: #{$navbarBackgroundColor}
|
||||
|
||||
--error-bg-color: #{$errorBackgroundColor}
|
||||
--success-bg-color: #{$successBackgroundColor}
|
||||
|
||||
color: var(--main-color)
|
||||
background: var(--main-bg-color)
|
||||
|
||||
pre
|
||||
font-family: 'Roboto', sans-serif
|
||||
margin: 0
|
||||
white-space: pre-wrap
|
||||
|
||||
body[theme='red']
|
||||
--main-bg-color: #{$primaryBackgroundColorRed}
|
||||
--sec-bg-color: #{$secondaryBackgroundColorRed}
|
||||
--block-color: #{$blockBackgroundColorRed}
|
||||
--block-sec-color: #{$blockSecondaryBackgroundColorRed}
|
||||
--nav-bg-color: #{$navbarBackgroundColorRed}
|
||||
|
||||
body[theme='blue']
|
||||
--main-bg-color: #{$primaryBackgroundColorBlue}
|
||||
--sec-bg-color: #{$secondaryBackgroundColorBlue}
|
||||
--block-color: #{$blockBackgroundColorBlue}
|
||||
--block-sec-color: #{$blockSecondaryBackgroundColorBlue}
|
||||
--nav-bg-color: #{$navbarBackgroundColorBlue}
|
||||
|
||||
body[theme='green']
|
||||
--main-bg-color: #{$primaryBackgroundColorGreen}
|
||||
--sec-bg-color: #{$secondaryBackgroundColorGreen}
|
||||
--block-color: #{$blockBackgroundColorGreen}
|
||||
--block-sec-color: #{$blockSecondaryBackgroundColorGreen}
|
||||
--nav-bg-color: #{$navbarBackgroundColorGreen}
|
||||
|
||||
body[theme='light']
|
||||
--main-color: #{$mainColorLight}
|
||||
--link-color: #{$linkHoverColorLight}
|
||||
--sec-color: #{$secondaryColorLight}
|
||||
|
||||
--main-bg-color: #{$primaryBackgroundColorLight}
|
||||
--sec-bg-color: #{$secondaryBackgroundColorLight}
|
||||
--block-color: #{$blockBackgroundColorLight}
|
||||
--block-sec-color: #{$blockSecondaryBackgroundColorLight}
|
||||
--nav-bg-color: #{$navbarBackgroundColorLight}
|
||||
|
||||
a
|
||||
color: var(--main-color)
|
||||
text-decoration: none
|
||||
|
||||
&:hover
|
||||
color: var(--link-color)
|
||||
|
||||
input, button, textarea
|
||||
color: var(--main-color)
|
||||
|
||||
button
|
||||
cursor: pointer
|
||||
border-radius: $borderRadius
|
||||
background-color: var(--block-color)
|
||||
border: 1px solid var(--main-color)
|
||||
|
||||
&:hover
|
||||
background-color: var(--block-sec-color)
|
||||
color: var(--link-color)
|
||||
border: 1px solid var(--link-color)
|
||||
|
||||
|
||||
input
|
||||
background-color: var(--sec-block-color) !important
|
||||
border: none
|
||||
border-bottom: 1px solid var(--main-color) !important
|
||||
|
||||
*
|
||||
scrollbar-color: #eee #222
|
||||
scrollbar-width: thin
|
||||
|
||||
.Container
|
||||
max-width: 1200px
|
||||
min-height: calc( 100% - 4rem )
|
||||
margin: .5rem auto
|
||||
|
||||
.Notifications
|
||||
position: fixed
|
||||
bottom: 0
|
||||
left: 0
|
||||
z-index: 100000
|
||||
padding: 1rem
|
||||
|
||||
.Error
|
||||
border: var(--link-color) 1px solid
|
||||
padding: 1rem
|
||||
border-radius: 3px
|
||||
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
import './App.sass';
|
||||
import Navbar from "../Navbar/Navbar";
|
||||
import Novel from "../Novel/Novel";
|
||||
import User from "../User/User";
|
||||
import Main from "../Main/Main"
|
||||
import NotFoundError from "../NotFoundError";
|
||||
import {fetchCurrentUser} from "../../api/users";
|
||||
import {connect} from "react-redux";
|
||||
import {setUser} from "../../store/actions";
|
||||
import LoginModal from "../LoginModal/LoginModal";
|
||||
import RegisterModal from "../LoginModal/RegisterModal";
|
||||
import Character from "../Character/Character";
|
||||
// @ts-ignore
|
||||
import parser from 'bbcode-to-react';
|
||||
import SpoilerTag from "../Spoiler/SpoilerTag";
|
||||
import Novels from "../Novels/Novels";
|
||||
import Footer from '../Footer/Footer';
|
||||
import UserNovels from '../User/Novels/UserNovels';
|
||||
import ColorTag from "../Tags/ColorTag";
|
||||
import ActivateAccount from "../ActivateAccount";
|
||||
|
||||
type Props = {
|
||||
notifications: React.ReactNode[]
|
||||
setUser: (user: UserType) => void
|
||||
};
|
||||
|
||||
type State = {
|
||||
loginModalVisible: boolean
|
||||
registerModalVisible: boolean
|
||||
error: string
|
||||
login: string
|
||||
password: string
|
||||
}
|
||||
|
||||
class App extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
loginModalVisible: false, registerModalVisible: false, error: '',
|
||||
login: '', password: ''
|
||||
};
|
||||
|
||||
const { setUser } = this.props;
|
||||
fetchCurrentUser().then(currentUser => setUser(currentUser)).catch(console.log);
|
||||
|
||||
const theme = localStorage.getItem('theme');
|
||||
if(theme)
|
||||
document.body.setAttribute('theme', theme);
|
||||
|
||||
parser.registerTag('spoiler', SpoilerTag);
|
||||
parser.registerTag('color', ColorTag);
|
||||
}
|
||||
|
||||
showLoginModal = () => this.setState({ loginModalVisible: true, registerModalVisible: false });
|
||||
hideLoginModal = () => this.setState({ loginModalVisible: false });
|
||||
|
||||
showRegisterModal = () => this.setState({ registerModalVisible: true, loginModalVisible: false });
|
||||
hideRegisterModal = () => this.setState({ registerModalVisible: false });
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<Navbar showLoginModal={this.showLoginModal} showRegisterModal={this.showRegisterModal}/>
|
||||
<div className={'Container'}>
|
||||
<div className="Notifications">
|
||||
{this.props.notifications.map((n, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{n}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<Switch>
|
||||
<Route exact path={'/'} component={Main}/>
|
||||
<Route exact path={'/novels'} component={Novels}/>
|
||||
<Route exact path={'/novels/:novelId'} component={Novel}/>
|
||||
<Route exact path={'/user/:userId'} component={User}/>
|
||||
<Route exact path={'/user/:userId/novels'} component={UserNovels}/>
|
||||
<Route exact path={'/character/:charId'} component={Character}/>
|
||||
<Route exact path={'/activate/:token'} component={ActivateAccount}/>
|
||||
<Route component={NotFoundError}/>
|
||||
</Switch>
|
||||
|
||||
{this.state.loginModalVisible && <LoginModal hideModal={this.hideLoginModal} openRegisterModal={this.showRegisterModal}/>}
|
||||
{this.state.registerModalVisible && <RegisterModal hideModal={this.hideRegisterModal}/>}
|
||||
</div>
|
||||
<Footer/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: { notifications: React.ReactNode[] }) => ({
|
||||
notifications: state.notifications
|
||||
}),
|
||||
dispatch => ({
|
||||
setUser: (userData: UserType) => {
|
||||
dispatch(setUser(userData))
|
||||
}
|
||||
})
|
||||
)(App);
|
||||
@@ -0,0 +1,62 @@
|
||||
@import '../Constants'
|
||||
|
||||
.Character
|
||||
.Character__Main
|
||||
display: flex
|
||||
margin-bottom: .5rem
|
||||
padding: $primaryPadding
|
||||
background-color: var(--sec-bg-color)
|
||||
border-radius: $borderRadius
|
||||
|
||||
@media (max-width: 600px)
|
||||
flex-direction: column
|
||||
|
||||
.Character__Info_Name
|
||||
margin-top: 1rem
|
||||
|
||||
.Character__Image
|
||||
width: 150px !important
|
||||
height: 250px
|
||||
margin-right: .5rem
|
||||
border-radius: $borderRadius
|
||||
|
||||
.Character__Info
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
.Character__Info_Name
|
||||
font-size: 1.5rem
|
||||
|
||||
.Character__Info_Description
|
||||
margin-top: .5rem
|
||||
margin-left: .3rem
|
||||
font-size: 1rem
|
||||
|
||||
.Character__Novels
|
||||
display: flex
|
||||
padding: $primaryPadding
|
||||
background-color: var(--sec-bg-color)
|
||||
border-radius: $borderRadius
|
||||
|
||||
@media (max-width: 600px)
|
||||
flex-direction: column
|
||||
align-items: center
|
||||
|
||||
.Character__Novel
|
||||
width: 100% !important
|
||||
margin: 0 !important
|
||||
|
||||
.Character__Novel
|
||||
background-color: var(--block-color)
|
||||
padding-bottom: $blockPadding
|
||||
border-radius: $borderRadius
|
||||
margin: 0 .2rem .2rem
|
||||
width: calc(100% / 4 - .6rem)
|
||||
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: center
|
||||
|
||||
img
|
||||
width: 100%
|
||||
border-radius: $borderRadius
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from "react";
|
||||
import {fetchChar, fetchCharNovels} from "../../api/characters";
|
||||
import './Character.sass'
|
||||
// @ts-ignore
|
||||
import parser from 'bbcode-to-react';
|
||||
import NovelItem from "../NovelItem/NovelItem";
|
||||
import Loading from "../Loading";
|
||||
|
||||
type Props = {
|
||||
match: { params: { id: number } }
|
||||
}
|
||||
|
||||
type State = {
|
||||
char: CharacterType | null
|
||||
novels: NovelType[] | null
|
||||
};
|
||||
|
||||
class Character extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
char: null, novels: null
|
||||
};
|
||||
|
||||
componentDidMount = () => {
|
||||
const { id } = this.props.match.params;
|
||||
fetchChar(id).then(char => {
|
||||
this.setState({ char });
|
||||
document.title = char.localized_name ? `${char.original_name} / ${char.localized_name}` : char.original_name;
|
||||
});
|
||||
fetchCharNovels(id).then(novels => this.setState({ novels }));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { char, novels } = this.state;
|
||||
if(!char) return <Loading/>;
|
||||
return (
|
||||
<div className={'Character'}>
|
||||
<div className="Character__Main">
|
||||
<div style={{ backgroundImage: `url(${char.image})` }} className="Character__Image"/>
|
||||
<div className="Character__Info">
|
||||
<div className="Character__Info_Name">
|
||||
{char.original_name !== char.localized_name ? (
|
||||
`${char.original_name} / ${char.localized_name}`
|
||||
) : char.original_name}
|
||||
</div>
|
||||
<pre className="Character__Info_Description">{parser.toReact(char.description)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{novels &&
|
||||
<div className="Character__Novels">
|
||||
{novels.map(novel => <NovelItem {...novel} key={novel.id} />)}
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Character;
|
||||
@@ -0,0 +1,21 @@
|
||||
@import "../Constants"
|
||||
|
||||
.Comment
|
||||
display: flex
|
||||
background-color: var(--block-color)
|
||||
border-radius: $borderRadius
|
||||
padding: $blockPadding * 2
|
||||
|
||||
&:not(:last-child)
|
||||
margin-bottom: $blockPadding
|
||||
|
||||
.Comment__User_Avatar
|
||||
border-radius: 50%
|
||||
width: 48px
|
||||
height: 48px
|
||||
margin-right: .5rem
|
||||
|
||||
&__Content
|
||||
.Comment__User_Username
|
||||
font-size: 1.1rem
|
||||
font-weight: 600
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import './Comment.sass'
|
||||
import {Link} from "react-router-dom";
|
||||
// @ts-ignore
|
||||
import parser from 'bbcode-to-react';
|
||||
import Loading from "../Loading";
|
||||
|
||||
type Props = {
|
||||
user: UserType | undefined
|
||||
text: string
|
||||
}
|
||||
type State = {
|
||||
user: UserType | null
|
||||
}
|
||||
|
||||
class Comment extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
user: null
|
||||
};
|
||||
|
||||
render() {
|
||||
const { user } = this.props;
|
||||
const { text } = this.props;
|
||||
if(!user) return <Loading/>;
|
||||
|
||||
return (user &&
|
||||
<div className={'Comment'}>
|
||||
<img className={'Comment__User_Avatar'} src={user.avatar} alt={user.username}/>
|
||||
<div className="Comment__Content">
|
||||
<Link to={`/user/${user.id}`} className="Comment__User_Username">{user.username}</Link>
|
||||
<pre className={'Comment__Text'}>{parser.toReact(text)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Comment;
|
||||
@@ -0,0 +1,46 @@
|
||||
@import '../Constants'
|
||||
|
||||
.Comments
|
||||
margin-top: 1rem
|
||||
padding: $primaryPadding
|
||||
background-color: var(--sec-bg-color)
|
||||
border-radius: $borderRadius
|
||||
|
||||
form
|
||||
width: 100%
|
||||
margin-bottom: .5rem
|
||||
|
||||
.Comments_BBCodes
|
||||
font-size: 1.3rem
|
||||
margin-bottom: .2rem
|
||||
|
||||
svg
|
||||
cursor: pointer
|
||||
|
||||
svg:first-child
|
||||
padding-right: .5rem
|
||||
|
||||
svg:last-child
|
||||
padding-left: .5rem
|
||||
|
||||
svg:not(:first-child):not(:last-child)
|
||||
padding: 0 .5rem
|
||||
|
||||
.Comments_TextArea
|
||||
width: 100%
|
||||
background-color: var(--block-color)
|
||||
font-size: 1rem
|
||||
padding: .25rem
|
||||
border: none
|
||||
border-radius: .2rem
|
||||
box-sizing: border-box
|
||||
min-height: 4rem
|
||||
resize: none
|
||||
margin-bottom: .5rem
|
||||
|
||||
.Comments_Submit
|
||||
background-color: var(--block-color) !important
|
||||
border-radius: .2rem
|
||||
border: none
|
||||
font-size: 1rem
|
||||
padding: .5rem
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faBold, faExclamationTriangle, faItalic, faStrikethrough, faUnderline, faTint } from "@fortawesome/free-solid-svg-icons";
|
||||
import Comment from './Comment';
|
||||
import './Comments.sass';
|
||||
import Loading from "../Loading";
|
||||
import {fetchUser} from "../../api/users";
|
||||
|
||||
type Props = {
|
||||
comments: CommentType[]
|
||||
hasMore: boolean
|
||||
user: UserType
|
||||
loadMore: () => void
|
||||
sendComment: (text: string) => void
|
||||
};
|
||||
|
||||
type State = {
|
||||
commentText: string
|
||||
users: Map<number, UserType>
|
||||
};
|
||||
|
||||
class Comments extends React.Component<Props, State> {
|
||||
input: HTMLTextAreaElement | null = null;
|
||||
state: State = {
|
||||
commentText: '', users: new Map()
|
||||
};
|
||||
|
||||
addTagToComment = (event: React.MouseEvent, tag: string, defaultValue?: string) => {
|
||||
event.preventDefault();
|
||||
if(!this.input) {
|
||||
console.error('Input not initialized!');
|
||||
return;
|
||||
}
|
||||
let { commentText } = this.state;
|
||||
const [start, end] = [this.input.selectionStart, this.input.selectionEnd];
|
||||
|
||||
const openTag = `${tag}${defaultValue ? `=${defaultValue}` : ''}`;
|
||||
this.setState({ commentText: `${commentText.slice(0, start)}[${openTag}]${commentText.slice(start, end)}[/${tag}]${commentText.slice(end, commentText.length)}` });
|
||||
this.input.focus();
|
||||
};
|
||||
|
||||
sendComment = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const { sendComment } = this.props;
|
||||
const { commentText } = this.state;
|
||||
|
||||
if(commentText === '')
|
||||
console.log('empty');
|
||||
else {
|
||||
sendComment(commentText);
|
||||
this.setState({ commentText: '' });
|
||||
}
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: Readonly<Props>) {
|
||||
if(prevProps.comments.length !== this.props.comments.length) {
|
||||
let map: Map<number, UserType> = new Map();
|
||||
this.props.comments.forEach(comment => {
|
||||
if(!map.has(comment.user_id)) {
|
||||
fetchUser(comment.user_id).then(user => {
|
||||
map = map.set(comment.user_id, user)
|
||||
});
|
||||
}
|
||||
});
|
||||
console.log(map)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { user, comments, hasMore, loadMore } = this.props;
|
||||
const { users } = this.state;
|
||||
if(!user || !comments) return <Loading/>;
|
||||
|
||||
return (
|
||||
<div className="Comments">
|
||||
{user.authorized &&
|
||||
<form onSubmit={this.sendComment}>
|
||||
<div className="Comments_BBCodes">
|
||||
<FontAwesomeIcon onClick={event => this.addTagToComment(event, 'b')} icon={faBold}/>
|
||||
<FontAwesomeIcon onClick={event => this.addTagToComment(event, 'i')} icon={faItalic}/>
|
||||
<FontAwesomeIcon onClick={event => this.addTagToComment(event, 'u')} icon={faUnderline}/>
|
||||
<FontAwesomeIcon onClick={event => this.addTagToComment(event, 's')} icon={faStrikethrough}/>
|
||||
<FontAwesomeIcon onClick={event => this.addTagToComment(event, 'spoiler')} icon={faExclamationTriangle}/>
|
||||
<FontAwesomeIcon onClick={event => this.addTagToComment(event, 'color', 'white')} icon={faTint}/>
|
||||
</div>
|
||||
<textarea
|
||||
className={'Comments_TextArea'}
|
||||
placeholder={'Текст комментария'}
|
||||
value={this.state.commentText}
|
||||
onChange={(event) => this.setState({ commentText: event.target.value })}
|
||||
ref={ref => this.input = ref}
|
||||
/>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<button type="submit" className={'Comments_Submit'}>Написать</button>
|
||||
{hasMore && <button type={"button"} onClick={loadMore}>Загрузить ещё</button>}
|
||||
</div>
|
||||
</form>}
|
||||
{comments && comments.length > 0 ? (
|
||||
comments.map(comment => <Comment text={comment.text} key={comment.id} user={users.get(comment.user_id)} />)
|
||||
) : (
|
||||
'Никто не оставил комментарий к этой новелле... :c'
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Comments;
|
||||
@@ -0,0 +1,54 @@
|
||||
$mainColor: #efefef
|
||||
$secondaryColor: #aaaaaa
|
||||
$linkHoverColor: #dddddd
|
||||
|
||||
// BLACK
|
||||
$primaryBackgroundColor: #121212
|
||||
$secondaryBackgroundColor: #212121
|
||||
$blockBackgroundColor: #191919
|
||||
$blockSecondaryBackgroundColor: #121212
|
||||
$navbarBackgroundColor: #0b0b0b
|
||||
|
||||
// BLUE
|
||||
$primaryBackgroundColorBlue: #121222
|
||||
$secondaryBackgroundColorBlue: #212131
|
||||
$blockBackgroundColorBlue: #191929
|
||||
$blockSecondaryBackgroundColorBlue: #121222
|
||||
$navbarBackgroundColorBlue: #0b0b1a
|
||||
|
||||
// RED
|
||||
$primaryBackgroundColorRed: #221212
|
||||
$secondaryBackgroundColorRed: #312121
|
||||
$blockBackgroundColorRed: #291919
|
||||
$blockSecondaryBackgroundColorRed: #221212
|
||||
$navbarBackgroundColorRed: #2a0b0b
|
||||
|
||||
// GREEN
|
||||
$primaryBackgroundColorGreen: #122212
|
||||
$secondaryBackgroundColorGreen: #213121
|
||||
$blockBackgroundColorGreen: #192919
|
||||
$blockSecondaryBackgroundColorGreen: #122212
|
||||
$navbarBackgroundColorGreen: #0b2a0b
|
||||
|
||||
// LIGHT
|
||||
$mainColorLight: #121212
|
||||
$secondaryColorLight: #444444
|
||||
$linkHoverColorLight: #333333
|
||||
|
||||
$primaryBackgroundColorLight: #efefef
|
||||
$secondaryBackgroundColorLight: #fefefe
|
||||
$blockBackgroundColorLight: #f1f1f1
|
||||
$blockSecondaryBackgroundColorLight: #dfdfdf
|
||||
$navbarBackgroundColorLight: #d0d0d0
|
||||
|
||||
|
||||
// ANOTHER
|
||||
$errorBackgroundColor: #993333
|
||||
$successBackgroundColor: #339933
|
||||
|
||||
$primaryPadding: .5rem
|
||||
$blockPadding: .2rem
|
||||
|
||||
$borderRadius: 4px
|
||||
|
||||
$novelBlockSize: calc(100% / 4 - 1rem)
|
||||
@@ -0,0 +1,59 @@
|
||||
.Footer
|
||||
background: var(--nav-bg-color)
|
||||
display: flex
|
||||
flex-direction: column
|
||||
position: relative
|
||||
bottom: 0
|
||||
padding: 1rem
|
||||
|
||||
.Footer__Tabs
|
||||
display: flex
|
||||
|
||||
@media (max-width: 600px)
|
||||
flex-direction: column
|
||||
|
||||
.Footer__Tab
|
||||
font-weight: 300
|
||||
display: flex
|
||||
flex-direction: column
|
||||
margin-right: 4rem
|
||||
|
||||
.Footer__Tab_Title
|
||||
color: var(--main-color)
|
||||
font-weight: 400
|
||||
margin-bottom: 1rem
|
||||
|
||||
.Link
|
||||
margin-bottom: 1rem
|
||||
|
||||
.Links
|
||||
display: flex
|
||||
|
||||
.Link
|
||||
height: 46px
|
||||
width: 46px
|
||||
font-size: 1rem
|
||||
background-color: var(--block-color)
|
||||
border-radius: 50%
|
||||
margin-right: 1rem
|
||||
|
||||
&:hover
|
||||
background-color: var(--sec-bg-color)
|
||||
|
||||
svg
|
||||
position: relative
|
||||
top: 14px
|
||||
left: 14px
|
||||
width: 18px
|
||||
height: 18px
|
||||
|
||||
a
|
||||
color: var(--sec-color)
|
||||
|
||||
&:hover
|
||||
color: var(--main-color)
|
||||
|
||||
.Footer__Bottom
|
||||
border-top: 1px solid var(--main-color)
|
||||
padding: 2rem 0
|
||||
text-align: center
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import './Footer.sass';
|
||||
import {Link} from "react-router-dom";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faDiscord, faVk, faTelegramPlane } from '@fortawesome/free-brands-svg-icons';
|
||||
|
||||
const Footer: React.FC = () => (
|
||||
<div className='Footer'>
|
||||
<footer className='Footer__Tabs'>
|
||||
<div className="Footer__Tab">
|
||||
<div className="Footer__Tab_Title">Команда</div>
|
||||
<Link to={'/user/1'} className={'Link'}>Nix13</Link>
|
||||
</div>
|
||||
<div className="Footer__Tab">
|
||||
<div className="Footer__Tab_Title">Соцсети</div>
|
||||
|
||||
<div className="Links">
|
||||
<a href="https://vk.com/unmei_site">
|
||||
<div className="Link"><FontAwesomeIcon icon={faVk}/></div>
|
||||
</a>
|
||||
<a href="https://discord.gg/GMMymRU">
|
||||
<div className="Link"><FontAwesomeIcon icon={faDiscord}/></div>
|
||||
</a>
|
||||
<a href="https://t.me/unmei_site">
|
||||
<div className="Link"><FontAwesomeIcon icon={faTelegramPlane}/></div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<div className="Footer__Bottom">
|
||||
Unmei © 2019-2020
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Footer;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
// @ts-ignore
|
||||
import Loader from 'react-loader-spinner'
|
||||
|
||||
const Loading: React.FC = () => (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Loader color='#aaa' type='Rings' style={{ height: '100%' }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Loading;
|
||||
@@ -0,0 +1,46 @@
|
||||
@import '../Constants'
|
||||
|
||||
.LoginModal
|
||||
border-radius: $borderRadius
|
||||
background-color: var(--block-color)
|
||||
padding: .5rem
|
||||
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
h1
|
||||
margin: 0
|
||||
|
||||
.LoginModal__Error
|
||||
background-color: #CC0000
|
||||
border: red 1px solid
|
||||
padding: .5rem .2rem
|
||||
border-radius: 3px
|
||||
|
||||
.LoginModal__Form
|
||||
display: flex
|
||||
flex-direction: column
|
||||
margin-top: .5rem
|
||||
|
||||
@media (min-width: 400px)
|
||||
width: 24rem
|
||||
|
||||
.LoginModal__Field
|
||||
font-size: 1.5rem
|
||||
padding: .3rem
|
||||
margin: .5rem 0
|
||||
|
||||
.LoginModal__Submit
|
||||
margin-top: 1rem
|
||||
font-size: 1.2rem
|
||||
padding: .5rem
|
||||
|
||||
.LoginModal__Links
|
||||
display: flex
|
||||
margin-top: .75rem
|
||||
margin-left: auto
|
||||
|
||||
p
|
||||
margin: 0 .2rem
|
||||
*
|
||||
margin: 0
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, {FormEvent, ChangeEvent} from "react";
|
||||
import Modal from "../Modal/Modal";
|
||||
import {login} from "../../api/users";
|
||||
import {connect} from "react-redux";
|
||||
import {addNotification, setUser} from "../../store/actions";
|
||||
import './LoginModal.sass'
|
||||
import {Link} from "react-router-dom";
|
||||
import errors from "../../api/errors";
|
||||
import Notification from "../Notification/Notification";
|
||||
|
||||
type Props = {
|
||||
setUser: (user: UserType) => void
|
||||
hideModal: () => void
|
||||
openRegisterModal: () => void
|
||||
addNotification: (notification: React.ReactNode) => void
|
||||
};
|
||||
|
||||
type State = {
|
||||
error: string
|
||||
login: string
|
||||
password: string
|
||||
};
|
||||
|
||||
class LoginModal extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
error: '', login: '', password: ''
|
||||
};
|
||||
|
||||
loginAndFetchToken = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const { setUser, hideModal, addNotification } = this.props;
|
||||
login(this.state.login, this.state.password)
|
||||
.then(user => {
|
||||
if(user) {
|
||||
setUser(user);
|
||||
const successful = (
|
||||
<Notification level={"success"}>
|
||||
Ты успешно вошел!
|
||||
</Notification>
|
||||
);
|
||||
addNotification(successful);
|
||||
hideModal();
|
||||
}
|
||||
})
|
||||
.catch((r: ApiError) => this.setState({ error: errors[r.code] }));
|
||||
};
|
||||
|
||||
handlePasswordChange = (event: ChangeEvent) => this.setState({ password: (event.target as HTMLInputElement).value });
|
||||
handleLoginChange = (event: ChangeEvent) => this.setState({ login: (event.target as HTMLInputElement).value });
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Modal onCloseRequest={this.props.hideModal} className={'LoginModal'}>
|
||||
<h1>Авторизация</h1>
|
||||
{this.state.error && <div className="LoginModal__Error">{this.state.error}</div>}
|
||||
<form className={'LoginModal__Form'} onSubmit={this.loginAndFetchToken}>
|
||||
<input
|
||||
type="text"
|
||||
name={'login'}
|
||||
placeholder={'Логин'}
|
||||
className={'LoginModal__Field'}
|
||||
onChange={this.handleLoginChange}
|
||||
value={this.state.login}/>
|
||||
<input
|
||||
type="password"
|
||||
name={'password'}
|
||||
placeholder={'Пароль'}
|
||||
className={'LoginModal__Field'}
|
||||
onChange={this.handlePasswordChange}
|
||||
value={this.state.password}/>
|
||||
<button className={'LoginModal__Submit'} type={"submit"}>Войти</button>
|
||||
</form>
|
||||
<div className="LoginModal__Links">
|
||||
<Link to={'/'}>Забыли пароль?</Link>
|
||||
<p>|</p>
|
||||
<Link to={''} onClick={this.props.openRegisterModal}>Регистрация</Link>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(null,
|
||||
dispatch => ({
|
||||
setUser: (user: UserType) => dispatch(setUser(user)),
|
||||
addNotification: (notification: React.ReactNode) => dispatch(addNotification(notification))
|
||||
})
|
||||
)(LoginModal);
|
||||
@@ -0,0 +1,49 @@
|
||||
@import '../Constants'
|
||||
|
||||
.RegisterModal
|
||||
border-radius: $borderRadius
|
||||
background-color: var(--block-color)
|
||||
padding: .5rem
|
||||
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
h1
|
||||
margin: 0
|
||||
|
||||
.RegisterModal__Error
|
||||
background-color: #CC0000
|
||||
border: red 1px solid
|
||||
padding: .5rem .2rem
|
||||
border-radius: 3px
|
||||
|
||||
.RegisterModal__Form
|
||||
display: flex
|
||||
flex-direction: column
|
||||
margin-top: .5rem
|
||||
|
||||
@media (min-width: 400px)
|
||||
width: 24rem
|
||||
|
||||
.RegisterModal__Field
|
||||
font-size: 1.5rem
|
||||
padding: .3rem
|
||||
margin: .5rem 0
|
||||
|
||||
.RegisterModal__Recaptcha
|
||||
margin: .5rem 0
|
||||
|
||||
.RegisterModal__Submit
|
||||
margin-top: 1rem
|
||||
font-size: 1.2rem
|
||||
padding: .5rem
|
||||
|
||||
.RegisterModal__Links
|
||||
display: flex
|
||||
margin-top: .75rem
|
||||
margin-left: auto
|
||||
|
||||
p
|
||||
margin: 0 .2rem
|
||||
*
|
||||
margin: 0
|
||||
@@ -0,0 +1,123 @@
|
||||
import React from "react";
|
||||
import Modal from "../Modal/Modal";
|
||||
// @ts-ignore
|
||||
import Recaptcha from 'react-recaptcha'
|
||||
import './RegisterModal.sass'
|
||||
import {registerUser} from "../../api/users";
|
||||
import {connect} from "react-redux";
|
||||
import {addNotification} from "../../store/actions";
|
||||
import Notification from "../Notification/Notification";
|
||||
|
||||
type Props = {
|
||||
addNotification: (notification: React.ReactNode) => void
|
||||
hideModal: () => void
|
||||
}
|
||||
|
||||
type State = {
|
||||
error: string
|
||||
login: string
|
||||
password1: string
|
||||
password2: string
|
||||
email: string
|
||||
recaptcha: string
|
||||
}
|
||||
|
||||
class RegisterModal extends React.Component<Props, State> {
|
||||
private recaptcha: any;
|
||||
state = {
|
||||
error: '', login: '', password1: '', password2: '', email: '', recaptcha: ''
|
||||
};
|
||||
|
||||
handlePassword1Change = (event: any) => this.setState({ password1: event.target.value });
|
||||
handlePassword2Change = (event: any) => this.setState({ password2: event.target.value });
|
||||
handleLoginChange = (event: any) => this.setState({ login: event.target.value });
|
||||
handleEmailChange = (event: any) => this.setState({ email: event.target.value });
|
||||
|
||||
componentWillUnmount() {
|
||||
this.recaptcha.reset()
|
||||
}
|
||||
|
||||
submitForm = (e: any) => {
|
||||
const { email, login, password1, password2, recaptcha } = this.state;
|
||||
e.preventDefault();
|
||||
let error = '';
|
||||
if(!email) error += 'Вы не ввели E-mail!<br>';
|
||||
if(!login) error += 'Вы не ввели логин!<br>';
|
||||
if(!password1) error += 'Вы не ввели пароль!<br>';
|
||||
if(!password2) error += 'Вы не ввели пароль второй раз<br>';
|
||||
if(password1 && password2 && password1 !== password2) error += 'Пароли не совпадают!<br>';
|
||||
if(!recaptcha) error += 'Не пройдена проверка ReCaptcha<br>';
|
||||
if(error) this.setState({ error });
|
||||
else {
|
||||
registerUser(login, password1, email, recaptcha).then(() => {
|
||||
const notification = (
|
||||
<Notification level={"success"}>
|
||||
Аккаунт успешно создан!
|
||||
Проверьте почту <strong>{email}</strong>. На неё была отправлена ссылка для подтверждения аккаунта.
|
||||
</Notification>
|
||||
);
|
||||
const { addNotification, hideModal } = this.props;
|
||||
addNotification(notification);
|
||||
hideModal();
|
||||
}).catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { hideModal } = this.props;
|
||||
const { error, email, login, password1, password2 } = this.state;
|
||||
|
||||
return (
|
||||
<Modal onCloseRequest={hideModal} className={'RegisterModal'}>
|
||||
<h1>Регистрация</h1>
|
||||
{error && <div className="RegisterModal__Error" dangerouslySetInnerHTML={{ __html: error }}/>}
|
||||
<form className="RegisterModal__Form" onSubmit={this.submitForm}>
|
||||
<input
|
||||
type="text"
|
||||
name={'login'}
|
||||
placeholder={'Логин'}
|
||||
className={'RegisterModal__Field'}
|
||||
onChange={this.handleLoginChange}
|
||||
value={login}/>
|
||||
<input
|
||||
type="email"
|
||||
name={'email'}
|
||||
placeholder={'E-mail'}
|
||||
className={'RegisterModal__Field'}
|
||||
onChange={this.handleEmailChange}
|
||||
value={email}/>
|
||||
<input
|
||||
type="password"
|
||||
name={'password1'}
|
||||
placeholder={'Пароль'}
|
||||
className={'RegisterModal__Field'}
|
||||
onChange={this.handlePassword1Change}
|
||||
value={password1}/>
|
||||
<input
|
||||
type="password"
|
||||
name={'password2'}
|
||||
placeholder={'Повтор пароля'}
|
||||
className={'RegisterModal__Field'}
|
||||
onChange={this.handlePassword2Change}
|
||||
value={password2}/>
|
||||
|
||||
<Recaptcha
|
||||
ref={(e: any) => this.recaptcha = e}
|
||||
className={'RegisterModal__Recaptcha'}
|
||||
verifyCallback={(res: any) => this.setState({ recaptcha: res })}
|
||||
theme={'dark'}
|
||||
badge={'inline'}
|
||||
sitekey={'6LfnDsMUAAAAAEDfD5ubCdFQbNUnKxxJlMWeUMzN'}/>
|
||||
|
||||
<button className={'RegisterModal__Submit'} type={"submit"}>Регистрация</button>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(null,
|
||||
dispatch => ({
|
||||
addNotification: (notification: React.ReactNode) => dispatch(addNotification(notification))
|
||||
})
|
||||
)(RegisterModal);
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
class Main extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div>В разработке!</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Main;
|
||||
@@ -0,0 +1,22 @@
|
||||
.Modal__Overlay
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
position: fixed
|
||||
top: 0
|
||||
right: 0
|
||||
left: 0
|
||||
bottom: 0
|
||||
background-color: #0f0f0fef
|
||||
z-index: 9999
|
||||
opacity: 1
|
||||
overflow: hidden
|
||||
|
||||
.Modal__CloseButton
|
||||
background: none
|
||||
border: none
|
||||
font-size: 1.5rem
|
||||
position: absolute
|
||||
top: 0
|
||||
right: 0
|
||||
margin: 1rem
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from "react";
|
||||
import './Modal.sass'
|
||||
|
||||
type Props = {
|
||||
onCloseRequest: any
|
||||
children: any
|
||||
className?: string
|
||||
}
|
||||
|
||||
class Modal extends React.Component<Props> {
|
||||
private modal: HTMLDivElement | null | undefined;
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('keyup', this.handleKeyUp, false);
|
||||
document.addEventListener('mousedown', this.handleOutsideClick, false);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('keyup', this.handleKeyUp, false);
|
||||
document.removeEventListener('mousedown', this.handleOutsideClick, false);
|
||||
}
|
||||
|
||||
handleKeyUp = (e: KeyboardEvent) => {
|
||||
if(e.code === 'Escape') {
|
||||
const { onCloseRequest } = this.props;
|
||||
e.preventDefault();
|
||||
onCloseRequest();
|
||||
}
|
||||
};
|
||||
|
||||
handleOutsideClick = (e: any) => {
|
||||
if(this.modal && !this.modal.contains(e.target)) {
|
||||
const { onCloseRequest } = this.props;
|
||||
onCloseRequest();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { onCloseRequest, children } = this.props;
|
||||
|
||||
return (
|
||||
<div className="Modal__Overlay">
|
||||
<button type="button" className={"Modal__CloseButton"} onClick={onCloseRequest}>X</button>
|
||||
<div className={"Modal"} ref={node => (this.modal = node)}>
|
||||
<div className={this.props.className}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Modal;
|
||||
@@ -0,0 +1,46 @@
|
||||
@import "../App/App"
|
||||
|
||||
.Header
|
||||
color: var(--main-color)
|
||||
background-color: var(--nav-bg-color)
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
min-height: 3rem
|
||||
|
||||
.Header_Links
|
||||
min-height: 3rem
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
|
||||
.Header_Button
|
||||
font-size: 1rem
|
||||
padding: .5rem
|
||||
height: 3rem
|
||||
color: var(--main-color)
|
||||
background-color: var(--nav-bg-color)
|
||||
border: none
|
||||
border-radius: 0
|
||||
|
||||
.Header_Button:hover, .Header__User_Profile:hover
|
||||
background-color: var(--main-bg-color)
|
||||
|
||||
.Header_User
|
||||
display: flex
|
||||
|
||||
.Header__User_Profile
|
||||
display: flex
|
||||
align-items: center
|
||||
font-size: 1rem
|
||||
height: 3rem
|
||||
color: var(--main-color)
|
||||
background-color: var(--nav-bg-color)
|
||||
border: none
|
||||
border-radius: 0
|
||||
|
||||
.Header__User_Avatar
|
||||
background-size: cover
|
||||
background-position: center
|
||||
width: 40px
|
||||
height: 40px
|
||||
border-radius: 50%
|
||||
margin-right: .2rem
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from "react";
|
||||
import './Navbar.sass'
|
||||
import {connect} from "react-redux";
|
||||
import {userLogout} from "../../api/users";
|
||||
import {Link} from "react-router-dom";
|
||||
import {logout} from "../../store/actions";
|
||||
|
||||
type Props = {
|
||||
showRegisterModal: () => void
|
||||
showLoginModal: () => void
|
||||
logout: () => void
|
||||
currentUser: UserType
|
||||
};
|
||||
|
||||
class Navbar extends React.Component<Props> {
|
||||
logout = () => {
|
||||
this.props.logout();
|
||||
localStorage.removeItem('user');
|
||||
userLogout().then(console.log).catch(console.error);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { currentUser } = this.props;
|
||||
return (
|
||||
<nav className={'Header'}>
|
||||
<div className="Header_Links">
|
||||
<Link to="/">
|
||||
<button className={'Header_Button'}>
|
||||
Главная
|
||||
</button>
|
||||
</Link>
|
||||
<Link to="/novels">
|
||||
<button className={'Header_Button'}>
|
||||
Новеллы
|
||||
</button>
|
||||
</Link>
|
||||
<Link to="/">
|
||||
<button className={'Header_Button'}>
|
||||
Пользователи
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
{this.props.currentUser &&
|
||||
<div className={'Header_User'}>
|
||||
{this.props.currentUser.authorized &&
|
||||
<Link to={`/user/${currentUser.id}`}>
|
||||
<button className="Header__User_Profile">
|
||||
<div className={'Header__User_Avatar'} style={{ backgroundImage: `url(${currentUser.avatar})` }}/>
|
||||
<div>{this.props.currentUser.username}</div>
|
||||
</button>
|
||||
</Link>}
|
||||
{!currentUser.authorized && <button className={'Header_Button'} onClick={this.props.showRegisterModal}>Регистрация</button>}
|
||||
{!currentUser.authorized
|
||||
? <button className={'Header_Button'} onClick={this.props.showLoginModal}>Войти</button>
|
||||
: <button className={'Header_Button'} onClick={this.logout}>Выйти</button>}
|
||||
</div>}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: any) => ({
|
||||
currentUser: state.currentUser
|
||||
}),
|
||||
dispatch => ({
|
||||
logout: () => dispatch(logout())
|
||||
}))(Navbar);
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
|
||||
function NotFoundError() {
|
||||
return (
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<div style={{fontSize: '2rem'}}>Увы, данная страница не найдена!</div>
|
||||
<div>Проверьте правильность написания ссылки!</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotFoundError;
|
||||
@@ -0,0 +1,21 @@
|
||||
.Notification
|
||||
background-color: var(--block-color)
|
||||
border: 1px solid var(--link-color)
|
||||
padding: .5rem
|
||||
max-width: 500px
|
||||
width: 500px
|
||||
cursor: pointer
|
||||
|
||||
&--Error
|
||||
background: var(--error-bg-color)
|
||||
|
||||
&--Success
|
||||
background: var(--success-bg-color)
|
||||
|
||||
&:not(:last-child)
|
||||
margin-bottom: .5rem
|
||||
|
||||
.Progress
|
||||
height: 5px
|
||||
background: white
|
||||
transition: width 100ms
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import './Notification.sass'
|
||||
import {capitalize} from "../../utils";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
level?: 'info' | 'success' | 'error'
|
||||
}
|
||||
|
||||
type State = {
|
||||
time: number
|
||||
paused: boolean
|
||||
};
|
||||
|
||||
class Notification extends React.Component<Props, State> {
|
||||
timer: NodeJS.Timeout | undefined;
|
||||
element: any;
|
||||
state = {
|
||||
time: 5000, paused: false
|
||||
};
|
||||
|
||||
onMouseOver = () => {
|
||||
this.setState({ paused: true });
|
||||
};
|
||||
|
||||
onMouseOut = () => {
|
||||
this.setState({ paused: false });
|
||||
};
|
||||
|
||||
onClick = () => {
|
||||
this.timer = setInterval(() => this.interval(400), 50);
|
||||
this.setState({ paused: false });
|
||||
};
|
||||
|
||||
interval = (step=50) => {
|
||||
const { time, paused } = this.state;
|
||||
if(time <= 0 && this.timer)
|
||||
clearInterval(this.timer);
|
||||
if(!paused)
|
||||
this.setState({ time: time-step })
|
||||
};
|
||||
|
||||
componentDidMount(): void {
|
||||
this.timer = setInterval(() => this.interval(), 50)
|
||||
};
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if(this.timer)
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { time } = this.state;
|
||||
const { children, level } = this.props;
|
||||
if(time <= 0)
|
||||
return null;
|
||||
|
||||
const styleClass = level ? `Notification--${capitalize(level)}` : '';
|
||||
|
||||
return (
|
||||
<div className={`Notification ${styleClass}`} onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut} onClick={this.onClick}>
|
||||
{children}
|
||||
<div className="Progress" style={{ width: `${(time / 5000 * 100)}%` }}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Notification;
|
||||
@@ -0,0 +1,151 @@
|
||||
@import "../Constants"
|
||||
|
||||
.Novel
|
||||
.Novel__Main
|
||||
display: flex
|
||||
padding: $primaryPadding
|
||||
background-color: var(--sec-bg-color)
|
||||
border-radius: $borderRadius
|
||||
|
||||
@media (max-width: 600px)
|
||||
flex-direction: column
|
||||
|
||||
&_Left
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
.Novel__Main_Image
|
||||
max-width: 350px
|
||||
max-height: 200px
|
||||
margin-right: .5rem
|
||||
border-radius: $borderRadius
|
||||
|
||||
.Novel__Main_Status
|
||||
margin-bottom: 1rem
|
||||
|
||||
&_Primary
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
background-color: var(--block-color)
|
||||
font-size: 1rem
|
||||
margin-top: .5rem
|
||||
padding: .25rem
|
||||
|
||||
&_Extended
|
||||
background-color: var(--block-color)
|
||||
font-size: 1rem
|
||||
|
||||
.Novel__Main_Status_Element
|
||||
padding: .25rem
|
||||
border-top: var(--sec-bg-color) 2px solid
|
||||
|
||||
.Novel__Main_Status_Element
|
||||
cursor: pointer
|
||||
|
||||
.Novel__Info_Title
|
||||
@media (max-width: 600px)
|
||||
margin-top: 1rem
|
||||
font-size: 1.7rem
|
||||
font-weight: 600
|
||||
margin-bottom: 1rem
|
||||
|
||||
.Novel__Info_Description
|
||||
margin-top: .5rem
|
||||
margin-left: 1rem
|
||||
font-size: 1rem
|
||||
|
||||
.Novel__Info_Genres
|
||||
display: flex
|
||||
align-items: center
|
||||
font-size: 1rem
|
||||
|
||||
.Genre
|
||||
margin: .2rem
|
||||
border: #992222 1px solid
|
||||
padding: .1rem
|
||||
border-radius: 3px
|
||||
font-weight: 300
|
||||
|
||||
.Novel__Comments
|
||||
margin-top: 1rem
|
||||
padding: $primaryPadding
|
||||
background-color: var(--sec-bg-color)
|
||||
border-radius: $borderRadius
|
||||
|
||||
form
|
||||
width: 100%
|
||||
margin-bottom: .5rem
|
||||
|
||||
.Novel__Comments_BBCodes
|
||||
font-size: 1.3rem
|
||||
margin-bottom: .2rem
|
||||
|
||||
svg
|
||||
cursor: pointer
|
||||
|
||||
svg:first-child
|
||||
padding-right: .5rem
|
||||
|
||||
svg:last-child
|
||||
padding-left: .5rem
|
||||
|
||||
svg:not(:first-child):not(:last-child)
|
||||
padding: 0 .5rem
|
||||
|
||||
.Novel__Comments_TextArea
|
||||
width: 100%
|
||||
background-color: var(--block-color)
|
||||
font-size: 1rem
|
||||
padding: .25rem
|
||||
border: none
|
||||
border-radius: $borderRadius
|
||||
box-sizing: border-box
|
||||
min-height: 4rem
|
||||
resize: none
|
||||
margin-bottom: .5rem
|
||||
|
||||
.Novel__Comments_Submit
|
||||
background-color: var(--block-color) !important
|
||||
border-radius: $borderRadius
|
||||
border: none
|
||||
font-size: 1rem
|
||||
padding: .5rem
|
||||
|
||||
.Novel__Characters
|
||||
margin-top: 1rem
|
||||
padding: $primaryPadding 0
|
||||
background-color: var(--sec-bg-color)
|
||||
border-radius: $borderRadius
|
||||
|
||||
.Novel__Characters_Title
|
||||
margin-left: .5rem
|
||||
font-size: 1.2rem
|
||||
font-weight: bold
|
||||
|
||||
.Novel__Characters_List
|
||||
width: 100%
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
|
||||
@media (max-width: 600px)
|
||||
justify-content: center
|
||||
|
||||
.Novel__Character
|
||||
box-sizing: border-box
|
||||
width: 150px
|
||||
height: 250px
|
||||
border-radius: $borderRadius
|
||||
background-color: var(--block-color)
|
||||
margin: 0 .5rem
|
||||
padding-bottom: .2rem
|
||||
|
||||
.Novel__Character_Name
|
||||
text-align: center
|
||||
|
||||
.Character_Image
|
||||
border-radius: $borderRadius $borderRadius 0 0
|
||||
width: 100%
|
||||
height: calc(100% - 1.5rem)
|
||||
background-size: cover
|
||||
background-position: center
|
||||
margin-bottom: .2rem
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from "react";
|
||||
import './Novel.sass'
|
||||
import NotFoundError from "../NotFoundError";
|
||||
import {connect} from "react-redux";
|
||||
import {
|
||||
fetchNovel,
|
||||
fetchNovelCharacters,
|
||||
fetchNovelComments,
|
||||
fetchUserNovel,
|
||||
updateUserNovel,
|
||||
deleteUserNovel,
|
||||
fetchNovelGenres, postNovelComment, createUserNovel
|
||||
} from "../../api/novels";
|
||||
import {Link} from "react-router-dom";
|
||||
import Loading from "../Loading";
|
||||
import { TranslateStatus } from "../../api/api";
|
||||
import Comments from "../Comment/Comments";
|
||||
|
||||
type Props = {
|
||||
currentUser: UserType
|
||||
match: { params: { novelId: number } }
|
||||
};
|
||||
|
||||
type State = {
|
||||
novel: NovelType | null
|
||||
characters: CharacterType[]
|
||||
genres: GenreType[]
|
||||
|
||||
errorCode: number | null
|
||||
userData: UserNovelType | null
|
||||
statusExpanded: boolean
|
||||
|
||||
comments: CommentType[]
|
||||
commentsOffset: number
|
||||
hasMoreComments: boolean
|
||||
}
|
||||
|
||||
class Novel extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
novel: null, characters: [], genres: [],
|
||||
errorCode: null, userData: null, statusExpanded: false,
|
||||
comments: [], commentsOffset: 5, hasMoreComments: true
|
||||
};
|
||||
|
||||
updateNovelStatus = (status: string) => {
|
||||
const { match: { params } , currentUser } = this.props;
|
||||
const { userData } = this.state;
|
||||
|
||||
if(userData) {
|
||||
updateUserNovel(currentUser.id, params.novelId, { status }).then(r => {
|
||||
this.setState({ userData: r, statusExpanded: false })
|
||||
}).catch(console.error);
|
||||
} else {
|
||||
createUserNovel(currentUser.id, params.novelId, status).then(r => {
|
||||
this.setState({ userData: r, statusExpanded: false })
|
||||
}).catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
deleteNovelStatus = () => {
|
||||
const { match: { params } , currentUser } = this.props;
|
||||
|
||||
deleteUserNovel(currentUser.id, params.novelId).then(() => {
|
||||
this.setState({ userData: null, statusExpanded: false });
|
||||
});
|
||||
};
|
||||
|
||||
sendComment = (text: string) => {
|
||||
const { match: { params: { novelId } } } = this.props;
|
||||
postNovelComment(novelId, text)
|
||||
.then((comment: CommentType) => {
|
||||
const { comments } = this.state;
|
||||
console.log(Object.assign({}, comments, { comment }));
|
||||
this.setState({ comments: [comment, ...comments] });
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
loadMoreComments = () => {
|
||||
const { match: { params } } = this.props;
|
||||
let { comments, commentsOffset } = this.state;
|
||||
|
||||
fetchNovelComments(params.novelId, commentsOffset, 10)
|
||||
.then(r => {
|
||||
const hasMoreComments = r.count <= commentsOffset;
|
||||
commentsOffset += 5;
|
||||
this.setState({ comments: [...r.comments.reverse(), ...comments], hasMoreComments, commentsOffset })
|
||||
});
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: any) {
|
||||
const { match: { params }, currentUser } = this.props;
|
||||
const { currentUser: prevUser } = prevProps;
|
||||
|
||||
if(!prevUser.authorized && currentUser.authorized)
|
||||
fetchUserNovel(currentUser.id, params.novelId)
|
||||
.then(data => this.setState({ userData: data }))
|
||||
.catch(err => console.error(err));
|
||||
else if(prevUser.authorized && !currentUser.authorized)
|
||||
this.setState({ userData: null })
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { match: { params }, currentUser } = this.props;
|
||||
|
||||
fetchNovel(params.novelId)
|
||||
.then(novel => {
|
||||
this.setState({ novel });
|
||||
document.title = novel.localized_name ? `${novel.original_name} / ${novel.localized_name}` : novel.original_name;
|
||||
|
||||
fetchNovelComments(params.novelId)
|
||||
.then(r => {
|
||||
const hasMoreComments = r.count > 5;
|
||||
this.setState({ comments: r.comments.reverse(), hasMoreComments })
|
||||
});
|
||||
fetchNovelCharacters(params.novelId)
|
||||
.then(characters => this.setState({ characters }));
|
||||
fetchNovelGenres(params.novelId)
|
||||
.then(genres => this.setState({ genres }));
|
||||
})
|
||||
.catch((err: ApiError) => {
|
||||
this.setState({ errorCode: err.code });
|
||||
});
|
||||
|
||||
if(currentUser.authorized)
|
||||
fetchUserNovel(currentUser.id, params.novelId)
|
||||
.then((userData: UserNovelType) => {
|
||||
this.setState({ userData });
|
||||
})
|
||||
.catch((err: ApiError) => {
|
||||
if(err.code !== 100) console.error(err.text);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { errorCode, novel, comments, characters, genres, userData, statusExpanded, hasMoreComments } = this.state;
|
||||
const { currentUser } = this.props;
|
||||
|
||||
if(errorCode === 100) return <NotFoundError/>;
|
||||
if(!novel || !genres) return <Loading/>;
|
||||
return (
|
||||
<div className={'Novel'}>
|
||||
<div className="Novel__Main">
|
||||
<div className={'Novel__Main_Left'}>
|
||||
{novel.image
|
||||
? <img src={novel.image} className={"Novel__Main_Image"} alt={novel.original_name}/>
|
||||
: <img src={"/static/img/no-image.png"} className={"Novel__Main_Image"} alt=""/>}
|
||||
{currentUser.authorized &&
|
||||
<div className={'Novel__Main_Status'}>
|
||||
<div className="Novel__Main_Status_Primary">
|
||||
{!userData
|
||||
? <div className={'Novel__Main_Status_Element'} onClick={() => this.updateNovelStatus('planned')}>Добавить в список</div>
|
||||
: <div className={"Novel__Main_Status_Element"}>{TranslateStatus[userData.status]}</div>}
|
||||
<div className={'Novel__Main_Status_Element'} onClick={() => this.setState({ statusExpanded: !statusExpanded })}>+</div>
|
||||
</div>
|
||||
{statusExpanded &&
|
||||
<div className="Novel__Main_Status_Extended">
|
||||
{userData && Object.entries(TranslateStatus).filter(([eng]) => eng !== userData.status).map(([eng, rus]) => (
|
||||
<div key={eng} className="Novel__Main_Status_Element" onClick={() => this.updateNovelStatus(eng)}>{rus}</div>
|
||||
))}
|
||||
<div className={"Novel__Main_Status_Element"} style={{ color: 'red' }} onClick={this.deleteNovelStatus}>Удалить из списка</div>
|
||||
</div>}
|
||||
</div>}
|
||||
</div>
|
||||
<div className={'Novel__Info'}>
|
||||
<div className={'Novel__Info_Title'}>{novel.localized_name || novel.original_name}</div>
|
||||
{novel.localized_name && <div className="Novel__Info_OriginalName">
|
||||
<strong>Оригинальное название</strong>: {novel.original_name}
|
||||
</div>}
|
||||
{genres.length > 0 &&
|
||||
<div className="Novel__Info_Genres">
|
||||
<strong>Жанры</strong>: {genres.map(genre => <div className={'Genre'} key={genre.id}>{genre.localized_name}</div>)}
|
||||
</div>}
|
||||
<div className="Novel__Info_Rating"><strong>Ср. оценка</strong>: {novel.rating.toFixed(2)}</div>
|
||||
<pre className={'Novel__Info_Description'}>{novel.description}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{characters && characters.length > 0 &&
|
||||
<div className="Novel__Characters">
|
||||
<div className="Novel__Characters_Title">Главные герои:</div>
|
||||
<div className="Novel__Characters_List">
|
||||
{this.state.characters.filter(char => char.main).map(char =>
|
||||
<div className={'Novel__Character'} key={char.id}>
|
||||
<div className='Character_Image' style={{ backgroundImage: `url(${char.image})`}}/>
|
||||
<Link to={`/character/${char.id}`}><div className={'Novel__Character_Name'}>{char.localized_name}</div></Link>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>}
|
||||
<Comments user={currentUser} comments={comments} sendComment={this.sendComment} loadMore={this.loadMoreComments} hasMore={hasMoreComments} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: any) => ({
|
||||
currentUser: state.currentUser
|
||||
})
|
||||
)(Novel);
|
||||
@@ -0,0 +1,37 @@
|
||||
@import '../Constants'
|
||||
|
||||
.NovelItem
|
||||
background-color: var(--block-color)
|
||||
padding-bottom: $blockPadding
|
||||
border-radius: $borderRadius
|
||||
margin: .5rem
|
||||
|
||||
@media (min-width: 601px)
|
||||
width: $novelBlockSize
|
||||
@media (max-width: 600px)
|
||||
width: 100%
|
||||
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: center
|
||||
|
||||
&__Image
|
||||
border-radius: $borderRadius $borderRadius 0 0
|
||||
background-size: cover
|
||||
background-position: center
|
||||
width: 100%
|
||||
height: 130px
|
||||
|
||||
&__Title
|
||||
width: 100%
|
||||
text-align: center
|
||||
display: inline-block
|
||||
overflow: hidden
|
||||
text-overflow: ellipsis
|
||||
white-space: nowrap
|
||||
padding: .2rem
|
||||
box-sizing: border-box
|
||||
font-weight: 400
|
||||
|
||||
&__Status
|
||||
font-weight: 300
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { TranslateStatus } from '../../api/api';
|
||||
import { Link } from 'react-router-dom';
|
||||
import './NovelItem.sass';
|
||||
|
||||
type Props = {
|
||||
id: number
|
||||
original_name: string
|
||||
localized_name?: string
|
||||
image?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
const NovelItem = ({ id, original_name, localized_name, image, status }: Props) => (
|
||||
<Link to={`/novels/${id}`} className="NovelItem">
|
||||
<div className='NovelItem__Image' style={{ backgroundImage: `url(${image || '/static/img/no-image.png'})` }}/>
|
||||
<div className="NovelItem__Title">
|
||||
{localized_name || original_name}
|
||||
</div>
|
||||
{status && <div className="NovelItem__Status">{TranslateStatus[status]}</div>}
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default NovelItem;
|
||||
@@ -0,0 +1,57 @@
|
||||
@import "../Constants"
|
||||
|
||||
.Novels
|
||||
background-color: var(--sec-bg-color)
|
||||
border-radius: $borderRadius
|
||||
padding: $primaryPadding
|
||||
display: flex
|
||||
|
||||
@media (max-width: 600px)
|
||||
flex-direction: column
|
||||
|
||||
.Novels__List
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
width: 100%
|
||||
|
||||
@media (max-width: 600px)
|
||||
justify-content: center
|
||||
|
||||
.Novels__Novel
|
||||
border-radius: $borderRadius
|
||||
background-color: var(--block-color)
|
||||
text-align: center
|
||||
margin: $primaryPadding
|
||||
|
||||
@media (min-width: 601px)
|
||||
width: $novelBlockSize
|
||||
|
||||
img
|
||||
width: 100%
|
||||
border-radius: $borderRadius
|
||||
align-self: flex-start
|
||||
|
||||
.Novels__Novel_Title
|
||||
margin: $blockPadding
|
||||
|
||||
.Novels__Filters
|
||||
padding: $primaryPadding
|
||||
background-color: var(--block-color)
|
||||
border-radius: $borderRadius
|
||||
width: calc(100% / 4 - 1rem)
|
||||
margin-left: auto
|
||||
|
||||
@media (max-width: 600px)
|
||||
align-self: center
|
||||
order: -1
|
||||
|
||||
div
|
||||
margin: 0 0 .5rem 0
|
||||
display: flex
|
||||
flex-direction: column
|
||||
border-bottom: var(--sec-block-color) 2px solid
|
||||
|
||||
.Novels__Filters_Title
|
||||
margin-bottom: .1rem
|
||||
font-size: 1.2rem
|
||||
font-weight: bold
|
||||
@@ -0,0 +1,80 @@
|
||||
import './Novels.sass'
|
||||
import React from "react";
|
||||
import {fetchNovels} from "../../api/novels";
|
||||
import NovelItem from '../NovelItem/NovelItem';
|
||||
import Loading from "../Loading";
|
||||
|
||||
type State = {
|
||||
novels: NovelType[] | null
|
||||
years: string[]
|
||||
genres: string[]
|
||||
}
|
||||
|
||||
class Novels extends React.Component<{}, State> {
|
||||
constructor(props: {}) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
novels: null,
|
||||
genres: [], years: []
|
||||
};
|
||||
|
||||
document.title = 'Новелы';
|
||||
}
|
||||
|
||||
componentDidMount = () => {
|
||||
fetchNovels('rating').then((novels: NovelType[]) => {
|
||||
novels?.forEach(novel => {
|
||||
novel.release_date = new Date(novel.release_date);
|
||||
});
|
||||
this.setState({ novels });
|
||||
})
|
||||
};
|
||||
|
||||
switchFilter(filter: string, key: any) {
|
||||
// @ts-ignore
|
||||
const filters: string[] = this.state[filter];
|
||||
if(filters.includes(key)) {
|
||||
// @ts-ignore
|
||||
this.setState({ [filter]: [...filters.filter(x => x !== key)] })
|
||||
} else {
|
||||
// @ts-ignore
|
||||
this.setState({ [filter]: [...filters, key] })
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { novels } = this.state;
|
||||
if(!novels)
|
||||
return <Loading/>;
|
||||
/*const filteredNovels = novels.filter(novel => {
|
||||
if(years.includes(novel.release_date.getFullYear().toString())) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});*/
|
||||
|
||||
return (
|
||||
<div className={'Novels'}>
|
||||
<div className="Novels__List">
|
||||
{novels.map((novel: NovelType) => <NovelItem {...novel} key={novel.id} />)}
|
||||
</div>
|
||||
{/*<div className="Novels__Filters">
|
||||
<div className="Novels__Filters_Genre">
|
||||
<div className="Novels__Filters_Title">Жанры</div>
|
||||
<label><input type="checkbox"/>Романтика</label>
|
||||
<label><input type="checkbox"/>Детектив</label>
|
||||
</div>
|
||||
|
||||
<div className="Novels__Filters_Year">
|
||||
<div className="Novels__Filters_Title">Год выпуска</div>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', '2020')}/>2020</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', '2019')}/>2019</label>
|
||||
</div>
|
||||
</div>*/}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Novels;
|
||||
@@ -0,0 +1,5 @@
|
||||
@import "../Constants"
|
||||
|
||||
.Spoiler
|
||||
cursor: pointer
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
import './Spoiler.sass'
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
type State = {
|
||||
expand: boolean
|
||||
}
|
||||
|
||||
class Spoiler extends React.Component<Props, State> {
|
||||
state = {
|
||||
expand: false
|
||||
};
|
||||
|
||||
render() {
|
||||
const { expand } = this.state;
|
||||
const { children } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="Spoiler"
|
||||
onClick={() => this.setState({ expand: !expand })}
|
||||
style={{ fontStyle: expand ? 'normal' : 'italic' }}
|
||||
>
|
||||
{expand ? children : '[Спойлер]'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Spoiler;
|
||||
@@ -0,0 +1,13 @@
|
||||
// @ts-ignore
|
||||
import { Tag } from 'bbcode-to-react';
|
||||
import Spoiler from "./Spoiler";
|
||||
import React from 'react';
|
||||
|
||||
class SpoilerTag extends Tag {
|
||||
toReact() {
|
||||
// @ts-ignore
|
||||
return (<Spoiler>{this.getContent(true)}</Spoiler>)
|
||||
}
|
||||
}
|
||||
|
||||
export default SpoilerTag;
|
||||
@@ -0,0 +1,16 @@
|
||||
// @ts-ignore
|
||||
import { Tag } from 'bbcode-to-react';
|
||||
import React from 'react';
|
||||
|
||||
class ColorTag extends Tag {
|
||||
toReact() {
|
||||
// @ts-ignore
|
||||
const text = this.getContent(true);
|
||||
// @ts-ignore
|
||||
const color = this.params.color;
|
||||
// @ts-ignore
|
||||
return <span style={{ color }}>{text}</span>
|
||||
}
|
||||
}
|
||||
|
||||
export default ColorTag;
|
||||
@@ -0,0 +1,29 @@
|
||||
@import "../../Constants"
|
||||
|
||||
.UserNovels
|
||||
background-color: var(--sec-bg-color)
|
||||
padding: .5rem
|
||||
|
||||
&__Title
|
||||
font-size: 1.5rem
|
||||
text-align: center
|
||||
|
||||
&__List
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
&:not(:last-child)
|
||||
border-bottom: var(--link-color) 1px solid
|
||||
margin-bottom: 1rem
|
||||
padding-bottom: 1rem
|
||||
|
||||
&_Title
|
||||
display: flex
|
||||
font-size: 1.5rem
|
||||
margin-right: 1rem
|
||||
|
||||
&_Novels
|
||||
display: flex
|
||||
flex-direction: row
|
||||
flex-wrap: wrap
|
||||
justify-content: center
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import './UserNovels.sass';
|
||||
import { fetchUserNovels, fetchUser } from '../../../api/users';
|
||||
import Loading from '../../Loading';
|
||||
import NovelItem from '../../NovelItem/NovelItem';
|
||||
import {getRandomInt} from "../../../utils";
|
||||
|
||||
type Props = {
|
||||
match: { params: { userId: number } }
|
||||
history: { push: (path: string) => void }
|
||||
};
|
||||
type State = {
|
||||
novels: NovelType[]
|
||||
user: UserType | null
|
||||
};
|
||||
|
||||
class UserNovels extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
novels: [], user: null
|
||||
};
|
||||
|
||||
getRandomNovel = () => {
|
||||
const { history } = this.props;
|
||||
const { novels } = this.state;
|
||||
const planned = novels.filter(n => n.status === 'planned');
|
||||
const index = getRandomInt(0, planned.length-1);
|
||||
history.push(`/novels/${planned[index].id}`);
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { match: { params: { userId } } } = this.props;
|
||||
|
||||
fetchUserNovels(userId)
|
||||
.then(novels => this.setState({ novels }));
|
||||
fetchUser(userId)
|
||||
.then(user => this.setState({ user }));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { novels, user } = this.state;
|
||||
if(!novels || !user) return <Loading/>;
|
||||
|
||||
const planned = novels.filter(n => n.status === 'planned');
|
||||
const completed = novels.filter(n => n.status === 'completed');
|
||||
|
||||
return (
|
||||
<div className='UserNovels'>
|
||||
<div className="UserNovels__Title">
|
||||
Новеллы пользователя {user.username}
|
||||
</div>
|
||||
|
||||
{planned.length > 0 &&<div className="UserNovels__List">
|
||||
<div className="UserNovels__List_Title">
|
||||
<div style={{ marginRight: '1rem' }}>Запланированные</div>
|
||||
<button onClick={this.getRandomNovel}>Рандомная новелла</button>
|
||||
</div>
|
||||
<div className="UserNovels__List_Novels">
|
||||
{planned.map(novel => <NovelItem {...novel} key={novel.id}/>)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{completed.length > 0 &&<div className="UserNovels__List">
|
||||
<div className="UserNovels__List_Title">
|
||||
Пройденные
|
||||
</div>
|
||||
<div className="UserNovels__List_Novels">
|
||||
{completed.map(novel => <NovelItem {...novel} key={novel.id}/>)}
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default UserNovels;
|
||||
@@ -0,0 +1,116 @@
|
||||
@import "../App/App"
|
||||
|
||||
.User
|
||||
.User__Info
|
||||
background-color: var(--sec-bg-color)
|
||||
border-radius: $borderRadius
|
||||
padding: $primaryPadding
|
||||
display: flex
|
||||
align-items: flex-end
|
||||
|
||||
@media (max-width: 250px)
|
||||
flex-direction: column
|
||||
|
||||
.User__Info_Nick
|
||||
font-size: 1.5rem
|
||||
margin-left: .5rem
|
||||
|
||||
.User__Info_Group
|
||||
margin-left: .5rem
|
||||
border: 1px solid #eeeeee
|
||||
border-radius: 5px
|
||||
padding: .1rem
|
||||
|
||||
.User__Info_Avatar
|
||||
background-size: cover
|
||||
background-position: center
|
||||
width: 96px
|
||||
height: 96px
|
||||
border-radius: 50%
|
||||
margin-right: .2rem
|
||||
|
||||
.User__Statistic
|
||||
background-color: var(--sec-bg-color)
|
||||
border-radius: $borderRadius
|
||||
padding: $primaryPadding
|
||||
margin-top: 1rem
|
||||
display: flex
|
||||
flex-direction: row
|
||||
flex-wrap: wrap
|
||||
|
||||
.User__Statistic_Item
|
||||
width: calc(100% / 5 - 1rem)
|
||||
@media (max-width: 1060px)
|
||||
width: calc(100% / 4 - 1rem)
|
||||
@media (max-width: 900px)
|
||||
width: calc(100% / 3 - 1rem)
|
||||
@media (max-width: 680px)
|
||||
width: calc(100% / 2 - 1rem)
|
||||
@media (max-width: 460px)
|
||||
width: calc(100% - 1rem)
|
||||
|
||||
|
||||
padding: .5rem 0
|
||||
margin: 0 .5rem
|
||||
|
||||
.User__Statistic_Title
|
||||
font-weight: 500
|
||||
margin-bottom: .2rem
|
||||
|
||||
.User__Statistic_Blocks
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
@media (max-width: 300px)
|
||||
width: 100%
|
||||
|
||||
.User__Statistic_Block
|
||||
display: flex
|
||||
padding: .5rem
|
||||
height: 1.2rem !important
|
||||
|
||||
.User__Statistic_Block:first-child
|
||||
border-radius: $borderRadius $borderRadius 0 0
|
||||
|
||||
.User__Statistic_Block:last-child
|
||||
border-radius: 0 0 $borderRadius $borderRadius
|
||||
|
||||
.Planned
|
||||
background: #339999
|
||||
|
||||
.Completed
|
||||
background: #339933
|
||||
|
||||
.Dropped
|
||||
background: #993333
|
||||
|
||||
.InProgress
|
||||
background: #993399
|
||||
|
||||
.Deferred
|
||||
background: #333333
|
||||
|
||||
.Planned:after, .Completed:after, .Dropped:after, .InProgress:after, .Deferred:after
|
||||
content: attr(data-count)
|
||||
margin-left: 1rem
|
||||
height: 1rem
|
||||
|
||||
.User__List
|
||||
background-color: var(--sec-bg-color)
|
||||
border-radius: $borderRadius
|
||||
padding: $primaryPadding
|
||||
margin-top: 1rem
|
||||
|
||||
.User__List_Title
|
||||
font-size: 1.2rem
|
||||
margin: .3rem 0
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
|
||||
.User__List_Items
|
||||
padding: $blockPadding * 3
|
||||
background-color: var(--block-sec-color)
|
||||
border-radius: $borderRadius
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
justify-content: center
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from "react";
|
||||
import {fetchUser, fetchUserNovels} from "../../api/users";
|
||||
import './User.sass'
|
||||
import NotFoundError from "../NotFoundError";
|
||||
import {Link} from "react-router-dom";
|
||||
import Loading from "../Loading";
|
||||
import NovelItem from "../NovelItem/NovelItem";
|
||||
|
||||
type State = {
|
||||
user: UserType | null
|
||||
novels: NovelType[]
|
||||
errorCode: number | null
|
||||
}
|
||||
|
||||
class User extends React.Component<any, State> {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
this.state = {
|
||||
user: null, novels: [], errorCode: null
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const params = this.props.match.params;
|
||||
|
||||
fetchUser(params.userId)
|
||||
.then((user: UserType) => {
|
||||
this.setState({ user });
|
||||
document.title = `${user.username} / Unmei`;
|
||||
})
|
||||
.catch((err: ApiError) => this.setState({ errorCode: err.code }));
|
||||
|
||||
fetchUserNovels(params.userId).then((novels: NovelType[]) => {
|
||||
this.setState({ novels });
|
||||
}).catch((err: ApiError) => console.error(err));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { errorCode, user, novels } = this.state;
|
||||
if(errorCode === 100) return <NotFoundError/>;
|
||||
if(!user) return <Loading />;
|
||||
|
||||
return (
|
||||
<div className={'User'}>
|
||||
<div className="User__Info">
|
||||
<div className="User__Info_Avatar" style={{ backgroundImage: `url(${user.avatar})` }} />
|
||||
<div>
|
||||
<div className='User__Info_Nick'>{user.username}</div>
|
||||
<div className='User__Info_Group' style={{ borderColor: user.group.color, color: user.group.color }}>{user.group.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="User__Statistic">
|
||||
<div className="User__Statistic_Item">
|
||||
<div className="User__Statistic_Title">Новеллы</div>
|
||||
<div className="User__Statistic_Blocks">
|
||||
<div className="User__Statistic_Block Planned" data-count={novels.filter(n => n.status === 'planned').length}>Запланированно</div>
|
||||
<div className="User__Statistic_Block Completed" data-count={novels.filter(n => n.status === 'completed').length}>Пройдено</div>
|
||||
<div className="User__Statistic_Block InProgress" data-count={novels.filter(n => n.status === 'in_progress').length}>Прохожу</div>
|
||||
<div className="User__Statistic_Block Deferred" data-count={novels.filter(n => n.status === 'deferred').length}>Отложено</div>
|
||||
<div className="User__Statistic_Block Dropped" data-count={novels.filter(n => n.status === 'dropped').length}>Брошено</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"User__List"}>
|
||||
<div className={"User__List_Title"}>
|
||||
<div>Список новелл пользователя:</div>
|
||||
<Link to={`/user/${user.id}/novels`}>Все новеллы</Link>
|
||||
</div>
|
||||
<div className="User__List_Items">
|
||||
{novels.length > 0 ?
|
||||
novels.slice(0, 4).map((novel) => <NovelItem {...novel} key={novel.id} />
|
||||
) : (
|
||||
<div>У этого пользователя нет новелл!</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*<div className="User__List">
|
||||
<div className="User__List_Title">Список аниме пользователя:</div>
|
||||
<div className="User__List_Items">В разработке...</div>
|
||||
</div>
|
||||
<div className="User__List">
|
||||
<div className="User__List_Title">Список манги и ранобэ пользователя:</div>
|
||||
<div className="User__List_Items">В разработке...</div>
|
||||
</div>
|
||||
<div className="User__List">
|
||||
<div className="User__List_Title">Список игр пользователя:</div>
|
||||
<div className="User__List_Items">В разработке...</div>
|
||||
</div>
|
||||
<div className="User__List">
|
||||
<div className="User__List_Title">Список фильмов пользователя:</div>
|
||||
<div className="User__List_Items">В разработке...</div>
|
||||
</div>
|
||||
<div className="User__List">
|
||||
<div className="User__List_Title">Список сериалов пользователя:</div>
|
||||
<div className="User__List_Items">В разработке...</div>
|
||||
</div>*/}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default User;
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {BrowserRouter} from "react-router-dom";
|
||||
|
||||
import {Provider} from 'react-redux';
|
||||
import store from "./store/store";
|
||||
|
||||
// import * as Sentry from '@sentry/browser';
|
||||
|
||||
import App from './components/App/App';
|
||||
import * as serviceWorker from './serviceWorker';
|
||||
|
||||
|
||||
//Sentry.init({ dsn: "https://27a5473611834ece9aca26216d79ad86@sentry.io/1810028" });
|
||||
|
||||
ReactDOM.render(
|
||||
<BrowserRouter>
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
</BrowserRouter>,
|
||||
document.getElementById('root'));
|
||||
|
||||
// Better not disable this
|
||||
serviceWorker.register();
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
@@ -0,0 +1,107 @@
|
||||
const isLocalhost = Boolean(
|
||||
window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '[::1]' ||
|
||||
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
|
||||
);
|
||||
|
||||
type Config = {
|
||||
onSuccess?: (registration: ServiceWorkerRegistration) => void;
|
||||
onUpdate?: (registration: ServiceWorkerRegistration) => void;
|
||||
};
|
||||
|
||||
export function register(config?: Config) {
|
||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||
const publicUrl = new URL(
|
||||
(process as { env: { [key: string]: string } }).env.PUBLIC_URL,
|
||||
window.location.href
|
||||
);
|
||||
if (publicUrl.origin !== window.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||
|
||||
if (isLocalhost) {
|
||||
checkValidServiceWorker(swUrl, config);
|
||||
|
||||
navigator.serviceWorker.ready.then(() => {
|
||||
console.log(
|
||||
'This web app is being served cache-first by a service ' +
|
||||
'worker. To learn more, visit https://bit.ly/CRA-PWA'
|
||||
);
|
||||
});
|
||||
} else {
|
||||
registerValidSW(swUrl, config);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function registerValidSW(swUrl: string, config?: Config) {
|
||||
navigator.serviceWorker
|
||||
.register(swUrl)
|
||||
.then(registration => {
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing;
|
||||
if (installingWorker == null) {
|
||||
return;
|
||||
}
|
||||
installingWorker.onstatechange = () => {
|
||||
if (installingWorker.state === 'installed') {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log(
|
||||
'New content is available and will be used when all ' +
|
||||
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
|
||||
);
|
||||
|
||||
if (config && config.onUpdate) {
|
||||
config.onUpdate(registration);
|
||||
}
|
||||
} else {
|
||||
console.log('Content is cached for offline use.');
|
||||
|
||||
if (config && config.onSuccess) {
|
||||
config.onSuccess(registration);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error during service worker registration:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function checkValidServiceWorker(swUrl: string, config?: Config) {
|
||||
fetch(swUrl)
|
||||
.then(response => {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (
|
||||
response.status === 404 ||
|
||||
(contentType != null && contentType.indexOf('javascript') === -1)
|
||||
) {
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
registerValidSW(swUrl, config);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.log(
|
||||
'No internet connection found. App is running in offline mode.'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function unregister() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
enum ActionTypes {
|
||||
SET_USER = 'SET_USER',
|
||||
LOGOUT = 'LOGOUT',
|
||||
ADD_NOTIFICATION = 'ADD_NOTIFICATION'
|
||||
}
|
||||
|
||||
export default ActionTypes;
|
||||
@@ -0,0 +1,18 @@
|
||||
import ActionTypes from "./actionTypes";
|
||||
import React from "react";
|
||||
|
||||
const setUser = (userData: UserType) => ({
|
||||
type: ActionTypes.SET_USER,
|
||||
payload: { userData }
|
||||
});
|
||||
const logout = () => ({
|
||||
type: ActionTypes.LOGOUT
|
||||
});
|
||||
|
||||
const addNotification = (notification: React.ReactNode) => ({
|
||||
type: ActionTypes.ADD_NOTIFICATION,
|
||||
payload: { notification }
|
||||
});
|
||||
|
||||
export { setUser, logout,
|
||||
addNotification };
|
||||
@@ -0,0 +1,30 @@
|
||||
import ActionTypes from "../actionTypes";
|
||||
|
||||
const initialState = {
|
||||
authorized: false
|
||||
};
|
||||
|
||||
type Action = {
|
||||
type: string
|
||||
payload: {
|
||||
userData: UserType
|
||||
}
|
||||
}
|
||||
|
||||
export default (state = initialState, action: Action) => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.SET_USER:
|
||||
if(action.payload.userData) {
|
||||
state = Object.assign({}, state, action.payload.userData);
|
||||
state.authorized = true;
|
||||
}
|
||||
return state;
|
||||
case ActionTypes.LOGOUT:
|
||||
state = {
|
||||
authorized: false
|
||||
};
|
||||
return state;
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {combineReducers} from "redux";
|
||||
import currentUser from "./currentUser";
|
||||
import notifications from "./notifications";
|
||||
|
||||
|
||||
export default combineReducers({ currentUser, notifications })
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
import ActionTypes from "../actionTypes";
|
||||
|
||||
const initialState: React.ReactNode[] = [];
|
||||
|
||||
type Action = {
|
||||
type: string
|
||||
payload: {
|
||||
notification: React.ReactNode[]
|
||||
}
|
||||
}
|
||||
|
||||
export default (state = initialState, action: Action) => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.ADD_NOTIFICATION: {
|
||||
if(state.length === 3)
|
||||
return [...state.slice(1), action.payload.notification];
|
||||
return [...state, action.payload.notification];
|
||||
}
|
||||
default: return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {createStore} from "redux";
|
||||
import index from "./reducers";
|
||||
import {composeWithDevTools} from "redux-devtools-extension";
|
||||
|
||||
const composeEnhancers = composeWithDevTools({
|
||||
trace: true
|
||||
});
|
||||
export default createStore(index, composeEnhancers());
|
||||
@@ -0,0 +1,56 @@
|
||||
type ApiError = {
|
||||
code: number
|
||||
text: string
|
||||
}
|
||||
|
||||
type NovelType = {
|
||||
id: number
|
||||
original_name: string
|
||||
localized_name: string
|
||||
description: string
|
||||
image: string
|
||||
status: string
|
||||
rating: number
|
||||
release_date: Date
|
||||
}
|
||||
|
||||
type CharacterType = {
|
||||
id: number
|
||||
original_name: string
|
||||
localized_name: string
|
||||
description: string
|
||||
image: string
|
||||
main: boolean
|
||||
}
|
||||
|
||||
type UserType = {
|
||||
authorized: boolean
|
||||
id: number
|
||||
username: string
|
||||
avatar: string
|
||||
group: UserGroupType
|
||||
}
|
||||
|
||||
type UserNovelType = {
|
||||
mark?: number
|
||||
status: string
|
||||
}
|
||||
|
||||
type UserGroupType = {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
type CommentType = {
|
||||
id: number
|
||||
user_id: number
|
||||
novel_id: number
|
||||
text: string
|
||||
}
|
||||
|
||||
type GenreType = {
|
||||
id: number
|
||||
name: string
|
||||
localized_name: string
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
const capitalize = (s: string) => {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||
};
|
||||
|
||||
const getRandomInt: (min: number, max: number) => number = (min: number, max: number) => {
|
||||
min = Math.ceil(min);
|
||||
max = Math.floor(max);
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
};
|
||||
|
||||
export { capitalize, getRandomInt };
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||