Corp/frontend/src/components/documents/DocumentComments.tsx

310 lines
10 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.

"use client";
import { useState, useEffect } from "react";
import { MessageCircle, Edit2, CornerDownRight } from "lucide-react";
import { apiClient } from "@/lib/api";
import { toast } from "sonner";
import { useUser } from "@/contexts/UserContext";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
interface Comment {
id: number;
text: string;
author: {
id: number;
first_name: string;
last_name: string;
patronymic?: string;
};
created_at: string;
updated_at: string;
parent?: number;
replies?: Comment[];
}
interface DocumentCommentsProps {
documentId: number;
}
export function DocumentComments({ documentId }: DocumentCommentsProps) {
const { user } = useUser();
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [newComment, setNewComment] = useState("");
const [replyTo, setReplyTo] = useState<number | null>(null);
const [editingId, setEditingId] = useState<number | null>(null);
const [editText, setEditText] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
loadComments();
}, [documentId]);
const loadComments = async () => {
try {
setLoading(true);
const response = await apiClient.getDocumentComments(documentId);
const data = Array.isArray(response) ? response : (response.results || []);
// Структурируем комментарии в дерево
const commentsMap = new Map<number, Comment>();
const rootComments: Comment[] = [];
// Сначала создаем все комментарии
data.forEach((comment: Comment) => {
commentsMap.set(comment.id, { ...comment, replies: [] });
});
// Затем строим дерево
data.forEach((comment: Comment) => {
const commentNode = commentsMap.get(comment.id)!;
if (comment.parent) {
const parentNode = commentsMap.get(comment.parent);
if (parentNode) {
parentNode.replies!.push(commentNode);
}
} else {
rootComments.push(commentNode);
}
});
setComments(rootComments);
} catch (error) {
console.error("Ошибка загрузки комментариев:", error);
toast.error("Не удалось загрузить комментарии");
} finally {
setLoading(false);
}
};
const handleSubmitComment = async () => {
if (!newComment.trim()) return;
setSubmitting(true);
try {
await apiClient.createDocumentComment({
document: documentId,
text: newComment,
parent: replyTo || undefined,
});
toast.success("Комментарий добавлен");
setNewComment("");
setReplyTo(null);
await loadComments();
} catch (error) {
console.error("Ошибка добавления комментария:", error);
toast.error("Не удалось добавить комментарий");
} finally {
setSubmitting(false);
}
};
const handleUpdateComment = async (id: number) => {
if (!editText.trim()) return;
setSubmitting(true);
try {
await apiClient.updateDocumentComment(id, editText);
toast.success("Комментарий обновлен");
setEditingId(null);
setEditText("");
await loadComments();
} catch (error) {
console.error("Ошибка обновления комментария:", error);
toast.error("Не удалось обновить комментарий");
} finally {
setSubmitting(false);
}
};
const handleDeleteComment = async (id: number) => {
if (!window.confirm("Удалить комментарий?")) return;
try {
await apiClient.deleteDocumentComment(id);
toast.success("Комментарий удален");
await loadComments();
} catch (error) {
console.error("Ошибка удаления комментария:", error);
toast.error("Не удалось удалить комментарий");
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "только что";
if (diffMins < 60) return `${diffMins} мин назад`;
if (diffHours < 24) return `${diffHours} ч назад`;
if (diffDays < 7) return `${diffDays} дн назад`;
return date.toLocaleDateString("ru-RU", {
day: "numeric",
month: "short",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
};
const renderComment = (comment: Comment, depth: number = 0) => {
const isAuthor = user?.id === comment.author.id;
const isEditing = editingId === comment.id;
return (
<div
key={comment.id}
className={`${depth > 0 ? "app-divider ml-6 border-l-2 pl-4" : ""}`}
>
<div className="app-surface-muted rounded-lg p-3">
<div className="mb-2 flex items-start justify-between gap-2">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--foreground)]">
{comment.author.last_name} {comment.author.first_name}
</span>
<span className="app-text-muted text-xs">
{formatDate(comment.created_at)}
</span>
{comment.updated_at !== comment.created_at && (
<span className="app-text-muted text-xs">(изменено)</span>
)}
</div>
</div>
{isAuthor && !isEditing && (
<div className="flex gap-1">
<button
onClick={() => {
setEditingId(comment.id);
setEditText(comment.text);
}}
className="app-action-ghost app-text-muted rounded p-1 hover:text-[var(--foreground)]"
title="Редактировать"
>
<Edit2 size={14} />
</button>
<CommentDeleteButton onClick={() => handleDeleteComment(comment.id)} />
</div>
)}
</div>
{isEditing ? (
<div className="space-y-2">
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
className="app-input w-full rounded-lg p-2 text-sm"
rows={2}
/>
<div className="flex gap-2">
<button
onClick={() => handleUpdateComment(comment.id)}
disabled={submitting || !editText.trim()}
className="app-action-primary rounded-lg px-3 py-1.5 text-xs font-medium disabled:opacity-50"
>
Сохранить
</button>
<button
onClick={() => {
setEditingId(null);
setEditText("");
}}
className="app-action-secondary rounded-lg px-3 py-1.5 text-xs font-medium"
>
Отмена
</button>
</div>
</div>
) : (
<>
<p className="app-text-wrap whitespace-pre-wrap text-sm text-[var(--foreground)]">{comment.text}</p>
{depth < 3 && (
<button
onClick={() => setReplyTo(comment.id)}
className="app-text-muted hover:app-accent-text mt-2 inline-flex items-center gap-1 text-xs"
>
<CornerDownRight size={12} />
Ответить
</button>
)}
</>
)}
</div>
{/* Replies */}
{comment.replies && comment.replies.length > 0 && (
<div className="mt-2 space-y-2">
{comment.replies.map((reply) => renderComment(reply, depth + 1))}
</div>
)}
{/* Reply form */}
{replyTo === comment.id && (
<div className="app-divider ml-6 mt-2 border-l-2 pl-4">
<CommentComposer
value={newComment}
onChange={setNewComment}
onSubmit={handleSubmitComment}
onCancel={() => {
setReplyTo(null);
setNewComment("");
}}
disabled={submitting}
multiline
placeholder="Напишите ответ..."
/>
</div>
)}
</div>
);
};
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[var(--accent-primary)] border-t-transparent" />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<MessageCircle size={18} className="app-text-muted" />
<h3 className="text-sm font-medium text-[var(--foreground)]">
Комментарии ({comments.reduce((acc, c) => acc + 1 + (c.replies?.length || 0), 0)})
</h3>
</div>
{/* New comment form */}
{!replyTo && (
<CommentComposer
value={newComment}
onChange={setNewComment}
onSubmit={handleSubmitComment}
disabled={submitting}
multiline
rows={3}
placeholder="Добавить комментарий..."
/>
)}
{/* Comments list */}
{comments.length === 0 ? (
<p className="app-text-muted py-8 text-center text-sm">
Пока нет комментариев. Будьте первым!
</p>
) : (
<div className="space-y-3">
{comments.map((comment) => renderComment(comment))}
</div>
)}
</div>
);
}