many new
This commit is contained in:
Executable
+67
@@ -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 "../ui/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);
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
@import "../../Constants"
|
||||
|
||||
.AdminPanel
|
||||
background-color: var(--sec-bg-color)
|
||||
padding: .5rem
|
||||
|
||||
&__Title
|
||||
font-size: 1.5rem
|
||||
font-weight: 600
|
||||
text-align: center
|
||||
|
||||
&__Router
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
border-bottom: 1px solid white
|
||||
|
||||
a
|
||||
margin: 0 .2rem
|
||||
padding: .1rem
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
import {connect} from "react-redux";
|
||||
import React from "react";
|
||||
import NotFoundError from "../NotFoundError";
|
||||
import {hasAccessToAdminPanel} from "../../utils";
|
||||
import './AdminPanel.sass';
|
||||
import {Link} from "react-router-dom";
|
||||
import AdminPanelUsers from "./Users/AdminPanelUsers";
|
||||
import AdminPanelNovels from "./Novels/AdminPanelNovels";
|
||||
import ModifyUser from "./Users/ModifyUser";
|
||||
import APNews from "./News/APNews";
|
||||
import APModifyPost from "./News/APModifyPost";
|
||||
|
||||
type Props = {
|
||||
user: UserType
|
||||
match: { path: string }
|
||||
location: { pathname: string }
|
||||
}
|
||||
|
||||
type State = {}
|
||||
|
||||
class AdminPanel extends React.Component<Props, State> {
|
||||
render() {
|
||||
const { user, match: { path }, location: { pathname } } = this.props;
|
||||
if(!user || !user.group) return <NotFoundError/>;
|
||||
if(!hasAccessToAdminPanel(user)) return <NotFoundError/>;
|
||||
const subPath = pathname.slice(path.length+1);
|
||||
let component;
|
||||
switch(subPath) {
|
||||
case 'users': component = <AdminPanelUsers path={`${path}/${subPath}`}/>; break;
|
||||
case 'novels': component = <AdminPanelNovels/>; break;
|
||||
case 'news': component = <APNews path={`${path}/${subPath}`}/>; break;
|
||||
default: {
|
||||
const users = /users\/(\d+)/;
|
||||
const news = /news\/(\d+)/;
|
||||
|
||||
const userMatch = subPath.match(users);
|
||||
const newsMatch = subPath.match(news);
|
||||
|
||||
if(userMatch) component = <ModifyUser userId={parseInt(userMatch[1])}/>;
|
||||
if(newsMatch) component = <APModifyPost postId={parseInt(newsMatch[1])}/>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'AdminPanel'}>
|
||||
<div className="AdminPanel__Title">Админ панель <span style={{ color: 'var(--error-bg-color)' }}>(в разработке)</span></div>
|
||||
<div className="AdminPanel__Router">
|
||||
<Link to={path}>Главная</Link>
|
||||
<Link to={`${path}/users`}>Пользователи</Link>
|
||||
<Link to={`${path}/novels`}>Новелы</Link>
|
||||
<Link to={`${path}/news`}>Новости</Link>
|
||||
</div>
|
||||
{component}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: StoreState) => ({
|
||||
user: state.currentUser
|
||||
})
|
||||
)(AdminPanel);
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
import React, {ChangeEvent, FormEvent} from "react";
|
||||
import Group from "../../../ui/Group/Group";
|
||||
import NotFoundError from "../../NotFoundError";
|
||||
import {fetchPost, updatePost} from "../../../api/news";
|
||||
import './APNews.sass';
|
||||
import '../../../ui/TextField/TextField.sass'
|
||||
import Input from "../../../ui/Input/Input";
|
||||
import Button from "../../../ui/Button/Button";
|
||||
import BBEditor from "../../../ui/BBEditor/BBEditor";
|
||||
// @ts-ignore
|
||||
import parser from 'bbcode-to-react';
|
||||
import Title from "../../../ui/Title/Title";
|
||||
|
||||
type Props = {
|
||||
postId: number
|
||||
//addNotification: (notification: React.ReactNode) => void
|
||||
};
|
||||
|
||||
type State = {
|
||||
post: PostType | null
|
||||
title: string
|
||||
|
||||
previewShort: string
|
||||
previewFull: string
|
||||
};
|
||||
|
||||
class APModifyPost extends React.Component<Props, State> {
|
||||
private shortPost = React.createRef<HTMLTextAreaElement>();
|
||||
private fullPost = React.createRef<HTMLTextAreaElement>();
|
||||
|
||||
state: State = {
|
||||
post: null, title: '',
|
||||
previewShort: '', previewFull: ''
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { postId } = this.props;
|
||||
fetchPost(postId).then((post: PostType) => {
|
||||
this.setState({ post, title: post.title });
|
||||
});
|
||||
}
|
||||
|
||||
savePost = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const post = this.state.post;
|
||||
if(!post) return;
|
||||
if(!this.shortPost.current || !this.fullPost.current) return;
|
||||
|
||||
post.short_post = this.shortPost.current.value;
|
||||
post.full_post = this.fullPost.current.value;
|
||||
|
||||
await updatePost(post);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<State>) {
|
||||
if(prevState.post) return;
|
||||
if(!this.state.post) return;
|
||||
if(!this.shortPost.current || !this.fullPost.current) return;
|
||||
this.shortPost.current.value = this.state.post.short_post;
|
||||
this.fullPost.current.value = this.state.post.full_post;
|
||||
}
|
||||
|
||||
previewShort = () => {
|
||||
if(!this.shortPost.current) return;
|
||||
this.setState({ previewShort: parser.toReact(this.shortPost.current.value) })
|
||||
}
|
||||
|
||||
previewFull = () => {
|
||||
if(!this.fullPost.current) return;
|
||||
this.setState({ previewFull: parser.toReact(this.fullPost.current.value) })
|
||||
}
|
||||
|
||||
changeTitle = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const post = this.state.post;
|
||||
if(!post) return;
|
||||
post.title = event.target.value;
|
||||
this.setState({ post });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { post, title, previewShort, previewFull } = this.state;
|
||||
|
||||
if(!post) return <NotFoundError/>;
|
||||
return (
|
||||
<Group title={title}>
|
||||
<form onSubmit={this.savePost} className={'APPost'}>
|
||||
<Input type="text" placeholder={'Заголовок'} value={post.title} onChange={this.changeTitle}/>
|
||||
|
||||
<div className={'APPost__Short'}>
|
||||
<Title>Короткая новость</Title>
|
||||
<BBEditor inputRef={this.shortPost} placeholder={'Короткая новость'} />
|
||||
{previewShort.length > 0 && (<>
|
||||
<Title>Предпросмотр</Title>
|
||||
<div className={'APPost__Short_Preview TextField'}>{previewShort}</div>
|
||||
</>)}
|
||||
<Button type={"button"} onClick={this.previewShort}>Предпросмотр</Button>
|
||||
</div>
|
||||
|
||||
<div className={'APPost__Full'}>
|
||||
<Title>Полная новость</Title>
|
||||
<BBEditor inputRef={this.fullPost} placeholder={'Полная новость'} />
|
||||
{previewFull.length > 0 && (<>
|
||||
<Title>Предпросмотр</Title>
|
||||
<div className={'APPost__Full_Preview TextField'}>{previewFull}</div>
|
||||
</>)}
|
||||
<Button type={"button"} onClick={this.previewFull}>Предпросмотр</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button>Сохранить</Button>
|
||||
<Button type={"button"} style={{ color: 'var(--error) '}}>Удалить</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default APModifyPost;
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
.APPost
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
.Input, .TextField, .Button
|
||||
font-size: 1.2rem
|
||||
|
||||
.Input, .TextField
|
||||
margin-bottom: 1rem
|
||||
|
||||
.TextField
|
||||
width: 100%
|
||||
min-height: 4rem
|
||||
resize: none
|
||||
|
||||
&__Short, &__Full
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: flex-start
|
||||
margin-bottom: 1rem
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import {fetchNews} from "../../../api/news";
|
||||
import Group from "../../../ui/Group/Group";
|
||||
import {Link} from "react-router-dom";
|
||||
|
||||
type Props = {
|
||||
path: string
|
||||
};
|
||||
|
||||
type State = {
|
||||
news: PostType[]
|
||||
};
|
||||
|
||||
class APNews extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
news: []
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
fetchNews().then(news => this.setState({ news }));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { news } = this.state;
|
||||
const { path } = this.props;
|
||||
|
||||
return (
|
||||
<Group title={'Новости'}>
|
||||
{news.map(post => (
|
||||
<Link to={`${path}/${post.id}`} key={post.id}>
|
||||
<div style={{ display: "flex" }}>
|
||||
<div style={{ marginRight: '.5rem', fontWeight: 600 }}>{post.id}</div>
|
||||
<div>{post.title}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default APNews;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import {connect} from "react-redux";
|
||||
|
||||
type Props = {
|
||||
|
||||
};
|
||||
|
||||
type State = {
|
||||
|
||||
};
|
||||
|
||||
class AdminPanelNovels extends React.Component<Props, State> {
|
||||
render() {
|
||||
return (
|
||||
<div>Novels</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: StoreState) => ({
|
||||
user: state.currentUser
|
||||
})
|
||||
)(AdminPanelNovels);
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
.APUser__Modify
|
||||
&_Title
|
||||
text-align: center
|
||||
font-weight: 600
|
||||
font-size: 1.5rem
|
||||
|
||||
&_Form
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
button
|
||||
font-size: 1.5rem
|
||||
|
||||
label
|
||||
display: flex
|
||||
flex-wrap: nowrap
|
||||
white-space: nowrap
|
||||
align-items: center
|
||||
font-size: 1.2rem
|
||||
|
||||
input
|
||||
margin: .5rem 0 .5rem .5rem
|
||||
font-size: 1.2rem
|
||||
width: 100%
|
||||
|
||||
&[type='checkbox']
|
||||
width: 0
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
import {connect} from "react-redux";
|
||||
import {fetchUsers} from "../../../api/users";
|
||||
import {Link} from "react-router-dom";
|
||||
|
||||
type Props = {
|
||||
path: string
|
||||
};
|
||||
|
||||
type State = {
|
||||
users: UserType[]
|
||||
};
|
||||
|
||||
class AdminPanelUsers extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
users: []
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
fetchUsers().then(users => this.setState({ users }))
|
||||
}
|
||||
|
||||
render() {
|
||||
const { users } = this.state;
|
||||
const { path } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ textAlign: "center", fontWeight: 600, fontSize: '1.5rem' }}>Пользователи</div>
|
||||
<div>
|
||||
{users.sort((a, b) => a.id - b.id).map(user => (
|
||||
<Link to={`${path}/${user.id}`} key={user.id}>
|
||||
<div style={{ display: "flex" }}>
|
||||
<div style={{ marginRight: '.5rem', fontWeight: 600 }}>{user.id}</div>
|
||||
<div>{user.username}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: StoreState) => ({
|
||||
user: state.currentUser
|
||||
})
|
||||
)(AdminPanelUsers);
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
import React, {ChangeEvent, FormEvent} from "react";
|
||||
import {connect} from "react-redux";
|
||||
import {fetchUser, updateUser} from "../../../api/users";
|
||||
import Loading from "../../../ui/Loading";
|
||||
import './APUser.sass';
|
||||
import {addNotification} from "../../../store/actions";
|
||||
import NotificationMessage from "../../../ui/Notifications/NotificationMessage";
|
||||
import Button from "../../../ui/Button/Button";
|
||||
|
||||
type Props = {
|
||||
userId: number
|
||||
addNotification: (notification: React.ReactNode) => void
|
||||
};
|
||||
|
||||
type State = {
|
||||
user: UserType | null
|
||||
username: string
|
||||
};
|
||||
|
||||
class ModifyUser extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
user: null, username: ''
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { userId } = this.props;
|
||||
fetchUser(userId).then((user: UserType) => {
|
||||
this.setState({ user, username: user.username });
|
||||
document.title = `${user.username} / Админ-панель`;
|
||||
})
|
||||
}
|
||||
|
||||
onChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const user = Object.assign({}, this.state.user);
|
||||
const target = event.target;
|
||||
// @ts-ignore
|
||||
user[target.name] = target.type === 'checkbox' ? target.checked : target.value;
|
||||
this.setState({ user })
|
||||
}
|
||||
|
||||
onSubmit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const { user } = this.state;
|
||||
const { addNotification } = this.props;
|
||||
|
||||
if(user === null) {
|
||||
console.error('Пользователь - null!');
|
||||
return;
|
||||
}
|
||||
updateUser(user).then(() => {
|
||||
const notification = (
|
||||
<NotificationMessage level={"success"}>
|
||||
Пользователь успешно изменен!
|
||||
</NotificationMessage>
|
||||
)
|
||||
addNotification(notification)
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { user, username } = this.state;
|
||||
|
||||
if(!user) return <Loading/>;
|
||||
return (
|
||||
<div className={'APUser__Modify'}>
|
||||
<div className={'APUser__Modify_Title'}>{username}</div>
|
||||
<form className={'APUser__Modify_Form'} onSubmit={this.onSubmit}>
|
||||
<label>Имя пользователя: <input type="text" onChange={this.onChange} name={'username'} value={user.username}/></label>
|
||||
<label>Email: <input type='email' onChange={this.onChange} name={'email'} value={user.email}/></label>
|
||||
<label>Аватар: <input type='text' onChange={this.onChange} name={'avatar'} value={user.avatar}/></label>
|
||||
|
||||
<label>Суперюзер: <input type='checkbox' onChange={this.onChange} name={'is_superuser'} checked={user.is_superuser}/></label>
|
||||
<label>Активирован: <input type='checkbox' onChange={this.onChange} name={'is_activated'} checked={user.is_activated}/></label>
|
||||
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<Button>Сохранить!</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: StoreState) => ({
|
||||
user: state.currentUser
|
||||
}),
|
||||
(dispatch) => ({
|
||||
addNotification: (notification: React.ReactNode) => {
|
||||
dispatch(addNotification(notification))
|
||||
}
|
||||
})
|
||||
)(ModifyUser);
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
@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, pre, textarea
|
||||
font-family: 'Roboto', sans-serif
|
||||
|
||||
h1, h2, h3, h4, h5, h6, p, body, pre
|
||||
margin: 0
|
||||
|
||||
body
|
||||
padding: 0
|
||||
|
||||
--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
|
||||
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}
|
||||
|
||||
color: var(--main-color)
|
||||
background: var(--main-bg-color)
|
||||
|
||||
a
|
||||
color: var(--main-color)
|
||||
text-decoration: none
|
||||
|
||||
&:hover
|
||||
color: var(--link-color)
|
||||
|
||||
*
|
||||
scrollbar-color: #eee #222
|
||||
scrollbar-width: thin
|
||||
|
||||
.Container
|
||||
max-width: 1200px
|
||||
min-height: calc(100vh - 16rem)
|
||||
margin: .5rem auto
|
||||
|
||||
.Notifications
|
||||
position: fixed
|
||||
bottom: 0
|
||||
left: 0
|
||||
z-index: 100000
|
||||
|
||||
@media (min-width: 500px)
|
||||
padding: 1rem
|
||||
box-sizing: border-box
|
||||
|
||||
.Successful, .Error
|
||||
border: var(--link-color) 1px solid
|
||||
padding: .5rem
|
||||
border-radius: 3px
|
||||
margin-bottom: 1rem
|
||||
|
||||
.Error
|
||||
background-color: var(--error-bg-color)
|
||||
|
||||
.Successful
|
||||
background-color: var(--success-bg-color)
|
||||
Executable
+126
@@ -0,0 +1,126 @@
|
||||
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 Character from "../Character/Character";
|
||||
// @ts-ignore
|
||||
import parser from 'bbcode-to-react';
|
||||
import SpoilerTag from "../../ui/Spoiler/SpoilerTag";
|
||||
import Novels from "../Novels/Novels";
|
||||
import Footer from '../Footer/Footer';
|
||||
import UserNovels from '../User/Novels/UserNovels';
|
||||
import ColorTag from "../../ui/Tags/ColorTag";
|
||||
import ActivateAccount from "../ActivateAccount";
|
||||
import AllNews from "../News/AllNews";
|
||||
import Post from "../News/Post";
|
||||
import AdminPanel from "../AdminPanel/AdminPanel";
|
||||
import PasswordRestoreGenerate from "../PasswordRestore/PasswordRestoreGenerate";
|
||||
import PasswordRestore from "../PasswordRestore/PasswordRestore";
|
||||
import Users from "../Users/Users";
|
||||
import {getVersion, version} from "../../api/api";
|
||||
import Settings from "../User/Settings/Settings";
|
||||
|
||||
type Props = {
|
||||
notifications: React.ReactNode[]
|
||||
setUser: (user: UserType) => void
|
||||
modal: React.ReactNode
|
||||
};
|
||||
|
||||
type State = {}
|
||||
|
||||
class App extends React.Component<Props, State> {
|
||||
state: State = {}
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
const { setUser } = this.props;
|
||||
fetchCurrentUser().then(setUser).catch((err: ApiError) => {
|
||||
if(err.code !== 3 && err.text) console.error(err.text);
|
||||
});
|
||||
|
||||
const theme = localStorage.getItem('theme');
|
||||
if(theme)
|
||||
document.body.setAttribute('theme', theme);
|
||||
|
||||
parser.registerTag('spoiler', SpoilerTag);
|
||||
parser.registerTag('color', ColorTag);
|
||||
|
||||
getVersion().then(res => {
|
||||
if(version !== res.version) {
|
||||
console.debug(`Current back-end version: ${res.version}. Clearing cache`);
|
||||
|
||||
caches.keys().then(names => {
|
||||
names.forEach(name => caches.delete(name).catch(console.error))
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { modal } = this.props;
|
||||
const notificationWidth = window.screen.width >= 500 ? 500 : window.screen.width;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar/>
|
||||
<div className={'Container'}>
|
||||
<div className="Notifications" style={{ width: notificationWidth }}>
|
||||
{this.props.notifications.map((n, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{n}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<Switch>
|
||||
<Route exact path={'/'} component={Main}/>
|
||||
|
||||
<Route exact path={'/news'} component={AllNews}/>
|
||||
<Route exact path={'/news/:postId'} component={Post}/>
|
||||
|
||||
<Route exact path={'/novels'} component={Novels}/>
|
||||
<Route exact path={'/novels/:novelId'} component={Novel}/>
|
||||
|
||||
<Route exact path={'/users'} component={Users}/>
|
||||
<Route exact path={'/user/:userId'} component={User}/>
|
||||
<Route exact path={'/user/:userId/novels'} component={UserNovels}/>
|
||||
<Route exact path={'/user/:userId/settings'} component={Settings}/>
|
||||
|
||||
<Route exact path={'/character/:charId'} component={Character}/>
|
||||
|
||||
<Route exact path={'/activate/:token'} component={ActivateAccount}/>
|
||||
|
||||
<Route exact path={'/restore'} component={PasswordRestoreGenerate}/>
|
||||
<Route exact path={'/restore/:token'} component={PasswordRestore}/>
|
||||
|
||||
<Route path={'/kawaii__neko'} component={AdminPanel}/>
|
||||
|
||||
<Route component={NotFoundError}/>
|
||||
</Switch>
|
||||
|
||||
{modal && modal}
|
||||
</div>
|
||||
<Footer/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: StoreState) => ({
|
||||
notifications: state.notifications,
|
||||
modal: state.modal
|
||||
}),
|
||||
dispatch => ({
|
||||
setUser: (userData: UserType) => {
|
||||
dispatch(setUser(userData))
|
||||
}
|
||||
})
|
||||
)(App);
|
||||
Executable
+62
@@ -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
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
import {fetchChar, fetchCharNovels} from "../../api/characters";
|
||||
import './Character.sass'
|
||||
// @ts-ignore
|
||||
import parser from 'bbcode-to-react';
|
||||
import NovelItem from "../../ui/NovelItem/NovelItem";
|
||||
import Loading from "../../ui/Loading";
|
||||
import NotFoundError from "../NotFoundError";
|
||||
|
||||
type Props = {
|
||||
match: { params: { charId: number } }
|
||||
}
|
||||
|
||||
type State = {
|
||||
char: CharacterType | null
|
||||
novels: NovelType[]
|
||||
errorCode: number
|
||||
};
|
||||
|
||||
class Character extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
char: null, novels: [], errorCode: 0
|
||||
};
|
||||
|
||||
componentDidMount = () => {
|
||||
const { charId } = this.props.match.params;
|
||||
fetchChar(charId).then(char => {
|
||||
this.setState({ char });
|
||||
document.title = char.localized_name ? `${char.original_name} / ${char.localized_name}` : char.original_name;
|
||||
}).catch(r => this.setState({ errorCode: r.code }));
|
||||
fetchCharNovels(charId).then(novels => this.setState({ novels }));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { char, novels, errorCode } = this.state;
|
||||
if(errorCode === 100) return <NotFoundError/>;
|
||||
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.length > 0 &&
|
||||
<div className="Character__Novels">
|
||||
{novels.map(novel => <NovelItem {...novel} key={novel.id} viewType='grid' />)}
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Character;
|
||||
Executable
+46
@@ -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
|
||||
Executable
+133
@@ -0,0 +1,133 @@
|
||||
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 '../../ui/Comment/Comment';
|
||||
import './Comments.sass';
|
||||
import Loading from "../../ui/Loading";
|
||||
import {fetchUser} from "../../api/users";
|
||||
import {connect} from "react-redux";
|
||||
import {addNotification} from "../../store/actions";
|
||||
import NotificationMessage from "../../ui/Notifications/NotificationMessage";
|
||||
import Button from "../../ui/Button/Button";
|
||||
|
||||
type Props = {
|
||||
comments: CommentType[]
|
||||
hasMore: boolean
|
||||
user: UserType
|
||||
loadMore: () => void
|
||||
sendComment: (text: string) => void
|
||||
addNotification: (notification: React.ReactNode) => 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, addNotification } = this.props;
|
||||
const { commentText } = this.state;
|
||||
|
||||
if(commentText === '') {
|
||||
const notification = (
|
||||
<NotificationMessage level={"error"}>
|
||||
Пустой комментарий
|
||||
</NotificationMessage>
|
||||
);
|
||||
addNotification(notification);
|
||||
} else {
|
||||
sendComment(commentText);
|
||||
this.setState({ commentText: '' });
|
||||
}
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: Readonly<Props>) {
|
||||
const { comments } = this.props;
|
||||
const { comments: prevComments } = prevProps;
|
||||
|
||||
if(prevComments.length !== comments.length) {
|
||||
let { users } = this.state;
|
||||
|
||||
comments.forEach(async comment => {
|
||||
if(!users.has(comment.user_id)) {
|
||||
const user = await fetchUser(comment.user_id);
|
||||
users = users.set(comment.user_id, user);
|
||||
this.setState({ users });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { user, comments, hasMore, loadMore } = this.props;
|
||||
const { users, commentText } = this.state;
|
||||
if(!user) 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={commentText}
|
||||
onChange={(event) => this.setState({ commentText: event.target.value })}
|
||||
ref={ref => this.input = ref}
|
||||
/>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<Button type="submit" className={'Comments_Submit'}>Написать</Button>
|
||||
<div style={{ marginLeft: '.5rem', color: '#bb3333' }}>Комментарии в разработке!</div>
|
||||
</div>
|
||||
{hasMore && <Button onClick={loadMore}>Загрузить ещё</Button>}
|
||||
</div>
|
||||
</form>}
|
||||
{!comments && (
|
||||
<Loading/>
|
||||
)}
|
||||
{comments && comments.length > 0 ? (
|
||||
comments.map(comment => <Comment text={comment.text} key={comment.id} user={users.get(comment.user_id)} />)
|
||||
) : (
|
||||
'Никто не оставил комментарий к этой новелле... :c'
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(null,
|
||||
dispatch => ({
|
||||
addNotification: (notification: React.ReactNode) => {
|
||||
dispatch(addNotification(notification))
|
||||
}
|
||||
})
|
||||
)(Comments);
|
||||
Executable
+59
@@ -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
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
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 = () => {
|
||||
const secretChangeTheme = () => {
|
||||
const theme = localStorage.getItem('theme');
|
||||
if(theme == null) {
|
||||
localStorage.setItem('theme', 'light');
|
||||
document.body.setAttribute('theme', 'light')
|
||||
} else if(theme === 'light') {
|
||||
localStorage.setItem('theme', 'dark');
|
||||
document.body.setAttribute('theme', 'dark')
|
||||
} else if(theme === 'dark') {
|
||||
localStorage.setItem('theme', 'light');
|
||||
document.body.setAttribute('theme', 'light')
|
||||
}
|
||||
if(theme !== 'light') {
|
||||
console.log('Светлая тема находится в разработке! Не все может корректно отображаться.\nК тому же, эта кнопка секретная. Ты знал, на что шел ;)');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='Footer'>
|
||||
<footer className='Footer__Tabs'>
|
||||
<div className="Footer__Tab">
|
||||
<div className="Footer__Tab_Title" onClick={secretChangeTheme}>Команда</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/4CA8Cju">
|
||||
<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;
|
||||
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
.News
|
||||
background-color: var(--sec-bg-color)
|
||||
padding: .5rem
|
||||
|
||||
&__Title
|
||||
display: flex
|
||||
flex-direction: row
|
||||
justify-content: space-between
|
||||
font-size: 1.5rem
|
||||
font-weight: 600
|
||||
margin-bottom: .5rem
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
import News from "./News";
|
||||
import './Main.sass';
|
||||
|
||||
class Main extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className={'Main'}>
|
||||
<News/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Main;
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import {fetchNews} from "../../api/news";
|
||||
import Loading from "../../ui/Loading";
|
||||
import {Link} from "react-router-dom";
|
||||
import NewsPost from "../../ui/NewsPost/NewsPost";
|
||||
|
||||
type State = {
|
||||
news: PostType[]
|
||||
}
|
||||
|
||||
class News extends React.Component<{}, State> {
|
||||
state: State = {
|
||||
news: []
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
fetchNews().then((news: PostType[]) => {
|
||||
news.forEach(p => p.date = new Date(p.date));
|
||||
this.setState({ news })
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { news } = this.state;
|
||||
|
||||
if(news.length === 0) return <Loading/>;
|
||||
return (
|
||||
<div className="News">
|
||||
<div className="News__Title">
|
||||
<div>Новости</div>
|
||||
<Link to={'/news'}>Все новости</Link>
|
||||
</div>
|
||||
{news.slice(0, 3).map(post => (
|
||||
<Link key={post.id} to={`/news/${post.id}`}>
|
||||
<NewsPost {...post} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default News;
|
||||
Executable
+46
@@ -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
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
import React, {FormEvent, ChangeEvent} from "react";
|
||||
import Modal from "../../ui/Modal/Modal";
|
||||
import {login} from "../../api/users";
|
||||
import {connect} from "react-redux";
|
||||
import {addNotification, hideModal, setModal, setUser} from "../../store/actions";
|
||||
import './LoginModal.sass'
|
||||
import {Link} from "react-router-dom";
|
||||
import errors from "../../api/errors";
|
||||
import NotificationMessage from "../../ui/Notifications/NotificationMessage";
|
||||
import Recaptcha from '../../ui/Recaptcha/Recaptcha'
|
||||
import Button from "../../ui/Button/Button";
|
||||
import RegisterModal from "./RegisterModal";
|
||||
import Input from "../../ui/Input/Input";
|
||||
|
||||
type Props = {
|
||||
setUser: (user: UserType) => void
|
||||
addNotification: (notification: React.ReactNode) => void
|
||||
setModal: (modal: React.ReactNode | null) => void
|
||||
hideModal: () => void
|
||||
};
|
||||
|
||||
type State = {
|
||||
error: string
|
||||
login: string
|
||||
password: string
|
||||
recaptcha: string
|
||||
recaptchaNeeded: boolean
|
||||
};
|
||||
|
||||
class LoginModal extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
error: '', login: '', password: '', recaptcha: '', recaptchaNeeded: false
|
||||
};
|
||||
|
||||
loginAndFetchToken = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const { setUser, addNotification, setModal } = this.props;
|
||||
const { login: log, password, recaptcha } = this.state;
|
||||
|
||||
login(log, password, recaptcha).then(user => {
|
||||
if(!user) return;
|
||||
|
||||
setUser(user);
|
||||
const successful = (
|
||||
<NotificationMessage level={"success"}>
|
||||
Ты успешно вошел!
|
||||
</NotificationMessage>
|
||||
);
|
||||
addNotification(successful);
|
||||
setModal(null);
|
||||
})
|
||||
.catch((r: ApiError) => {
|
||||
this.setState({ error: errors[r.code] });
|
||||
if(r.code === 9) this.setState({ recaptchaNeeded: true });
|
||||
});
|
||||
};
|
||||
|
||||
handlePasswordChange = (event: ChangeEvent) => this.setState({ password: (event.target as HTMLInputElement).value });
|
||||
handleLoginChange = (event: ChangeEvent) => this.setState({ login: (event.target as HTMLInputElement).value });
|
||||
|
||||
render() {
|
||||
const { login, password, error, recaptchaNeeded } = this.state;
|
||||
const { hideModal, setModal } = this.props;
|
||||
|
||||
return (
|
||||
<Modal className={'LoginModal'} title={'Авторизация'}>
|
||||
{error && <div className="LoginModal__Error">{error}</div>}
|
||||
<form className={'LoginModal__Form'} onSubmit={this.loginAndFetchToken}>
|
||||
<Input
|
||||
type="text"
|
||||
name={'login'}
|
||||
placeholder={'Логин'}
|
||||
className={'LoginModal__Field'}
|
||||
onChange={this.handleLoginChange}
|
||||
value={login}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
name={'password'}
|
||||
placeholder={'Пароль'}
|
||||
className={'LoginModal__Field'}
|
||||
onChange={this.handlePasswordChange}
|
||||
value={password}
|
||||
/>
|
||||
{recaptchaNeeded &&
|
||||
<Recaptcha
|
||||
onVerify={(res: any) => this.setState({ recaptcha: res })}
|
||||
theme={'dark'}
|
||||
sitekey={'6LfnDsMUAAAAAEDfD5ubCdFQbNUnKxxJlMWeUMzN'}
|
||||
/>}
|
||||
<Button className={'LoginModal__Submit'}>Войти</Button>
|
||||
</form>
|
||||
<div className="LoginModal__Links">
|
||||
<Link to={'/restore'} onClick={hideModal}>Забыли пароль?</Link>
|
||||
<p>|</p>
|
||||
<Link to={''} onClick={() => setModal(<RegisterModal/>)}>Регистрация</Link>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(null,
|
||||
dispatch => ({
|
||||
setUser: (user: UserType) => dispatch(setUser(user)),
|
||||
addNotification: (notification: React.ReactNode) => dispatch(addNotification(notification)),
|
||||
setModal: (modal: React.ReactNode | null) => dispatch(setModal(modal)),
|
||||
hideModal: () => dispatch(hideModal())
|
||||
})
|
||||
)(LoginModal);
|
||||
Executable
+49
@@ -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
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
import React from "react";
|
||||
import Modal from "../../ui/Modal/Modal";
|
||||
import Recaptcha from '../../ui/Recaptcha/Recaptcha'
|
||||
import './RegisterModal.sass'
|
||||
import {registerUser} from "../../api/users";
|
||||
import {connect} from "react-redux";
|
||||
import {addNotification, setModal} from "../../store/actions";
|
||||
import NotificationMessage from "../../ui/Notifications/NotificationMessage";
|
||||
import Button from "../../ui/Button/Button";
|
||||
import Input from "../../ui/Input/Input";
|
||||
|
||||
type Props = {
|
||||
addNotification: (notification: React.ReactNode) => void
|
||||
setModal: (modal: React.ReactNode | null) => 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() {
|
||||
if(this.recaptcha) 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 = (
|
||||
<NotificationMessage level={"success"}>
|
||||
Аккаунт успешно создан!
|
||||
Проверьте почту <strong>{email}</strong>. На неё была отправлена ссылка для подтверждения аккаунта.
|
||||
</NotificationMessage>
|
||||
);
|
||||
const { addNotification, setModal } = this.props;
|
||||
addNotification(notification);
|
||||
setModal(null);
|
||||
}).catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { error, email, login, password1, password2 } = this.state;
|
||||
|
||||
return (
|
||||
<Modal className={'RegisterModal'} title={'Регистрация'}>
|
||||
{error && <div className="RegisterModal__Error" dangerouslySetInnerHTML={{ __html: error }}/>}
|
||||
<form className="RegisterModal__Form" onSubmit={this.submitForm}>
|
||||
<Input
|
||||
type="text"
|
||||
min={4}
|
||||
max={20}
|
||||
pattern={'[a-zA-Z0-9_-~[]]+'}
|
||||
name={'login'}
|
||||
placeholder={'Логин'}
|
||||
className={'RegisterModal__Field'}
|
||||
onChange={this.handleLoginChange}
|
||||
value={login}/>
|
||||
<Input
|
||||
type="email"
|
||||
name={'email'}
|
||||
placeholder={'E-mail'}
|
||||
pattern={'.+@\\w{2,6}\\.\\w{2,3}'}
|
||||
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}
|
||||
onVerify={(res: any) => this.setState({ recaptcha: res })}
|
||||
theme={'dark'}
|
||||
sitekey={'6LfnDsMUAAAAAEDfD5ubCdFQbNUnKxxJlMWeUMzN'}/>
|
||||
|
||||
<Button className={'RegisterModal__Submit'}>Регистрация</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(null,
|
||||
dispatch => ({
|
||||
addNotification: (notification: React.ReactNode) => dispatch(addNotification(notification)),
|
||||
setModal: (modal: React.ReactNode | null) => dispatch(setModal(modal))
|
||||
})
|
||||
)(RegisterModal);
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
$hoverColor: #f22
|
||||
|
||||
.Minimized
|
||||
height: 3rem
|
||||
|
||||
.Expanded
|
||||
min-height: 3rem
|
||||
|
||||
&__Links
|
||||
position: relative
|
||||
width: 100%
|
||||
|
||||
.Header
|
||||
color: var(--main-color)
|
||||
background-color: var(--nav-bg-color)
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
align-items: flex-start
|
||||
flex-wrap: wrap
|
||||
|
||||
&__Links, &__User
|
||||
min-height: 3rem
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
|
||||
&_Button
|
||||
font-size: 1rem
|
||||
padding: .5rem
|
||||
color: var(--main-color)
|
||||
background-color: var(--nav-bg-color)
|
||||
border: none
|
||||
display: flex
|
||||
justify-content: center
|
||||
align-items: center
|
||||
cursor: pointer
|
||||
position: relative
|
||||
|
||||
&::before
|
||||
content: ""
|
||||
width: 100%
|
||||
position: absolute
|
||||
bottom: 0
|
||||
left: 0
|
||||
height: 0
|
||||
background: $hoverColor
|
||||
transition: heigth .1s ease-in
|
||||
-webkit-transition: height .1s ease-in
|
||||
|
||||
&:hover
|
||||
background-color: var(--main-bg-color)
|
||||
|
||||
&::before
|
||||
height: 5px
|
||||
|
||||
&__User
|
||||
justify-content: flex-end
|
||||
|
||||
&_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
|
||||
padding: 0 .5rem
|
||||
position: relative
|
||||
|
||||
&::before
|
||||
content: ""
|
||||
width: 100%
|
||||
position: absolute
|
||||
bottom: 0
|
||||
left: 0
|
||||
height: 0
|
||||
background: $hoverColor
|
||||
transition: heigth .1s linear
|
||||
-webkit-transition: height .1s linear
|
||||
|
||||
&:hover
|
||||
background-color: var(--main-bg-color)
|
||||
|
||||
&::before
|
||||
height: 5px
|
||||
|
||||
&_Avatar
|
||||
background-size: cover
|
||||
background-position: center
|
||||
width: 40px
|
||||
height: 40px
|
||||
border-radius: 50%
|
||||
margin-right: .2rem
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
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, setModal} from "../../store/actions";
|
||||
import {hasAccessToAdminPanel} from "../../utils";
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faBars} from '@fortawesome/free-solid-svg-icons';
|
||||
import RegisterModal from "../Modals/RegisterModal";
|
||||
import LoginModal from "../Modals/LoginModal";
|
||||
|
||||
type Props = {
|
||||
setModal: SetModalType
|
||||
logout: () => void
|
||||
currentUser: UserType
|
||||
};
|
||||
|
||||
type State = {
|
||||
expand: boolean
|
||||
width: number
|
||||
}
|
||||
|
||||
class Navbar extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
expand: false, width: window.screen.width
|
||||
}
|
||||
|
||||
logout = () => {
|
||||
this.props.logout();
|
||||
localStorage.removeItem('user');
|
||||
userLogout().then();
|
||||
};
|
||||
|
||||
changeSize = () => {
|
||||
this.setState({ expand: !this.state.expand })
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener("resize", () => {
|
||||
if(this.state.width !== window.screen.width)
|
||||
this.setState({ width: window.screen.width });
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const { currentUser, setModal } = this.props;
|
||||
const { expand } = this.state;
|
||||
|
||||
const isMinimized = window.screen.width <= 1000;
|
||||
|
||||
const links = (
|
||||
<div className="Header__Links">
|
||||
<Link to="/" className={'Header_Button'}>
|
||||
Главная
|
||||
</Link>
|
||||
<Link to="/novels" className={'Header_Button'}>
|
||||
Новеллы
|
||||
</Link>
|
||||
<Link to="/users" className={'Header_Button'}>
|
||||
Пользователи
|
||||
</Link>
|
||||
{expand && isMinimized && (
|
||||
<div onClick={this.changeSize} className={'Header_Button'} style={{ position: "absolute", right: 0, margin: '.5rem' }}>X</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className={`Header ${expand ? 'Expanded' : 'Minimized'}`}>
|
||||
{expand || !isMinimized ? links : (
|
||||
<div className="Header__Links">
|
||||
<div onClick={this.changeSize} className={'Header_Button'}>
|
||||
<FontAwesomeIcon icon={faBars}/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentUser &&
|
||||
(<div className={'Header__User'}>
|
||||
{currentUser.authorized ? (<>
|
||||
<Link to={`/user/${currentUser.id}`} className="Header__User_Profile">
|
||||
<div className={'Header__User_Avatar'} style={{ backgroundImage: `url(${currentUser.avatar}?s=40&t=${new Date().getTime()})` }}/>
|
||||
<div>{this.props.currentUser.username}</div>
|
||||
</Link>
|
||||
{currentUser.group && hasAccessToAdminPanel(currentUser) && (
|
||||
<Link to={'/kawaii__neko'} className={'Header_Button'}>
|
||||
Админ-панель
|
||||
</Link>
|
||||
)}
|
||||
<Link className="Header_Button" to={`/user/${currentUser.id}/settings`}>Настройки</Link>
|
||||
<div className={'Header_Button'} onClick={this.logout}>Выйти</div>
|
||||
</>) : (<>
|
||||
<div className={'Header_Button'} onClick={() => setModal(<RegisterModal/>)}>Регистрация</div>
|
||||
<div className={'Header_Button'} onClick={() => setModal(<LoginModal/>)}>Войти</div>
|
||||
</>)}
|
||||
</div>)
|
||||
}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: StoreState) => ({
|
||||
currentUser: state.currentUser
|
||||
}),
|
||||
dispatch => ({
|
||||
logout: () => dispatch(logout()),
|
||||
setModal: (modal: React.ReactNode | null) => dispatch(setModal(modal))
|
||||
})
|
||||
)(Navbar);
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import Loading from "../../ui/Loading";
|
||||
import {fetchNews} from "../../api/news";
|
||||
import NewsPost from "../../ui/NewsPost/NewsPost";
|
||||
import {Link} from "react-router-dom";
|
||||
|
||||
type State = {
|
||||
news: PostType[]
|
||||
}
|
||||
|
||||
class AllNews extends React.Component<{}, State> {
|
||||
state: State = {
|
||||
news: []
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
document.title = 'Новости';
|
||||
fetchNews().then((news: PostType[]) => {
|
||||
news.forEach(p => p.date = new Date(p.date));
|
||||
this.setState({ news })
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { news } = this.state;
|
||||
if(news.length === 0) return <Loading/>;
|
||||
|
||||
return (
|
||||
<div className="News">
|
||||
<div className="News__Title">
|
||||
Новости
|
||||
</div>
|
||||
{news.map(post => (
|
||||
<Link key={post.id} to={`/post/${post.id}`}>
|
||||
<NewsPost {...post} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AllNews;
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
.Post
|
||||
background-color: var(--sec-bg-color)
|
||||
padding: .5rem
|
||||
|
||||
&__Title
|
||||
font-size: 1.5rem
|
||||
font-weight: 600
|
||||
|
||||
&__Content
|
||||
border-top: var(--link-color) 1px solid
|
||||
border-bottom: var(--link-color) 1px solid
|
||||
padding: .5rem 0
|
||||
margin: .5rem 0
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
import {fetchPost} from "../../api/news";
|
||||
import Loading from "../../ui/Loading";
|
||||
import NotFoundError from "../NotFoundError";
|
||||
import './Post.sass'
|
||||
import {Link} from "react-router-dom";
|
||||
// @ts-ignore
|
||||
import parser from 'bbcode-to-react';
|
||||
|
||||
type Props = {
|
||||
match: { params: { postId: number } }
|
||||
};
|
||||
|
||||
type State = {
|
||||
post: PostType | null
|
||||
code: number
|
||||
};
|
||||
|
||||
class Post extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
post: null, code: 0
|
||||
};
|
||||
|
||||
componentDidMount(): void {
|
||||
const { match: { params: { postId }} } = this.props;
|
||||
|
||||
fetchPost(postId).then((post: PostType) => {
|
||||
post.date = new Date(post.date);
|
||||
this.setState({ post });
|
||||
}).catch((error: ApiError) => {
|
||||
this.setState({ code: error.code })
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { post, code } = this.state;
|
||||
|
||||
if(code === 100) return <NotFoundError/>;
|
||||
if(!post) return <Loading/>;
|
||||
return (
|
||||
<div className={'Post'}>
|
||||
<div className="Post__Title">{post.title}</div>
|
||||
<pre className='Post__Content'>{parser.toReact(post.short_post)}</pre>
|
||||
<div className="Post__Footer">
|
||||
<div>
|
||||
Автор: <Link to={`/user/${post.author_id}`}>{post.author}</Link>
|
||||
</div>
|
||||
<div>Дата: {post.date.toLocaleDateString()}, {post.date.toLocaleTimeString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Post;
|
||||
Executable
+12
@@ -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;
|
||||
Executable
+172
@@ -0,0 +1,172 @@
|
||||
@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
|
||||
|
||||
&_Mark
|
||||
margin-top: .5rem
|
||||
width: 100%
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
|
||||
.Selected
|
||||
background-color: var(--block-sec-color)
|
||||
|
||||
&_Element
|
||||
padding: .5rem
|
||||
background-color: var(--block-color)
|
||||
width: 100%
|
||||
text-align: center
|
||||
cursor: pointer
|
||||
|
||||
&:hover
|
||||
background-color: var(--sec-bg-color)
|
||||
|
||||
&_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
|
||||
@media (min-width: 600px)
|
||||
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
|
||||
Executable
+221
@@ -0,0 +1,221 @@
|
||||
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 "../../ui/Loading";
|
||||
import { TranslateStatus } from "../../api/api";
|
||||
import Comments from "../Comments/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;
|
||||
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: NovelType) => {
|
||||
novel.release_date = new Date(novel.release_date);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
updateNovelMark = (mark: number) => {
|
||||
const { match: { params }, currentUser } = this.props;
|
||||
const { userData } = this.state;
|
||||
if(userData?.mark === mark) return;
|
||||
updateUserNovel(currentUser.id, params.novelId, { mark }).then(res => {
|
||||
userData!!.mark = mark;
|
||||
this.setState({ userData })
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { errorCode, novel, comments, characters, genres, userData, statusExpanded, hasMoreComments } = this.state;
|
||||
const { currentUser } = this.props;
|
||||
|
||||
if(errorCode === 100) return <NotFoundError/>;
|
||||
if(!novel) 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>}
|
||||
{userData && (
|
||||
<div
|
||||
className={'Novel__Main_Status_Element'}
|
||||
onClick={() => this.setState({ statusExpanded: !statusExpanded })}
|
||||
style={{ paddingLeft: 5, borderLeft: '1px solid white' }}
|
||||
>
|
||||
+
|
||||
</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 className={'Novel__Main_Status_Mark'}>
|
||||
{userData && [0,1,2,3,4,5,6,7,8,9,10].map(mark => (
|
||||
<div
|
||||
key={mark}
|
||||
className={mark === userData.mark && userData.mark !== 0 ? 'Novel__Main_Status_Mark_Element Selected' : 'Novel__Main_Status_Mark_Element'}
|
||||
onClick={() => this.updateNovelMark(mark)}
|
||||
>
|
||||
{mark}
|
||||
</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 && 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>
|
||||
<strong>Год выхода</strong>: {novel.release_date.toLocaleDateString()}
|
||||
</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);
|
||||
Executable
+57
@@ -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
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
import './Novels.sass'
|
||||
import React from "react";
|
||||
import {fetchNovels} from "../../api/novels";
|
||||
import NovelItem from '../../ui/NovelItem/NovelItem';
|
||||
import Loading from "../../ui/Loading";
|
||||
|
||||
type State = {
|
||||
novels: NovelType[] | null
|
||||
years: string[]
|
||||
genres: string[]
|
||||
}
|
||||
|
||||
class Novels extends React.Component<{}, State> {
|
||||
state: State = {
|
||||
novels: null,
|
||||
genres: [], years: []
|
||||
};
|
||||
|
||||
componentDidMount = () => {
|
||||
fetchNovels('rating').then((novels: NovelType[]) => {
|
||||
novels.forEach(novel => {
|
||||
novel.release_date = new Date(novel.release_date);
|
||||
});
|
||||
this.setState({ novels });
|
||||
document.title = 'Новелы';
|
||||
})
|
||||
};
|
||||
|
||||
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} viewType={'grid'} />)}
|
||||
</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;
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
.PasswordRestore
|
||||
display: flex
|
||||
flex-direction: column
|
||||
padding: .5rem
|
||||
|
||||
.Button, input
|
||||
font-size: 1.2rem
|
||||
|
||||
input
|
||||
width: 100%
|
||||
margin-bottom: .5rem
|
||||
|
||||
.Button
|
||||
align-self: flex-start
|
||||
margin-top: .5rem
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import {restore} from "../../api/auth";
|
||||
import errors from "../../api/errors";
|
||||
import './PasswordRestore.sass';
|
||||
import Button from "../../ui/Button/Button";
|
||||
|
||||
type Props = {
|
||||
match: { params: { token: string } }
|
||||
}
|
||||
|
||||
type State = {
|
||||
p1: string
|
||||
p2: string
|
||||
error: string
|
||||
message: string
|
||||
}
|
||||
|
||||
class PasswordRestore extends React.Component<Props, State> {
|
||||
state = {
|
||||
p1: '', p2: '', error: '', message: ''
|
||||
}
|
||||
timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
onSubmit = (event: React.FormEvent) => {
|
||||
const { token } = this.props.match.params;
|
||||
const { p1, p2 } = this.state;
|
||||
event.preventDefault();
|
||||
if(p1 !== p2) {
|
||||
this.setState({ error: 'Пароли не совпадают!' })
|
||||
return;
|
||||
}
|
||||
restore(token, p1).then(res => {
|
||||
if(res === 'ok') {
|
||||
this.setState({ message: 'Успешно!\nВы будете пренаправлены на главную через 3 секунды!' })
|
||||
this.timeout = setTimeout(() => {
|
||||
|
||||
}, 3000)
|
||||
}
|
||||
}).catch((err: ApiError) => {
|
||||
const error = errors[err.code];
|
||||
if(error) this.setState({ error });
|
||||
else this.setState({ error: `Произошла ошибка! Сообщите номер и текст разработчику!\nКод: ${err.code}\nТекст: ${err.text}` })
|
||||
})
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if(this.timeout) clearInterval(this.timeout);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { p1, p2, error, message } = this.state;
|
||||
|
||||
return (
|
||||
<form onSubmit={this.onSubmit} className={'PasswordRestore'}>
|
||||
{error && <pre className="Error">{error}</pre>}
|
||||
{message && <pre className="Successful">{message}</pre>}
|
||||
<input
|
||||
type="password"
|
||||
placeholder={'Новый пароль'}
|
||||
minLength={6}
|
||||
value={p1}
|
||||
onChange={event => this.setState({ p1: event.target.value })}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={'Повторите пароль'}
|
||||
minLength={6}
|
||||
value={p2}
|
||||
onChange={event => this.setState({ p2: event.target.value })}
|
||||
required
|
||||
/>
|
||||
<Button>Сменить</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default PasswordRestore;
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
// @ts-ignore
|
||||
// import Recaptcha from "react-recaptcha";
|
||||
import Recaptcha from '../../ui/Recaptcha/Recaptcha';
|
||||
import './PasswordRestore.sass';
|
||||
import {generateRestoreLink} from "../../api/users";
|
||||
import Button from "../../ui/Button/Button";
|
||||
|
||||
type State = {
|
||||
email: string
|
||||
recaptcha: string
|
||||
}
|
||||
|
||||
class PasswordRestoreGenerate extends React.Component<{}, State> {
|
||||
private recaptcha: any;
|
||||
|
||||
state: State = {
|
||||
email: '', recaptcha: ''
|
||||
}
|
||||
|
||||
onSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const { email, recaptcha } = this.state;
|
||||
generateRestoreLink(email, recaptcha).then(console.log)
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.recaptcha.reset()
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<form onSubmit={this.onSubmit} className={'PasswordRestore'}>
|
||||
<input
|
||||
type="email"
|
||||
onChange={event => this.setState({ email: event.target.value })}
|
||||
placeholder={'E-mail'}
|
||||
/>
|
||||
|
||||
<Recaptcha
|
||||
ref={(e: any) => this.recaptcha = e}
|
||||
onVerify={(res: string) => this.setState({ recaptcha: res })}
|
||||
theme={'dark'}
|
||||
sitekey={'6LfnDsMUAAAAAEDfD5ubCdFQbNUnKxxJlMWeUMzN'}
|
||||
/>
|
||||
<Button>Восстановить</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PasswordRestoreGenerate;
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
@import "../../../Constants"
|
||||
|
||||
.UserNovels
|
||||
background-color: var(--sec-bg-color)
|
||||
padding: .5rem
|
||||
|
||||
&__Title
|
||||
font-size: 1.5rem
|
||||
font-weight: 600
|
||||
text-align: center
|
||||
|
||||
.svg-inline--fa
|
||||
margin: 0 .5rem
|
||||
cursor: pointer
|
||||
|
||||
&:hover
|
||||
color: var(--link-color)
|
||||
|
||||
&__List
|
||||
display: flex
|
||||
flex-direction: column
|
||||
width: 100%
|
||||
|
||||
&: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
|
||||
|
||||
button
|
||||
font-weight: 600
|
||||
|
||||
&_Novels
|
||||
display: flex
|
||||
justify-content: center
|
||||
|
||||
&.Grid
|
||||
flex-direction: row
|
||||
flex-wrap: wrap
|
||||
|
||||
&.Table
|
||||
flex-direction: column
|
||||
width: 100%
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import './UserNovels.sass';
|
||||
import { fetchUserNovels, fetchUser } from '../../../api/users';
|
||||
import Loading from '../../../ui/Loading';
|
||||
import NovelItem from '../../../ui/NovelItem/NovelItem';
|
||||
import {capitalize, generateClassName, getRandomInt} from "../../../utils";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faAlignJustify, faThLarge} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
type Props = {
|
||||
match: { params: { userId: number } }
|
||||
history: { push: (path: string) => void }
|
||||
};
|
||||
type State = {
|
||||
novels: NovelType[]
|
||||
user: UserType | null
|
||||
viewType: string
|
||||
};
|
||||
|
||||
class UserNovels extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
novels: [], user: null, viewType: 'grid'
|
||||
};
|
||||
|
||||
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 }));
|
||||
|
||||
const viewType = localStorage.getItem('viewType');
|
||||
if(viewType) {
|
||||
if(viewType !== 'grid' && viewType !== 'table') {
|
||||
console.warn('Тип был неизвестен... Сбрасываю...');
|
||||
localStorage.setItem('viewType', 'grid')
|
||||
} else {
|
||||
this.setState({ viewType });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
changeViewType = (type: 'grid' | 'table') => {
|
||||
this.setState({ viewType: type });
|
||||
localStorage.setItem('viewType', type);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { novels, user, viewType } = 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}
|
||||
<FontAwesomeIcon icon={faThLarge} onClick={() => this.changeViewType('grid')}/>
|
||||
<FontAwesomeIcon icon={faAlignJustify} onClick={() => this.changeViewType('table')}/>
|
||||
</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={generateClassName("UserNovels__List_Novels", capitalize(viewType) || 'Grid')}>
|
||||
{planned.map((novel, id) => <NovelItem {...novel} key={novel.id} novelId={id+1} viewType={viewType}/>)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{completed.length > 0 && <div className={'UserNovels__List'}>
|
||||
<div className="UserNovels__List_Title">
|
||||
Пройденные
|
||||
</div>
|
||||
<div className={generateClassName("UserNovels__List_Novels", capitalize(viewType) || 'Grid')}>
|
||||
{completed.map((novel, id) => <NovelItem {...novel} key={novel.id} novelId={id+1} viewType={viewType}/>)}
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default UserNovels;
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
.Group
|
||||
.Settings_Avatar
|
||||
form
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: flex-start
|
||||
|
||||
input
|
||||
margin: .5rem 0
|
||||
|
||||
.Button
|
||||
flex-grow: 0
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
import React, {ChangeEvent, FormEvent} from "react";
|
||||
import Group from "../../../ui/Group/Group";
|
||||
import {connect} from "react-redux";
|
||||
import NotFoundError from "../../NotFoundError";
|
||||
import Button from "../../../ui/Button/Button";
|
||||
import './Settings.sass';
|
||||
import {fetchUserSettings, updateUserSettings, uploadAvatar} from "../../../api/users";
|
||||
import {setUser} from "../../../store/actions";
|
||||
|
||||
type Props = {
|
||||
match: { params: { userId: string } }
|
||||
currentUser: UserType
|
||||
setUser: (user: UserType) => void
|
||||
}
|
||||
|
||||
type State = {
|
||||
// Avatar
|
||||
useGravatar: boolean
|
||||
avatar: File | null
|
||||
}
|
||||
|
||||
class Settings extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
useGravatar: false, avatar: null
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
fetchUserSettings().then(settings => {
|
||||
this.setState({ useGravatar: settings.use_gravatar })
|
||||
})
|
||||
}
|
||||
|
||||
onChangeAvatar = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if(!files) return;
|
||||
this.setState({ avatar: files[0] });
|
||||
}
|
||||
|
||||
saveAvatar = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const { avatar, useGravatar } = this.state;
|
||||
const { setUser } = this.props;
|
||||
|
||||
if(avatar) {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', avatar);
|
||||
|
||||
uploadAvatar(formData).then(avatarUrl => {
|
||||
updateUserSettings(useGravatar, avatarUrl).then(setUser)
|
||||
})
|
||||
} else {
|
||||
updateUserSettings(useGravatar, '').then(setUser)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { currentUser, match: { params: { userId } } } = this.props;
|
||||
const { useGravatar } = this.state
|
||||
|
||||
if(parseInt(userId) !== currentUser.id) return <NotFoundError/>;
|
||||
|
||||
return (
|
||||
<Group title={'Настройки'}>
|
||||
<Group title={'Аватар'} className={'Settings_Avatar'}>
|
||||
<form onSubmit={this.saveAvatar}>
|
||||
<label>Использовать Gravatar <input type="checkbox" onChange={event => this.setState({ useGravatar: event.target.checked })} checked={useGravatar}/></label>
|
||||
<label>Загрузить аватар <input type="file" onChange={this.onChangeAvatar} disabled={useGravatar}/></label>
|
||||
<Button>Сохранить</Button>
|
||||
</form>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: StoreState) => ({
|
||||
currentUser: state.currentUser
|
||||
}),
|
||||
dispatch => ({
|
||||
setUser: (user: UserType) => dispatch(setUser(user))
|
||||
})
|
||||
)(Settings);
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
@import "../App/App"
|
||||
|
||||
.User
|
||||
&__Activate
|
||||
background-color: var(--sec-bg-color)
|
||||
border-radius: $borderRadius
|
||||
padding: .5rem
|
||||
|
||||
|
||||
&__Info
|
||||
background-color: var(--sec-bg-color)
|
||||
border-radius: $borderRadius
|
||||
display: flex
|
||||
align-items: flex-end
|
||||
flex-direction: column
|
||||
|
||||
@media (max-width: 250px)
|
||||
flex-direction: column
|
||||
|
||||
&_Cover
|
||||
height: 20rem
|
||||
width: 100%
|
||||
background-position: center
|
||||
background-size: cover
|
||||
background-repeat: no-repeat
|
||||
border-radius: $borderRadius $borderRadius 0 0
|
||||
|
||||
@media (max-width: 500px)
|
||||
height: 10rem !important
|
||||
|
||||
&_Main
|
||||
display: flex
|
||||
align-items: flex-end
|
||||
background-color: var(--sec-bg-color)
|
||||
width: 100%
|
||||
padding: 0.5rem
|
||||
border-radius: 0 0 $borderRadius $borderRadius
|
||||
box-sizing: border-box
|
||||
|
||||
&_Nick
|
||||
font-size: 1.5rem
|
||||
font-weight: 600
|
||||
|
||||
&_Group
|
||||
border-color: #eeeeee
|
||||
border-width: 1px 1px 1px 4px
|
||||
border-style: solid
|
||||
|
||||
border-radius: 4px
|
||||
padding: .2rem
|
||||
text-align: center
|
||||
font-size: 1rem
|
||||
font-weight: 300
|
||||
|
||||
&_Avatar
|
||||
background-size: cover
|
||||
background-position: center
|
||||
width: 96px
|
||||
height: 96px
|
||||
border-radius: 50%
|
||||
margin-right: .5rem
|
||||
|
||||
&__Statistic
|
||||
background-color: var(--sec-bg-color)
|
||||
border-radius: $borderRadius
|
||||
padding: $primaryPadding
|
||||
margin-top: 1rem
|
||||
display: flex
|
||||
flex-direction: row
|
||||
flex-wrap: wrap
|
||||
|
||||
&_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
|
||||
color: white
|
||||
|
||||
@media (max-width: 300px)
|
||||
width: 100%
|
||||
|
||||
.User__Statistic_Block
|
||||
display: flex
|
||||
padding: .5rem
|
||||
height: 1.2rem !important
|
||||
|
||||
&:first-child
|
||||
border-radius: $borderRadius $borderRadius 0 0
|
||||
|
||||
&: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
|
||||
|
||||
&__List
|
||||
background-color: var(--sec-bg-color)
|
||||
border-radius: $borderRadius
|
||||
padding: $primaryPadding
|
||||
margin-top: 1rem
|
||||
|
||||
&_Title
|
||||
font-size: 1.2rem
|
||||
margin: .3rem 0
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
|
||||
&_Items
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
justify-content: center
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
import React from "react";
|
||||
import {fetchUser, fetchUserNovels, generateActivateLink} from "../../api/users";
|
||||
import './User.sass'
|
||||
import NotFoundError from "../NotFoundError";
|
||||
import {Link} from "react-router-dom";
|
||||
import Loading from "../../ui/Loading";
|
||||
import NovelItem from "../../ui/NovelItem/NovelItem";
|
||||
import {connect} from "react-redux";
|
||||
import Button from "../../ui/Button/Button";
|
||||
|
||||
type Props = {
|
||||
match: { params: { userId: string } }
|
||||
currentUser: UserType
|
||||
}
|
||||
|
||||
type State = {
|
||||
user: UserType | null
|
||||
novels: NovelType[]
|
||||
errorCode: number | null
|
||||
}
|
||||
|
||||
class User extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
user: null, novels: [], errorCode: null
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const params = this.props.match.params;
|
||||
|
||||
fetchUser(parseInt(params.userId)).then((user: UserType) => {
|
||||
this.setState({ user });
|
||||
document.title = `${user.username} / Unmei`;
|
||||
|
||||
fetchUserNovels(parseInt(params.userId)).then((novels: NovelType[]) => {
|
||||
this.setState({ novels });
|
||||
}).catch((err: ApiError) => console.error(err));
|
||||
}).catch((err: ApiError) => this.setState({ errorCode: err.code }));
|
||||
}
|
||||
|
||||
sendActivateLink = () => {
|
||||
generateActivateLink().then(console.log).catch(console.error)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { currentUser } = this.props;
|
||||
const { errorCode, user, novels } = this.state;
|
||||
if(errorCode === 100) return <NotFoundError/>;
|
||||
if(!user) return <Loading />;
|
||||
|
||||
const style = !user.is_activated ? { marginTop: '1rem' } : {}
|
||||
|
||||
return (
|
||||
<div className={'User'}>
|
||||
{!user.is_activated && user.id === currentUser.id && (
|
||||
<div className={'User__Activate'}>
|
||||
<h2>Ваш аккаунт не активирован!</h2>
|
||||
<p>Активируйте аккаунт, для доступа ко многим функциям. Так же актвация аккаунта позволит восстановить пароль.</p>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button onClick={this.sendActivateLink} style={{ fontSize: '1.2rem' }}>Активировать!</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="User__Info" style={style}>
|
||||
{user.cover && <div className="User__Info_Cover" style={{ backgroundImage: `url(${user.cover})` }}/>}
|
||||
<div className={'User__Info_Main'}>
|
||||
<div className="User__Info_Avatar" style={{ backgroundImage: `url(${user.avatar}?s=96&t=${new Date().getTime()})` }} />
|
||||
<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>
|
||||
|
||||
<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, id) => <NovelItem {...novel} key={novel.id} novelId={id+1} viewType={'grid'} />
|
||||
) : (
|
||||
<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 connect(
|
||||
(state: StoreState) => ({
|
||||
currentUser: state.currentUser
|
||||
})
|
||||
)(User);
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
.Users
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
|
||||
&__User
|
||||
background-color: var(--sec-bg-color)
|
||||
padding: .5rem
|
||||
|
||||
&_Avatar
|
||||
width: 96px
|
||||
height: 96px
|
||||
background-size: cover
|
||||
border-radius: 50%
|
||||
margin-bottom: .5rem
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import {fetchUsers} from "../../api/users";
|
||||
import Loading from "../../ui/Loading";
|
||||
import './Users.sass';
|
||||
import {Link} from "react-router-dom";
|
||||
|
||||
type Props = {
|
||||
|
||||
}
|
||||
|
||||
type State = {
|
||||
users: UserType[]
|
||||
}
|
||||
|
||||
class Users extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
users: []
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
fetchUsers().then(users => this.setState({ users }))
|
||||
}
|
||||
|
||||
render() {
|
||||
const { users } = this.state;
|
||||
if(users.length === 0) return <Loading/>;
|
||||
|
||||
return (
|
||||
<div className={'Users'}>
|
||||
{users.map(user => (
|
||||
<Link className={'Users__User'} key={user.id} to={`user/${user.id}`}>
|
||||
<div className="Users__User_Avatar" style={{ backgroundImage: `url(${user.avatar}?s=96?t=${new Date().getTime()})` }}/>
|
||||
{user.username}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Users;
|
||||
Reference in New Issue
Block a user