Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
/.idea/
/node_modules/
/dist/
/.godo/
.qodo
Binary file added .qodo/history.sqlite
Binary file not shown.
59 changes: 52 additions & 7 deletions src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,22 @@ class APIService {
* @returns {Promise<{}>}
*/
async request({ url, method = 'GET', headers = {}, ...options }) {
if (!url.match(/^(http|\/\/)/)) url = this.config.baseUrl + url;
const res = await fetch(url, {
method,
headers: { ...this.defaultHeaders, ...headers },
...options,
});
return { data: await res.json(), status: res.status, headers: res.headers };
const fullUrl = url.match(/^(http|\/\/)/) ? url : this.config.baseUrl + url;
console.log('API Request:', { fullUrl, method, headers });

try {
const res = await fetch(fullUrl, {
method,
headers: { ...this.defaultHeaders, ...headers },
...options
});
const data = await res.json();
console.log('API Response:', { status: res.status, data });
return { data, status: res.status };
} catch (e) {
console.error('API Error:', e);
throw e;
}
}

/**
Expand All @@ -41,6 +50,42 @@ class APIService {
delete this.defaultHeaders[name];
}
}

/**
* Загрузка комментариев
* @param parentId {String} ID родителя (товара или комментария)
*/
async getComments(parentId) {
const res = await this.request({
url: `/api/v1/comments?fields=items(_id,text,dateCreate,author(profile(name)),parent(_id,_type),isDeleted),count&limit=*&search[parent]=${parentId}`
});
// Явно возвращаем result или дефолтные значения
return {
items: res.data?.result?.items || [],
count: res.data?.result?.count || 0
};
}

/**
* Отправка комментария
* @param parentId {String} ID родителя
* @param parentType {'article'|'comment'} Тип родителя
* @param text {String} Текст комментария
*/
async addComment(parentId, parentType, text) {
const res = await this.request({
url: `/api/v1/comments`,
method: 'POST',
headers: {
'X-Token': localStorage.getItem('token')
},
body: JSON.stringify({
text,
parent: { _id: parentId, _type: parentType }
})
});
return res.data?.result; // Учитываем вложенность
}
}

export default APIService;
65 changes: 45 additions & 20 deletions src/app/article/index.js
Original file line number Diff line number Diff line change
@@ -1,45 +1,62 @@
import { memo, useCallback } from 'react';
import { memo, useCallback, useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import useStore from '../../hooks/use-store';
import useTranslate from '../../hooks/use-translate';
import useInit from '../../hooks/use-init';
import useSelector from '../../hooks/use-selector';
import PageLayout from '../../components/page-layout';
import Head from '../../components/head';
import Navigation from '../../containers/navigation';
import Spinner from '../../components/spinner';
import ArticleCard from '../../components/article-card';
import ArticleCommentList from '../../components/article-comment-list';
import LocaleSelect from '../../containers/locale-select';
import TopHead from '../../containers/top-head';
import { useDispatch, useSelector } from 'react-redux';
import shallowequal from 'shallowequal';
import { useDispatch, useSelector as useSelectorRedux, shallowEqual } from 'react-redux';
import articleActions from '../../store-redux/article/actions';
import commentsActions from '../../store-redux/comments/actions';
import HeadLayout from '../../components/head-layout';

function Article() {
const store = useStore();

const { id: articleId } = useParams();
const [activeForm, setActiveForm] = useState(null);
const dispatch = useDispatch();
// Параметры из пути /articles/:id

const params = useParams();
// Сессия и статья
const { data: article, waiting: articleWaiting } = useSelectorRedux(
state => state.article,
shallowEqual
);

const select = useSelector(state => ({
exists: state.session.exists,
}));

const exists = select.exists;
const rootComments = useSelectorRedux(state =>
state.comments.items[articleId] || []
);

// Загрузка данных
useInit(() => {
//store.actions.article.load(params.id);
dispatch(articleActions.load(params.id));
}, [params.id]);
dispatch(articleActions.load(articleId));
dispatch(commentsActions.load(articleId)); // Убрали проверку sessionWaiting
}, [articleId]);
console.log('useSelectorRedux Article', useSelectorRedux(state => state));

const select = useSelector(
state => ({
article: state.article.data,
waiting: state.article.waiting,
}),
shallowequal,
); // Нужно указать функцию для сравнения свойства объекта, так как хуком вернули объект
const handleAddComment = async (parentId, parentType, text) => {
try {
await dispatch(commentsActions.add(parentId, parentType, text));
setActiveForm(null);
} catch (e) {
console.error('Ошибка добавления комментария:', e);
}
};

const { t } = useTranslate();

const callbacks = {
// Добавление в корзину
addToBasket: useCallback(_id => store.actions.basket.addToBasket(_id), [store]),
};

