Initial commit

This commit is contained in:
Nix1304
2020-04-02 02:14:23 +03:00
commit 36cff8be53
73 changed files with 19938 additions and 0 deletions
Executable
+1
View File
@@ -0,0 +1 @@
GENERATE_SOURCEMAP=false
Executable
+4
View File
@@ -0,0 +1,4 @@
node_modules/
build/
.idea/
.DS_Store
Generated Executable
+17118
View File
File diff suppressed because it is too large Load Diff
Executable
+52
View File
@@ -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"
]
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+25
View File
@@ -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>
+17
View File
@@ -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"
}
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /admin
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Executable
+36
View File
@@ -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 };
+9
View File
@@ -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);
+9
View File
@@ -0,0 +1,9 @@
const errors: { [key: number]: string } = {
1: 'Неверный логин и/или пароль!',
2: 'Проверка ReCaptcha не пройдена!',
5: 'Аккаунт уже активирован! Вы будете перенаправлены на главную через 3 секунды.',
6: 'Токен истек!',
100: 'Не найдено!'
};
export default errors;
+15
View File
@@ -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}`);
+10
View File
@@ -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');
+67
View File
@@ -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);
+113
View File
@@ -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
+104
View File
@@ -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);
+62
View File
@@ -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
+57
View File
@@ -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;
+21
View File
@@ -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
+38
View File
@@ -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;
+46
View File
@@ -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
+108
View File
@@ -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;
+54
View File
@@ -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)
+59
View File
@@ -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
+37
View File
@@ -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;
+11
View File
@@ -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;
+46
View File
@@ -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
+88
View File
@@ -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);
+49
View File
@@ -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
+123
View File
@@ -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);
View File
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
class Main extends React.Component {
render() {
return (
<div>В разработке!</div>
);
}
}
export default Main;
+22
View File
@@ -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
+52
View File
@@ -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;
+46
View File
@@ -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
+68
View File
@@ -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);
+12
View File
@@ -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;
+21
View File
@@ -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
+69
View File
@@ -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;
+151
View File
@@ -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
+199
View File
@@ -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);
+37
View File
@@ -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
+24
View File
@@ -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;
+57
View File
@@ -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
+80
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
@import "../Constants"
.Spoiler
cursor: pointer
+33
View File
@@ -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;
+13
View File
@@ -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;
+16
View File
@@ -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;
+29
View File
@@ -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
+75
View File
@@ -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;
+116
View File
@@ -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
+105
View File
@@ -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;
Executable
+25
View File
@@ -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();
Vendored Executable
+1
View File
@@ -0,0 +1 @@
/// <reference types="react-scripts" />
+107
View File
@@ -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();
});
}
}
+7
View File
@@ -0,0 +1,7 @@
enum ActionTypes {
SET_USER = 'SET_USER',
LOGOUT = 'LOGOUT',
ADD_NOTIFICATION = 'ADD_NOTIFICATION'
}
export default ActionTypes;
+18
View File
@@ -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 };
+30
View File
@@ -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
}
}
+6
View File
@@ -0,0 +1,6 @@
import {combineReducers} from "redux";
import currentUser from "./currentUser";
import notifications from "./notifications";
export default combineReducers({ currentUser, notifications })
+22
View File
@@ -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;
}
}
+8
View File
@@ -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());
Vendored Executable
+56
View File
@@ -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
}
Executable
+11
View File
@@ -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 };
Executable
+25
View File
@@ -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"
]
}