import { useT } from "@agent-native/core/client/i18n";
import {
IconX,
IconCheck,
IconTrash,
IconMessageCircle,
IconChevronDown,
IconAlertTriangle,
IconRefresh,
} from "@tabler/icons-react";
import { useState, useRef, useEffect } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
useSlideComments,
useCreateSlideComment,
useResolveSlideComment,
useDeleteSlideComment,
emailToColor,
formatRelativeTime,
type CommentThread,
type SlideComment,
} from "@/hooks/use-slide-comments";
interface SlideCommentsPanelProps {
deckId: string | null;
slideId: string | null;
pendingComment: { quotedText: string } | null;
onPendingDone: () => void;
onClose: () => void;
}
/** Initials avatar */
function Avatar({ email, name }: { email: string; name?: string | null }) {
const color = emailToColor(email);
const initials = (name || email)
.split(/[@.\s]/)
.filter(Boolean)
.slice(0, 2)
.map((s) => s[0].toUpperCase())
.join("")
.slice(0, 2);
return (
{initials}
);
}
/** Single comment (inside a thread) */
function CommentItem({
comment,
onDelete,
}: {
comment: SlideComment;
onDelete: () => void;
}) {
const t = useT();
const [hovered, setHovered] = useState(false);
return (
setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{comment.author_name || comment.author_email.split("@")[0]}
{formatRelativeTime(comment.created_at)}
{hovered && (
{t("comments.deleteComment")}
)}
{comment.content}
);
}
/** Pending new comment input */
function PendingCommentInput({
quotedText,
deckId,
slideId,
onDone,
onCancel,
}: {
quotedText: string;
deckId: string;
slideId: string;
onDone: () => void;
onCancel: () => void;
}) {
const t = useT();
const [text, setText] = useState("");
const [error, setError] = useState(null);
const textareaRef = useRef(null);
const createComment = useCreateSlideComment();
useEffect(() => {
textareaRef.current?.focus();
}, []);
const submit = async () => {
const trimmed = text.trim();
if (!trimmed) return;
setError(null);
try {
await createComment.mutateAsync({
deckId,
slideId,
content: trimmed,
quotedText: quotedText || undefined,
});
setText("");
onDone();
} catch (err) {
setError(
err instanceof Error ? err.message : t("comments.saveCommentFailed"),
);
}
};
return (
{quotedText && (
"{quotedText}"
)}
);
}
/** Inline reply input below a thread */
function ReplyInput({
deckId,
slideId,
threadId,
onDone,
}: {
deckId: string;
slideId: string;
threadId: string;
onDone: () => void;
}) {
const t = useT();
const [text, setText] = useState("");
const [error, setError] = useState(null);
const textareaRef = useRef(null);
const createComment = useCreateSlideComment();
useEffect(() => {
textareaRef.current?.focus();
}, []);
const submit = async () => {
const trimmed = text.trim();
if (!trimmed) return;
setError(null);
try {
await createComment.mutateAsync({
deckId,
slideId,
threadId,
content: trimmed,
});
setText("");
onDone();
} catch (err) {
setError(
err instanceof Error ? err.message : t("comments.saveReplyFailed"),
);
}
};
return (
);
}
/** A single comment thread card */
function ThreadCard({
thread,
deckId,
slideId,
}: {
thread: CommentThread;
deckId: string;
slideId: string;
}) {
const t = useT();
const [replyOpen, setReplyOpen] = useState(false);
const [showReplies, setShowReplies] = useState(false);
const [hovered, setHovered] = useState(false);
const resolveComment = useResolveSlideComment();
const deleteComment = useDeleteSlideComment();
const rootComment = thread.comments[0];
const replies = thread.comments.slice(1);
if (!rootComment) return null;
return (
setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Quoted text */}
{thread.quotedText && (
"{thread.quotedText}"
)}
{/* Root comment */}
{rootComment.author_name ||
rootComment.author_email.split("@")[0]}
{formatRelativeTime(rootComment.created_at)}
{hovered && !thread.resolved && (
<>
{t("comments.resolveThread")}
{t("comments.deleteComment")}
>
)}
{rootComment.content}
{/* Replies toggle */}
{replies.length > 0 && (
)}
{/* Expanded replies */}
{showReplies && (
{replies.map((r) => (
deleteComment.mutate({ id: r.id })}
/>
))}
)}
{/* Reply & resolve actions */}
{!thread.resolved && (
{!replyOpen && (
)}
)}
{replyOpen && (
setReplyOpen(false)}
/>
)}
);
}
export function SlideCommentsPanel({
deckId,
slideId,
pendingComment,
onPendingDone,
onClose,
}: SlideCommentsPanelProps) {
const t = useT();
const commentsQuery = useSlideComments(deckId, slideId);
const threads = commentsQuery.data ?? [];
const [showResolved, setShowResolved] = useState(false);
const [addingComment, setAddingComment] = useState(false);
const activeThreads = threads.filter((t) => !t.resolved);
const resolvedThreads = threads.filter((t) => t.resolved);
const visibleThreads = showResolved ? threads : activeThreads;
const showLoadError = commentsQuery.isError && threads.length === 0;
// When pending comment arrives, cancel any manual "add comment" mode
useEffect(() => {
if (pendingComment) setAddingComment(false);
}, [pendingComment]);
const showInput = pendingComment || addingComment;
return (
{/* Header */}
{t("comments.title")}
{!showInput && deckId && slideId && (
{t("comments.addComment")}
)}
{t("comments.close")}
{/* Content */}
{/* Pending / manual new comment input */}
{showInput && deckId && slideId && (
{
onPendingDone();
setAddingComment(false);
}}
onCancel={() => {
onPendingDone();
setAddingComment(false);
}}
/>
)}
{showLoadError && (
{t("comments.loadFailed")}
)}
{/* Thread list */}
{!showLoadError &&
visibleThreads.map((thread) => (
))}
{/* Resolved toggle */}
{resolvedThreads.length > 0 && (
)}
{/* Empty state */}
{!showLoadError &&
!showInput &&
visibleThreads.length === 0 &&
(deckId && slideId ? (
) : (
{t("comments.noCommentsYet")}
{t("comments.selectSlideToAdd")}
))}
);
}