Expand All @@ -48,13 +65,21 @@ function Article() {
<HeadLayout>
<TopHead />
</HeadLayout>
<Head title={select.article.title}>
<Head title={article.title}>
<LocaleSelect />
</Head>
<PageLayout>
<Navigation />
<Spinner active={select.waiting}>
<ArticleCard article={select.article} onAdd={callbacks.addToBasket} t={t} />
<Spinner active={articleWaiting}>
<ArticleCard article={article} onAdd={callbacks.addToBasket} t={t} />
<ArticleCommentList
articleId={articleId}
comments={rootComments}
onAddComment={handleAddComment}
activeForm={activeForm}
setActiveForm={setActiveForm}
isAuth={exists}
/>
</Spinner>
</PageLayout>
</>
Expand Down
135 changes: 135 additions & 0 deletions src/components/article-comment-list/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { memo, useCallback, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import useSelector from '../../hooks/use-selector';
import { useDispatch, useSelector as useSelectorRedux } from 'react-redux';
import Button from '../button';
import NewComment from '../new-comment';
import Comment from '../comment';
import commentsActions from '../../store-redux/comments/actions';
import useInit from '../../hooks/use-init';
import { cn as bem } from '@bem-react/classname';
import './style.css';

function ArticleCommentList({ articleId }) {
const cn = bem('ArticleCommentList');
const navigate = useNavigate();
const location = useLocation();
const dispatch = useDispatch();
const [activeForm, setActiveForm] = useState(null);

// Получаем данные из хранилища
const select = useSelector(state => ({
exists: state.session.exists,
}));

const exists = select.exists;
const buildCommentTree = (comments, articleId) => {
// Создаем хеш-таблицу для всех комментариев
const commentMap = {};

// Сначала создаем все узлы с пустыми children
comments.forEach(comment => {
commentMap[comment._id] = { ...comment, children: [] };
});

// Собираем дерево комментариев
const rootComments = [];

comments.forEach(comment => {
const parentId = comment.parent._id;

if (parentId === articleId) {
// Корневой комментарий
rootComments.push(commentMap[comment._id]);
} else if (commentMap[parentId]) {
// Вложенный комментарий (любого уровня)
commentMap[parentId].children.push(commentMap[comment._id]);
}
});

return rootComments;
};
const rootComments = useSelectorRedux(state => {
const items = state.comments.items[articleId] || [];
return buildCommentTree(items, articleId);
});
console.log('rootComments',rootComments);

const isLoading = useSelectorRedux(state => state.comments.loadingParents.includes(articleId));
const commentsCount = useSelectorRedux(state => state.comments.counts[articleId] || 0);

// Загрузка комментариев
useInit(() => {
dispatch(commentsActions.load(articleId));
}, [articleId]);

const callbacks = {
// Переход к авторизации
onSignIn: useCallback(() => {
navigate('/login', { state: { back: location.pathname } });
}, [location.pathname]),

// Отправка комментария (добавляем тип родителя)
onSubmit: async (parentId, parentType, text) => {
try {
await dispatch(commentsActions.add(parentId, parentType, text));
setActiveForm(null);
} catch (e) {
console.error('Ошибка при отправке комментария:', e);
}
},

// Открытие формы ответа
onReply: useCallback((commentId) => setActiveForm(commentId), []),
};

return (
<section className={cn()}>
<h2 className={cn('caption')}>Комментарии ({commentsCount})</h2>

{isLoading ? (
<div>Загрузка комментариев...</div>
) : (
<>
{/* Список корневых комментариев */}
{rootComments.map(comment => (
<Comment
key={comment._id}
comment={comment}
childComments={comment.children}
onReply={exists ? () => callbacks.onReply(comment._id) : null}
isFormOpen={activeForm === comment._id}
onCancel={() => setActiveForm(null)}
onSubmit={(text) => callbacks.onSubmit(
comment._id,
'comment',
text
)}
/>
))}

{/* Форма для нового комментария */}
{exists ? (
<NewComment
onSubmit={(text) => callbacks.onSubmit(
articleId,
'article', // Для корневого комментария
text
)}
/>
) : (
<div className={cn('stranger')}>
<Button
style="text"
onClick={callbacks.onSignIn}
title="Войдите"
/>, чтобы иметь возможность комментировать
</div>
)}
</>
)}
</section>
);
}

export default memo(ArticleCommentList);
9 changes: 9 additions & 0 deletions src/components/article-comment-list/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.ArticleCommentList-stranger .Button {
display: inline;
}

.ArticleCommentList-stranger button {
font-weight: 400;
color: var(--primary);
font-family: var(--font-family);
}
Loading