commit 3dc496b7a40bc7c391f3a4b8ce025f08b9cd38ec Author: Nix13 Date: Sun May 2 16:00:16 2021 +0300 initial commit on github diff --git a/.env b/.env new file mode 100644 index 0000000..7d6ef28 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +GENERATE_SOURCEMAP=false +CDN_URL=https://cdn.unmei.space/ diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..987c3b5 --- /dev/null +++ b/.env.local @@ -0,0 +1 @@ +REACT_EDITOR=goland \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe92b2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +build/ +.idea/ +.DS_Store \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..78278a7 --- /dev/null +++ b/package.json @@ -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" + ] + } +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..ec7f125 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..9bdcfd6 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..22bfa80 --- /dev/null +++ b/public/index.html @@ -0,0 +1,40 @@ + + + + Unmei + + + + + + + + + + + + + + + + + + + + + + + +
+ + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..009819f --- /dev/null +++ b/public/manifest.json @@ -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" +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..3be1b6a --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Disallow: /kawaii__neko + +Sitemap: sitemap.xml \ No newline at end of file diff --git a/public/static/img/no-image.png b/public/static/img/no-image.png new file mode 100644 index 0000000..a900b9a Binary files /dev/null and b/public/static/img/no-image.png differ diff --git a/public/static/img/not-found.png b/public/static/img/not-found.png new file mode 100644 index 0000000..13e3bbd Binary files /dev/null and b/public/static/img/not-found.png differ diff --git a/src/Constants.sass b/src/Constants.sass new file mode 100755 index 0000000..a579247 --- /dev/null +++ b/src/Constants.sass @@ -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) diff --git a/src/api/api.ts b/src/api/api.ts new file mode 100755 index 0000000..b0c2b84 --- /dev/null +++ b/src/api/api.ts @@ -0,0 +1,78 @@ +const baseUrl = process.env.NODE_ENV === "development" ? 'http://localhost:8080/v1' : 'https://api.unmei.space/v1'; + +async function request(url: string, method: string, body?: string | FormData): Promise { + 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; + 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 = (url: string, data?: object): Promise => request(url, 'GET', JSON.stringify(data)); +const post = (url: string, data?: object | FormData): Promise => request(url, 'POST', data instanceof FormData ? data : JSON.stringify(data)); +const put = (url: string, data?: object): Promise => request(url, 'PUT', JSON.stringify(data)); +const del = (url: string, data?: object): Promise => request(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('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 }; diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100755 index 0000000..77e0276 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,24 @@ +import { post } from "./api"; + +export const restore = (token: string, new_password: string) => post('auth/restore', { token, new_password }); +export const registerUser = (login: string, password: string, email: string, recaptcha: string) => post('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('auth/login', { + login, + password, + recaptcha +}); +export const userLogout = () => post('auth/logout'); +export const changeEmail = (old_email: string, new_email: string) => post('auth/changeEmail', { + old_email, + new_email +}); +export const changePassword = (old_password: string, new_password: string) => post('auth/changePassword', { + old_password, + new_password +}); diff --git a/src/api/characters.ts b/src/api/characters.ts new file mode 100755 index 0000000..e187f3e --- /dev/null +++ b/src/api/characters.ts @@ -0,0 +1,10 @@ +import { del, get, post, put } from "./api"; + +export const fetchChars = () => get('characters'); +export const createChar = (char: Unmei.CharacterType) => post('chars', char); + +export const fetchChar = (id: number) => get(`characters/${id}`); +export const deleteChar = (id: number) => del(`characters/${id}`); +export const updateChar = (char: Unmei.CharacterType) => put(`characters/${char.id}`, char); + +export const fetchCharNovels = (id: number) => get(`characters/${id}/novels`); diff --git a/src/api/clubs.ts b/src/api/clubs.ts new file mode 100644 index 0000000..882661f --- /dev/null +++ b/src/api/clubs.ts @@ -0,0 +1,4 @@ +import { get } from "./api"; + +export const getClubs = () => get('/clubs'); +export const getClub = (id: number) => get(`/clubs/${id}`); diff --git a/src/api/errors.ts b/src/api/errors.ts new file mode 100755 index 0000000..c1ff2aa --- /dev/null +++ b/src/api/errors.ts @@ -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; diff --git a/src/api/genres.ts b/src/api/genres.ts new file mode 100644 index 0000000..3fd5d04 --- /dev/null +++ b/src/api/genres.ts @@ -0,0 +1,3 @@ +import { get } from "./api"; + +export const fetchGenres = () => get('genres'); diff --git a/src/api/news.ts b/src/api/news.ts new file mode 100755 index 0000000..8dfe361 --- /dev/null +++ b/src/api/news.ts @@ -0,0 +1,8 @@ +import { del, get, put, post } from './api'; + +export const fetchNews = () => get('news'); +export const createPost = (p: Unmei.PostType) => post('news', p); + +export const fetchPost = (id: number) => get(`news/${id}`); +export const updatePost = (post: Unmei.PostType) => put(`news/${post.id}`, post); +export const deletePost = (id: number) => del(`news/${id}`); diff --git a/src/api/novels.ts b/src/api/novels.ts new file mode 100755 index 0000000..2ba8360 --- /dev/null +++ b/src/api/novels.ts @@ -0,0 +1,25 @@ +import { del, get, post, put } from "./api"; + + +export const fetchNovels = (orderBy?: string) => get(orderBy ? `novels?sort=${orderBy}` : 'novels'); +export const searchNovel = (query: string) => get(`novels?q=${query}`); +export const createNovel = (novel: Unmei.NovelType) => post('novels', novel); + +export const fetchNovel = (id: number) => get(`novels/${id}`); +export const updateNovel = (novel: Unmei.NovelType) => put(`novels/${novel.id}`, novel); +export const uploadNovelCover = (id: number, cover: FormData) => post(`novels/${id}/cover`, cover); + +export const fetchNovelCharacters = (id: number) => get(`novels/${id}/characters`); + +export const fetchNovelGenres = (id: number) => get(`novels/${id}/genres`); + +export const fetchNovelComments = (id: number, offset = 0, count = 5) => get(`novels/${id}/comments?offset=${offset}&count=${count}`); +export const postNovelComment = (id: number, text: string) => post(`novels/${id}/comments`, { text }); + +export const fetchUserNovel = (userId: number, novelId: number) => get(`users/${userId}/novels/${novelId}`); +export const createUserNovel = (userId: number, novelId: number, status = 'planned') => post(`users/${userId}/novels`, { + novel_id: Number(novelId), + status +}); +export const updateUserNovel = (userId: number, novelId: number, data: { mark?: number; status?: string }) => put(`users/${userId}/novels/${novelId}`, data); +export const deleteUserNovel = (userId: number, novelId: number) => del(`users/${userId}/novels/${novelId}`); diff --git a/src/api/users.ts b/src/api/users.ts new file mode 100755 index 0000000..6b3227a --- /dev/null +++ b/src/api/users.ts @@ -0,0 +1,25 @@ +import { get, post, put } from "./api"; + +export const fetchUsers = () => get('users'); + +export const fetchUser = (id: number) => get(`users/${id}`); +export const updateUser = (user: Unmei.UserType) => put(`users/${user.id}`, { user }); + +export const fetchCurrentUser = () => get('users/me'); +export const fetchUserNovels = (id: number) => get(`users/${id}/novels`); + +export const activateAccount = (token: string) => post('auth/activate', { token }); +export const generateActivateLink = () => post('auth/activateToken'); + +export const fetchUserSettings = () => get('users/me/settings'); +export const updateUserGeneralSettings = (use_gravatar: boolean, avatar: string) => post('users/me/settings', { + use_gravatar, + avatar +}); +export const updateUserAppearanceSettings = (theme: string) => post('users/me/settings', { theme }); +export const uploadAvatar = (avatar: FormData) => post('users/me/avatar', avatar); + +export const generateRestoreLink = (email: string, recaptcha: string) => post('auth/restoreToken', { + email, + recaptcha +}); diff --git a/src/classNames.ts b/src/classNames.ts new file mode 100755 index 0000000..2a23fe3 --- /dev/null +++ b/src/classNames.ts @@ -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(' '); +} diff --git a/src/components/App/App.sass b/src/components/App/App.sass new file mode 100755 index 0000000..d2b737f --- /dev/null +++ b/src/components/App/App.sass @@ -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) diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx new file mode 100755 index 0000000..bc161cb --- /dev/null +++ b/src/components/App/App.tsx @@ -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 { + 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 ( +
+ Вы были забанены! +
+ ); + } + + return ( + <> + {preloader && } + + + + + {snowfall.snowfallStatus && ( + + )} + +
+
+ {this.props.notifications.map((n, i) => ( + + {n} + + ))} +
+ {Router} + + {modal && modal} +
+ +