initial commit on github
This commit is contained in:
@@ -0,0 +1 @@
|
||||
REACT_EDITOR=goland
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
build/
|
||||
.idea/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "unmei-frontend",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.35",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.3",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.3",
|
||||
"@fortawesome/react-fontawesome": "0.1.14",
|
||||
"@sentry/react": "6.3.1",
|
||||
"@sentry/tracing": "6.3.1",
|
||||
"bbcode-to-react": "0.2.9",
|
||||
"eruda": "2.4.1",
|
||||
"eruda-dom": "2.0.0",
|
||||
"eruda-features": "2.0.0",
|
||||
"eruda-memory": "2.0.0",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-loader-spinner": "4.0.0",
|
||||
"react-redux": "7.2.3",
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-snowfall": "1.1.0",
|
||||
"react-helmet": "6.1.0",
|
||||
"redux": "4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "14.14.41",
|
||||
"@types/react": "17.0.3",
|
||||
"@types/react-dom": "17.0.3",
|
||||
"@types/react-redux": "7.1.16",
|
||||
"@types/react-router-dom": "5.1.7",
|
||||
"@types/react-helmet": "6.1.1",
|
||||
"react-scripts": "4.0.3",
|
||||
"redux-devtools-extension": "2.13.9",
|
||||
"sass": "1.32.11",
|
||||
"typescript": "4.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version"
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<title>Unmei</title>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
|
||||
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="Unmei">
|
||||
<link rel="apple-touch-icon" href="/favicon.png">
|
||||
|
||||
<meta name="application-name" content="Unmei">
|
||||
<meta name="theme-color" content="#0b0b0b">
|
||||
|
||||
<link sizes="512x512" href="/favicon.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||
|
||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XZ24R3MWFX"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() { dataLayer.push(arguments); }
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'G-XZ24R3MWFX');
|
||||
window.gtag = gtag;
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
Unmei can't work without JavaScript!<br>
|
||||
Unmei не может работать без JavaScript!
|
||||
</noscript>
|
||||
<main id="root"></main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"short_name": "Unmei",
|
||||
"name": "Unmei",
|
||||
"icons": [{
|
||||
"src": "favicon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}, {
|
||||
"src": "favicon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}],
|
||||
"display": "fullscreen",
|
||||
"start_url": "/",
|
||||
"theme_color": "#0b0b0b",
|
||||
"background_color": "#121212"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Disallow: /kawaii__neko
|
||||
|
||||
Sitemap: sitemap.xml
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 435 KiB |
Executable
+60
@@ -0,0 +1,60 @@
|
||||
@keyframes dissolve
|
||||
0%
|
||||
opacity: 1
|
||||
100%
|
||||
opacity: 0
|
||||
|
||||
$mainColor: #efefef
|
||||
$secondaryColor: #aaaaaa
|
||||
$linkHoverColor: #cccccc
|
||||
|
||||
// BLACK
|
||||
$primaryBackgroundColor: #121212
|
||||
$secondaryBackgroundColor: #191919
|
||||
$blockBackgroundColor: #212121
|
||||
$blockSecondaryBackgroundColor: #121212
|
||||
$navbarBackgroundColor: #0b0b0b
|
||||
|
||||
// BLUE
|
||||
$primaryBackgroundColorBlue: #121222
|
||||
$secondaryBackgroundColorBlue: #191929
|
||||
$blockBackgroundColorBlue: #212131
|
||||
$blockSecondaryBackgroundColorBlue: #121222
|
||||
$navbarBackgroundColorBlue: #0b0b1a
|
||||
|
||||
// RED
|
||||
$primaryBackgroundColorRed: #221212
|
||||
$secondaryBackgroundColorRed: #291919
|
||||
$blockBackgroundColorRed: #312121
|
||||
$blockSecondaryBackgroundColorRed: #221212
|
||||
$navbarBackgroundColorRed: #2a0b0b
|
||||
|
||||
// GREEN
|
||||
$primaryBackgroundColorGreen: #122212
|
||||
$secondaryBackgroundColorGreen: #192919
|
||||
$blockBackgroundColorGreen: #213121
|
||||
$blockSecondaryBackgroundColorGreen: #122212
|
||||
$navbarBackgroundColorGreen: #0b2a0b
|
||||
|
||||
// LIGHT
|
||||
$mainColorLight: #121212
|
||||
$secondaryColorLight: #444444
|
||||
$linkHoverColorLight: #333333
|
||||
|
||||
$primaryBackgroundColorLight: #e0e0e0
|
||||
$secondaryBackgroundColorLight: #f1f1f1
|
||||
$blockBackgroundColorLight: #fefefe
|
||||
$blockSecondaryBackgroundColorLight: #dfdfdf
|
||||
$navbarBackgroundColorLight: #d0d0d0
|
||||
|
||||
|
||||
// ANOTHER
|
||||
$errorBackgroundColor: #993333
|
||||
$successBackgroundColor: #339933
|
||||
|
||||
$primaryPadding: 8px
|
||||
$blockPadding: 4px
|
||||
|
||||
$borderRadius: 4px
|
||||
|
||||
$novelBlockSize: calc(100% / 4 - 1rem)
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
const baseUrl = process.env.NODE_ENV === "development" ? 'http://localhost:8080/v1' : 'https://api.unmei.space/v1';
|
||||
|
||||
async function request<T>(url: string, method: string, body?: string | FormData): Promise<T> {
|
||||
let bUrl = baseUrl;
|
||||
if(baseUrl.endsWith('/'))
|
||||
bUrl = bUrl.slice(0, baseUrl.length - 1);
|
||||
if(url.startsWith('/'))
|
||||
url = url.slice(1, baseUrl.length);
|
||||
|
||||
// Caching
|
||||
const cached = sessionStorage.getItem(url);
|
||||
if(cached && method.toLowerCase() === 'get')
|
||||
return Promise.resolve(JSON.parse(cached));
|
||||
|
||||
const res = await fetch(`${bUrl}/${url}`, {
|
||||
method, body,
|
||||
credentials: "include",
|
||||
headers: new Headers({
|
||||
'Dev': /(dev\..+)/.test(window.location.href) ? '1' : '0'
|
||||
})
|
||||
});
|
||||
|
||||
let json: Unmei.ApiResponse<T>;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch(e) {
|
||||
json = { error: false };
|
||||
}
|
||||
|
||||
if(res.status === 429) return new Promise(res => {
|
||||
setTimeout(res.bind(null, request(url, method, body)), 1500);
|
||||
});
|
||||
if(json?.error) return Promise.reject(json.error_data);
|
||||
if(!res.ok) return Promise.reject(res.statusText);
|
||||
if(!json.data) return Promise.reject(null);
|
||||
|
||||
// Caching
|
||||
sessionStorage.setItem(url, JSON.stringify(json.data));
|
||||
return Promise.resolve(json.data);
|
||||
}
|
||||
|
||||
const get = <T>(url: string, data?: object): Promise<T> => request<T>(url, 'GET', JSON.stringify(data));
|
||||
const post = <T>(url: string, data?: object | FormData): Promise<T> => request(url, 'POST', data instanceof FormData ? data : JSON.stringify(data));
|
||||
const put = <T>(url: string, data?: object): Promise<T> => request<T>(url, 'PUT', JSON.stringify(data));
|
||||
const del = <T>(url: string, data?: object): Promise<T> => request<T>(url, 'DELETE', JSON.stringify(data));
|
||||
|
||||
const TranslateStatus: { [id: string]: string } = {
|
||||
planned: 'Запланировано',
|
||||
completed: 'Пройдено',
|
||||
in_progress: 'Прохожу',
|
||||
dropped: 'Брошено',
|
||||
deferred: 'Отложено'
|
||||
};
|
||||
|
||||
const TranslatePlatform: { [id: string]: string } = {
|
||||
win: 'Windows',
|
||||
mac: 'macOS',
|
||||
linux: 'Linux',
|
||||
ios: 'IOS',
|
||||
android: 'Andriod'
|
||||
};
|
||||
|
||||
const TranslateExitStatus: { [id: string]: string } = {
|
||||
in_developing: 'В разработке',
|
||||
came_out: 'Вышло',
|
||||
canceled: 'Отменено',
|
||||
announcement: 'Анонс',
|
||||
postponed: 'Перенесено'
|
||||
};
|
||||
|
||||
export const getVersion = () => get<Unmei.VersionResponse>('version').then(r => {
|
||||
r.build = parseInt(r.build.toString());
|
||||
return r;
|
||||
});
|
||||
export const version = '1.2';
|
||||
export const build = 123;
|
||||
|
||||
export { get, post, put, del, TranslateStatus, TranslatePlatform, TranslateExitStatus };
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
import { post } from "./api";
|
||||
|
||||
export const restore = (token: string, new_password: string) => post<string>('auth/restore', { token, new_password });
|
||||
export const registerUser = (login: string, password: string, email: string, recaptcha: string) => post<Unmei.UserType>('auth/register', {
|
||||
login,
|
||||
password,
|
||||
email,
|
||||
recaptcha
|
||||
});
|
||||
export const registerUserVk = (code: string) => post('/auth/register/vk', { code });
|
||||
export const login = (login: string, password: string, recaptcha: string) => post<Unmei.UserType>('auth/login', {
|
||||
login,
|
||||
password,
|
||||
recaptcha
|
||||
});
|
||||
export const userLogout = () => post<string>('auth/logout');
|
||||
export const changeEmail = (old_email: string, new_email: string) => post<string>('auth/changeEmail', {
|
||||
old_email,
|
||||
new_email
|
||||
});
|
||||
export const changePassword = (old_password: string, new_password: string) => post<string>('auth/changePassword', {
|
||||
old_password,
|
||||
new_password
|
||||
});
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
import { del, get, post, put } from "./api";
|
||||
|
||||
export const fetchChars = () => get<Unmei.CharacterType[]>('characters');
|
||||
export const createChar = (char: Unmei.CharacterType) => post<Unmei.CharacterType>('chars', char);
|
||||
|
||||
export const fetchChar = (id: number) => get<Unmei.CharacterType>(`characters/${id}`);
|
||||
export const deleteChar = (id: number) => del(`characters/${id}`);
|
||||
export const updateChar = (char: Unmei.CharacterType) => put<Unmei.CharacterType>(`characters/${char.id}`, char);
|
||||
|
||||
export const fetchCharNovels = (id: number) => get<Unmei.NovelType[]>(`characters/${id}/novels`);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { get } from "./api";
|
||||
|
||||
export const getClubs = () => get<Unmei.ClubType[]>('/clubs');
|
||||
export const getClub = (id: number) => get<Unmei.ClubType>(`/clubs/${id}`);
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
const errors: { [key: number]: string } = {
|
||||
1: 'Неверный логин и/или пароль!',
|
||||
2: 'Проверка ReCaptcha не пройдена!',
|
||||
4: 'Нет доступа!',
|
||||
5: 'Аккаунт уже активирован! Вы будете перенаправлены на главную через 3 секунды.',
|
||||
6: 'Токен истек!',
|
||||
8: 'Новый и старый пароли совпадают!',
|
||||
9: 'Необходима проверка ReCaptcha',
|
||||
10: 'Неверно указан старый E-Mail',
|
||||
11: 'Неверно указан старый пароль',
|
||||
100: 'Не найдено!'
|
||||
};
|
||||
|
||||
export default errors;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { get } from "./api";
|
||||
|
||||
export const fetchGenres = () => get<Unmei.GenreType[]>('genres');
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
import { del, get, put, post } from './api';
|
||||
|
||||
export const fetchNews = () => get<Unmei.PostType[]>('news');
|
||||
export const createPost = (p: Unmei.PostType) => post<Unmei.PostType>('news', p);
|
||||
|
||||
export const fetchPost = (id: number) => get<Unmei.PostType>(`news/${id}`);
|
||||
export const updatePost = (post: Unmei.PostType) => put<Unmei.PostType>(`news/${post.id}`, post);
|
||||
export const deletePost = (id: number) => del(`news/${id}`);
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
import { del, get, post, put } from "./api";
|
||||
|
||||
|
||||
export const fetchNovels = (orderBy?: string) => get<Unmei.NovelType[]>(orderBy ? `novels?sort=${orderBy}` : 'novels');
|
||||
export const searchNovel = (query: string) => get<Unmei.NovelType[]>(`novels?q=${query}`);
|
||||
export const createNovel = (novel: Unmei.NovelType) => post<Unmei.NovelType>('novels', novel);
|
||||
|
||||
export const fetchNovel = (id: number) => get<Unmei.NovelType>(`novels/${id}`);
|
||||
export const updateNovel = (novel: Unmei.NovelType) => put<Unmei.NovelType>(`novels/${novel.id}`, novel);
|
||||
export const uploadNovelCover = (id: number, cover: FormData) => post<string>(`novels/${id}/cover`, cover);
|
||||
|
||||
export const fetchNovelCharacters = (id: number) => get<Unmei.CharacterType[]>(`novels/${id}/characters`);
|
||||
|
||||
export const fetchNovelGenres = (id: number) => get<Unmei.GenreType[]>(`novels/${id}/genres`);
|
||||
|
||||
export const fetchNovelComments = (id: number, offset = 0, count = 5) => get<Unmei.CommentsResponse>(`novels/${id}/comments?offset=${offset}&count=${count}`);
|
||||
export const postNovelComment = (id: number, text: string) => post<Unmei.CommentType>(`novels/${id}/comments`, { text });
|
||||
|
||||
export const fetchUserNovel = (userId: number, novelId: number) => get<Unmei.UserNovelType>(`users/${userId}/novels/${novelId}`);
|
||||
export const createUserNovel = (userId: number, novelId: number, status = 'planned') => post<Unmei.UserNovelType>(`users/${userId}/novels`, {
|
||||
novel_id: Number(novelId),
|
||||
status
|
||||
});
|
||||
export const updateUserNovel = (userId: number, novelId: number, data: { mark?: number; status?: string }) => put<Unmei.UserNovelType>(`users/${userId}/novels/${novelId}`, data);
|
||||
export const deleteUserNovel = (userId: number, novelId: number) => del(`users/${userId}/novels/${novelId}`);
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
import { get, post, put } from "./api";
|
||||
|
||||
export const fetchUsers = () => get<Unmei.UserType[]>('users');
|
||||
|
||||
export const fetchUser = (id: number) => get<Unmei.UserType>(`users/${id}`);
|
||||
export const updateUser = (user: Unmei.UserType) => put<Unmei.UserType>(`users/${user.id}`, { user });
|
||||
|
||||
export const fetchCurrentUser = () => get<Unmei.UserType>('users/me');
|
||||
export const fetchUserNovels = (id: number) => get<Unmei.NovelType[]>(`users/${id}/novels`);
|
||||
|
||||
export const activateAccount = (token: string) => post<string>('auth/activate', { token });
|
||||
export const generateActivateLink = () => post<string>('auth/activateToken');
|
||||
|
||||
export const fetchUserSettings = () => get<Unmei.UserSettingsType>('users/me/settings');
|
||||
export const updateUserGeneralSettings = (use_gravatar: boolean, avatar: string) => post<Unmei.UserType>('users/me/settings', {
|
||||
use_gravatar,
|
||||
avatar
|
||||
});
|
||||
export const updateUserAppearanceSettings = (theme: string) => post<Unmei.UserType>('users/me/settings', { theme });
|
||||
export const uploadAvatar = (avatar: FormData) => post<string>('users/me/avatar', avatar);
|
||||
|
||||
export const generateRestoreLink = (email: string, recaptcha: string) => post('auth/restoreToken', {
|
||||
email,
|
||||
recaptcha
|
||||
});
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
export interface ObjectClassNames {
|
||||
[index: string]: boolean;
|
||||
}
|
||||
|
||||
export type ClassName = number | string | ObjectClassNames | false | null | undefined;
|
||||
|
||||
export default function classNames(...classNames: ClassName[]) {
|
||||
let result: string[] = [];
|
||||
|
||||
classNames.forEach((item: ClassName): void => {
|
||||
if(!item) {
|
||||
return;
|
||||
}
|
||||
switch(typeof item) {
|
||||
case 'string':
|
||||
result.push(item);
|
||||
break;
|
||||
case 'object':
|
||||
Object.keys(item).forEach((key: string) => {
|
||||
if(item[key]) {
|
||||
result.push(key);
|
||||
}
|
||||
});
|
||||
break;
|
||||
default:
|
||||
result.push(`${item}`);
|
||||
}
|
||||
});
|
||||
|
||||
return result.join(' ');
|
||||
}
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Rubik:wght@100;200;300;400;500;600;700;800;900&display=swap')
|
||||
@import "../../Constants"
|
||||
|
||||
html, body, #root
|
||||
width: 100%
|
||||
height: 100%
|
||||
|
||||
body, pre, textarea
|
||||
font-family: var(--font-family), sans-serif
|
||||
font-weight: var(--font-weight)
|
||||
|
||||
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}
|
||||
|
||||
--planned-bg-color: #399
|
||||
--completed-bg-color: #393
|
||||
--dropped-bg-color: #933
|
||||
--in_progress-bg-color: #939
|
||||
--deferred-bg-color: #333
|
||||
|
||||
--accent-color: #f22
|
||||
|
||||
--site-width: 1200px
|
||||
--sidebar-width: 350px
|
||||
|
||||
--font-family: 'Rubik'
|
||||
--font-weight: 400
|
||||
|
||||
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}
|
||||
|
||||
*::-webkit-scrollbar
|
||||
width: 6px
|
||||
|
||||
*::-webkit-scrollbar-track
|
||||
background-color: var(--nav-bg-color)
|
||||
|
||||
*::-webkit-scrollbar-thumb
|
||||
background-color: var(--link-color)
|
||||
outline: none
|
||||
|
||||
a
|
||||
color: var(--main-color)
|
||||
text-decoration: none
|
||||
|
||||
&:hover
|
||||
color: var(--link-color)
|
||||
|
||||
.Container
|
||||
position: relative
|
||||
max-width: var(--site-width)
|
||||
min-height: calc(100vh - 240px - 48px)
|
||||
padding: 8px 0
|
||||
margin: 0 auto
|
||||
box-sizing: border-box
|
||||
|
||||
.Notifications
|
||||
width: 100%
|
||||
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
|
||||
color: #{mainColor}
|
||||
background-color: var(--error-bg-color)
|
||||
|
||||
.Successful
|
||||
color: #{mainColor}
|
||||
background-color: var(--success-bg-color)
|
||||
Executable
+163
@@ -0,0 +1,163 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { connect } from "react-redux";
|
||||
import { withProfiler } from "@sentry/react";
|
||||
|
||||
import './App.sass';
|
||||
|
||||
import { fetchCurrentUser, fetchUserSettings } from "../../api/users";
|
||||
import { build, getVersion} from "../../api/api";
|
||||
|
||||
import { setUser } from "../../store/ducks/currentUser";
|
||||
import { setSettings } from "../../store/ducks/userSettings";
|
||||
|
||||
import { SpoilerTag, ColorTag } from "../../ui/ui";
|
||||
import { hasPermission } from "../../utils";
|
||||
import Preloader from "../Preloader/Preloader";
|
||||
import Router from "./Router";
|
||||
// @ts-ignore
|
||||
import eruda from 'eruda';
|
||||
// @ts-ignore
|
||||
import parser from 'bbcode-to-react';
|
||||
import Navbar from "../Navbar/Navbar";
|
||||
|
||||
const Snowfall = React.lazy(() => import("react-snowfall"));
|
||||
// const Sidebar = React.lazy(() => import("../Sidebar/Sidebar"));
|
||||
const Footer = React.lazy(() => import('../Footer/Footer'));
|
||||
|
||||
type Props = {
|
||||
notifications: React.ReactNode[]
|
||||
modal: React.ReactNode
|
||||
setUser: SetUser
|
||||
setSettings: SetSettings
|
||||
snowfall: Snowfall
|
||||
preloader: boolean
|
||||
};
|
||||
|
||||
type State = {
|
||||
user: Unmei.UserType | null
|
||||
};
|
||||
|
||||
class App extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
user: null
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
parser.registerTag('spoiler', SpoilerTag);
|
||||
parser.registerTag('color', ColorTag);
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const { setUser, setSettings } = this.props;
|
||||
|
||||
const cachedTheme = localStorage.getItem('theme');
|
||||
if(cachedTheme) {
|
||||
if(cachedTheme === 'custom') {
|
||||
const customTheme = localStorage.getItem('customTheme') ?? '';
|
||||
|
||||
customTheme.split('\n').forEach(line => {
|
||||
const [name, value] = line.split(':');
|
||||
document.body.style.setProperty(name, value);
|
||||
});
|
||||
} else
|
||||
document.body.setAttribute('theme', cachedTheme);
|
||||
}
|
||||
|
||||
const accentColor = localStorage.getItem('accentColor');
|
||||
if(accentColor)
|
||||
document.body.style.setProperty('--accent-color', accentColor);
|
||||
|
||||
fetchCurrentUser().then(user => {
|
||||
setUser(user);
|
||||
this.setState({ user });
|
||||
localStorage.setItem('banned', JSON.stringify(user.is_banned));
|
||||
|
||||
fetchUserSettings().then(settings => {
|
||||
setSettings(settings);
|
||||
if(settings.theme !== cachedTheme) {
|
||||
localStorage.setItem('theme', settings.theme);
|
||||
document.body.setAttribute('theme', settings.theme);
|
||||
}
|
||||
});
|
||||
if(hasPermission(user, 'mobile_debug'))
|
||||
eruda.init();
|
||||
}).catch((err: Unmei.ApiError) => {
|
||||
if(err.code !== 3 && err.text)
|
||||
console.error(err.text);
|
||||
});
|
||||
|
||||
getVersion().then(res => {
|
||||
if(build !== res.build && !/(dev\..+)/.test(window.location.href)) {
|
||||
console.debug(`Current back-end version: ${res.version}. Clearing cache`);
|
||||
|
||||
caches.keys().then(names => {
|
||||
names.forEach(name => caches.delete(name).catch(console.error))
|
||||
}).then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
} else if(/(dev\..+)/.test(window.location.href)) {
|
||||
caches.keys().then(names => {
|
||||
names.forEach(name => caches.delete(name).catch(console.error))
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { modal, snowfall, preloader } = this.props;
|
||||
const { user } = this.state;
|
||||
const isBanned = user?.is_banned || JSON.parse(localStorage.getItem('banned') ?? 'false');
|
||||
|
||||
if(isBanned) {
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100%', justifyContent: "center", alignItems: "center", fontSize: '2rem' }}>
|
||||
Вы были забанены!
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{preloader && <Preloader/>}
|
||||
<Suspense fallback={null}>
|
||||
<Navbar/>
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
{snowfall.snowfallStatus && (
|
||||
<Snowfall color={'white'} snowflakeCount={snowfall.snowflakeCount} style={{ zIndex: 1000, position: "fixed" }}/>
|
||||
)}
|
||||
</Suspense>
|
||||
<div className={'Container'}>
|
||||
<div className="Notifications">
|
||||
{this.props.notifications.map((n, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{n}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
{Router}
|
||||
|
||||
{modal && modal}
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
<Footer/>
|
||||
</Suspense>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: StoreState) => ({
|
||||
notifications: state.notifications,
|
||||
modal: state.modal,
|
||||
snowfall: state.snowfall,
|
||||
preloader: state.preloader
|
||||
}),
|
||||
dispatch => ({
|
||||
setUser: (userData: Unmei.UserType) => dispatch(setUser(userData)),
|
||||
setSettings: (settings: Unmei.UserSettingsType) => dispatch(setSettings(settings))
|
||||
})
|
||||
)(withProfiler(App));
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { Suspense } from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import Preloader from "../Preloader/Preloader";
|
||||
|
||||
const Main = React.lazy(() => import('../../pages/Main/Main'));
|
||||
const AllNews = React.lazy(() => import('../../pages/News/AllNews'));
|
||||
const Post = React.lazy(() => import('../../pages/News/Post'));
|
||||
const Novels = React.lazy(() => import('../../pages/Novels/Novels'));
|
||||
const Novel = React.lazy(() => import('../../pages/Novel/Novel'));
|
||||
const Clubs = React.lazy(() => import('../../pages/Clubs/Clubs'));
|
||||
const Club = React.lazy(() => import('../../pages/Club/Club'));
|
||||
const Users = React.lazy(() => import('../../pages/Users/Users'));
|
||||
const User = React.lazy(() => import('../../pages/User/User'));
|
||||
const UserNovels = React.lazy(() => import('../../pages/User/Novels/UserNovels'));
|
||||
const Settings = React.lazy(() => import('../../pages/User/Settings/Settings'));
|
||||
const Character = React.lazy(() => import('../../pages/Character/Character'));
|
||||
const ActivateAccount = React.lazy(() => import('../../pages/ActivateAccount'));
|
||||
const PasswordRestoreGenerate = React.lazy(() => import('../../pages/PasswordRestore/PasswordRestoreGenerate'));
|
||||
const PasswordRestore = React.lazy(() => import('../../pages/PasswordRestore/PasswordRestore'));
|
||||
const AdminPanel = React.lazy(() => import('../../pages/AdminPanel/AdminPanel'));
|
||||
const VKAuth = React.lazy(() => import('../../pages/VKAuth/VKAuth'));
|
||||
const NotFoundError = React.lazy(() => import('../NotFound/NotFoundError'));
|
||||
|
||||
export default (
|
||||
<Suspense fallback={<Preloader/>}>
|
||||
<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={'/clubs'} component={Clubs}/>
|
||||
<Route exact path={'/clubs/:clubId'} component={Club}/>
|
||||
|
||||
<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 exact path={'/vk_auth'} component={VKAuth}/>
|
||||
|
||||
<Route path={'/kawaii__neko'} component={AdminPanel}/>
|
||||
|
||||
<Route component={NotFoundError}/>
|
||||
</Switch>
|
||||
</Suspense>
|
||||
);
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
.Footer
|
||||
background: var(--nav-bg-color)
|
||||
display: flex
|
||||
flex-direction: column
|
||||
position: relative
|
||||
bottom: 0
|
||||
padding: 1rem
|
||||
box-sizing: border-box
|
||||
height: 240px
|
||||
|
||||
.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
+50
@@ -0,0 +1,50 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import './Footer.sass';
|
||||
import { Link } from "react-router-dom";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faDiscord, faTelegramPlane, faVk } from '@fortawesome/free-brands-svg-icons';
|
||||
import { build, version, getVersion } from "../../api/api";
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
const [backend, setVersion] = useState<Unmei.VersionResponse>({ version: '', build: 0 });
|
||||
useMemo(() => {
|
||||
getVersion().then(setVersion);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='Footer'>
|
||||
<footer className='Footer__Tabs'>
|
||||
<div className="Footer__Tab">
|
||||
<div className="Footer__Tab_Title">Команда</div>
|
||||
<Link to={'/user/1'} className={'Link'}>Nix13</Link>
|
||||
<Link to={'/user/6'} className={'Link'}>RonFall</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} title='VK' /></div>
|
||||
</a>
|
||||
<a href="https://discord.gg/4CA8Cju">
|
||||
<div className="Link"><FontAwesomeIcon icon={faDiscord} title='Discord'/></div>
|
||||
</a>
|
||||
<a href="https://t.me/unmei_site">
|
||||
<div className="Link"><FontAwesomeIcon icon={faTelegramPlane} title='Telegram'/></div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<div className="Footer__Bottom">
|
||||
<div>Unmei © 2019-2021</div>
|
||||
<div>
|
||||
Frontend version: <span style={{ color: backend.build === build ? "green" : "red" }}>{version} ({build})</span>;
|
||||
Backend version: <span style={{ color: backend.build === build ? "green" : "red" }}>{backend.version} ({backend.build})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
// @ts-ignore
|
||||
import Loader from 'react-loader-spinner'
|
||||
|
||||
const Loading: React.FC = () => (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Loader color='#aaa' type='Rings'/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Loading;
|
||||
Executable
+118
@@ -0,0 +1,118 @@
|
||||
.Minimized
|
||||
height: 48px
|
||||
|
||||
.Expanded
|
||||
min-height: 48px
|
||||
|
||||
&__Links
|
||||
position: relative
|
||||
width: 100%
|
||||
|
||||
.Navbar
|
||||
color: var(--main-color)
|
||||
background-color: var(--nav-bg-color)
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
align-items: flex-start
|
||||
flex-wrap: wrap
|
||||
padding: 0 8px
|
||||
|
||||
&__Links, &__User
|
||||
min-height: 48px
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
|
||||
&_Button
|
||||
font-size: 1rem
|
||||
padding: 4px
|
||||
color: var(--main-color)
|
||||
background-color: var(--nav-bg-color)
|
||||
border: none
|
||||
display: flex
|
||||
justify-content: center
|
||||
align-items: center
|
||||
cursor: pointer
|
||||
position: relative
|
||||
font-weight: 300
|
||||
|
||||
&::before
|
||||
content: ""
|
||||
width: 100%
|
||||
position: absolute
|
||||
bottom: 0
|
||||
left: 0
|
||||
height: 0
|
||||
background: var(--accent-color)
|
||||
transition: heigth .1s ease-in
|
||||
-webkit-transition: height .1s ease-in
|
||||
|
||||
&:hover
|
||||
background-color: var(--main-bg-color)
|
||||
|
||||
&::before
|
||||
height: 5px
|
||||
|
||||
&__Dropdown
|
||||
cursor: pointer
|
||||
|
||||
&:hover > &_Menu
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
&_Menu
|
||||
display: none
|
||||
position: absolute
|
||||
background-color: var(--nav-bg-color)
|
||||
box-sizing: border-box
|
||||
padding: 4px
|
||||
max-width: 250px
|
||||
right: 0
|
||||
z-index: 100000
|
||||
|
||||
&_Item
|
||||
margin: .2rem 0
|
||||
color: var(--main-color)
|
||||
|
||||
&:hover
|
||||
color: var(--link-color)
|
||||
|
||||
&__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
|
||||
font-weight: 500
|
||||
|
||||
&::before
|
||||
content: ""
|
||||
width: 100%
|
||||
position: absolute
|
||||
bottom: 0
|
||||
left: 0
|
||||
height: 0
|
||||
background: var(--accent-color)
|
||||
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
+137
@@ -0,0 +1,137 @@
|
||||
import React from "react";
|
||||
import './Navbar.sass'
|
||||
import { connect } from "react-redux";
|
||||
import { userLogout } from "../../api/auth";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hasAccessToAdminPanel } from "../../utils";
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faBars } from '@fortawesome/free-solid-svg-icons';
|
||||
import RegisterModal from "../../pages/Modals/RegisterModal";
|
||||
import LoginModal from "../../pages/Modals/LoginModal";
|
||||
import { logout } from "../../store/ducks/currentUser";
|
||||
import { setModal } from "../../store/ducks/modal";
|
||||
|
||||
type Props = {
|
||||
setModal: SetModal
|
||||
logout: LogoutUser
|
||||
currentUser: Unmei.UserType
|
||||
};
|
||||
|
||||
type State = {
|
||||
expand: boolean
|
||||
width: number
|
||||
}
|
||||
|
||||
class Navbar extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
expand: false, width: window.screen.width
|
||||
}
|
||||
|
||||
logout = () => {
|
||||
const { logout } = this.props;
|
||||
logout();
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('theme');
|
||||
document.body.removeAttribute('theme');
|
||||
userLogout().catch();
|
||||
};
|
||||
|
||||
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="Navbar__Links">
|
||||
<Link to="/" className={'Navbar_Button'}>
|
||||
Главная
|
||||
</Link>
|
||||
<Link to="/novels" className={'Navbar_Button'}>
|
||||
Новеллы
|
||||
</Link>
|
||||
{/*<Link to="/clubs" className={'Navbar_Button'}>*/}
|
||||
{/* Клубы*/}
|
||||
{/*</Link>*/}
|
||||
{expand && isMinimized && (
|
||||
<div onClick={this.changeSize} className={'Navbar_Button'}
|
||||
style={{ position: "absolute", right: 0, margin: '.5rem' }}>X</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const user = isMinimized ? (
|
||||
<div className={'Navbar__Dropdown'}>
|
||||
<div className="Navbar__Dropdown_Button Navbar__User_Profile">
|
||||
<div className={'Navbar__User_Avatar'}
|
||||
style={{ backgroundImage: `url(${currentUser.avatar}?s=40&t=${new Date().getTime()})` }}/>
|
||||
<div>{this.props.currentUser.username}</div>
|
||||
</div>
|
||||
<div className="Navbar__Dropdown_Menu">
|
||||
<Link to={`/user/${currentUser.id}`} className="Navbar__Dropdown_Item">Профиль</Link>
|
||||
{currentUser.group && hasAccessToAdminPanel(currentUser) && (
|
||||
<Link to={'/kawaii__neko'} className={'Navbar__Dropdown_Item'}>
|
||||
Админ-панель
|
||||
</Link>
|
||||
)}
|
||||
<Link className="Navbar__Dropdown_Item" to={`/user/${currentUser.id}/settings`}>Настройки</Link>
|
||||
<div className="Navbar__Dropdown_Item" onClick={this.logout}>Выход</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (<>
|
||||
<Link to={`/user/${currentUser.id}`} className="Navbar__User_Profile">
|
||||
<div className={'Navbar__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={'Navbar_Button'}>
|
||||
Админ-панель
|
||||
</Link>
|
||||
)}
|
||||
<Link className="Navbar_Button" to={`/user/${currentUser.id}/settings`}>Настройки</Link>
|
||||
<div className={'Navbar_Button'} onClick={this.logout}>Выйти</div>
|
||||
</>)
|
||||
|
||||
return (
|
||||
<nav className={`Navbar ${expand ? 'Expanded' : 'Minimized'}`}>
|
||||
{expand || !isMinimized ? links : (
|
||||
<div className="Navbar__Links">
|
||||
<div onClick={this.changeSize} className={'Navbar_Button'}>
|
||||
<FontAwesomeIcon icon={faBars}/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentUser &&
|
||||
(<div className={'Navbar__User'}>
|
||||
{currentUser.authorized ? user : (<>
|
||||
<div className={'Navbar_Button'} onClick={() => setModal(<RegisterModal/>)}>Регистрация</div>
|
||||
<div className={'Navbar_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
+13
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
|
||||
function NotFoundError() {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', width: '100%', height: '100%' }}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 600 }}>Увы, данная страница не найдена!</div>
|
||||
<div>Проверьте правильность написания ссылки!</div>
|
||||
<img src="/static/img/not-found.png" style={{ width: 300, height: 400 }} alt=""/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotFoundError;
|
||||
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;
|
||||
@@ -0,0 +1,54 @@
|
||||
#Preloader
|
||||
position: fixed
|
||||
top: 0
|
||||
left: 0
|
||||
width: 100%
|
||||
height: 100%
|
||||
background: var(--main-bg-color)
|
||||
z-index: 10000
|
||||
|
||||
#Loader
|
||||
display: block
|
||||
position: relative
|
||||
left: 50%
|
||||
top: 50%
|
||||
width: 150px
|
||||
height: 150px
|
||||
margin: -75px 0 0 -75px
|
||||
border-radius: 50%
|
||||
border: 3px solid transparent
|
||||
border-top-color: #9370DB
|
||||
-webkit-animation: spin 2s linear infinite
|
||||
animation: spin 2s linear infinite
|
||||
|
||||
&:before
|
||||
content: ""
|
||||
position: absolute
|
||||
top: 5px
|
||||
left: 5px
|
||||
right: 5px
|
||||
bottom: 5px
|
||||
border-radius: 50%
|
||||
border: 3px solid transparent
|
||||
border-top-color: #BA55D3
|
||||
-webkit-animation: spin 3s linear infinite
|
||||
animation: spin 3s linear infinite
|
||||
|
||||
&:after
|
||||
content: ""
|
||||
position: absolute
|
||||
top: 15px
|
||||
left: 15px
|
||||
right: 15px
|
||||
bottom: 15px
|
||||
border-radius: 50%
|
||||
border: 3px solid transparent
|
||||
border-top-color: #FF00FF
|
||||
-webkit-animation: spin 1.5s linear infinite
|
||||
animation: spin 1.5s linear infinite
|
||||
|
||||
@keyframes spin
|
||||
0%
|
||||
transform: rotate(0deg)
|
||||
100%
|
||||
transform: rotate(360deg)
|
||||
@@ -0,0 +1,10 @@
|
||||
import './Preloader.sass';
|
||||
//import './PreloaderTest.sass';
|
||||
|
||||
const Preloader = () => (
|
||||
<div id={'Preloader'}>
|
||||
<div id="Loader"/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Preloader;
|
||||
@@ -0,0 +1,18 @@
|
||||
@keyframes preloader
|
||||
0%
|
||||
background-position: 0% 50%
|
||||
50%
|
||||
background-position: 100% 50%
|
||||
100%
|
||||
background-position: 0% 50%
|
||||
|
||||
#Preloader
|
||||
height: 100vh
|
||||
width: 100vw
|
||||
position: fixed
|
||||
z-index: 10000
|
||||
background-color: #6b0f1a
|
||||
background-image: linear-gradient(315deg, #6b0f1a 0%, #b91372 74%)
|
||||
background-size: 400%
|
||||
animation: preloader 5s ease-in-out
|
||||
animation-iteration-count: infinite
|
||||
@@ -0,0 +1,165 @@
|
||||
@keyframes slideOut
|
||||
0%
|
||||
left: -350px
|
||||
100%
|
||||
left: 0
|
||||
|
||||
@keyframes slideIn
|
||||
0%
|
||||
left: 0
|
||||
100%
|
||||
left: -350px
|
||||
|
||||
.OpenSidebar, .CloseSidebar
|
||||
position: fixed
|
||||
width: 32px
|
||||
height: 32px
|
||||
top: 8px
|
||||
z-index: 1000
|
||||
background-color: var(--nav-bg-color)
|
||||
padding: 8px
|
||||
box-sizing: border-box
|
||||
|
||||
.OpenSidebar
|
||||
left: 8px
|
||||
|
||||
.CloseSidebar
|
||||
@media screen and (max-width: 400px)
|
||||
left: calc(100vw - 32px)
|
||||
|
||||
left: calc(var(--sidebar-width) - 32px)
|
||||
animation: slideOut 500ms linear
|
||||
|
||||
.Sidebar
|
||||
box-sizing: border-box
|
||||
position: fixed
|
||||
top: 0
|
||||
left: 0
|
||||
height: 100%
|
||||
background-color: var(--nav-bg-color)
|
||||
z-index: 10
|
||||
animation: slideOut 500ms linear
|
||||
width: 100%
|
||||
|
||||
@media (min-width: 400px)
|
||||
width: var(--sidebar-width)
|
||||
|
||||
&_Hide
|
||||
animation: slideIn 500ms linear
|
||||
|
||||
&__Close
|
||||
font-size: 20px
|
||||
position: absolute
|
||||
top: 8px
|
||||
right: 8px
|
||||
|
||||
&__Button
|
||||
padding: .5rem
|
||||
color: var(--main-color)
|
||||
background-color: var(--block-color)
|
||||
width: 100%
|
||||
text-align: center
|
||||
cursor: pointer
|
||||
border: none
|
||||
|
||||
&:hover
|
||||
background-color: var(--sec-bg-color)
|
||||
|
||||
&__Auth, &__User
|
||||
border-bottom: var(--block-color) 1px solid
|
||||
padding: 8px
|
||||
box-sizing: border-box
|
||||
|
||||
&__Auth
|
||||
width: 100%
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
.Sidebar__Button
|
||||
font-size: 1.2rem !important
|
||||
|
||||
&__User
|
||||
display: flex
|
||||
|
||||
&_Buttons
|
||||
display: flex
|
||||
|
||||
&_Info
|
||||
margin-left: 8px
|
||||
width: 100%
|
||||
|
||||
&_Name
|
||||
font-size: 1.5rem
|
||||
font-weight: 900
|
||||
|
||||
&_Group
|
||||
border-color: #eeeeee
|
||||
border-width: 1px
|
||||
border-style: solid
|
||||
color: var(--main-color)
|
||||
|
||||
border-radius: 2px
|
||||
padding: .2rem
|
||||
text-align: center
|
||||
font-size: 1rem
|
||||
font-weight: 300
|
||||
position: relative
|
||||
z-index: 1
|
||||
margin: 4px 0
|
||||
|
||||
&::before
|
||||
content: ''
|
||||
width: 100%
|
||||
border-top-color: inherit
|
||||
border-top-width: 27px
|
||||
border-top-style: solid
|
||||
opacity: 0.5
|
||||
position: absolute
|
||||
z-index: -1
|
||||
top: 0
|
||||
left: 0
|
||||
|
||||
.Button
|
||||
margin-top: 8px
|
||||
font-size: 1rem
|
||||
|
||||
&_Avatar
|
||||
width: 80px
|
||||
height: 80px
|
||||
max-width: 80px
|
||||
max-height: 80px
|
||||
border-radius: 50%
|
||||
|
||||
&__Links
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
&__Link
|
||||
cursor: pointer
|
||||
font-size: 1.5rem
|
||||
width: 100%
|
||||
background-color: var(--main-bg-color)
|
||||
box-sizing: border-box
|
||||
padding: 4px 4px 4px 16px
|
||||
position: relative
|
||||
transition: background-color 200ms
|
||||
|
||||
&::before
|
||||
content: ""
|
||||
width: 100%
|
||||
position: absolute
|
||||
bottom: 0
|
||||
left: 0
|
||||
height: 0
|
||||
background: var(--accent-color)
|
||||
transition: heigth 200ms linear
|
||||
-webkit-transition: height .1s linear
|
||||
|
||||
&:hover
|
||||
background-color: var(--main-bg-color)
|
||||
|
||||
&::before
|
||||
height: 2px
|
||||
|
||||
&:hover
|
||||
background-color: var(--sec-bg-color)
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from "react";
|
||||
import './Sidebar.sass';
|
||||
import { connect } from "react-redux";
|
||||
import { setModal } from "../../store/ducks/modal";
|
||||
import LoginModal from "../../pages/Modals/LoginModal";
|
||||
import { logout } from "../../store/ducks/currentUser";
|
||||
import RegisterModal from "../../pages/Modals/RegisterModal";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faAlignJustify, faTimesCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { hasAccessToAdminPanel } from "../../utils";
|
||||
|
||||
type Props = {
|
||||
modal: React.ReactNode | null
|
||||
user: Unmei.UserType
|
||||
setModal: SetModal
|
||||
logout: LogoutUser
|
||||
}
|
||||
|
||||
type State = {
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
class Sidebar extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
closed: true
|
||||
}
|
||||
private sidebar: HTMLDivElement | null | undefined;
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('keyup', this.handleKeyUp, false);
|
||||
document.addEventListener('mousedown', this.handleOutsideClick, false);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('keyup', this.handleKeyUp, false);
|
||||
document.removeEventListener('mousedown', this.handleOutsideClick, false);
|
||||
}
|
||||
|
||||
handleKeyUp = (e: KeyboardEvent) => {
|
||||
if(e.code === 'Escape' && !this.props.modal) {
|
||||
e.preventDefault();
|
||||
this.closeSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
handleOutsideClick = (e: any) => {
|
||||
if(this.sidebar && !this.sidebar.contains(e.target) && !this.props.modal) {
|
||||
this.closeSidebar()
|
||||
}
|
||||
};
|
||||
|
||||
closeSidebar = () => {
|
||||
const closeButton = document.getElementById('CloseSidebar');
|
||||
if(!this.sidebar) return;
|
||||
if(!closeButton) return;
|
||||
|
||||
this.sidebar.classList.add('Sidebar_Hide');
|
||||
closeButton.classList.add('Sidebar_Hide');
|
||||
setTimeout(() => {
|
||||
this.setState({ closed: true });
|
||||
}, 450);
|
||||
}
|
||||
|
||||
openSidebar = () => {
|
||||
this.setState({ closed: false });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { user, setModal, logout } = this.props;
|
||||
const { closed } = this.state;
|
||||
|
||||
const userNameStyle: React.CSSProperties = JSON.parse(localStorage.getItem('color-name') || 'false') && user.group && user.group.id !== 0 ? { color: user.group.color } : {};
|
||||
|
||||
if(closed)
|
||||
return <div onClick={this.openSidebar} className='OpenSidebar'><FontAwesomeIcon icon={faAlignJustify}/></div>;
|
||||
return (<>
|
||||
<div className="Sidebar" ref={ref => this.sidebar=ref}>
|
||||
<div onClick={this.closeSidebar} className='CloseSidebar' id='CloseSidebar'><FontAwesomeIcon icon={faTimesCircle}/></div>
|
||||
|
||||
{user.authorized ? (
|
||||
<div className="Sidebar__User">
|
||||
<img className='Sidebar__User_Avatar' src={user.avatar} alt={user.username}/>
|
||||
<div className='Sidebar__User_Info'>
|
||||
<Link to={`/user/${user.id}`} onClick={this.closeSidebar} className='Sidebar__User_Info_Name' style={userNameStyle}>{user.username}</Link>
|
||||
<div className='Sidebar__User_Info_Group' style={{ borderColor: user.group.color }}>
|
||||
{user.group.name}
|
||||
</div>
|
||||
<div className="Sidebar__User_Buttons">
|
||||
<Link to={`/user/${user.id}/settings`} onClick={this.closeSidebar} className={'Sidebar__Button'}>Настройки</Link>
|
||||
{user.group && hasAccessToAdminPanel(user) && <Link to={`/kawaii__neko`} onClick={this.closeSidebar} className={'Sidebar__Button'}>АП</Link>}
|
||||
<button onClick={logout} className={'Sidebar__Button'}>Выход</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="Sidebar__Auth">
|
||||
<button onClick={() => setModal(<LoginModal/>)} className={'Sidebar__Button'}>Войти</button>
|
||||
<button onClick={() => setModal(<RegisterModal/>)} className={'Sidebar__Button'}>Регистрация</button>
|
||||
</div>
|
||||
)}
|
||||
<div className='Sidebar__Links'>
|
||||
<Link className='Sidebar__Link' to={'/'} onClick={this.closeSidebar}>Главная</Link>
|
||||
<Link className='Sidebar__Link' to={'/novels'} onClick={this.closeSidebar}>Новеллы</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: StoreState) => ({
|
||||
user: state.currentUser,
|
||||
modal: state.modal
|
||||
}),
|
||||
dispatch => ({
|
||||
setModal: (modal: React.ReactNode | null) => dispatch(setModal(modal)),
|
||||
logout: () => dispatch(logout())
|
||||
})
|
||||
)(Sidebar);
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { Provider } from 'react-redux';
|
||||
import store from "./store/store";
|
||||
import * as Sentry from '@sentry/react';
|
||||
import App from './components/App/App';
|
||||
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
|
||||
|
||||
if(process.env.NODE_ENV === 'production')
|
||||
Sentry.init({
|
||||
dsn: "https://27a5473611834ece9aca26216d79ad86@sentry.io/1810028"
|
||||
});
|
||||
|
||||
ReactDOM.render(
|
||||
<BrowserRouter>
|
||||
<Provider store={store}>
|
||||
<App/>
|
||||
</Provider>
|
||||
</BrowserRouter>,
|
||||
document.getElementById('root'));
|
||||
|
||||
serviceWorkerRegistration.register();
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
import { activateAccount } from "../api/users";
|
||||
import { connect } from "react-redux";
|
||||
import errors from "../api/errors";
|
||||
import Loading from "../components/Loading";
|
||||
import { addNotification } from "../store/ducks/notifications";
|
||||
|
||||
type Props = {
|
||||
history: { push: (path: string) => void }
|
||||
match: { params: { token: string } }
|
||||
addNotification: AddNotification
|
||||
};
|
||||
|
||||
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: Unmei.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
+71
@@ -0,0 +1,71 @@
|
||||
import { connect } from "react-redux";
|
||||
import React from "react";
|
||||
import NotFoundError from "../../components/NotFound/NotFoundError";
|
||||
import { hasAccessToAdminPanel } from "../../utils";
|
||||
import './AdminPanel.sass';
|
||||
import { Link, Route, Switch } from "react-router-dom";
|
||||
import APNews from "./News/APNews";
|
||||
import APModifyPost from "./News/APModifyPost";
|
||||
import APAddPost from "./News/APAddPost";
|
||||
import APNovels from "./Novels/APNovels";
|
||||
import APUsers from "./Users/APUsers";
|
||||
import APModifyUser from "./Users/APModifyUser";
|
||||
import APModifyNovel from "./Novels/APModifyNovel";
|
||||
import APCharacters from "./Characters/APCharacters";
|
||||
import APModifyCharacter from "./Characters/APModifyCharacter";
|
||||
import APMain from "./Main/APMain";
|
||||
import APAddNovel from "./Novels/APAddNovel";
|
||||
|
||||
type Props = {
|
||||
user: Unmei.UserType
|
||||
match: { path: string }
|
||||
}
|
||||
|
||||
type State = {}
|
||||
|
||||
class AdminPanel extends React.Component<Props, State> {
|
||||
render() {
|
||||
const { user, match: { path } } = this.props;
|
||||
if(!user || !user.group) return <NotFoundError/>;
|
||||
if(!hasAccessToAdminPanel(user)) return <NotFoundError/>;
|
||||
|
||||
return (
|
||||
<div className={'AdminPanel'}>
|
||||
<div className="AdminPanel__Title">Админ панель</div>
|
||||
|
||||
<div className="AdminPanel__Router">
|
||||
<Link to={path}>Главная</Link>
|
||||
<Link to={`${path}/users`}>Пользователи</Link>
|
||||
<Link to={`${path}/novels`}>Новелы</Link>
|
||||
<Link to={`${path}/news`}>Новости</Link>
|
||||
<Link to={`${path}/characters`}>Персонажи</Link>
|
||||
</div>
|
||||
|
||||
<Switch>
|
||||
<Route exact path={path} component={APMain}/>
|
||||
|
||||
<Route exact path={`${path}/news`} component={APNews}/>
|
||||
<Route exact path={`${path}/news/new`} component={APAddPost}/>
|
||||
<Route exact path={`${path}/news/:id`} component={APModifyPost}/>
|
||||
|
||||
<Route exact path={`${path}/novels`} component={APNovels}/>
|
||||
<Route exact path={`${path}/novels/new`} component={APAddNovel}/>
|
||||
<Route exact path={`${path}/novels/:id`} component={APModifyNovel}/>
|
||||
|
||||
<Route exact path={`${path}/users`} component={APUsers}/>
|
||||
<Route exact path={`${path}/users/new`} component={NotFoundError}/>
|
||||
<Route exact path={`${path}/users/:id`} component={APModifyUser}/>
|
||||
|
||||
<Route exact path={`${path}/characters`} component={APCharacters}/>
|
||||
<Route exact path={`${path}/characters/:id`} component={APModifyCharacter}/>
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: StoreState) => ({
|
||||
user: state.currentUser
|
||||
})
|
||||
)(AdminPanel);
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
.APCharacter
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
.Input, .TextField, .Button
|
||||
font-size: 1.2rem
|
||||
|
||||
.Input, .TextField
|
||||
margin-bottom: 1rem
|
||||
|
||||
&__Description
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: flex-start
|
||||
margin-bottom: 1rem
|
||||
|
||||
&_Preview, .TextField
|
||||
color: var(--main-color) !important
|
||||
width: 100%
|
||||
height: 10rem
|
||||
resize: vertical
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
import Group from "../../../ui/Group/Group";
|
||||
import { Link } from "react-router-dom";
|
||||
import Button from "../../../ui/Button/Button";
|
||||
import { fetchChars } from "../../../api/characters";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
type Props = {
|
||||
match: { path: string }
|
||||
}
|
||||
|
||||
type State = {
|
||||
characters: Unmei.CharacterType[]
|
||||
}
|
||||
|
||||
class APCharacters extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
characters: []
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
fetchChars().then(characters => this.setState({ characters }));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { characters } = this.state;
|
||||
const { match: { path } } = this.props;
|
||||
|
||||
return (
|
||||
<Group title={'Персонажи'}>
|
||||
<Helmet>
|
||||
<title>Персонажи</title>
|
||||
</Helmet>
|
||||
|
||||
<Link to={`${path}/new`}>
|
||||
<Button>Добавить персонажа</Button>
|
||||
</Link>
|
||||
{characters.map(char => (
|
||||
<Link to={`${path}/${char.id}`} key={char.id}>
|
||||
<div style={{ display: "flex" }}>
|
||||
<div style={{ marginRight: '.5rem', fontWeight: 600 }}>{char.id}</div>
|
||||
<div>{char.original_name}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default APCharacters;
|
||||
@@ -0,0 +1,151 @@
|
||||
import React, { ChangeEvent } from "react";
|
||||
import Input from "../../../ui/Input/Input";
|
||||
import Title from "../../../ui/Title/Title";
|
||||
import BBEditor from "../../../ui/BBEditor/BBEditor";
|
||||
import Button from "../../../ui/Button/Button";
|
||||
import Group from "../../../ui/Group/Group";
|
||||
import NotFoundError from "../../../components/NotFound/NotFoundError";
|
||||
// @ts-ignore
|
||||
import parser from 'bbcode-to-react';
|
||||
import ConfirmPopout from "../../../ui/ConfirmPopout/ConfirmPopout";
|
||||
import { deleteChar, fetchChar, updateChar } from "../../../api/characters";
|
||||
import './APCharacters.sass';
|
||||
import { connect } from "react-redux";
|
||||
import { setPreloader } from "../../../store/ducks/preloader";
|
||||
import { setModal } from "../../../store/ducks/modal";
|
||||
import { addNotification } from "../../../store/ducks/notifications";
|
||||
import NotificationMessage from "../../../ui/Notifications/NotificationMessage";
|
||||
import TextField from "../../../ui/TextField/TextField";
|
||||
import errors from "../../../api/errors";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
type Props = {
|
||||
match: { params: { id: number } }
|
||||
history: { push: (path: string) => void }
|
||||
setPopout: SetModal
|
||||
addNotification: AddNotification
|
||||
setPreloader: SetPreloaderStatus
|
||||
};
|
||||
|
||||
type State = {
|
||||
char: Unmei.CharacterType | null
|
||||
originalName: string
|
||||
previewDescription: string
|
||||
};
|
||||
|
||||
class APModifyCharacter extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
char: null, originalName: '', previewDescription: ''
|
||||
}
|
||||
private description = React.createRef<HTMLTextAreaElement>();
|
||||
|
||||
componentDidMount() {
|
||||
const { match: { params: { id } }, setPreloader } = this.props;
|
||||
|
||||
setPreloader(true);
|
||||
fetchChar(id).then(char => {
|
||||
this.setState({ char });
|
||||
setPreloader(false);
|
||||
});
|
||||
}
|
||||
|
||||
saveCharacter = (e: React.FormEvent) => {
|
||||
const { char } = this.state;
|
||||
const { addNotification } = this.props;
|
||||
|
||||
e.preventDefault();
|
||||
if(!char) {
|
||||
addNotification(
|
||||
<NotificationMessage>
|
||||
Персонаж - null!
|
||||
</NotificationMessage>
|
||||
);
|
||||
return;
|
||||
}
|
||||
updateChar(char).then(char => {
|
||||
addNotification(
|
||||
<NotificationMessage level={'success'}>
|
||||
Персонаж #{char.id} сохранён!
|
||||
</NotificationMessage>
|
||||
);
|
||||
this.setState({ char });
|
||||
}).catch((err: Unmei.ApiError) => {
|
||||
addNotification(
|
||||
<NotificationMessage level={'error'}>
|
||||
{errors[err.code] ?? err.text}
|
||||
</NotificationMessage>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
onChange = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const char = Object.assign({}, this.state.char);
|
||||
const target = event.target;
|
||||
// @ts-ignore
|
||||
char[target.name] = target.type === 'checkbox' ? target.checked : target.value;
|
||||
this.setState({ char });
|
||||
}
|
||||
|
||||
previewDescription = () => {
|
||||
if(!this.description.current) return;
|
||||
this.setState({ previewDescription: parser.toReact(this.description.current.value) })
|
||||
}
|
||||
|
||||
deleteChar = async (postId: number) => {
|
||||
await deleteChar(postId);
|
||||
this.props.history.push('/');
|
||||
}
|
||||
|
||||
_deleteChar = () => {
|
||||
const { match: { params: { id} }, setPopout } = this.props;
|
||||
|
||||
const popout = (
|
||||
<ConfirmPopout onConfirm={() => this.deleteChar(id)}>
|
||||
Вы уверены?
|
||||
</ConfirmPopout>
|
||||
);
|
||||
|
||||
setPopout(popout);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { char, originalName, previewDescription } = this.state;
|
||||
|
||||
if(!char) return <NotFoundError/>;
|
||||
return (
|
||||
<Group title={originalName}>
|
||||
<Helmet>
|
||||
<title>{char.original_name} / Админ-панель</title>
|
||||
</Helmet>
|
||||
|
||||
<form onSubmit={this.saveCharacter} className={'APCharacter'}>
|
||||
<Input type="text" placeholder={'Оригинальное имя'} value={char.original_name} onChange={this.onChange} name={'original_name'}/>
|
||||
|
||||
<div className={'APCharacter__Description'}>
|
||||
<Title>Описание</Title>
|
||||
<BBEditor inputRef={this.description} value={char.description} onChange={this.onChange} placeholder={'Описание'} name={'description'}/>
|
||||
{previewDescription.length > 0 && (<>
|
||||
<Title>Предпросмотр</Title>
|
||||
<TextField className={'APCharacter__Description_Preview'} value={previewDescription} onChange={()=>{}}/>
|
||||
</>)}
|
||||
<Button type={"button"} onClick={this.previewDescription}>Предпросмотр</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button style={{ marginRight: '1rem' }}>Сохранить</Button>
|
||||
<Button type={"button"} style={{ color: 'var(--error-bg-color)' }} onClick={this._deleteChar}>Удалить</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
null,
|
||||
dispatch => ({
|
||||
setPreloader: (s: boolean) => dispatch(setPreloader(s)),
|
||||
setPopout: (modal: React.ReactNode | null) => dispatch(setModal(modal)),
|
||||
addNotification: (notification: React.ReactNode) => dispatch(addNotification(notification))
|
||||
})
|
||||
)(APModifyCharacter);
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import Group from "../../../ui/Group/Group";
|
||||
import { build, getVersion, version } from "../../../api/api";
|
||||
import Loading from "../../../components/Loading";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
type State = {
|
||||
backend: Unmei.VersionResponse | null
|
||||
}
|
||||
|
||||
class APMain extends React.Component<any, State> {
|
||||
state: State = {
|
||||
backend: null
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
getVersion().then(backend => this.setState({ backend }));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { backend } = this.state;
|
||||
|
||||
if(!backend) return <Loading/>;
|
||||
return (
|
||||
<Group title={'Основное'}>
|
||||
<Helmet>
|
||||
<title>Главное / Админ-панель</title>
|
||||
</Helmet>
|
||||
|
||||
<div>
|
||||
Текущая версия сайта: <span style={{ color: backend.build === build ? "green" : "red" }}>{version} (билд: {build})</span>
|
||||
</div>
|
||||
<div>
|
||||
Текущая версия API: <span style={{ color: backend.build === build ? "green" : "red" }}>{backend.version} (билд: {backend.build})</span>
|
||||
</div>
|
||||
<div>
|
||||
Совпадение версий сборок: <span style={{ color: backend.build === build ? "green" : "red" }}>{backend.build === build ? 'да' : 'нет'}</span>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default APMain;
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { ChangeEvent, FormEvent } from "react";
|
||||
import Group from "../../../ui/Group/Group";
|
||||
import { createPost } 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";
|
||||
import { connect } from "react-redux";
|
||||
import NotificationMessage from "../../../ui/Notifications/NotificationMessage";
|
||||
import { setModal } from "../../../store/ducks/modal";
|
||||
import { addNotification } from "../../../store/ducks/notifications";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
type Props = {
|
||||
history: { push: (path: string) => void }
|
||||
setPopout: (modal: React.ReactNode | null) => void
|
||||
addNotification: (notification: React.ReactNode) => void
|
||||
};
|
||||
|
||||
type State = {
|
||||
post: Unmei.PostType
|
||||
|
||||
previewShort: string
|
||||
previewFull: string
|
||||
};
|
||||
|
||||
class APAddPost extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
post: {} as Unmei.PostType,
|
||||
previewShort: '', previewFull: ''
|
||||
}
|
||||
private shortPost = React.createRef<HTMLTextAreaElement>();
|
||||
private fullPost = React.createRef<HTMLTextAreaElement>();
|
||||
|
||||
savePost = async(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const { post } = this.state;
|
||||
if(!this.shortPost.current || !this.fullPost.current)
|
||||
return;
|
||||
|
||||
post.short_post = this.shortPost.current.value;
|
||||
post.full_post = this.fullPost.current.value;
|
||||
const { addNotification } = this.props;
|
||||
|
||||
createPost(post).then(createdPost => {
|
||||
const notification = (
|
||||
<NotificationMessage level={"success"}>
|
||||
Успешно создана новость с ID {createdPost.id}!
|
||||
</NotificationMessage>
|
||||
);
|
||||
addNotification(notification);
|
||||
this.props.history.push('/');
|
||||
});
|
||||
}
|
||||
|
||||
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.title = event.target.value;
|
||||
document.title = event.target.value || 'Unmei';
|
||||
this.setState({ post });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { post, previewShort, previewFull } = this.state;
|
||||
|
||||
return (
|
||||
<Group title='Новая новость'>
|
||||
<Helmet>
|
||||
<title>{post.title ? `${post.title} / Админ-панель` : 'Новая новость'}</title>
|
||||
</Helmet>
|
||||
|
||||
<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 style={{ marginRight: '1rem' }}>Сохранить</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(null,
|
||||
dispatch => ({
|
||||
setPopout: (modal: React.ReactNode | null) => dispatch(setModal(modal)),
|
||||
addNotification: (notification: React.ReactNode) => dispatch(addNotification(notification))
|
||||
})
|
||||
)(APAddPost);
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
import React, { ChangeEvent, FormEvent } from "react";
|
||||
import Group from "../../../ui/Group/Group";
|
||||
import NotFoundError from "../../../components/NotFound/NotFoundError";
|
||||
import { deletePost, 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";
|
||||
import { connect } from "react-redux";
|
||||
import ConfirmPopout from "../../../ui/ConfirmPopout/ConfirmPopout";
|
||||
import NotificationMessage from "../../../ui/Notifications/NotificationMessage";
|
||||
import { setModal } from "../../../store/ducks/modal";
|
||||
import { addNotification } from "../../../store/ducks/notifications";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
type Props = {
|
||||
match: { params: { id: number } }
|
||||
history: { push: (path: string) => void }
|
||||
setPopout: (modal: React.ReactNode | null) => void
|
||||
addNotification: (notification: React.ReactNode) => void
|
||||
};
|
||||
|
||||
type State = {
|
||||
post: Unmei.PostType | null
|
||||
title: string
|
||||
|
||||
previewShort: string
|
||||
previewFull: string
|
||||
};
|
||||
|
||||
class APModifyPost extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
post: null, title: '',
|
||||
previewShort: '', previewFull: ''
|
||||
}
|
||||
private shortPost = React.createRef<HTMLTextAreaElement>();
|
||||
private fullPost = React.createRef<HTMLTextAreaElement>();
|
||||
|
||||
componentDidMount() {
|
||||
const { match } = this.props;
|
||||
|
||||
fetchPost(match.params.id).then(post => {
|
||||
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;
|
||||
const { addNotification } = this.props;
|
||||
|
||||
updatePost(post).then(() => {
|
||||
const notification = (
|
||||
<NotificationMessage level={"success"}>
|
||||
Успешно!
|
||||
</NotificationMessage>
|
||||
)
|
||||
addNotification(notification)
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
document.title = event.target.value || 'Unmei';
|
||||
this.setState({ post });
|
||||
}
|
||||
|
||||
deletePost = async (postId: number) => {
|
||||
await deletePost(postId);
|
||||
this.props.history.push('/');
|
||||
}
|
||||
|
||||
_deletePost = () => {
|
||||
const { match: { params: { id} }, setPopout } = this.props;
|
||||
|
||||
const popout = (
|
||||
<ConfirmPopout onConfirm={() => this.deletePost(id)}>
|
||||
Вы уверены?
|
||||
</ConfirmPopout>
|
||||
);
|
||||
|
||||
setPopout(popout);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { post, title, previewShort, previewFull } = this.state;
|
||||
|
||||
if(!post) return <NotFoundError/>;
|
||||
return (
|
||||
<Group title={title}>
|
||||
<Helmet>
|
||||
<title>{title} / Админ-панель</title>
|
||||
</Helmet>
|
||||
|
||||
<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 style={{ marginRight: '1rem' }}>Сохранить</Button>
|
||||
<Button type={"button"} style={{ color: 'var(--error-bg-color)' }}
|
||||
onClick={this._deletePost}>Удалить</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(null,
|
||||
dispatch => ({
|
||||
setPopout: (modal: React.ReactNode | null) => dispatch(setModal(modal)),
|
||||
addNotification: (notification: React.ReactNode) => dispatch(addNotification(notification))
|
||||
})
|
||||
)(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
+51
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
import { fetchNews } from "../../../api/news";
|
||||
import Group from "../../../ui/Group/Group";
|
||||
import { Link } from "react-router-dom";
|
||||
import Button from "../../../ui/Button/Button";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
type Props = {
|
||||
match: { path: string }
|
||||
};
|
||||
|
||||
type State = {
|
||||
news: Unmei.PostType[]
|
||||
};
|
||||
|
||||
class APNews extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
news: []
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
fetchNews().then(news => this.setState({ news }));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { news } = this.state;
|
||||
const { match: { path } } = this.props;
|
||||
|
||||
return (
|
||||
<Group title={'Новости'}>
|
||||
<Helmet>
|
||||
<title>Новости</title>
|
||||
</Helmet>
|
||||
|
||||
<Link to={`${path}/new`}>
|
||||
<Button>Добавить новость</Button>
|
||||
</Link>
|
||||
{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;
|
||||
@@ -0,0 +1,143 @@
|
||||
import React, { ChangeEvent, FormEvent } from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import './APNovel.sass';
|
||||
|
||||
import Input from "../../../ui/Input/Input";
|
||||
import Button from "../../../ui/Button/Button";
|
||||
import BBEditor from "../../../ui/BBEditor/BBEditor";
|
||||
import Title from "../../../ui/Title/Title";
|
||||
import Group from "../../../ui/Group/Group";
|
||||
import NotificationMessage from "../../../ui/Notifications/NotificationMessage";
|
||||
|
||||
import { createNovel } from "../../../api/novels";
|
||||
import { fetchGenres } from '../../../api/genres'
|
||||
|
||||
import { setModal } from "../../../store/ducks/modal";
|
||||
import { addNotification } from "../../../store/ducks/notifications";
|
||||
// @ts-ignore
|
||||
import parser from 'bbcode-to-react';
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
type Props = {
|
||||
history: { push: (path: string) => void }
|
||||
setPopout: (modal: React.ReactNode | null) => void
|
||||
addNotification: (notification: React.ReactNode) => void
|
||||
};
|
||||
|
||||
type State = {
|
||||
novel: Unmei.NovelType
|
||||
genres: Unmei.GenreType[]
|
||||
|
||||
previewDescription: string
|
||||
};
|
||||
|
||||
class APAddNovel extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
novel: {} as Unmei.NovelType, genres: [],
|
||||
previewDescription: '',
|
||||
}
|
||||
private description = React.createRef<HTMLTextAreaElement>();
|
||||
|
||||
saveNovel = async(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const { novel } = this.state;
|
||||
if(!this.description.current)
|
||||
return;
|
||||
|
||||
novel.description = this.description.current.value;
|
||||
const { addNotification } = this.props;
|
||||
|
||||
createNovel(novel).then(createdNovel => {
|
||||
const notification = (
|
||||
<NotificationMessage level={"success"}>
|
||||
Успешно создана новелла с ID {createdNovel.id}!
|
||||
</NotificationMessage>
|
||||
);
|
||||
addNotification(notification);
|
||||
this.props.history.push('/');
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
fetchGenres().then(genres => this.setState({ genres }));
|
||||
}
|
||||
|
||||
previewDescription = () => {
|
||||
if(!this.description.current) return;
|
||||
this.setState({ previewDescription: parser.toReact(this.description.current.value) })
|
||||
}
|
||||
|
||||
changeOriginalName = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const { novel } = this.state;
|
||||
novel.original_name = event.target.value;
|
||||
this.setState({ novel: novel });
|
||||
}
|
||||
|
||||
changeLocalizedName = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const { novel } = this.state;
|
||||
novel.localized_name = event.target.value;
|
||||
this.setState({ novel });
|
||||
}
|
||||
|
||||
changeGenres = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selected = Array.from(e.target.selectedOptions);
|
||||
console.log(selected.forEach(el => el.value))
|
||||
}
|
||||
|
||||
onChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const novel = Object.assign({}, this.state.novel);
|
||||
const target = event.target;
|
||||
// @ts-ignore
|
||||
novel[target.name] = target.type === 'checkbox' ? target.checked : target.value;
|
||||
this.setState({ novel });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { novel, genres, previewDescription } = this.state;
|
||||
|
||||
return (
|
||||
<Group title={'Новая новелла'}>
|
||||
<Helmet>
|
||||
<title>{novel.original_name ? `${novel.original_name} / Админ-панель` : 'Новая новелла'}</title>
|
||||
</Helmet>
|
||||
|
||||
<form onSubmit={this.saveNovel} className='APNovel'>
|
||||
<Input type="text" placeholder={'Оригинальное название'} value={novel.original_name} onChange={this.changeOriginalName}/>
|
||||
<Input type="text" placeholder={'Имя на русском'} value={novel.localized_name} onChange={this.changeLocalizedName}/>
|
||||
<label>Демо? <Input type="checkbox" checked={novel.is_demo} onChange={this.onChange} name='is_demo'/></label>
|
||||
|
||||
<label>Жанры:
|
||||
<select multiple onChange={this.changeGenres}>
|
||||
{genres.map(genre => (
|
||||
<option value={genre.id}>{genre.localized_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className={'APNovel__Description'}>
|
||||
<Title>Описание</Title>
|
||||
<BBEditor inputRef={this.description} placeholder={'Описание'}/>
|
||||
{previewDescription.length > 0 && (<>
|
||||
<Title>Предпросмотр</Title>
|
||||
<div>{previewDescription}</div>
|
||||
</>)}
|
||||
<Button type={"button"} onClick={this.previewDescription}>Предпросмотр</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button style={{ marginRight: '1rem' }}>Сохранить</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(null,
|
||||
dispatch => ({
|
||||
setPopout: (modal: React.ReactNode | null) => dispatch(setModal(modal)),
|
||||
addNotification: (notification: React.ReactNode) => dispatch(addNotification(notification))
|
||||
})
|
||||
)(APAddNovel);
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
import React, { ChangeEvent, FormEvent } from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import './APNovel.sass';
|
||||
|
||||
import Loading from "../../../components/Loading";
|
||||
|
||||
import { addNotification } from "../../../store/ducks/notifications";
|
||||
import { setPreloader } from "../../../store/ducks/preloader";
|
||||
|
||||
import { fetchNovel, updateNovel, uploadNovelCover } from "../../../api/novels";
|
||||
|
||||
import NotificationMessage from "../../../ui/Notifications/NotificationMessage";
|
||||
import Button from "../../../ui/Button/Button";
|
||||
import Title from "../../../ui/Title/Title";
|
||||
import BBEditor from "../../../ui/BBEditor/BBEditor";
|
||||
import Group from "../../../ui/Group/Group";
|
||||
import Input from "../../../ui/Input/Input";
|
||||
import Select from "../../../ui/Select/Select";
|
||||
import Option from "../../../ui/Select/Option";
|
||||
|
||||
import { fetchGenres } from "../../../api/genres";
|
||||
// @ts-ignore
|
||||
import parser from 'bbcode-to-react';
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
type Props = {
|
||||
match: { params: { id: number } }
|
||||
addNotification: (notification: React.ReactNode) => void
|
||||
setPreloader: SetPreloaderStatus
|
||||
};
|
||||
|
||||
type State = {
|
||||
novel: Unmei.NovelType | null
|
||||
genres: Unmei.GenreType[]
|
||||
cover: File | null
|
||||
originalName: string
|
||||
previewDescription: string
|
||||
};
|
||||
|
||||
class APModifyNovel extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
novel: null, originalName: '',
|
||||
genres: [], cover: null,
|
||||
previewDescription: ''
|
||||
}
|
||||
private description = React.createRef<HTMLTextAreaElement>();
|
||||
|
||||
componentDidMount() {
|
||||
const { match: { params: { id } }, setPreloader } = this.props;
|
||||
|
||||
setPreloader(true);
|
||||
fetchNovel(id).then(async novel => {
|
||||
novel.release_date = new Date(novel.release_date);
|
||||
const genres = await fetchGenres();
|
||||
this.setState({ novel, genres, originalName: novel.original_name });
|
||||
setPreloader(false);
|
||||
});
|
||||
}
|
||||
|
||||
onChange = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const novel = Object.assign({}, this.state.novel);
|
||||
const target = event.target;
|
||||
// @ts-ignore
|
||||
novel[target.name] = target.type === 'checkbox' ? target.checked : target.value;
|
||||
if(target.type === 'date')
|
||||
novel.release_date = new Date(novel.release_date);
|
||||
this.setState({ novel });
|
||||
}
|
||||
|
||||
onChangeCover = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if(!files) return;
|
||||
this.setState({ cover: files[0] });
|
||||
}
|
||||
|
||||
saveCover = () => {
|
||||
const { novel, cover } = this.state;
|
||||
if(cover && novel) {
|
||||
const formData = new FormData();
|
||||
formData.append('cover', cover);
|
||||
uploadNovelCover(novel.id, formData).then(console.log)
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const { novel } = this.state;
|
||||
const { addNotification } = this.props;
|
||||
|
||||
if(novel === null) {
|
||||
const notification = (
|
||||
<NotificationMessage level={"error"}>
|
||||
Новелла - null!
|
||||
</NotificationMessage>
|
||||
);
|
||||
addNotification(notification);
|
||||
return;
|
||||
}
|
||||
updateNovel(novel).then(() => {
|
||||
this.saveCover();
|
||||
const notification = (
|
||||
<NotificationMessage level={"success"}>
|
||||
Новелла успешно изменена
|
||||
</NotificationMessage>
|
||||
);
|
||||
addNotification(notification)
|
||||
});
|
||||
}
|
||||
|
||||
changeGenres = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selected = Array.from(e.target.selectedOptions);
|
||||
const { novel, genres } = this.state;
|
||||
if(!novel) return;
|
||||
const selectedGenres = selected.map(el => genres.filter(genre => genre.id === parseInt(el.value))[0]);
|
||||
novel.genres = selectedGenres;
|
||||
this.setState({ novel });
|
||||
}
|
||||
|
||||
previewDescription = () => {
|
||||
if(!this.description.current) return;
|
||||
this.setState({ previewDescription: parser.toReact(this.description.current.value) });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { novel, genres, originalName, previewDescription } = this.state;
|
||||
|
||||
if(!novel) return <Loading/>;
|
||||
return (
|
||||
<Group title={originalName}>
|
||||
<Helmet>
|
||||
<title>{novel.original_name} / Админ-панель</title>
|
||||
</Helmet>
|
||||
|
||||
<form className={'APNovel'} onSubmit={this.onSubmit}>
|
||||
<Input type="text" onChange={this.onChange} name={'original_name'} value={novel.original_name}/>
|
||||
<Input type='text' onChange={this.onChange} name={'localized_name'} value={novel.localized_name}/>
|
||||
<label>Демо? <Input type="checkbox" checked={novel.is_demo} onChange={this.onChange} name='is_demo'/></label>
|
||||
<label><Input type="file" onChange={this.onChangeCover}/></label>
|
||||
<label>Дата выхода: <Input type="date" name='release_date' value={novel.release_date.toISOString().substring(0, 10)} onChange={this.onChange}/></label>
|
||||
|
||||
<label>Жанры:
|
||||
<Select multiple onChange={this.changeGenres} value={novel.genres.map(g => g.id.toString())}>
|
||||
{genres.map(genre => (
|
||||
<Option value={genre.id} key={genre.id}>{genre.localized_name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<div className={'APNovel_Description'}>
|
||||
<Title>Описание</Title>
|
||||
<BBEditor inputRef={this.description} value={novel.description} name={'description'} onChange={this.onChange} placeholder={'Описание'}/>
|
||||
{previewDescription.length > 0 && (<>
|
||||
<Title>Предпросмотр</Title>
|
||||
<div>{previewDescription}</div>
|
||||
</>)}
|
||||
<Button type={"button"} onClick={this.previewDescription}>Предпросмотр</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<Button>Сохранить!</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: StoreState) => ({
|
||||
user: state.currentUser
|
||||
}),
|
||||
(dispatch) => ({
|
||||
addNotification: (notification: React.ReactNode) => dispatch(addNotification(notification)),
|
||||
setPreloader: (status: boolean) => dispatch(setPreloader(status))
|
||||
})
|
||||
)(APModifyNovel);
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
.APNovel
|
||||
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
|
||||
|
||||
&_Title
|
||||
text-align: center
|
||||
font-weight: 600
|
||||
font-size: 1.5rem
|
||||
|
||||
&_Description
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: flex-start
|
||||
margin-bottom: 1rem
|
||||
|
||||
.TextField
|
||||
height: 10rem
|
||||
resize: vertical
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Link } from "react-router-dom";
|
||||
import { fetchNovels } from "../../../api/novels";
|
||||
import Group from "../../../ui/Group/Group";
|
||||
import Button from "../../../ui/Button/Button";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
type Props = {
|
||||
match: { path: string }
|
||||
};
|
||||
|
||||
type State = {
|
||||
novels: Unmei.NovelType[]
|
||||
};
|
||||
|
||||
class APNovels extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
novels: []
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
fetchNovels().then(novels => this.setState({ novels }));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { novels } = this.state;
|
||||
const { match: { path } } = this.props;
|
||||
|
||||
return (
|
||||
<Group title={'Новеллы'}>
|
||||
<Helmet>
|
||||
<title>Новеллы</title>
|
||||
</Helmet>
|
||||
|
||||
<Link to={`${path}/new`}>
|
||||
<Button>Добавить новеллу</Button>
|
||||
</Link>
|
||||
{novels.sort((a, b) => a.id - b.id).map(novel => (
|
||||
<Link to={`${path}/${novel.id}`} key={novel.id}>
|
||||
<div style={{ display: "flex" }}>
|
||||
<div style={{ marginRight: '.5rem', fontWeight: 600 }}>{novel.id}</div>
|
||||
<div style={{ marginRight: '2rem' }}>{novel.original_name}</div>
|
||||
<div>{novel.localized_name}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: StoreState) => ({
|
||||
user: state.currentUser
|
||||
})
|
||||
)(APNovels);
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
import React, { ChangeEvent, FormEvent } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
import './APUser.sass';
|
||||
|
||||
import { fetchUser, updateUser } from "../../../api/users";
|
||||
|
||||
import Loading from "../../../components/Loading";
|
||||
import NotificationMessage from "../../../ui/Notifications/NotificationMessage";
|
||||
import Button from "../../../ui/Button/Button";
|
||||
|
||||
import { addNotification } from "../../../store/ducks/notifications";
|
||||
|
||||
type Props = {
|
||||
match: { params: { id: number } }
|
||||
addNotification: (notification: React.ReactNode) => void
|
||||
};
|
||||
|
||||
type State = {
|
||||
user: Unmei.UserType | null
|
||||
username: string
|
||||
};
|
||||
|
||||
class APModifyUser extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
user: null, username: ''
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { match: { params: { id } } } = this.props;
|
||||
fetchUser(id).then((user: Unmei.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'}>
|
||||
<Helmet>
|
||||
<title>{user.username} / Админ-панель</title>
|
||||
</Helmet>
|
||||
|
||||
<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))
|
||||
}
|
||||
})
|
||||
)(APModifyUser);
|
||||
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
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { fetchUsers } from "../../../api/users";
|
||||
import { Link } from "react-router-dom";
|
||||
import Group from "../../../ui/Group/Group";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
type Props = {
|
||||
match: { path: string }
|
||||
};
|
||||
|
||||
type State = {
|
||||
users: Unmei.UserType[]
|
||||
};
|
||||
|
||||
class APUsers extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
users: []
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
fetchUsers().then(users => this.setState({ users }));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { users } = this.state;
|
||||
const { match: { path } } = this.props;
|
||||
|
||||
return (
|
||||
<Group title={'Пользователи'}>
|
||||
<Helmet>
|
||||
<title>Пользователи</title>
|
||||
</Helmet>
|
||||
|
||||
{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>
|
||||
))}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: StoreState) => ({
|
||||
user: state.currentUser
|
||||
})
|
||||
)(APUsers);
|
||||
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
|
||||
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
+75
@@ -0,0 +1,75 @@
|
||||
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 NotFoundError from "../../components/NotFound/NotFoundError";
|
||||
import { connect } from "react-redux";
|
||||
import { setPreloader } from "../../store/ducks/preloader";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
type Props = {
|
||||
match: { params: { charId: number } }
|
||||
setPreloader: SetPreloaderStatus
|
||||
}
|
||||
|
||||
type State = {
|
||||
char: Unmei.CharacterType | null
|
||||
novels: Unmei.NovelType[]
|
||||
errorCode: number
|
||||
};
|
||||
|
||||
class Character extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
char: null, novels: [], errorCode: 0
|
||||
};
|
||||
|
||||
componentDidMount = () => {
|
||||
const { setPreloader, match: { params: { charId } } } = this.props;
|
||||
|
||||
setPreloader(true);
|
||||
fetchChar(charId).then(async char => {
|
||||
document.title = char.localized_name ? `${char.original_name} / ${char.localized_name}` : char.original_name;
|
||||
|
||||
const novels = await fetchCharNovels(charId);
|
||||
this.setState({ novels, char });
|
||||
setPreloader(false);
|
||||
}).catch(r => this.setState({ errorCode: r.code }));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { char, novels, errorCode } = this.state;
|
||||
if(errorCode === 100) return <NotFoundError/>;
|
||||
if(!char) return null;
|
||||
return (
|
||||
<div className={'Character'}>
|
||||
<Helmet>
|
||||
<title>{char.localized_name && char.localized_name !== char.original_name ? `${char.original_name} / ${char.localized_name}` : char.original_name}</title>
|
||||
<meta name='description' content={char.description}/>
|
||||
</Helmet>
|
||||
|
||||
<div className="Character__Main">
|
||||
<img src={char.image} alt={char.original_name} 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 connect(
|
||||
null,
|
||||
dispatch => ({
|
||||
setPreloader: (s: boolean) => dispatch(setPreloader(s))
|
||||
})
|
||||
)(Character);
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import { getClub } from "../../api/clubs";
|
||||
import Loading from "../../components/Loading";
|
||||
import Group from "../../ui/Group/Group";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
type Props = {
|
||||
match: { params: { clubId: number } }
|
||||
};
|
||||
|
||||
type State = {
|
||||
club: Unmei.ClubType | null
|
||||
}
|
||||
|
||||
class Club extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
club: null
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { match } = this.props;
|
||||
|
||||
getClub(match.params.clubId).then(club => {
|
||||
this.setState({ club });
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { club } = this.state;
|
||||
if(!club) return <Loading/>;
|
||||
|
||||
return (
|
||||
<Group title={club.name}>
|
||||
<Helmet>
|
||||
<title>{club.name}</title>
|
||||
</Helmet>
|
||||
|
||||
<img src={club.avatar} width={96} height={96} style={{ borderRadius: '50%' }} alt=""/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Club;
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import { getClubs } from "../../api/clubs";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
type Props = {}
|
||||
type State = {
|
||||
clubs: Unmei.ClubType[]
|
||||
}
|
||||
|
||||
class Clubs extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
clubs: []
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
getClubs().then(clubs => {
|
||||
this.setState({ clubs });
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { clubs } = this.state;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{clubs.map(club => (
|
||||
<Link to={`/clubs/${club.id}`} key={club.id}>
|
||||
<div>
|
||||
<img src={club.avatar} width={96} height={96} style={{ borderRadius: '50%' }} alt=""/>
|
||||
<h2>{club.name}</h2>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Clubs;
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
@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%
|
||||
color: var(--main-color)
|
||||
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
+151
@@ -0,0 +1,151 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faBold,
|
||||
faExclamationTriangle,
|
||||
faItalic,
|
||||
faStrikethrough,
|
||||
faTint,
|
||||
faUnderline
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import './Comments.sass';
|
||||
import Loading from "../../components/Loading";
|
||||
import { fetchUser } from "../../api/users";
|
||||
import { connect } from "react-redux";
|
||||
import NotificationMessage from "../../ui/Notifications/NotificationMessage";
|
||||
import Button from "../../ui/Button/Button";
|
||||
import { addNotification } from "../../store/ducks/notifications";
|
||||
|
||||
const Comment = React.lazy(() => import('../../ui/Comment/Comment'));
|
||||
|
||||
type Props = {
|
||||
comments: Unmei.CommentType[]
|
||||
commentsLoaded: boolean
|
||||
hasMore: boolean
|
||||
user: Unmei.UserType
|
||||
loadMore: () => void
|
||||
sendComment: (text: string) => void
|
||||
addNotification: (notification: React.ReactNode) => void
|
||||
};
|
||||
|
||||
type State = {
|
||||
commentText: string
|
||||
users: Map<number, Unmei.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;
|
||||
|
||||
if(prevProps.comments.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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const { comments } = this.props;
|
||||
let { users } = this.state;
|
||||
|
||||
for(let comment of comments)
|
||||
if(!users.has(comment.user_id))
|
||||
users = users.set(comment.user_id, await fetchUser(comment.user_id));
|
||||
this.setState({ users });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { user, comments, commentsLoaded, 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>
|
||||
{hasMore && <Button onClick={loadMore} role={'button'}>Загрузить ещё</Button>}
|
||||
</div>
|
||||
</form>}
|
||||
{!commentsLoaded && <Loading/>}
|
||||
{comments.length > 0 ? (<Suspense fallback={<Loading/>}>
|
||||
{comments.map(comment => (
|
||||
<Comment text={comment.text} key={comment.id} user={users.get(comment.user_id)}/>
|
||||
))}
|
||||
</Suspense>) : (
|
||||
'Никто не оставил комментарий к этой новелле... :c'
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(null,
|
||||
dispatch => ({
|
||||
addNotification: (notification: React.ReactNode) => dispatch(addNotification(notification))
|
||||
})
|
||||
)(Comments);
|
||||
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
+24
@@ -0,0 +1,24 @@
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import './Main.sass';
|
||||
import { Helmet } from "react-helmet";
|
||||
import Loading from "../../ui/Loading";
|
||||
|
||||
const News = lazy(() => import('./News'));
|
||||
|
||||
class Main extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className={'Main'}>
|
||||
<Helmet>
|
||||
<title>Unmei</title>
|
||||
</Helmet>
|
||||
|
||||
<Suspense fallback={<Loading/>}>
|
||||
<News/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Main;
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import Loading from "../../components/Loading";
|
||||
import NewsPost from "../../ui/NewsPost/NewsPost";
|
||||
|
||||
import { fetchNews } from "../../api/news";
|
||||
|
||||
type State = {
|
||||
news: Unmei.PostType[]
|
||||
}
|
||||
|
||||
class News extends React.Component<{}, State> {
|
||||
state: State = {
|
||||
news: []
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
fetchNews().then(news => {
|
||||
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).sort((a, b) => b.id-a.id).map(post => (
|
||||
<Link key={post.id} to={`/news/${post.id}`}>
|
||||
<NewsPost {...post} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default News;
|
||||
@@ -0,0 +1,35 @@
|
||||
import React, { CSSProperties } from "react";
|
||||
import Modal from "../../ui/Modal/Modal";
|
||||
import Button from "../../ui/Button/Button";
|
||||
import { useStore } from "react-redux";
|
||||
import { hideModal } from "../../store/ducks/modal";
|
||||
|
||||
type Props = {
|
||||
onConfirm: () => void
|
||||
onDeny?: () => void
|
||||
}
|
||||
|
||||
const ConfirmModal = ({ onConfirm, onDeny }: Props) => {
|
||||
const buttonStyle: CSSProperties = {
|
||||
width: '50%', boxSizing: "border-box",
|
||||
margin: 6, fontSize: '1.2rem'
|
||||
};
|
||||
|
||||
const store = useStore<StoreState>();
|
||||
|
||||
return (
|
||||
<Modal title={'Подтвердите действие'}>
|
||||
<div style={{ display: "flex" }}>
|
||||
<Button onClick={onConfirm} style={buttonStyle}>Да</Button>
|
||||
<Button onClick={() => {
|
||||
if(onDeny)
|
||||
onDeny();
|
||||
store.dispatch(hideModal());
|
||||
|
||||
}} style={buttonStyle}>Нет</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmModal;
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
// @ts-ignore
|
||||
import Loader from 'react-loader-spinner';
|
||||
import Popout from "../../ui/Modal/Popout";
|
||||
|
||||
const LoadingModal = () => (
|
||||
<Popout>
|
||||
<Loader color='#aaa' type='Rings'/>
|
||||
</Popout>
|
||||
);
|
||||
|
||||
export default LoadingModal;
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
@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
+119
@@ -0,0 +1,119 @@
|
||||
import React, { ChangeEvent, FormEvent } from "react";
|
||||
import Modal from "../../ui/Modal/Modal";
|
||||
import { login } from "../../api/auth";
|
||||
import { connect } from "react-redux";
|
||||
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";
|
||||
import { setUser } from "../../store/ducks/currentUser";
|
||||
import { addNotification } from "../../store/ducks/notifications";
|
||||
import { hideModal, setModal } from "../../store/ducks/modal";
|
||||
import { fetchUserSettings } from "../../api/users";
|
||||
import { setSettings } from "../../store/ducks/userSettings";
|
||||
|
||||
type Props = {
|
||||
setUser: SetUser
|
||||
addNotification: AddNotification
|
||||
setModal: SetModal
|
||||
hideModal: HideModal
|
||||
setSettings: SetSettings
|
||||
};
|
||||
|
||||
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, setSettings } = this.props;
|
||||
const { login: log, password, recaptcha } = this.state;
|
||||
|
||||
login(log, password, recaptcha).then(user => {
|
||||
setUser(user);
|
||||
const successful = (
|
||||
<NotificationMessage level={"success"} position={"center"}>
|
||||
Ты успешно вошел!
|
||||
</NotificationMessage>
|
||||
);
|
||||
addNotification(successful);
|
||||
setModal(null);
|
||||
|
||||
fetchUserSettings().then(settings => {
|
||||
localStorage.setItem('theme', settings.theme);
|
||||
document.body.setAttribute('theme', settings.theme);
|
||||
setSettings(settings);
|
||||
});
|
||||
}).catch((err: Unmei.ApiError) => {
|
||||
this.setState({ error: errors[err.code] });
|
||||
if(err.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: Unmei.UserType) => dispatch(setUser(user)),
|
||||
setSettings: (settings: Unmei.UserSettingsType) => dispatch(setSettings(settings)),
|
||||
addNotification: (notification: React.ReactNode) => dispatch(addNotification(notification)),
|
||||
setModal: (modal: React.ReactNode | null) => dispatch(setModal(modal)),
|
||||
hideModal: () => dispatch(hideModal())
|
||||
})
|
||||
)(LoginModal);
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
@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
+127
@@ -0,0 +1,127 @@
|
||||
import React from "react";
|
||||
import Modal from "../../ui/Modal/Modal";
|
||||
import Recaptcha from '../../ui/Recaptcha/Recaptcha'
|
||||
import './RegisterModal.sass'
|
||||
import { registerUser } from "../../api/auth";
|
||||
import { connect } from "react-redux";
|
||||
import NotificationMessage from "../../ui/Notifications/NotificationMessage";
|
||||
import Button from "../../ui/Button/Button";
|
||||
import Input from "../../ui/Input/Input";
|
||||
import { addNotification } from "../../store/ducks/notifications";
|
||||
import { setModal } from "../../store/ducks/modal";
|
||||
|
||||
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> {
|
||||
state = {
|
||||
error: '', login: '', password1: '', password2: '', email: '', recaptcha: ''
|
||||
};
|
||||
private recaptcha: any;
|
||||
|
||||
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
+51
@@ -0,0 +1,51 @@
|
||||
import React, { Suspense } from "react";
|
||||
import Loading from "../../components/Loading";
|
||||
import { fetchNews } from "../../api/news";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet";
|
||||
const NewsPost = React.lazy(() => import('../../ui/NewsPost/NewsPost'));
|
||||
|
||||
type State = {
|
||||
news: Unmei.PostType[]
|
||||
}
|
||||
|
||||
class AllNews extends React.Component<{}, State> {
|
||||
state: State = {
|
||||
news: []
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
document.title = 'Новости';
|
||||
fetchNews().then(news => {
|
||||
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">
|
||||
<Helmet>
|
||||
<title>Новости</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="News__Title">
|
||||
Новости
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<Loading/>}>
|
||||
{news.map(post => (
|
||||
<Link key={post.id} to={`/news/${post.id}`}>
|
||||
<NewsPost {...post} />
|
||||
</Link>
|
||||
))}
|
||||
</Suspense>
|
||||
</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
+74
@@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
import { fetchPost } from "../../api/news";
|
||||
import Loading from "../../components/Loading";
|
||||
import NotFoundError from "../../components/NotFound/NotFoundError";
|
||||
import './Post.sass'
|
||||
import { Link } from "react-router-dom";
|
||||
// @ts-ignore
|
||||
import parser from 'bbcode-to-react';
|
||||
import { connect } from "react-redux";
|
||||
import { setPreloader } from "../../store/ducks/preloader";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
type Props = {
|
||||
match: { params: { postId: number } }
|
||||
setPreloader: SetPreloaderStatus
|
||||
};
|
||||
|
||||
type State = {
|
||||
post: Unmei.PostType | null
|
||||
code: number
|
||||
};
|
||||
|
||||
class Post extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
post: null, code: 0
|
||||
};
|
||||
|
||||
componentDidMount(): void {
|
||||
const { match: { params: { postId } }, setPreloader } = this.props;
|
||||
|
||||
setPreloader(true);
|
||||
fetchPost(postId).then(post => {
|
||||
post.date = new Date(post.date);
|
||||
this.setState({ post });
|
||||
setPreloader(false);
|
||||
}).catch((err: Unmei.ApiError) => {
|
||||
this.setState({ code: err.code })
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { post, code } = this.state;
|
||||
|
||||
if(code === 100) return <NotFoundError/>;
|
||||
if(!post) return <Loading/>;
|
||||
return (
|
||||
<div className={'Post'}>
|
||||
<Helmet>
|
||||
<title>{post.title}</title>
|
||||
<meta name='description' content={post.short_post}/>
|
||||
|
||||
<meta property="og:title" content={post.title} />
|
||||
<meta property="og:description" content={post.short_post} />
|
||||
</Helmet>
|
||||
|
||||
<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 connect(
|
||||
null,
|
||||
dispatch => ({
|
||||
setPreloader: (s: boolean) => dispatch(setPreloader(s))
|
||||
})
|
||||
)(Post);
|
||||
Executable
+173
@@ -0,0 +1,173 @@
|
||||
@import "../../Constants"
|
||||
|
||||
@keyframes growDown
|
||||
0%
|
||||
transform: scaleY(0)
|
||||
|
||||
80%
|
||||
transform: scaleY(1.1)
|
||||
|
||||
100%
|
||||
transform: scaleY(1)
|
||||
|
||||
.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
|
||||
max-width: 350px
|
||||
|
||||
&_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
|
||||
overflow: hidden
|
||||
animation: growDown 300ms ease-in-out forwards
|
||||
transform-origin: top center
|
||||
|
||||
.Novel__Main_Status_Element
|
||||
padding: .25rem
|
||||
border-top: var(--sec-bg-color) 2px solid
|
||||
|
||||
.Novel__Main_Status_Element
|
||||
cursor: pointer
|
||||
|
||||
&_Info
|
||||
width: 100%
|
||||
|
||||
&_Title
|
||||
@media (max-width: 600px)
|
||||
margin-top: 1rem
|
||||
|
||||
font-size: 1.7rem
|
||||
font-weight: 600
|
||||
margin-bottom: 4px
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
&_Demo
|
||||
margin: .2rem
|
||||
border: var(--accent-color) 1px solid
|
||||
padding: .1rem
|
||||
border-radius: 3px
|
||||
font-weight: 300
|
||||
|
||||
&_Description
|
||||
margin-top: 8px
|
||||
font-size: 1rem
|
||||
|
||||
&_Genres
|
||||
display: flex
|
||||
align-items: center
|
||||
font-size: 1rem
|
||||
|
||||
.Genre
|
||||
margin: 2px
|
||||
border: #992222 1px solid
|
||||
padding: .1rem
|
||||
border-radius: 3px
|
||||
font-weight: 300
|
||||
box-sizing: border-box
|
||||
|
||||
&_Rating
|
||||
|
||||
&_All
|
||||
width: 250px
|
||||
|
||||
@media (max-width: 600px)
|
||||
width: 100%
|
||||
|
||||
&_Mark
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
padding: 2px 4px
|
||||
margin: 4px 0
|
||||
box-sizing: border-box
|
||||
background-color: var(--block-color)
|
||||
border-radius: 2px
|
||||
|
||||
.Novel__Comments
|
||||
margin-top: 1rem
|
||||
padding: $primaryPadding
|
||||
background-color: var(--sec-bg-color)
|
||||
border-radius: $borderRadius
|
||||
|
||||
.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
|
||||
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: 150px
|
||||
height: 250px
|
||||
background-size: cover
|
||||
background-position: center
|
||||
margin-bottom: .2rem
|
||||
Executable
+318
@@ -0,0 +1,318 @@
|
||||
import React, { Suspense } from "react";
|
||||
import './Novel.sass'
|
||||
import NotFoundError from "../../components/NotFound/NotFoundError";
|
||||
import { connect } from "react-redux";
|
||||
import {
|
||||
createUserNovel,
|
||||
deleteUserNovel,
|
||||
fetchNovel,
|
||||
fetchNovelCharacters,
|
||||
fetchNovelComments,
|
||||
fetchUserNovel,
|
||||
postNovelComment,
|
||||
updateUserNovel
|
||||
} from "../../api/novels";
|
||||
import { TranslateExitStatus, TranslatePlatform, TranslateStatus } from "../../api/api";
|
||||
import { Link } from "react-router-dom";
|
||||
import Loading from "../../components/Loading";
|
||||
import { hideModal, setModal } from "../../store/ducks/modal";
|
||||
import { hasPermission } from "../../utils";
|
||||
import { setPreloader } from "../../store/ducks/preloader";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Title } from "../../ui/ui";
|
||||
const Comments = React.lazy(() => import('../Comments/Comments'));
|
||||
|
||||
type Props = {
|
||||
currentUser: Unmei.UserType
|
||||
setModal: SetModal
|
||||
hideModal: HideModal
|
||||
setPreloader: SetPreloaderStatus
|
||||
match: { params: { novelId: number } }
|
||||
};
|
||||
|
||||
type State = {
|
||||
novel: Unmei.NovelType | null
|
||||
characters: Unmei.CharacterType[]
|
||||
|
||||
errorCode: number | null
|
||||
userData: Unmei.UserNovelType | null
|
||||
statusExpanded: boolean
|
||||
|
||||
comments: Unmei.CommentType[]
|
||||
commentsLoaded: boolean
|
||||
commentsOffset: number
|
||||
hasMoreComments: boolean
|
||||
}
|
||||
|
||||
class Novel extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
novel: null, characters: [],
|
||||
errorCode: null, userData: null, statusExpanded: false,
|
||||
comments: [], commentsLoaded: false, commentsOffset: 5, hasMoreComments: true
|
||||
};
|
||||
|
||||
countNovelDuration = (t: number) => {
|
||||
const h = Math.round(t / 60);
|
||||
const m = t - (t * 60);
|
||||
|
||||
if(h === 0) return `${m} мин`;
|
||||
else return `${h} ч`;
|
||||
}
|
||||
|
||||
updateNovelMark = (mark: number) => {
|
||||
const { match: { params }, currentUser, setPreloader } = this.props;
|
||||
const { userData } = this.state;
|
||||
|
||||
if(!userData) return;
|
||||
if(userData.mark === mark) return;
|
||||
setPreloader(true);
|
||||
updateUserNovel(currentUser.id, params.novelId, { mark }).then(async userData => {
|
||||
const novel = await fetchNovel(params.novelId)
|
||||
novel.release_date = new Date(novel.release_date);
|
||||
this.setState({ novel, userData });
|
||||
setPreloader(false);
|
||||
});
|
||||
}
|
||||
|
||||
updateNovelStatus = (status: string) => {
|
||||
const { match: { params }, currentUser, setPreloader } = this.props;
|
||||
const { userData } = this.state;
|
||||
|
||||
setPreloader(true);
|
||||
if(userData) {
|
||||
updateUserNovel(currentUser.id, params.novelId, { status }).then(r => {
|
||||
this.setState({ userData: r, statusExpanded: false });
|
||||
setPreloader(false);
|
||||
}).catch(console.error);
|
||||
} else {
|
||||
createUserNovel(currentUser.id, params.novelId, status).then(r => {
|
||||
this.setState({ userData: r, statusExpanded: false });
|
||||
setPreloader(false);
|
||||
}).catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
deleteNovelStatus = () => {
|
||||
const { match: { params }, currentUser, setPreloader } = this.props;
|
||||
|
||||
setPreloader(true);
|
||||
deleteUserNovel(currentUser.id, params.novelId).then(() => {
|
||||
this.setState({ userData: null, statusExpanded: false });
|
||||
setPreloader(false);
|
||||
});
|
||||
};
|
||||
|
||||
sendComment = (text: string) => {
|
||||
const { match: { params: { novelId } } } = this.props;
|
||||
postNovelComment(novelId, text).then(comment => {
|
||||
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(userData => {
|
||||
this.setState({ userData });
|
||||
}).catch((err: Unmei.ApiError) => {
|
||||
if(err.code !== 100)
|
||||
console.error(err.text);
|
||||
});
|
||||
}
|
||||
else if(prevUser.authorized && !currentUser.authorized)
|
||||
this.setState({ userData: null })
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const { match: { params }, currentUser, setPreloader } = this.props;
|
||||
|
||||
setPreloader(true);
|
||||
fetchNovel(params.novelId).then(async novel => {
|
||||
novel.release_date = new Date(novel.release_date);
|
||||
const r = await fetchNovelComments(params.novelId);
|
||||
const hasMoreComments = r.count > 5;
|
||||
|
||||
const characters = await fetchNovelCharacters(params.novelId);
|
||||
this.setState({ novel, characters, comments: r.comments.reverse(), hasMoreComments, commentsLoaded: true });
|
||||
|
||||
setPreloader(false);
|
||||
}).catch((err: Unmei.ApiError) => {
|
||||
this.setState({ errorCode: err.code });
|
||||
setPreloader(false);
|
||||
});
|
||||
|
||||
if(currentUser.authorized) {
|
||||
fetchUserNovel(currentUser.id, params.novelId).then((userData: Unmei.UserNovelType) => {
|
||||
this.setState({ userData });
|
||||
}).catch((err: Unmei.ApiError) => {
|
||||
if(err.code !== 100) console.error(err.text);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { errorCode, novel, comments, commentsLoaded, characters, userData, statusExpanded, hasMoreComments } = this.state;
|
||||
const { currentUser } = this.props;
|
||||
|
||||
if(errorCode === 100) return <NotFoundError/>;
|
||||
if(!novel) return <Loading/>;
|
||||
const maxRating = Math.max(...novel.rating_all.map(r => r.count));
|
||||
const maxStatus = Math.max(...novel.status_all.map(r => r.count));
|
||||
return (
|
||||
<div className={'Novel'}>
|
||||
<Helmet>
|
||||
<title>{novel.localized_name ? `${novel.original_name} / ${novel.localized_name}` : novel.original_name}</title>
|
||||
<meta name='description' content={novel.description}/>
|
||||
|
||||
<meta property="og:title" content={novel.localized_name ? `${novel.original_name} / ${novel.localized_name}` : novel.original_name} />
|
||||
<meta property="og:description" content={novel.description} />
|
||||
<meta property="og:image" content={novel.image} />
|
||||
</Helmet>
|
||||
|
||||
<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>
|
||||
|
||||
{novel.walkthrough.length > 0 && <div className="Novel__Main_Status_Mark">
|
||||
<a className="Novel__Main_Status_Mark_Element" target='_blank' rel='noreferrer' href={novel.walkthrough}>Прохождение</a>
|
||||
</div>}
|
||||
|
||||
{novel.links.length > 0 && <div className="Novel__Main_Status_Mark">
|
||||
{novel.links.map(link => (
|
||||
<a className="Novel__Main_Status_Mark_Element" target='_blank' rel='noreferrer' href={link.link} key={link.name}>{link.name}</a>
|
||||
))}
|
||||
</div>}
|
||||
|
||||
{hasPermission(currentUser, 'novel') && (
|
||||
<div className="Novel__Main_Status_Mark">
|
||||
<Link className="Novel__Main_Status_Mark_Element" to={`/kawaii__neko/novels/${novel.id}`}>Изменить</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
<div className={'Novel__Main_Info'}>
|
||||
<div className={'Novel__Main_Info_Title'}>{novel.is_demo && <div className='Novel__Main_Info_Demo'>Demo</div>} {novel.localized_name || novel.original_name}</div>
|
||||
{novel.localized_name && <div className="Novel__Main_Info_OriginalName">
|
||||
<strong>Оригинальное название</strong>: {novel.original_name}
|
||||
</div>}
|
||||
{novel.genres.length > 0 &&
|
||||
<div className="Novel__Main_Info_Genres">
|
||||
<strong>Жанры</strong>: {novel.genres.map(genre => <div className={'Genre'} key={genre.id}>{genre.localized_name}</div>)}
|
||||
</div>}
|
||||
<div>
|
||||
<strong>Год выхода</strong>: {novel.release_date.toLocaleDateString()}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Статус</strong>: {TranslateExitStatus[novel.exit_status]}
|
||||
</div>
|
||||
{novel.duration > 0 && <div>
|
||||
<strong>Продолжительность</strong>: {this.countNovelDuration(novel.duration)}
|
||||
</div>}
|
||||
{novel.platforms && <div>
|
||||
<strong>Платформы</strong>: {novel.platforms.split(',').map(platform => TranslatePlatform[platform]).join(', ')}
|
||||
</div>}
|
||||
<div>
|
||||
<strong>Ср. оценка</strong>: {novel.rating.toFixed(2)}
|
||||
</div>
|
||||
<pre className={'Novel__Main_Info_Description'}>{novel.description}</pre>
|
||||
</div>
|
||||
<div className="Novel__Main_Rating">
|
||||
<div className="Novel__Main_Rating_All">
|
||||
{novel.rating_all.length > 0 && <Title>Оценки людей</Title>}
|
||||
{novel.rating_all.sort((a, b) => b.count-a.count).map(rating => (
|
||||
<div key={rating.mark} className={'Novel__Main_Rating_All_Mark'} style={{ width: `${rating.count / maxRating*100}%` }}>
|
||||
<div>{rating.mark}</div>
|
||||
<div>{rating.count}</div>
|
||||
</div>
|
||||
))}
|
||||
{novel.status_all.length > 0 && <Title>В списках</Title>}
|
||||
{novel.status_all.sort((a, b) => b.count-a.count).map(status => (
|
||||
<div key={status.status} className={'Novel__Main_Rating_All_Mark'} style={{ width: `${status.count / maxStatus*100}%`, backgroundColor: `var(--${status.status}-bg-color` }}>
|
||||
<div style={{ overflow: 'hidden' }}>{TranslateStatus[status.status]}</div>
|
||||
<div style={{ marginLeft: 4 }}>{status.count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</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 =>
|
||||
<Link to={`/character/${char.id}`} className={'Novel__Character'} key={char.id}>
|
||||
<div className='Character_Image' style={{ backgroundImage: `url(${char.image})` }}/>
|
||||
<div className={'Novel__Character_Name'}>{char.localized_name}</div>
|
||||
</Link>)}
|
||||
</div>
|
||||
</div>}
|
||||
<Suspense fallback={<Loading/>}>
|
||||
<Comments user={currentUser} comments={comments} commentsLoaded={commentsLoaded} sendComment={this.sendComment} loadMore={this.loadMoreComments} hasMore={hasMoreComments}/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: StoreState) => ({
|
||||
currentUser: state.currentUser
|
||||
}),
|
||||
dispatch => ({
|
||||
setModal: (modal: React.ReactNode | null) => dispatch(setModal(modal)),
|
||||
hideModal: () => dispatch(hideModal()),
|
||||
setPreloader: (status: boolean) => dispatch(setPreloader(status))
|
||||
})
|
||||
)(Novel);
|
||||
@@ -0,0 +1,72 @@
|
||||
@import "../../Constants"
|
||||
|
||||
.Novels
|
||||
background-color: var(--sec-bg-color)
|
||||
border-radius: $borderRadius
|
||||
padding: $primaryPadding
|
||||
display: flex
|
||||
|
||||
&__Search
|
||||
font-size: 1.5rem
|
||||
font-weight: 500
|
||||
|
||||
@media (max-width: 600px)
|
||||
flex-direction: column
|
||||
|
||||
.Novels__List
|
||||
margin-right: 8px
|
||||
width: 100%
|
||||
|
||||
&_Items
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
|
||||
.Novels__Search
|
||||
width: 100%
|
||||
height: 32px
|
||||
margin: 8px
|
||||
|
||||
@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
|
||||
box-sizing: border-box
|
||||
background-color: var(--block-color)
|
||||
border-radius: $borderRadius
|
||||
width: calc(100% / 4 - 1rem)
|
||||
height: fit-content
|
||||
|
||||
@media (max-width: 600px)
|
||||
align-self: center
|
||||
order: -1
|
||||
width: 100%
|
||||
margin: 8px
|
||||
|
||||
div
|
||||
margin: 0 0 .5rem 0
|
||||
display: flex
|
||||
flex-direction: column
|
||||
border-bottom: var(--sec-block-color) 2px solid
|
||||
|
||||
.Novels__Filters_Title
|
||||
margin-bottom: .1rem
|
||||
font-size: 1.2rem
|
||||
font-weight: bold
|
||||
@@ -0,0 +1,159 @@
|
||||
import './Novels.sass'
|
||||
import React, { Suspense } from "react";
|
||||
import { fetchNovels, searchNovel } from "../../api/novels";
|
||||
import { fetchGenres } from "../../api/genres";
|
||||
import Loading from "../../components/Loading";
|
||||
import { connect } from "react-redux";
|
||||
import { setPreloader } from "../../store/ducks/preloader";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Input } from "../../ui/ui";
|
||||
const NovelItem = React.lazy(() => import('../../ui/NovelItem/NovelItem'))
|
||||
|
||||
type Props = {
|
||||
setPreloader: SetPreloaderStatus
|
||||
}
|
||||
|
||||
type State = {
|
||||
novels: Unmei.NovelType[] | null
|
||||
lastSearch: number
|
||||
allGenres: Unmei.GenreType[]
|
||||
years: number[]
|
||||
genres: string[]
|
||||
}
|
||||
|
||||
class Novels extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
novels: null, lastSearch: 0,
|
||||
allGenres: [],
|
||||
genres: [], years: []
|
||||
};
|
||||
|
||||
componentDidMount = () => {
|
||||
const { setPreloader } = this.props;
|
||||
|
||||
setPreloader(true);
|
||||
fetchNovels('rating').then(async novels => {
|
||||
novels.forEach(novel => {
|
||||
novel.release_date = new Date(novel.release_date);
|
||||
});
|
||||
|
||||
const allGenres = await fetchGenres();
|
||||
|
||||
this.setState({ novels, allGenres });
|
||||
setPreloader(false);
|
||||
});
|
||||
};
|
||||
|
||||
switchFilter(filter: string, key: any) {
|
||||
// @ts-ignore
|
||||
let filters: string[] = this.state[filter];
|
||||
|
||||
filters = filters as string[];
|
||||
if(filters.includes(key)) {
|
||||
this.setState({ [filter]: [...filters.filter(x => x !== key)] } as Pick<State, any>)
|
||||
} else {
|
||||
this.setState({ [filter]: [...filters, key] } as Pick<State, any>)
|
||||
}
|
||||
};
|
||||
|
||||
searchNovels = (q: string) => {
|
||||
if(q.length === 0) {
|
||||
fetchNovels('rating').then(async novels => {
|
||||
novels.forEach(novel => {
|
||||
novel.release_date = new Date(novel.release_date);
|
||||
});
|
||||
|
||||
const allGenres = await fetchGenres();
|
||||
this.setState({ novels, allGenres });
|
||||
});
|
||||
} else {
|
||||
searchNovel(q).then(novels => {
|
||||
novels.forEach(novel => {
|
||||
novel.release_date = new Date(novel.release_date);
|
||||
});
|
||||
this.setState({ novels });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let { novels } = this.state;
|
||||
const { years, genres, allGenres } = this.state;
|
||||
if(!novels) return <Loading/>;
|
||||
|
||||
novels = novels
|
||||
.filter(novel => {
|
||||
if(years.length === 0) return novel;
|
||||
return years.includes(novel.release_date.getFullYear())
|
||||
})
|
||||
.filter(novel => {
|
||||
if(genres.length === 0) return novel;
|
||||
if(novel.genres.filter(genre => genres.includes(genre.name)).length > 0) return novel;
|
||||
return null;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={'Novels'}>
|
||||
<Helmet>
|
||||
<title>Новеллы</title>
|
||||
<meta name='description' content=''/>
|
||||
</Helmet>
|
||||
<div className="Novels__List">
|
||||
<Input placeholder={'Поиск...'} className={'Novels__Search'} onChange={e => this.searchNovels(e.target.value)}/>
|
||||
<div className="Novels__List_Items">
|
||||
<Suspense fallback={<Loading/>}>
|
||||
{novels.length === 0 && (
|
||||
<div style={{ textAlign: 'center', width: '100%' }}>По данным фильтрам ничего не найдено...</div>
|
||||
)}
|
||||
{novels.map(novel => <NovelItem {...novel} key={novel.id} viewType={'grid'}/>)}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
<div className="Novels__Filters">
|
||||
<div className="Novels__Filters_Genre">
|
||||
<div className="Novels__Filters_Title">Жанры</div>
|
||||
{allGenres.map(genre => (
|
||||
<label key={genre.id}>
|
||||
<input type="checkbox" onChange={() => this.switchFilter('genres', genre.name)}/>
|
||||
{genre.localized_name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="Novels__Filters_Year">
|
||||
<div className="Novels__Filters_Title">Год выпуска</div>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2021)}/>2021</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2020)}/>2020</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2019)}/>2019</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2018)}/>2018</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2017)}/>2017</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2016)}/>2016</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2015)}/>2015</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2014)}/>2014</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2013)}/>2013</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2012)}/>2012</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2011)}/>2011</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2010)}/>2010</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2009)}/>2009</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2008)}/>2008</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2007)}/>2007</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2006)}/>2006</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2005)}/>2005</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2004)}/>2004</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2003)}/>2003</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2002)}/>2002</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2001)}/>2001</label>
|
||||
<label><input type="checkbox" onClick={() => this.switchFilter('years', 2000)}/>2000</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
null,
|
||||
dispatch => ({
|
||||
setPreloader: (s: boolean) => dispatch(setPreloader(s))
|
||||
})
|
||||
)(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: Unmei.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;
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
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> {
|
||||
state: State = {
|
||||
email: '', recaptcha: ''
|
||||
}
|
||||
private recaptcha: any;
|
||||
|
||||
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
+46
@@ -0,0 +1,46 @@
|
||||
@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
|
||||
|
||||
&.Grid
|
||||
flex-direction: row
|
||||
flex-wrap: wrap
|
||||
|
||||
&.Table
|
||||
flex-direction: column
|
||||
width: 100%
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import './UserNovels.sass';
|
||||
import { fetchUser, fetchUserNovels } from '../../../api/users';
|
||||
import Loading from '../../../components/Loading';
|
||||
import { capitalize, generateClassName, getRandomInt } from "../../../utils";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faAlignJustify, faThLarge } from "@fortawesome/free-solid-svg-icons";
|
||||
import Button from "../../../ui/Button/Button";
|
||||
import { Helmet } from "react-helmet";
|
||||
const NovelItem = React.lazy(() => import('../../../ui/NovelItem/NovelItem'));
|
||||
|
||||
type Props = {
|
||||
match: { params: { userId: number } }
|
||||
history: { push: (path: string) => void }
|
||||
};
|
||||
type State = {
|
||||
novels: Unmei.NovelType[]
|
||||
user: Unmei.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');
|
||||
const in_progress = novels.filter(n => n.status === 'in_progress');
|
||||
|
||||
return (
|
||||
<div className='UserNovels'>
|
||||
<Helmet>
|
||||
<title>{user.username} / Новеллы</title>
|
||||
</Helmet>
|
||||
|
||||
<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')}>
|
||||
<Suspense fallback={<Loading/>}>
|
||||
{planned.map((novel, id) => <NovelItem {...novel} key={novel.id} novelId={id + 1} viewType={viewType}/>)}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{completed.length > 0 && <div className={'UserNovels__List'}>
|
||||
<div className="UserNovels__List_Title">
|
||||
Пройденные
|
||||
</div>
|
||||
<div className={generateClassName("UserNovels__List_Novels", capitalize(viewType) || 'Grid')}>
|
||||
<Suspense fallback={<Loading/>}>
|
||||
{completed.map((novel, id) => <NovelItem {...novel} key={novel.id} novelId={id + 1} viewType={viewType}/>)}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{in_progress.length > 0 && <div className={'UserNovels__List'}>
|
||||
<div className="UserNovels__List_Title">
|
||||
Прохожу
|
||||
</div>
|
||||
<div className={generateClassName("UserNovels__List_Novels", capitalize(viewType) || 'Grid')}>
|
||||
<Suspense fallback={<Loading/>}>
|
||||
{in_progress.map((novel, id) => <NovelItem {...novel} key={novel.id} novelId={id + 1} viewType={viewType}/>)}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default UserNovels;
|
||||
Executable
+171
@@ -0,0 +1,171 @@
|
||||
import Button from "../../../ui/Button/Button";
|
||||
import Group from "../../../ui/Group/Group";
|
||||
import React, { FormEvent } from "react";
|
||||
import { updateUserAppearanceSettings } from "../../../api/users";
|
||||
import { connect } from "react-redux";
|
||||
import { setUser } from "../../../store/ducks/currentUser";
|
||||
import { setSnowflakeCount, setSnowfallStatus } from "../../../store/ducks/snowfall";
|
||||
import Input from "../../../ui/Input/Input";
|
||||
import TextField from "../../../ui/TextField/TextField";
|
||||
|
||||
type Props = {
|
||||
setUser: SetUser
|
||||
settings: Unmei.UserSettingsType
|
||||
|
||||
snowfallSettings: Snowfall
|
||||
setSnowflakeCount: SetSnowflakeCount
|
||||
setSnowfallStatus: SetSnowflakeStatus
|
||||
}
|
||||
|
||||
type State = {
|
||||
theme: Unmei.Theme
|
||||
accentColor: string
|
||||
customTheme: string
|
||||
colorName: boolean
|
||||
snowflakeCount: 0
|
||||
}
|
||||
|
||||
class Appearance extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
theme: this.props.settings.theme, accentColor: localStorage.getItem('accentColor') ?? '#f22',
|
||||
customTheme: localStorage.getItem('customTheme') ?? '',
|
||||
colorName: JSON.parse(localStorage.getItem('color-name') || 'false'),
|
||||
snowflakeCount: JSON.parse(localStorage.getItem('snowflakeCount') ?? '250')
|
||||
}
|
||||
|
||||
saveTheme = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const { theme } = this.state;
|
||||
const { setUser } = this.props;
|
||||
|
||||
localStorage.setItem('theme', theme);
|
||||
document.body.setAttribute('theme', theme);
|
||||
|
||||
updateUserAppearanceSettings(theme).then(user => {
|
||||
setUser(user);
|
||||
});
|
||||
}
|
||||
|
||||
saveAccentColor = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const { accentColor } = this.state;
|
||||
|
||||
localStorage.setItem('accentColor', accentColor);
|
||||
document.body.style.setProperty('--accent-color', accentColor);
|
||||
}
|
||||
|
||||
saveCustomTheme = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const { customTheme } = this.state;
|
||||
|
||||
localStorage.setItem('customTheme', customTheme);
|
||||
customTheme.split('\n').forEach(line => {
|
||||
const [name, value] = line.split(':');
|
||||
document.body.style.setProperty(name, value);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { snowfallSettings, setSnowflakeCount, setSnowfallStatus } = this.props;
|
||||
const { theme, colorName, accentColor, customTheme } = this.state;
|
||||
|
||||
return (<>
|
||||
<Group title={'Тема'} className={'Settings_Group'} columns={3}>
|
||||
<form onSubmit={this.saveTheme}>
|
||||
<select onChange={e => this.setState({ theme: e.target.value as Unmei.Theme })} value={theme}>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="blue">Blue</option>
|
||||
<option value="red">Red</option>
|
||||
<option value="green">Green</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<Button>Сохранить</Button>
|
||||
</form>
|
||||
|
||||
<form onSubmit={this.saveAccentColor}>
|
||||
<label>
|
||||
Акцентый цвет:
|
||||
<Input
|
||||
type="text"
|
||||
value={accentColor}
|
||||
onChange={e => this.setState({ accentColor: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<Button>Сохранить</Button>
|
||||
</form>
|
||||
|
||||
<form onSubmit={this.saveCustomTheme}>
|
||||
<TextField
|
||||
placeholder={'Переменные темы'}
|
||||
style={{ height: '8rem', width: '100%' }}
|
||||
value={customTheme}
|
||||
onChange={e => this.setState({ customTheme: e.target.value })}
|
||||
/>
|
||||
<Button>Сохранить</Button>
|
||||
</form>
|
||||
</Group>
|
||||
<Group title={'Остальное'}>
|
||||
<label>
|
||||
Выделять ник цветом группы
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={e => {
|
||||
localStorage.setItem('color-name', JSON.stringify(e.target.checked));
|
||||
this.setState({ colorName: e.target.checked });
|
||||
}}
|
||||
checked={colorName}
|
||||
/>
|
||||
</label>
|
||||
</Group>
|
||||
<Group title={'Снег'}>
|
||||
<label>
|
||||
Включить снег
|
||||
<input
|
||||
type={'checkbox'}
|
||||
checked={snowfallSettings.snowfallStatus}
|
||||
onChange={e => {
|
||||
setSnowfallStatus(e.target.checked);
|
||||
localStorage.setItem('snowfallStatus', JSON.stringify(e.target.checked));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div>
|
||||
Количество снежинок {snowfallSettings.snowflakeCount}
|
||||
{snowfallSettings.snowflakeCount > 700 && (
|
||||
<span style={{ color: 'red' }}> (использование большого количества снежинок может негативно сказаться на производительности!)</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1000}
|
||||
value={snowfallSettings.snowflakeCount}
|
||||
onChange={e => {
|
||||
const value = JSON.parse(e.target.value);
|
||||
setSnowflakeCount(value);
|
||||
localStorage.setItem('snowflakeCount', value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</Group>
|
||||
</>);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: StoreState) => ({
|
||||
settings: state.userSettings,
|
||||
snowfallSettings: state.snowfall
|
||||
}),
|
||||
dispatch => ({
|
||||
setUser: (user: Unmei.UserType) => dispatch(setUser(user)),
|
||||
setSnowflakeCount: (count: number) => dispatch(setSnowflakeCount(count)),
|
||||
setSnowfallStatus: (status: boolean) => dispatch(setSnowfallStatus(status))
|
||||
})
|
||||
)(Appearance);
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
import React, { ChangeEvent, FormEvent } from "react";
|
||||
import Button from "../../../ui/Button/Button";
|
||||
import Group from "../../../ui/Group/Group";
|
||||
import { updateUserGeneralSettings, uploadAvatar } from "../../../api/users";
|
||||
import { connect } from "react-redux";
|
||||
import { hasPermission } from "../../../utils";
|
||||
|
||||
type Props = {
|
||||
user: Unmei.UserType
|
||||
setUser: SetUser
|
||||
settings: Unmei.UserSettingsType
|
||||
}
|
||||
|
||||
type State = {
|
||||
// Avatar
|
||||
useGravatar: boolean
|
||||
avatar: File | null
|
||||
|
||||
error: string
|
||||
}
|
||||
|
||||
class General extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
useGravatar: this.props.settings.use_gravatar, avatar: null,
|
||||
error: ''
|
||||
}
|
||||
|
||||
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, user } = this.props;
|
||||
|
||||
let allowedTypes = ['image/png', 'image/jpeg'];
|
||||
if(avatar) {
|
||||
if(hasPermission(user, 'gif')) allowedTypes.push('image/gif');
|
||||
if(!allowedTypes.includes(avatar.type)) {
|
||||
this.setState({ error: 'GIF аватары доступны только людям, с группой Supporter!' });
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', avatar);
|
||||
|
||||
uploadAvatar(formData).then(avatarUrl => {
|
||||
updateUserGeneralSettings(useGravatar, avatarUrl).then(setUser);
|
||||
})
|
||||
} else {
|
||||
updateUserGeneralSettings(useGravatar, '').then(setUser)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { useGravatar, error } = this.state;
|
||||
|
||||
return (
|
||||
<Group title={'Аватар'} className={'Settings_Group'}>
|
||||
{error.length > 0 && (
|
||||
<div>{error}</div>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: StoreState) => ({
|
||||
settings: state.userSettings,
|
||||
user: state.currentUser
|
||||
})
|
||||
)(General);
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
import React from "react";
|
||||
import Group from "../../../ui/Group/Group";
|
||||
import Input from "../../../ui/Input/Input";
|
||||
import Button from "../../../ui/Button/Button";
|
||||
import { changeEmail, changePassword } from "../../../api/auth";
|
||||
import { connect } from "react-redux";
|
||||
import { addNotification } from "../../../store/ducks/notifications";
|
||||
import NotificationMessage from "../../../ui/Notifications/NotificationMessage";
|
||||
import errors from "../../../api/errors";
|
||||
|
||||
type Props = {
|
||||
addNotification: AddNotification
|
||||
}
|
||||
|
||||
type State = {
|
||||
oldEmail: string
|
||||
email1: string
|
||||
email2: string
|
||||
emailError: string
|
||||
|
||||
oldPassword: string
|
||||
password1: string
|
||||
password2: string
|
||||
passwordError: string
|
||||
}
|
||||
|
||||
class Security extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
oldEmail: '', email1: '', email2: '',
|
||||
oldPassword: '', password1: '', password2: '',
|
||||
emailError: '', passwordError: ''
|
||||
}
|
||||
|
||||
changeEmail = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const { oldEmail, email1, email2 } = this.state;
|
||||
const { addNotification } = this.props;
|
||||
|
||||
let error = '';
|
||||
if(!oldEmail) error += 'Введите старый E-mail\n';
|
||||
if(!email1) error += 'Введите новый E-mail\n';
|
||||
if(!email2) error += 'Введите повтор нового E-mail\n';
|
||||
if(email1 !== email2) error += 'E-mail не совпадают\n';
|
||||
if(oldEmail === email1) error += 'Старый и новый E-mail не должны совпадать';
|
||||
|
||||
if(error) {
|
||||
this.setState({ emailError: error });
|
||||
} else {
|
||||
changeEmail(oldEmail, email1).then(() => {
|
||||
addNotification(
|
||||
<NotificationMessage>
|
||||
E-mail успешно сменен!
|
||||
</NotificationMessage>
|
||||
);
|
||||
this.setState({
|
||||
oldEmail: '', email1: '', email2: ''
|
||||
});
|
||||
}).catch((err: Unmei.ApiError) => {
|
||||
this.setState({ emailError: errors[err.code] })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
changePassword = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { oldPassword, password1, password2 } = this.state;
|
||||
const { addNotification } = this.props;
|
||||
|
||||
let error = '';
|
||||
if(!oldPassword) error += 'Введите старый пароль\n';
|
||||
if(!password1) error += 'Введите новый пароль\n';
|
||||
if(!password2) error += 'Введите повтор нового пароля\n';
|
||||
if(password1 !== password2) error += 'Пароли не совпадают\n';
|
||||
if(oldPassword === password1) error += 'Старый и новый пароль не должны совпадать';
|
||||
|
||||
if(error) {
|
||||
this.setState({ passwordError: error });
|
||||
} else {
|
||||
changePassword(oldPassword, password1).then(() => {
|
||||
addNotification(
|
||||
<NotificationMessage>
|
||||
Пароль успешно сменен!
|
||||
</NotificationMessage>
|
||||
);
|
||||
this.setState({
|
||||
oldPassword: '', password1: '', password2: ''
|
||||
});
|
||||
}).catch((err: Unmei.ApiError) => {
|
||||
this.setState({ passwordError: errors[err.code] })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
oldEmail, email1, email2,
|
||||
oldPassword, password1, password2,
|
||||
emailError, passwordError
|
||||
} = this.state;
|
||||
|
||||
return (<>
|
||||
<Group title={'Смена E-mail'} className={'Settings_Group'}>
|
||||
{emailError && <pre className={'Error'}>{emailError}</pre>}
|
||||
<form onSubmit={this.changeEmail}>
|
||||
<Input
|
||||
placeholder={'Старый E-mail'}
|
||||
type={'email'}
|
||||
value={oldEmail}
|
||||
onChange={e => this.setState({ oldEmail: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
placeholder={'Новый E-mail'}
|
||||
type={'email'}
|
||||
value={email1}
|
||||
onChange={e => this.setState({ email1: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
placeholder={'Новый E-mail ещё раз'}
|
||||
type={'email'}
|
||||
value={email2}
|
||||
onChange={e => this.setState({ email2: e.target.value })}
|
||||
/>
|
||||
<Button>Сохранить</Button>
|
||||
</form>
|
||||
</Group>
|
||||
|
||||
<Group title={'Смена пароля'} className={'Settings_Group'}>
|
||||
{passwordError && <pre className={'Error'}>{passwordError}</pre>}
|
||||
<form onSubmit={this.changePassword}>
|
||||
<Input
|
||||
placeholder={'Старый пароль'}
|
||||
type={'password'}
|
||||
value={oldPassword}
|
||||
onChange={e => this.setState({ oldPassword: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
placeholder={'Новый пароль'}
|
||||
type={'password'}
|
||||
value={password1}
|
||||
onChange={e => this.setState({ password1: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
placeholder={'Новый пароль ещё раз'}
|
||||
type={'password'}
|
||||
value={password2}
|
||||
onChange={e => this.setState({ password2: e.target.value })}
|
||||
/>
|
||||
<Button>Сохранить</Button>
|
||||
</form>
|
||||
</Group>
|
||||
</>);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
null,
|
||||
dispatch => ({
|
||||
addNotification: (notification: React.ReactNode) => dispatch(addNotification(notification))
|
||||
})
|
||||
)(Security);
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
.Group
|
||||
.Settings_Group
|
||||
font-size: 1.1rem
|
||||
|
||||
form
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: flex-start
|
||||
|
||||
input
|
||||
margin: .5rem 0
|
||||
|
||||
input, .Button
|
||||
font-size: 1.1rem
|
||||
|
||||
.Button
|
||||
margin: .5rem 0
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
import React from "react";
|
||||
import Group from "../../../ui/Group/Group";
|
||||
import { connect } from "react-redux";
|
||||
import NotFoundError from "../../../components/NotFound/NotFoundError";
|
||||
import './Settings.sass';
|
||||
import Tabs from "../../../ui/Tabs/Tabs";
|
||||
import TabItem from "../../../ui/Tabs/TabItem";
|
||||
import Appearance from "./Appearance";
|
||||
import General from "./General";
|
||||
import Security from "./Security";
|
||||
import { setUser } from "../../../store/ducks/currentUser";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
enum TabsNames {
|
||||
General,
|
||||
Security,
|
||||
Appearance
|
||||
}
|
||||
|
||||
type Props = {
|
||||
match: { params: { userId: string } }
|
||||
currentUser: Unmei.UserType
|
||||
setUser: (user: Unmei.UserType) => void
|
||||
}
|
||||
|
||||
type State = {
|
||||
currentTab: TabsNames
|
||||
}
|
||||
|
||||
class Settings extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
currentTab: TabsNames.General
|
||||
}
|
||||
|
||||
render() {
|
||||
const { currentUser, match: { params: { userId } }, setUser } = this.props;
|
||||
const { currentTab } = this.state
|
||||
|
||||
if(parseInt(userId) !== currentUser.id) return <NotFoundError/>;
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Helmet>
|
||||
<title>Настройки</title>
|
||||
</Helmet>
|
||||
|
||||
<Tabs>
|
||||
<TabItem
|
||||
selected={currentTab === TabsNames.General}
|
||||
onClick={() => this.setState({ currentTab: TabsNames.General })}
|
||||
>
|
||||
Главное
|
||||
</TabItem>
|
||||
<TabItem
|
||||
selected={currentTab === TabsNames.Security}
|
||||
onClick={() => this.setState({ currentTab: TabsNames.Security })}
|
||||
>
|
||||
Безопастность
|
||||
</TabItem>
|
||||
<TabItem
|
||||
selected={currentTab === TabsNames.Appearance}
|
||||
onClick={() => this.setState({ currentTab: TabsNames.Appearance })}
|
||||
>
|
||||
Внешний вид
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
{currentTab === TabsNames.General && (
|
||||
<General setUser={setUser}/>
|
||||
)}
|
||||
{currentTab === TabsNames.Security && (
|
||||
<Security/>
|
||||
)}
|
||||
{currentTab === TabsNames.Appearance && (
|
||||
<Appearance/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: StoreState) => ({
|
||||
currentUser: state.currentUser
|
||||
}),
|
||||
dispatch => ({
|
||||
setUser: (user: Unmei.UserType) => dispatch(setUser(user))
|
||||
})
|
||||
)(Settings);
|
||||
Executable
+158
@@ -0,0 +1,158 @@
|
||||
@import "../../components/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: 320px
|
||||
width: 100%
|
||||
background-position: center
|
||||
background-size: cover
|
||||
background-repeat: no-repeat
|
||||
|
||||
@media (max-width: 500px)
|
||||
height: 160px !important
|
||||
|
||||
&_Main
|
||||
display: flex
|
||||
align-items: flex-end
|
||||
background-color: var(--sec-bg-color)
|
||||
width: 100%
|
||||
padding: 0.5rem
|
||||
border-radius: $borderRadius
|
||||
box-sizing: border-box
|
||||
|
||||
&_Nick
|
||||
font-size: 1.5rem
|
||||
font-weight: 600
|
||||
|
||||
&_Group
|
||||
border-color: #eeeeee
|
||||
border-width: 1px
|
||||
border-style: solid
|
||||
color: var(--main-color)
|
||||
|
||||
border-radius: 2px
|
||||
padding: .2rem
|
||||
text-align: center
|
||||
font-size: 1.1rem
|
||||
font-weight: 300
|
||||
position: relative
|
||||
z-index: 1
|
||||
|
||||
&::before
|
||||
content: ''
|
||||
width: 100%
|
||||
border-top-color: inherit
|
||||
border-top-width: 27px
|
||||
border-top-style: solid
|
||||
opacity: 0.5
|
||||
position: absolute
|
||||
z-index: -1
|
||||
top: 0
|
||||
left: 0
|
||||
|
||||
&_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: var(--planned-bg-color)
|
||||
|
||||
.Completed
|
||||
background: var(--completed-bg-color)
|
||||
|
||||
.Dropped
|
||||
background: var(--dropped-bg-color)
|
||||
|
||||
.InProgress
|
||||
background: var(--in_progress-bg-color)
|
||||
|
||||
.Deferred
|
||||
background: var(--deferred-bg-color)
|
||||
|
||||
.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
+197
@@ -0,0 +1,197 @@
|
||||
import React, { CSSProperties } from "react";
|
||||
import { fetchUser, fetchUserNovels, generateActivateLink } from "../../api/users";
|
||||
import './User.sass'
|
||||
import NotFoundError from "../../components/NotFound/NotFoundError";
|
||||
import { Link } from "react-router-dom";
|
||||
import Loading from "../../components/Loading";
|
||||
import NovelItem from "../../ui/NovelItem/NovelItem";
|
||||
import { connect } from "react-redux";
|
||||
import Button from "../../ui/Button/Button";
|
||||
import NotificationMessage from "../../ui/Notifications/NotificationMessage";
|
||||
import { addNotification } from "../../store/ducks/notifications";
|
||||
import { setPreloader } from "../../store/ducks/preloader";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
type Props = {
|
||||
match: { params: { userId: string } }
|
||||
currentUser: Unmei.UserType
|
||||
addNotification: AddNotification
|
||||
setPreloader: SetPreloaderStatus
|
||||
}
|
||||
|
||||
type State = {
|
||||
user: Unmei.UserType | null
|
||||
novels: Unmei.NovelType[]
|
||||
errorCode: number | null
|
||||
}
|
||||
|
||||
class User extends React.Component<Props, State> {
|
||||
state: State = {
|
||||
user: null, novels: [], errorCode: null
|
||||
};
|
||||
|
||||
getAndSetUser = (id: number) => {
|
||||
const { setPreloader } = this.props;
|
||||
|
||||
setPreloader(true);
|
||||
|
||||
fetchUser(id).then(user => {
|
||||
this.setState({ user });
|
||||
document.title = `${user.username} / Unmei`;
|
||||
|
||||
fetchUserNovels(id).then(novels => {
|
||||
this.setState({ novels });
|
||||
}).then(() => {
|
||||
setPreloader(false);
|
||||
}).catch((err: Unmei.ApiError) => console.error(err));
|
||||
}).catch((err: Unmei.ApiError) => this.setState({ errorCode: err.code }));
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<State>, _?: any) {
|
||||
const newUserId = parseInt(this.props.match.params.userId);
|
||||
const prevUserId = parseInt(prevProps.match.params.userId);
|
||||
|
||||
if(newUserId !== prevUserId) {
|
||||
this.getAndSetUser(newUserId);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { match: { params } } = this.props;
|
||||
|
||||
this.getAndSetUser(parseInt(params.userId));
|
||||
}
|
||||
|
||||
sendActivateLink = () => {
|
||||
generateActivateLink().then(() => {
|
||||
const notification = (
|
||||
<NotificationMessage level={"success"}>
|
||||
Сообщение успешно отправлено!
|
||||
</NotificationMessage>
|
||||
);
|
||||
this.props.addNotification(notification);
|
||||
}).catch((err: Unmei.ApiError) => {
|
||||
const notification = (
|
||||
<NotificationMessage level={"error"}>
|
||||
Произошла ошибка! №{err.code} <br/>
|
||||
{err.text}
|
||||
</NotificationMessage>
|
||||
);
|
||||
this.props.addNotification(notification);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { currentUser } = this.props;
|
||||
const { errorCode, user, novels } = this.state;
|
||||
if(errorCode === 100) return <NotFoundError/>;
|
||||
if(!user) return <Loading/>;
|
||||
|
||||
const style: CSSProperties = !user.is_activated ? { marginTop: '1rem' } : {};
|
||||
const userNameStyle: CSSProperties = JSON.parse(localStorage.getItem('color-name') || 'false') && user.group.id !== 0 ? { color: user.group.color } : {};
|
||||
|
||||
return (
|
||||
<div className={'User'}>
|
||||
<Helmet>
|
||||
<title>{user.username}</title>
|
||||
<meta name='description' content={`Профиль пользователя ${user.username}`}/>
|
||||
</Helmet>
|
||||
|
||||
{!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 User__Info_Main"
|
||||
style={{ backgroundImage: `url(${user.cover})` }}>
|
||||
<div className="User__Info_Avatar"
|
||||
style={{ backgroundImage: `url(${user.avatar}?s=96&t=${new Date().getTime()})` }}/>
|
||||
<div>
|
||||
<div className='User__Info_Nick' style={userNameStyle}>{user.username}</div>
|
||||
<div className='User__Info_Group' style={{
|
||||
borderColor: user.group.color
|
||||
}}>
|
||||
{user.group.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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' style={userNameStyle}>{user.username}</div>
|
||||
<div className='User__Info_Group' style={{
|
||||
borderColor: 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: StoreState) => ({
|
||||
currentUser: state.currentUser
|
||||
}),
|
||||
dispatch => ({
|
||||
addNotification: (notification: React.ReactNode) => dispatch(addNotification(notification)),
|
||||
setPreloader: (status: boolean) => dispatch(setPreloader(status))
|
||||
})
|
||||
)(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
+45
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { fetchUsers } from "../../api/users";
|
||||
import Loading from "../../components/Loading";
|
||||
import './Users.sass';
|
||||
import { Link } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
type Props = {}
|
||||
|
||||
type State = {
|
||||
users: Unmei.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'}>
|
||||
<Helmet>
|
||||
<title>Пользователи</title>
|
||||
</Helmet>
|
||||
|
||||
{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;
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import Title from "../../ui/Title/Title";
|
||||
import { registerUserVk } from "../../api/auth";
|
||||
|
||||
class VKAuth extends React.Component {
|
||||
componentDidMount() {
|
||||
console.log()
|
||||
registerUserVk(window.location.href.split('?')[1])
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Title>{window.location.href}</Title>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default VKAuth;
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
@@ -0,0 +1,80 @@
|
||||
/// <reference lib="webworker" />
|
||||
/* eslint-disable no-restricted-globals */
|
||||
|
||||
// This service worker can be customized!
|
||||
// See https://developers.google.com/web/tools/workbox/modules
|
||||
// for the list of available Workbox modules, or add any other
|
||||
// code you'd like.
|
||||
// You can also remove this file if you'd prefer not to use a
|
||||
// service worker, and the Workbox build step will be skipped.
|
||||
|
||||
import { clientsClaim } from 'workbox-core';
|
||||
import { ExpirationPlugin } from 'workbox-expiration';
|
||||
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
|
||||
import { registerRoute } from 'workbox-routing';
|
||||
import { StaleWhileRevalidate } from 'workbox-strategies';
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
clientsClaim();
|
||||
|
||||
// Precache all of the assets generated by your build process.
|
||||
// Their URLs are injected into the manifest variable below.
|
||||
// This variable must be present somewhere in your service worker file,
|
||||
// even if you decide not to use precaching. See https://cra.link/PWA
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
|
||||
// Set up App Shell-style routing, so that all navigation requests
|
||||
// are fulfilled with your index.html shell. Learn more at
|
||||
// https://developers.google.com/web/fundamentals/architecture/app-shell
|
||||
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
|
||||
registerRoute(
|
||||
// Return false to exempt requests from being fulfilled by index.html.
|
||||
({ request, url }: { request: Request; url: URL }) => {
|
||||
// If this isn't a navigation, skip.
|
||||
if (request.mode !== 'navigate') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If this is a URL that starts with /_, skip.
|
||||
if (url.pathname.startsWith('/_')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If this looks like a URL for a resource, because it contains
|
||||
// a file extension, skip.
|
||||
if (url.pathname.match(fileExtensionRegexp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Return true to signal that we want to use the handler.
|
||||
return true;
|
||||
},
|
||||
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
|
||||
);
|
||||
|
||||
// An example runtime caching route for requests that aren't handled by the
|
||||
// precache, in this case same-origin .png requests like those from in public/
|
||||
registerRoute(
|
||||
// Add in any other file extensions or routing criteria as needed.
|
||||
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'),
|
||||
// Customize this strategy as needed, e.g., by changing to CacheFirst.
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'images',
|
||||
plugins: [
|
||||
// Ensure that once this runtime cache reaches a maximum size the
|
||||
// least-recently used images are removed.
|
||||
new ExpirationPlugin({ maxEntries: 50 }),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// This allows the web app to trigger skipWaiting via
|
||||
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
// Any other custom service worker logic can go here.
|
||||
@@ -0,0 +1,142 @@
|
||||
// This optional code is used to register a service worker.
|
||||
// register() is not called by default.
|
||||
|
||||
// This lets the app load faster on subsequent visits in production, and gives
|
||||
// it offline capabilities. However, it also means that developers (and users)
|
||||
// will only see deployed updates on subsequent visits to a page, after all the
|
||||
// existing tabs open on the page have been closed, since previously cached
|
||||
// resources are updated in the background.
|
||||
|
||||
// To learn more about the benefits of this model and instructions on how to
|
||||
// opt-in, read https://cra.link/PWA
|
||||
|
||||
const isLocalhost = Boolean(
|
||||
window.location.hostname === 'localhost' ||
|
||||
// [::1] is the IPv6 localhost address.
|
||||
window.location.hostname === '[::1]' ||
|
||||
// 127.0.0.0/8 are considered localhost for IPv4.
|
||||
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
|
||||
);
|
||||
|
||||
type Config = {
|
||||
onSuccess?: (registration: ServiceWorkerRegistration) => void;
|
||||
onUpdate?: (registration: ServiceWorkerRegistration) => void;
|
||||
};
|
||||
|
||||
export function register(config?: Config) {
|
||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||
// The URL constructor is available in all browsers that support SW.
|
||||
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
|
||||
if (publicUrl.origin !== window.location.origin) {
|
||||
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||
// from what our page is served on. This might happen if a CDN is used to
|
||||
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||
|
||||
if (isLocalhost) {
|
||||
// This is running on localhost. Let's check if a service worker still exists or not.
|
||||
checkValidServiceWorker(swUrl, config);
|
||||
|
||||
// Add some additional logging to localhost, pointing developers to the
|
||||
// service worker/PWA documentation.
|
||||
navigator.serviceWorker.ready.then(() => {
|
||||
console.log(
|
||||
'This web app is being served cache-first by a service ' +
|
||||
'worker. To learn more, visit https://cra.link/PWA'
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Is not localhost. Just register service worker
|
||||
registerValidSW(swUrl, config);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function registerValidSW(swUrl: string, config?: Config) {
|
||||
navigator.serviceWorker
|
||||
.register(swUrl)
|
||||
.then((registration) => {
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing;
|
||||
if (installingWorker == null) {
|
||||
return;
|
||||
}
|
||||
installingWorker.onstatechange = () => {
|
||||
if (installingWorker.state === 'installed') {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
// At this point, the updated precached content has been fetched,
|
||||
// but the previous service worker will still serve the older
|
||||
// content until all client tabs are closed.
|
||||
console.log(
|
||||
'New content is available and will be used when all ' +
|
||||
'tabs for this page are closed. See https://cra.link/PWA.'
|
||||
);
|
||||
|
||||
// Execute callback
|
||||
if (config && config.onUpdate) {
|
||||
config.onUpdate(registration);
|
||||
}
|
||||
} else {
|
||||
// At this point, everything has been precached.
|
||||
// It's the perfect time to display a
|
||||
// "Content is cached for offline use." message.
|
||||
console.log('Content is cached for offline use.');
|
||||
|
||||
// Execute callback
|
||||
if (config && config.onSuccess) {
|
||||
config.onSuccess(registration);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error during service worker registration:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function checkValidServiceWorker(swUrl: string, config?: Config) {
|
||||
// Check if the service worker can be found. If it can't reload the page.
|
||||
fetch(swUrl, {
|
||||
headers: { 'Service-Worker': 'script' },
|
||||
})
|
||||
.then((response) => {
|
||||
// Ensure service worker exists, and that we really are getting a JS file.
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (
|
||||
response.status === 404 ||
|
||||
(contentType != null && contentType.indexOf('javascript') === -1)
|
||||
) {
|
||||
// No service worker found. Probably a different app. Reload the page.
|
||||
navigator.serviceWorker.ready.then((registration) => {
|
||||
registration.unregister().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Service worker found. Proceed as normal.
|
||||
registerValidSW(swUrl, config);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.log('No internet connection found. App is running in offline mode.');
|
||||
});
|
||||
}
|
||||
|
||||
export function unregister() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready
|
||||
.then((registration) => {
|
||||
registration.unregister();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
const SET_USER = 'unmei/currentUser/SET_USER';
|
||||
const LOGOUT = 'unmei/currentUser/LOGOUT';
|
||||
|
||||
const initialState = {
|
||||
authorized: false
|
||||
};
|
||||
type Action = {
|
||||
type: string
|
||||
userData: Unmei.UserType
|
||||
};
|
||||
const reducer = (state = initialState, action: Action) => {
|
||||
switch(action.type) {
|
||||
case SET_USER:
|
||||
if(action.userData) {
|
||||
state = Object.assign({}, state, action.userData);
|
||||
state.authorized = true;
|
||||
}
|
||||
return state;
|
||||
case LOGOUT:
|
||||
state = {
|
||||
authorized: false
|
||||
};
|
||||
return state;
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
const setUser = (userData: Unmei.UserType) => ({
|
||||
type: SET_USER,
|
||||
userData
|
||||
});
|
||||
const logout = () => ({
|
||||
type: LOGOUT
|
||||
});
|
||||
|
||||
export default reducer;
|
||||
export { setUser, logout }
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
import { combineReducers } from "redux";
|
||||
import currentUser from './currentUser'
|
||||
import modal from "./modal";
|
||||
import notifications from './notifications'
|
||||
import userSettings from './userSettings';
|
||||
import snowfall from './snowfall';
|
||||
import preloader from './preloader';
|
||||
|
||||
export default combineReducers({
|
||||
currentUser, modal, notifications, userSettings, snowfall, preloader
|
||||
});
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
|
||||
// Actions
|
||||
const SET_MODAL = 'unmei/modal/SET_MODAL';
|
||||
const HIDE_MODAL = 'unmei/modal/HIDE_MODAL';
|
||||
|
||||
// Reducer
|
||||
const initialState: React.ReactNode | null = null;
|
||||
type Action = {
|
||||
type: string
|
||||
modal: React.ReactNode | null
|
||||
}
|
||||
const reducer = (state = initialState, action: Action) => {
|
||||
switch(action.type) {
|
||||
case SET_MODAL: {
|
||||
return action.modal;
|
||||
}
|
||||
case HIDE_MODAL: {
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// Action creators
|
||||
const setModal = (modal: React.ReactNode | null) => ({
|
||||
type: SET_MODAL,
|
||||
modal
|
||||
});
|
||||
|
||||
const hideModal = () => ({
|
||||
type: HIDE_MODAL
|
||||
});
|
||||
|
||||
export default reducer;
|
||||
export { setModal, hideModal }
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
|
||||
// Actions
|
||||
const ADD_NOTIFICATION = 'unmei/notifications/ADD_NOTIFICATION';
|
||||
|
||||
// Reducer
|
||||
const initialState: React.ReactNode[] = [];
|
||||
type Action = {
|
||||
type: string
|
||||
notification: React.ReactNode
|
||||
};
|
||||
const reducer = (state = initialState, action: Action) => {
|
||||
switch(action.type) {
|
||||
case ADD_NOTIFICATION: {
|
||||
if(state.length === 3)
|
||||
return [...state.slice(1), action.notification];
|
||||
return [...state, action.notification];
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// Actions creator
|
||||
const addNotification = (notification: React.ReactNode) => ({
|
||||
type: ADD_NOTIFICATION,
|
||||
notification
|
||||
});
|
||||
|
||||
export default reducer;
|
||||
export { addNotification };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user