astro_avtourist/frontend/src/components/blog/comments/Comments.tsx

504 lines
No EOL
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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, CommentRecord } from "../../../types/comments";
interface ToastMessage {
type: "success" | "error";
message: string;
}
interface CommentsProps {
postSlug: string;
}
export default function Comments(props: CommentsProps) {
const [isAuthenticated, setIsAuthenticated] = createSignal(false);
const [currentUser, setCurrentUser] = createSignal<{
id: string;
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 [editingComment, setEditingComment] = createSignal<string | null>(null);
const [editContent, setEditContent] = createSignal("");
const [toast, setToast] = createSignal<ToastMessage | null>(null);
const showToast = (message: ToastMessage): void => {
setToast(message);
setTimeout(() => setToast(null), 3000);
};
onMount(async () => {
try {
const response = await fetch("/api/auth/me", {
method: "GET",
credentials: "include",
});
const data = await response.json();
if (data.authenticated && data.user) {
setIsAuthenticated(true);
setCurrentUser({
id: data.user.id,
name: data.user.name || "Пользователь",
email: data.user.email,
avatar: data.user.avatar,
});
}
} catch (error) {
console.error("[Comments] Ошибка проверки авторизации:", error);
} finally {
setIsLoading(false);
}
});
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();
const commentsWithReplies: CommentWithReplies[] = await Promise.all(
data.items.map(async (comment: CommentRecord) => {
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");
}
await loadComments();
} catch (error) {
console.error("[Comments] Ошибка создания комментария:", error);
showToast({ type: "error", message: "Не удалось отправить комментарий" });
}
};
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: "Ответ успешно отправлен!" });
await loadComments();
setReplyTo(null);
} catch (error) {
console.error("[Comments] Ошибка создания ответа:", error);
showToast({ type: "error", message: "Не удалось создать ответ. Попробуйте позже." });
}
};
const handleEdit = async (commentId: string, data: { content: string }) => {
try {
const response = await fetch(`/api/comments/${commentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
content: data.content,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to update comment");
}
showToast({ type: "success", message: "Комментарий обновлен!" });
await loadComments();
setEditingComment(null);
setEditContent("");
} catch (error) {
console.error("[Comments] Ошибка обновления комментария:", error);
showToast({ type: "error", message: "Не удалось обновить комментарий" });
}
};
const handleDelete = async (commentId: string) => {
try {
const response = await fetch(`/api/comments/${commentId}`, {
method: "DELETE",
credentials: "include",
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to delete comment");
}
showToast({ type: "success", message: "Комментарий удален!" });
await loadComments();
setEditingComment(null);
} catch (error) {
console.error("[Comments] Ошибка удаления комментария:", error);
showToast({ type: "error", message: "Не удалось удалить комментарий" });
}
};
const handleReplyClick = (commentId: string) => {
const currentUserId = currentUser()?.id;
const comment = comments().find(c => c.id === commentId);
const commentUserId = comment?.user;
if (commentUserId && currentUserId === commentUserId) {
showToast({ type: "error", message: "Нельзя ответить на свой комментарий" });
return;
}
setReplyTo(replyTo() === commentId ? null : commentId);
};
const handleEditClick = (commentId: string, content: string) => {
setEditingComment(commentId);
setEditContent(content);
};
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-gradient-to-br from-yellow-400 to-yellow-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",
});
};
const isCommentOwner = (commentUserId?: string) => {
return currentUser()?.id === commentUserId;
};
return (
<>
<style>{`
@media (max-width: 768px) {
.comments-wrapper { padding: 0; }
.comments-wrapper h3 { font-size: 1.5rem !important; }
.comment-card { padding: 1rem !important; }
.comment-avatar { width: 40px !important; height: 40px !important; }
.reply-form { margin-left: 0 !important; padding-left: 1rem !important; }
}
`}</style>
<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?.firstName || 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?.firstName || comment.expand?.user?.name || "Аноним"}
</span>
<Show when={comment.is_verified}>
<svg
class="w-4 h-4 text-blue-600"
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>
<Show
when={editingComment() === comment.id}
fallback={
<p class="text-gray-700 leading-relaxed mb-3">
{comment.content}
</p>
}
>
<CommentForm
isEdit={true}
initialContent={editContent()}
onSubmit={(data) => handleEdit(comment.id, data)}
onCancel={() => {
setEditingComment(null);
setEditContent("");
}}
onDelete={() => handleDelete(comment.id)}
user={currentUser()}
/>
</Show>
<div class="flex items-center gap-1 flex-wrap">
<button
onClick={() => handleReplyClick(comment.id)}
class="text-sm text-blue-600 hover:text-blue-600-dark 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={isCommentOwner(comment.user)}>
<button
onClick={() => handleEditClick(comment.id, comment.content)}
class="text-sm text-gray-400 hover:text-blue-600 font-medium flex items-center gap-1 transition-colors hover:cursor-pointer ml-2"
>
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
Редактировать
</button>
<button
onClick={() => handleDelete(comment.id)}
class="text-sm text-gray-400 hover:text-red-500 font-medium flex items-center gap-1 transition-colors hover:cursor-pointer ml-2"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Удалить
</button>
</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?.firstName || 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?.firstName || reply.expand?.user?.name || "Аноним"}
</span>
<Show when={reply.is_verified}>
<svg
class="w-3 h-3 text-blue-600"
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>
)}
</>
);
}