astro_advokat/frontend/src/components/single-post/Comments.tsx
2026-03-31 18:45:18 +05:00

452 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createSignal, For, Show, onMount } from "solid-js";
import CommentLock from "./CommentLock";
import CommentForm from "./CommentForm";
import type { CommentWithReplies } from "../../types/comments";
interface CommentsProps {
postSlug: string;
}
interface ApiComment {
id: string;
post_slug: string;
user: string;
content: string;
parent?: string | null;
is_verified: boolean;
status: "pending" | "published" | "spam";
created: string;
updated: string;
expand?: {
user?: {
id: string;
name: string;
email: string;
avatar?: string;
};
};
}
// Тип для toast-уведомлений
interface ToastMessage {
type: "success" | "error";
message: string;
}
export default function Comments(props: CommentsProps) {
const [isAuthenticated, setIsAuthenticated] = createSignal(false);
const [currentUser, setCurrentUser] = createSignal<{
name: string;
email: string;
avatar?: string;
} | undefined>(undefined);
const [isLoading, setIsLoading] = createSignal(true);
const [comments, setComments] = createSignal<CommentWithReplies[]>([]);
const [replyTo, setReplyTo] = createSignal<string | null>(null);
const [commentAuthors, setCommentAuthors] = createSignal<Record<string, string>>({});
const [toastVisible, setToastVisible] = createSignal<string | null>(null);
// Добавляем сигнал для toast-уведомлений
const [toast, setToast] = createSignal<ToastMessage | null>(null);
// Функция показа toast-уведомлений
const showToast = (message: ToastMessage): void => {
setToast(message);
// Автоматически скрываем через 3 секунды
setTimeout(() => setToast(null), 3000);
};
onMount(async () => {
console.log("[Comments] Начало проверки авторизации...");
try {
const response = await fetch("/api/auth/me", {
method: "GET",
credentials: "include",
});
console.log("[Comments] Ответ API:", response.status);
const data = await response.json();
console.log("[Comments] Данные авторизации:", data);
if (data.authenticated && data.user) {
console.log("[Comments] Пользователь авторизован:", data.user);
setIsAuthenticated(true);
setCurrentUser({
name: data.user.name || "Пользователь",
email: data.user.email,
avatar: data.user.avatar,
});
} else {
console.log("[Comments] Пользователь НЕ авторизован");
}
} catch (error) {
console.error("[Comments] Ошибка проверки авторизации:", error);
} finally {
setIsLoading(false);
console.log(
"[Comments] Загрузка завершена, isAuthenticated:",
isAuthenticated()
);
}
});
const loadComments = async () => {
try {
const response = await fetch(
`/api/comments?post_slug=${encodeURIComponent(props.postSlug)}&parent=null`,
{
method: "GET",
credentials: "include",
}
);
if (!response.ok) {
throw new Error("Failed to fetch comments");
}
const data = await response.json();
console.log("[Comments] Загружены комментарии:", data);
// Сохраняем авторов комментариев для проверки
const authors: Record<string, string> = {};
data.items.forEach((comment: ApiComment) => {
if (comment.user) {
authors[comment.id] = comment.user;
}
});
setCommentAuthors(authors);
const commentsWithReplies: CommentWithReplies[] = await Promise.all(
data.items.map(async (comment: ApiComment) => {
const repliesResponse = await fetch(
`/api/comments?post_slug=${encodeURIComponent(props.postSlug)}&parent=${comment.id}`,
{
method: "GET",
credentials: "include",
}
);
const repliesData = await repliesResponse.json();
return {
...comment,
replies: repliesData.items || [],
};
})
);
setComments(commentsWithReplies);
} catch (error) {
console.error("[Comments] Ошибка загрузки комментариев:", error);
}
};
onMount(() => {
if (props.postSlug) {
loadComments();
}
});
const handleNewComment = async (data: { content: string }) => {
try {
const response = await fetch("/api/comments", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
content: data.content,
post_slug: props.postSlug,
parent: null,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to create comment");
}
const newComment = await response.json();
console.log("[Comments] Комментарий создан:", newComment);
await loadComments();
} catch (error) {
console.error("[Comments] Ошибка создания комментария:", error);
}
};
const handleReply = async (
commentId: string,
data: { content: string }
) => {
try {
const response = await fetch("/api/comments", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
content: data.content,
post_slug: props.postSlug,
parent: commentId,
}),
});
if (!response.ok) {
const errorData = await response.json();
if (errorData.error) {
showToast({ type: "error", message: errorData.error });
return;
}
throw new Error(errorData.error || "Failed to create reply");
}
showToast({ type: "success", message: "Ответ успешно отправлен!" });
console.log("[Comments] Ответ создан");
await loadComments();
setReplyTo(null);
} catch (error) {
console.error("[Comments] Ошибка создания ответа:", error);
showToast({ type: "error", message: "Не удалось создать ответ. Попробуйте позже." });
}
};
const handleReplyClick = (commentId: string) => {
// Получаем ID текущего пользователя из API авторизации
const checkAuthAndReply = async () => {
try {
const response = await fetch("/api/auth/me", {
credentials: "include",
});
const data = await response.json();
const currentUserId = data.user?.id;
const commentAuthorId = commentAuthors()[commentId];
// Проверяем, является ли текущий пользователь автором комментария
if (commentAuthorId && currentUserId === commentAuthorId) {
setToastVisible(commentId);
setTimeout(() => setToastVisible(null), 3000);
return;
}
setReplyTo(replyTo() === commentId ? null : commentId);
} catch (error) {
console.error("[handleReplyClick] Ошибка проверки автора:", error);
setReplyTo(replyTo() === commentId ? null : commentId);
}
};
checkAuthAndReply();
};
const Avatar = (props: {
author: string;
avatar?: string;
size?: "sm" | "md";
}) => {
const size = props.size || "md";
const sizeClasses = { sm: "w-8 h-8 text-sm", md: "w-12 h-12 text-lg" };
return (
<Show
when={props.avatar}
fallback={
<div
class={`${sizeClasses[size]} rounded-full bg-linear-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white font-bold shrink-0`}
>
{props.author.charAt(0)}
</div>
}
>
<img
src={props.avatar}
alt={props.author}
class={`${sizeClasses[size]} rounded-full object-cover shrink-0`}
/>
</Show>
);
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("ru-RU", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
return (
<>
{/* Глобальное toast-уведомление */}
<Show when={toast()}>
{(t) => (
<div
class={`fixed bottom-4 right-4 px-6 py-3 rounded-xl shadow-lg transition-all duration-300 z-50 ${
t().type === "success"
? "bg-green-500 text-white"
: "bg-red-500 text-white"
}`}
>
{t().message}
</div>
)}
</Show>
{isLoading() ? (
<div class="max-w-4xl mx-auto mt-12 pt-8 border-t border-gray-200">
<div class="flex items-center gap-3 mb-8">
<h3 class="text-2xl font-bold text-gray-900">Комментарии</h3>
<span class="px-3 py-1 bg-blue-100 text-blue-700 text-sm font-medium rounded-full">
0
</span>
</div>
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent"></div>
<p class="mt-4 text-gray-600">Загрузка...</p>
</div>
</div>
) : (
<Show when={isAuthenticated()} fallback={<CommentLock />}>
<div class="max-w-4xl mx-auto mt-12 pt-8 border-t border-gray-200">
<div class="flex items-center gap-3 mb-8">
<h3 class="text-2xl font-bold text-gray-900">Комментарии</h3>
<span class="px-3 py-1 bg-blue-100 text-blue-700 text-sm font-medium rounded-full">
{comments().length}
</span>
</div>
<Show
when={comments().length > 0}
fallback={
<div class="text-center py-12 text-gray-500">
<p>Пока нет комментариев. Будьте первым!</p>
</div>
}
>
<div class="space-y-6 mb-10">
<For each={comments()}>
{(comment) => (
<div class="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<div class="flex items-start gap-4">
<Avatar
author={comment.expand?.user?.name || "Аноним"}
avatar={comment.expand?.user?.avatar}
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2 flex-wrap">
<span class="font-semibold text-gray-900">
{comment.expand?.user?.name || "Аноним"}
</span>
<Show when={comment.is_verified}>
<svg
class="w-4 h-4 text-blue-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
</Show>
<span class="text-sm text-gray-400 ml-auto">
{formatDate(comment.created)}
</span>
</div>
<p class="text-gray-700 leading-relaxed mb-3">
{comment.content}
</p>
<div class="flex items-center gap-1">
<button
onClick={() => handleReplyClick(comment.id)}
class="text-sm text-blue-600 hover:text-blue-800 font-medium flex items-center gap-1 transition-colors hover:cursor-pointer"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
/>
</svg>
{replyTo() === comment.id ? "Отменить" : "Ответить"}
</button>
<Show when={toastVisible() === comment.id}>
<span class="text-xs text-yellow-700 bg-yellow-100 px-2 py-1 rounded-md whitespace-nowrap animate-slide-in">
Нельзя ответить на свой комментарий
</span>
</Show>
</div>
</div>
</div>
<Show when={replyTo() === comment.id}>
<div class="mt-4 ml-16 pl-4 border-l-2 border-blue-200">
<CommentForm
isReply={true}
onSubmit={(data) => handleReply(comment.id, data)}
onCancel={() => setReplyTo(null)}
user={currentUser()}
/>
</div>
</Show>
<Show when={comment.replies && comment.replies.length > 0}>
<div class="mt-4 ml-16 space-y-4">
<For each={comment.replies}>
{(reply) => (
<div class="flex items-start gap-3 pl-4 border-l-2 border-gray-200">
<Avatar
author={reply.expand?.user?.name || "Аноним"}
avatar={reply.expand?.user?.avatar}
size="sm"
/>
<div class="flex-1 bg-gray-50 rounded-xl p-4">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<span class="font-semibold text-gray-900 text-sm">
{reply.expand?.user?.name || "Аноним"}
</span>
<Show when={reply.is_verified}>
<svg
class="w-3 h-3 text-blue-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
</Show>
<span class="text-xs text-gray-400 ml-auto">
{formatDate(reply.created)}
</span>
</div>
<p class="text-gray-700 text-sm leading-relaxed">
{reply.content}
</p>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
)}
</For>
</div>
</Show>
{/* Основная форма с передачей данных пользователя */}
<CommentForm onSubmit={handleNewComment} user={currentUser()} />
</div>
</Show>
)}
</>
);
}