Compare commits

...

2 Commits

Author SHA1 Message Date
kuwsh1n
58b6d1387d ticket 17
All checks were successful
publish-main / release-image (push) Successful in 7m24s
2024-06-25 14:01:13 +03:00
kuwsh1n
0e7f962645 ticket 17 2024-06-25 13:52:14 +03:00
13 changed files with 904 additions and 2 deletions

View File

@ -28,6 +28,8 @@
"webpack-dev-server": "^5.0.2"
},
"dependencies": {
"@ckeditor/ckeditor5-build-classic": "^41.4.2",
"@ckeditor/ckeditor5-react": "^7.0.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/react-fontawesome": "^0.2.0",

View File

@ -0,0 +1,94 @@
.main {
width: 100%;
height: 100%;
padding: 4% 8%;
}
.wrapper {
width: 100%;
height: 100%;
}
.panel {
width: 100%;
height: 10%;
display: flex;
justify-content: space-between;
align-items: end;
padding: 0 5%;
}
.listArticles {
box-shadow: 0 0 5px 1px rgb(200, 200, 200);
border-radius: 5px;
margin-top: 5%;
height: 70%;
width: 100%;
&__columns {
display: flex;
justify-content: start;
align-items: center;
padding: 0 2%;
height: 15%;
width: 100%;
border-bottom: 1px solid rgb(220, 220, 220);
&__item {
width: 33.3%;
font-size: 15px;
// text-align: center;
font-family: "Montserrat", sans-serif;
}
}
&__forms {
width: 100%;
height: 85%;
overflow-y: auto;
&::-webkit-scrollbar {
width: 7px;
}
&::-webkit-scrollbar-thumb {
background-color: rgb(200, 200, 200);
}
&__item {
display: flex;
justify-content: start;
align-items: center;
padding: 0 2%;
height: 25%;
width: 100%;
font-family: "Montserrat", sans-serif;
position: relative;
&:hover {
background-color: rgba(240, 240, 240, 0.8);
}
&__title {
width: 33.3%;
// text-align: center;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
&__date {
width: 33.3%;
// text-align: center;
}
&__author {
width: 33.3%;
// text-align: center;
}
i {
position: absolute;
font-size: 15px;
right: 30px;
top: calc(50% - 7px);
cursor: pointer;
}
ul {
li {
cursor: pointer;
}
}
}
}
}

View File

@ -30,5 +30,31 @@
&:hover {
border: 1px solid rgba(0, 0, 0, 0.3);
}
<<<<<<< HEAD
&__green {
color: white;
background-color: rgb(150, 209, 158);
}
&__white {
color: black;
background-color: white;
border: 1px solid rgba(0, 0, 0, 0.2);
&:hover {
border: 1px solid rgba(0, 0, 0, 0.3);
}
}
&__transparent {
background-color: rgba(0, 0, 0, 0);
border: 1px solid rgba(0, 0, 0, 0);
transition: 0.3s;
&:hover {
background-color: rgba(180, 180, 180, 0.5);
color: white;
transition: 0.3s;
}
}
}
=======
}
}
>>>>>>> 750b571374c05b915d136af481fe93bf966bac39

View File

@ -0,0 +1,121 @@
.main {
width: 100%;
height: 100%;
padding: 4% 8%;
}
.wrapper {
width: 100%;
height: 100%;
}
.header {
display: flex;
justify-content: space-between;
width: 100%;
height: 8%;
&__listInput {
width: 40%;
height: 100%;
display: flex;
justify-content: space-between;
align-items: center;
&__date {
width: 100%;
height: 100%;
display: flex;
justify-content: space-between;
position: relative;
span {
position: absolute;
font-size: 8px;
font-family: "Montserrat", sans-serif;
top: -40%;
left: 2%;
}
}
&__title {
width: 100%;
height: 100%;
display: flex;
justify-content: space-between;
position: relative;
span {
position: absolute;
font-size: 8px;
font-family: "Montserrat", sans-serif;
top: -40%;
left: 2%;
}
}
}
&__listBtn {
display: flex;
justify-content: space-between;
width: 30%;
}
}
.tags {
width: 100%;
margin: 30px 0;
&__wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: start;
&__input {
position: relative;
&__title {
top: -40%;
left: 2px;
position: absolute;
font-size: 8px;
font-family: "Montserrat", sans-serif;
color: rgb(100, 100, 100);
}
}
&__list {
width: 100%;
// height: 100%;
flex-wrap: wrap;
display: flex;
justify-content: start;
align-items: center;
margin-top: 5px;
&__item {
padding: 0 5px;
border-radius: 5px;
border: 1px solid rgb(100, 100, 100);
margin: 1px 3px;
position: relative;
span {
font-size: 13px;
font-family: "Montserrat", sans-serif;
color: rgb(100, 100, 100);
}
&:hover span {
visibility: hidden;
}
&:hover i {
visibility: visible;
}
i {
visibility: hidden;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: rgb(239, 73, 73);
cursor: pointer;
}
}
}
}
}
.content {
}

View File

@ -0,0 +1,159 @@
.main {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.wrapper {
width: 90%;
height: 90%;
}
.header {
width: 100%;
height: 10%;
&__wrapper {
width: 100%;
height: 100%;
border: 1px solid rgb(180, 180, 180);
border-bottom: none;
border-radius: 5px 5px 0 0;
padding: 0 5px;
&__article {
width: 100%;
height: 100%;
display: flex;
justify-content: space-between;
align-items: center;
&__title {
width: 20%;
height: 75%;
display: flex;
border-right: 1px solid rgb(180, 180, 180);
justify-content: center;
align-items: center;
position: relative;
&__name {
position: absolute;
top: -20%;
left: 3px;
font-size: 8px;
font-family: "Montserrat", sans-serif;
}
&__text {
font-size: 15px;
font-family: "Montserrat", sans-serif;
}
}
&__tags {
width: 60%;
height: 75%;
display: flex;
justify-content: start;
align-items: center;
flex-wrap: wrap;
padding: 0 5px;
position: relative;
overflow-y: auto;
// &::-webkit-scrollbar {
// width: 10px;
// }
// &::-webkit-scrollbar-thumb {
// background-color: rgb(200, 200, 200);
// }
// &::-webkit-scrollbar-button:single-button {
// background-color: #bbbbbb;
// display: block;
// border-style: solid;
// height: 10px;
// width: 16px;
// }
&__item {
padding: 0 5px;
border-radius: 5px;
border: 1px solid rgb(100, 100, 100);
margin: 1px 3px;
span {
}
}
}
&__owner {
width: 20%;
height: 75%;
border-left: 1px solid rgb(180, 180, 180);
display: flex;
justify-content: center;
align-items: center;
position: relative;
&__name {
position: absolute;
top: -20%;
right: 3px;
font-size: 8px;
font-family: "Montserrat", sans-serif;
}
&__text {
font-size: 15px;
font-family: "Montserrat", sans-serif;
}
}
}
&__tags {
width: 100%;
height: 50%;
display: flex;
justify-content: start;
align-items: center;
margin-top: 20px;
&__item {
padding: 0 5px;
border-radius: 5px;
border: 1px solid rgb(100, 100, 100);
margin: 1px 3px;
span {
}
}
}
}
}
.content {
width: 100%;
height: 90%;
&__wrapper {
width: 100%;
height: 100%;
border: 1px solid rgb(180, 180, 180);
padding: 5px;
// border-radius: 0 0 5px 5px;
overflow: auto;
&::-webkit-scrollbar {
width: 7px;
}
&::-webkit-scrollbar {
height: 7px;
}
&::-webkit-scrollbar-thumb {
background-color: rgb(200, 200, 200);
}
&__article {
max-width: 100%;
height: 100%;
}
}
}
// .image {
// width: 400px;
// height: 200px;
// border: 4px solid red
// img {
// width: 400px;
// height: 200px;
// aspect-ratio: 1000/700;
// }
// }

View File

@ -4,7 +4,7 @@ import classes from "../assets/styles/components/myButton.module.scss"
const MyButton = (props) => {
return (
<div className={classes.main}>
<div className={classes.main} style={props.mainStyle}>
<button
type="button"
className={classes[props.class]}

View File

@ -14,7 +14,8 @@ const NavBar = ({navigate, auth, setAuth}) => {
auth ?
auth.is_admin ?
<><span onClick={() => navigate("/forms")}>Мои формы</span>
<span onClick={() => navigate("/admin")}>Админ панель</span></> :
<span onClick={() => navigate("/admin")}>Админ панель</span>
<span onClick={() => navigate("/articles")}>Мои статьи</span></> :
<span></span> :
<span></span>
}

View File

@ -0,0 +1,33 @@
import React, { Component } from 'react';
import { CKEditor } from '@ckeditor/ckeditor5-react';
import ClassicEditor from '@ckeditor/ckeditor5-build-classic';
const TextEditor = ({data, setData}) => {
return (
<div className="App">
<h2>Редактор статьи</h2>
<CKEditor
editor={ ClassicEditor }
data={data}
config = {{
mediaEmbed: {previewsInData: true }
}}
onReady={ editor => {
// You can store the "editor" and use when it is needed.
console.log( 'Editor is ready to use!', editor );
} }
onChange={ ( event, editor ) => {
setData(editor.getData())
}}
onBlur={ ( event, editor ) => {
console.log( 'Blur.', editor );
} }
onFocus={ ( event, editor ) => {
console.log( 'Focus.', editor );
} }
/>
</div>
);
}
export default TextEditor;

144
src/hooks/api/articleApi.js Normal file
View File

@ -0,0 +1,144 @@
import axios from "axios";
async function createArticleApi(token, data) {
try {
const response = await axios.post(`https://api.minerva.krbl.ru/articles/new`,
{
"tags": [],
"title": "Новая статья"
},
{
headers: {
"Authorization": `Token ${token}`
}
})
return response
}
catch (e) {
return e
}
};
async function getListArticlesApi(token) {
try {
const response = await axios.get(`https://api.minerva.krbl.ru/articles/list`,
{
headers: {
"Authorization": `Token ${token}`
}
})
return response
}
catch (e) {
return e
}
};
async function getArticleApi(token, articleId) {
try {
const response = await axios.get(`https://api.minerva.krbl.ru/articles/${articleId}/view`,
{
headers: {
"Authorization": `Token ${token}`
}
})
return response
}
catch (e) {
return e
}
};
async function editTagsApi(token, articleId, tags, flag) {
try {
const response = await axios.post(`https://api.minerva.krbl.ru/articles/edit/${articleId}/tags`,
{
"delete_flag": flag,
"tags": tags,
},
{
headers: {
"Authorization": `Token ${token}`
}
})
return response
}
catch (e) {
return e
}
};
async function editTitleArticleApi(token, articleId, title) {
console.log('set')
try {
const response = await axios.post(`https://api.minerva.krbl.ru/articles/edit/${articleId}/setTitle`,
{
"title": title,
},
{
headers: {
"Authorization": `Token ${token}`
}
})
return response
}
catch (e) {
return e
}
};
async function addArticleApi(token, articleId, content) {
console.log('add')
try {
const response = await axios.post(`https://api.minerva.krbl.ru/articles/edit/${articleId}/add`,
{
"data": content,
},
{
headers: {
"Authorization": `Token ${token}`
}
})
return response
}
catch (e) {
return e
}
};
async function editArticleApi(token, articleId, content) {
try {
const response = await axios.post(`https://api.minerva.krbl.ru/articles/edit/${articleId}/set`,
{
"order": 0,
"data": content,
},
{
headers: {
"Authorization": `Token ${token}`
}
})
return response
}
catch (e) {
return e
}
};
export {
createArticleApi,
getArticleApi,
getListArticlesApi,
editTagsApi,
editTitleArticleApi,
addArticleApi,
editArticleApi
}

86
src/pages/Articles.jsx Normal file
View File

@ -0,0 +1,86 @@
import React, { useState, useContext, useEffect } from "react";
import { useCookies } from "react-cookie";
import { useNavigate } from 'react-router-dom';
import classes from "../assets/styles/articles.module.scss"
import MyButton from "../components/MyButton.jsx";
import MyInput from "../components/MyInput.jsx";
import { UserData } from "../context";
import CheckModal from "../components/CheckModal.jsx";
import { createArticleApi, getListArticlesApi } from "../hooks/api/articleApi.js";
const Articles = () => {
const navigate = useNavigate();
const {user, setUser} = useContext(UserData);
const [stateLoading, setStateLoading] = useState(false);
const [listArticles, setListArticles] = useState([]);
const [cookies, _, __] = useCookies(["user"]);
useEffect(() => {
async function getListArticles() {
const response = await getListArticlesApi(cookies.token)
if (response.status === 200) {
setListArticles(response.data)
}
}
getListArticles()
}, [])
async function createArticle() {
const response = await createArticleApi(cookies.token)
console.log(response)
if (response.status === 200) {
navigate(`/articles/${response.data.id}/edit`)
}
}
return (
<div className={classes.main}>
<div className={classes.wrapper}>
<div className={classes.panel}>
<MyInput placeholder={'Поиск...'}/>
<MyButton click={createArticle} class={"main__green"} otherStyle={{width: '200px'}} text={
stateLoading ? <div class="spinner-border text-light" role="status">
<span class="visually-hidden">Загрузка...</span>
</div> : 'Создать'
}/>
</div>
<div className={classes.listArticles}>
<div className={classes.listArticles__columns}>
<div className={classes.listArticles__columns__item}>Название</div>
<div className={classes.listArticles__columns__item}>Дата публикации</div>
<div className={classes.listArticles__columns__item}>Автор</div>
</div>
<div className={classes.listArticles__forms}>
{listArticles.map(item => <div className={classes.listArticles__forms__item}>
<div className={classes.listArticles__forms__item__title} onClick={() => navigate(`/articles/${item.id}/edit`)}>{item.title}</div>
<div className={classes.listArticles__forms__item__date}>24.06.24</div>
<div className={classes.listArticles__forms__item__author}>kuwsh1n</div>
<i class="fa-solid fa-ellipsis-vertical" id="action" data-bs-toggle="dropdown"></i>
<ul class="dropdown-menu" aria-labelledby="action">
<li><a class="dropdown-item" onClick={() => navigate(`/articles/${item.id}/`)}>Открыть</a></li>
<li><a class="dropdown-item" data-bs-toggle="modal" data-bs-target={`#checkModaltest`}>Удалить</a></li>
</ul>
<CheckModal
postfix={'test'}
message={`Вы хотетите удалить статью <Test article>?`}
action={{
execute: () => {},
cancel: () => {}
}}
/>
</div>)}
</div>
</div>
</div>
</div>
)
}
export default Articles;

143
src/pages/NewArticle.jsx Normal file
View File

@ -0,0 +1,143 @@
import React, { useState, useContext, useEffect } from "react";
import { useNavigate, useLocation, useParams } from 'react-router-dom';
import { useCookies } from "react-cookie";
import classes from "../assets/styles/newArticle.module.scss";
import MyButton from "../components/MyButton.jsx";
import Loading from "../components/Loading.jsx";
import MyInput from "../components/MyInput.jsx";
import TextEditor from "../components/TextEditor.jsx";
import { getArticleApi, editTagsApi, editTitleArticleApi, editArticleApi, addArticleApi } from "../hooks/api/articleApi.js";
const NewArticle = () => {
const navigate = useNavigate();
const location = useLocation();
const { articleId } = useParams();
const [loading, setLoading] = useState(false);
const [title, setTitle] = useState("");
const [tags, setTags] = useState([]);
const [newTag, setNewTag] = useState("")
const [ownerId, setOwnerId] = useState("");
const [contentArticle, setContentArticle] = useState('');
const [blocks, setBlocks] = useState('');
const [cookies, _, __] = useCookies(["user"]);
useEffect(() => {
async function getArticle() {
const response = await getArticleApi(cookies.token, articleId)
if (response.status === 200) {
console.log(response)
setTitle(response.data.article.title)
setTags(response.data.article.tags ? response.data.article.tags : [])
setOwnerId(response.data.article.owner_id)
setContentArticle(response.data.blocks ? response.data.blocks[0].data : '')
setBlocks(response.data.blocks ? response.data.blocks : false)
}
}
getArticle()
}, [])
async function addTag() {
if (newTag.length > 0 && !tags.find(item => item === newTag)) {
const response = await editTagsApi(cookies.token, articleId, [newTag], false)
if (response.status === 200) {
setTags([...tags, newTag])
setNewTag("")
}
}
}
async function removeTag(item, index) {
const response = await editTagsApi(cookies.token, articleId, [item], true)
if (response.status === 200) {
const cTags = [...tags]
cTags.splice(index, 1)
setTags(cTags)
}
}
async function saveArticle() {
const responseTitle = await editTitleArticleApi(cookies.token, articleId, title)
console.log(blocks)
const responseContentArticle = blocks ?
await editArticleApi(cookies.token, articleId, contentArticle) :
await addArticleApi(cookies.token, articleId, contentArticle)
console.log(responseContentArticle)
if (responseTitle.status === 200 && responseContentArticle.status === 200) {
navigate("/articles")
}
}
return (
<div className={classes.main}>
<div className={classes.wrapper}>
<div className={classes.header}>
<div className={classes.header__listInput}>
<div className={classes.header__listInput__date}>
<span>Дата создания</span>
<MyInput type={"datetime-local"}/>
</div>
<div className={classes.header__listInput__title}>
<span>Название статьи</span>
<MyInput type={"text"} value={title} change={setTitle}/>
</div>
</div>
<div className={classes.header__listBtn}>
{/* <MyButton text={'Предпросмотр'} class={"main__white"}/> */}
<MyButton text={'Опубликовать'} class={"main__green"} click={() => saveArticle()}/>
</div>
</div>
<div className={classes.tags}>
<div className={classes.tags__wrapper}>
<div className={classes.tags__wrapper__input}>
<span className={classes.tags__wrapper__input__title}>Добавить тэг</span>
<MyInput
type={"text"}
otherMainStyle={{border: "1px solid rgb(180, 180, 180)", borderRadius: "5px"}}
otherInputStyle={{border: "0px solid rgb(180, 180, 180)", width: "85%"}}
value={newTag}
change={setNewTag}
/>
<MyButton
type={"button"}
text={<i class="fa-solid fa-arrow-right"></i>}
class={"main__transparent"}
mainStyle={
{
position: "absolute",
right: "0",
top: "0",
height: "100%",
}
}
otherStyle={{borderRadius: "0 5px 5px 0", padding: "3px 7px 0 7px"}}
click={() => addTag()}
/>
</div>
<div className={classes.tags__wrapper__list}>
{tags ? tags.map((item, i) =>
<div className={classes.tags__wrapper__list__item} key={i}>
<span>#{item}</span>
<i class="fa-solid fa-circle-xmark" onClick={() => removeTag(item, i)}></i>
</div>
) : <></>}
</div>
</div>
</div>
<div className={classes.content}>
<TextEditor data={contentArticle} setData={setContentArticle}/>
</div>
</div>
</div>
)
}
export default NewArticle;

78
src/pages/ViewArticle.jsx Normal file
View File

@ -0,0 +1,78 @@
import React, { useState, useContext, useEffect, useLocation } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useCookies } from "react-cookie";
import classes from "../assets/styles/viewArticle.module.scss";
import { FormsData, TypeAnswerData, answersData, UserData } from "../context";
import MyButton from "../components/MyButton.jsx";
import { getArticleApi } from "../hooks/api/articleApi.js";
import { getListUserApi } from "../hooks/api/profileApi.js";
const ViewArticle = () => {
const navigate = useNavigate();
// const location = useLocation();
const { articleId } = useParams();
const [title, setTitle] = useState("");
const [tags, setTags] = useState([]);
const [newTag, setNewTag] = useState("")
const [owner, setOwner] = useState("");
const [contentArticle, setContentArticle] = useState('');
const [cookies, _, __] = useCookies(["user"]);
useEffect(() => {
async function getArticle() {
const response = await getArticleApi(cookies.token, articleId)
const user = await getListUserApi(cookies.token)
if (response.status === 200) {
console.log(response)
setTitle(response.data.article.title)
setTags(response.data.article.tags ? response.data.article.tags : [])
setOwner(user.status === 200 ? user.data.find(item => item.id === response.data.article.owner_id).login : response.data.article.owner_id)
setContentArticle(response.data.blocks ? response.data.blocks[0].data : '')
}
}
getArticle()
}, [])
return (
<div className={classes.main}>
<div className={classes.wrapper}>
<div className={classes.header}>
<div className={classes.header__wrapper}>
<div className={classes.header__wrapper__article}>
<div className={classes.header__wrapper__article__title}>
<span className={classes.header__wrapper__article__title__name}>Название</span>
<span className={classes.header__wrapper__article__title__text}>{title}</span>
</div>
<div className={classes.header__wrapper__article__tags}>
{/* <span className={classes.header__wrapper__article__tags__name}>Тэги</span> */}
{tags.map(item => <div className={classes.header__wrapper__tags__item}>
<span>#{item}</span>
</div>)}
</div>
<div className={classes.header__wrapper__article__owner}>
<span className={classes.header__wrapper__article__owner__name}>Автор</span>
<span className={classes.header__wrapper__article__owner__text}>{owner}</span>
</div>
</div>
</div>
</div>
<div className={classes.content}>
<div className={classes.content__wrapper}>
<div className="classes.content__wrapper__article" dangerouslySetInnerHTML={{__html: contentArticle}}>
</div>
</div>
</div>
</div>
</div>
)
}
export default ViewArticle

View File

@ -10,6 +10,9 @@ import ViewForm from "../pages/ViewForm.jsx";
import AdminPanel from '../pages/AdminPanel.jsx';
import AnswersForm from '../pages/AnswersForm.jsx';
import TokensForm from '../pages/TokensForm.jsx';
import Articles from '../pages/Articles.jsx';
import NewArticle from '../pages/NewArticle.jsx';
import ViewArticle from '../pages/ViewArticle.jsx';
const router = createBrowserRouter([
{
@ -50,6 +53,18 @@ const router = createBrowserRouter([
{
path: "/tokens/:formId",
element: <TokensForm/>
},
{
path: "/articles",
element: <Articles/>
},
{
path: "/articles/:articleId/edit",
element: <NewArticle/>
},
{
path: "/articles/:articleId/",
element: <ViewArticle/>
}
]
}