/**
* SectionMarginComments - Google Docs-style margin comments for bill sections
*
* Features:
* - Appears in right margin beside the section
* - Compact inline comment form
* - Shows 1-2 preview comments
* - Expand to see all comments
* - Threaded replies support
*/
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { createPost, deletePost } from '@/actions/forum';
import { reportPost } from '@/actions/moderation';
import { VoteButtons } from '@/components/forum/VoteButtons';
import type { ForumPost } from '@/types/forum';
import {
MessageSquare,
ChevronDown,
ChevronUp,
Send,
X,
MoreHorizontal,
Reply,
Flag,
Trash2,
User,
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { fr, enUS } from 'date-fns/locale';
interface SectionMarginCommentsProps {
billNumber: string;
session: string;
sectionRef: string; // e.g., "s2.1"
sectionLabel: string; // e.g., "Section 2.1"
locale: string;
comments: ForumPost[];
isActive: boolean;
onClose: () => void;
onCommentCreated?: () => void;
/** Position offset from top for alignment */
topOffset?: number;
}
const MAX_PREVIEW_COMMENTS = 2;
const MAX_CONTENT_PREVIEW = 150; // characters
/**
* Single comment display with voting and actions
*/
function CommentItem({
comment,
locale,
onDelete,
onReport,
onReply,
onVoteChange,
isReply = false,
showReplies = false,
}: {
comment: ForumPost;
locale: string;
onDelete?: (id: string) => void;
onReport?: (id: string) => void;
onReply?: (id: string) => void;
onVoteChange?: () => void;
isReply?: boolean;
showReplies?: boolean;
}) {
const { user } = useAuth();
const [showMenu, setShowMenu] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const isOwner = user?.id === comment.author_id;
const dateLocale = locale === 'fr' ? fr : enUS;
const contentTruncated = comment.content.length > MAX_CONTENT_PREVIEW && !isExpanded;
const displayContent = contentTruncated
? comment.content.slice(0, MAX_CONTENT_PREVIEW) + '...'
: comment.content;
return (
<div className={`${isReply ? 'ml-4 pl-3 border-l-2 border-slate-600' : ''}`}>
<div className="py-2">
{/* Author row */}
<div className="flex items-center gap-2 mb-1">
{comment.author_avatar_url ? (
<img
src={comment.author_avatar_url}
alt={comment.author_name || 'User'}
className="w-5 h-5 rounded-full object-cover"
/>
) : (
<div className="w-5 h-5 rounded-full bg-slate-600 flex items-center justify-center">
<User className="w-3 h-3 text-slate-400" />
</div>
)}
<span className="text-xs font-medium text-slate-200 truncate max-w-[100px]">
{comment.author_name || (locale === 'fr' ? 'Anonyme' : 'Anonymous')}
</span>
<span className="text-xs text-slate-500">
{formatDistanceToNow(new Date(comment.created_at), {
addSuffix: true,
locale: dateLocale,
})}
</span>
</div>
{/* Content */}
<p className="text-sm text-slate-300 leading-relaxed whitespace-pre-wrap">
{displayContent}
</p>
{comment.content.length > MAX_CONTENT_PREVIEW && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-xs text-blue-400 hover:text-blue-300 mt-1"
>
{isExpanded
? (locale === 'fr' ? 'Voir moins' : 'Show less')
: (locale === 'fr' ? 'Voir plus' : 'Show more')}
</button>
)}
{/* Actions row */}
<div className="flex items-center gap-3 mt-2">
<VoteButtons
postId={comment.id}
upvotes={comment.upvotes_count}
downvotes={comment.downvotes_count}
userVote={comment.user_vote}
size="sm"
layout="horizontal"
onVoteChange={onVoteChange ? () => onVoteChange() : undefined}
/>
{onReply && user && (
<button
onClick={() => onReply(comment.id)}
className="text-xs text-slate-400 hover:text-slate-200 flex items-center gap-1"
>
<Reply className="w-3 h-3" />
{locale === 'fr' ? 'Répondre' : 'Reply'}
</button>
)}
{/* Menu */}
<div className="relative ml-auto">
<button
onClick={(e) => {
e.stopPropagation();
setShowMenu(!showMenu);
}}
className="p-1 text-slate-500 hover:text-slate-300 rounded"
>
<MoreHorizontal className="w-3 h-3" />
</button>
{showMenu && (
<>
{/* Invisible overlay to catch clicks outside - doesn't block scroll */}
<div
className="fixed inset-0 z-10"
onClick={() => setShowMenu(false)}
style={{ pointerEvents: 'auto' }}
/>
{/* Menu positioned above the button to avoid being cut off */}
<div className="absolute right-0 bottom-full mb-1 bg-slate-700 rounded shadow-lg z-20 min-w-[120px] py-1">
{isOwner && onDelete && (
<button
onClick={() => {
onDelete(comment.id);
setShowMenu(false);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-slate-600"
>
<Trash2 className="w-3 h-3" />
{locale === 'fr' ? 'Supprimer' : 'Delete'}
</button>
)}
{!isOwner && onReport && user && (
<button
onClick={() => {
onReport(comment.id);
setShowMenu(false);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-600"
>
<Flag className="w-3 h-3" />
{locale === 'fr' ? 'Signaler' : 'Report'}
</button>
)}
</div>
</>
)}
</div>
</div>
</div>
{/* Nested replies */}
{showReplies && comment.replies && comment.replies.length > 0 && (
<div className="mt-2">
{comment.replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
locale={locale}
onDelete={onDelete}
onReport={onReport}
onVoteChange={onVoteChange}
isReply={true}
/>
))}
</div>
)}
</div>
);
}
/**
* Compact inline comment form
*/
function CompactCommentForm({
billNumber,
session,
sectionRef,
locale,
onSuccess,
onCancel,
parentPostId,
autoFocus = false,
}: {
billNumber: string;
session: string;
sectionRef: string;
locale: string;
onSuccess?: () => void;
onCancel?: () => void;
parentPostId?: string;
autoFocus?: boolean;
}) {
const { user } = useAuth();
const [content, setContent] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (autoFocus && textareaRef.current) {
textareaRef.current.focus();
}
}, [autoFocus]);
// Auto-resize textarea
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;
}
}, [content]);
if (!user) {
return (
<div className="text-center py-2">
<a
href="/auth/signin"
className="text-xs text-blue-400 hover:text-blue-300"
>
{locale === 'fr' ? 'Connectez-vous pour commenter' : 'Sign in to comment'}
</a>
</div>
);
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim() || isSubmitting) return;
setIsSubmitting(true);
setError(null);
try {
const autoTitle = `Comment on ${session}/${billNumber} - ${sectionRef}`;
const result = await createPost({
post_type: 'bill_comment',
bill_number: billNumber,
bill_session: session,
title: autoTitle,
content: content.trim(),
entity_metadata: { section_ref: sectionRef },
parent_post_id: parentPostId,
});
if (result.success) {
setContent('');
onSuccess?.();
} else {
setError(result.error || (locale === 'fr' ? 'Erreur' : 'Error'));
}
} catch (err) {
setError(locale === 'fr' ? 'Une erreur est survenue' : 'An error occurred');
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="relative">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={
parentPostId
? (locale === 'fr' ? 'Votre réponse...' : 'Your reply...')
: (locale === 'fr' ? 'Ajouter un commentaire...' : 'Add a comment...')
}
className="w-full px-3 py-2 pr-9 text-sm bg-slate-700 border border-slate-600 rounded-lg text-slate-200 placeholder-slate-500 resize-none focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 min-h-[36px] max-h-[120px]"
rows={1}
disabled={isSubmitting}
/>
{/* Submit button - inside textarea area */}
<button
type="submit"
disabled={!content.trim() || isSubmitting}
className="absolute right-2 bottom-2 p-1 text-blue-400 hover:text-blue-300 disabled:text-slate-600 disabled:cursor-not-allowed transition-colors"
>
<Send className="w-4 h-4" />
</button>
{error && (
<p className="text-xs text-red-400 mt-1">{error}</p>
)}
{onCancel && (
<button
type="button"
onClick={onCancel}
className="text-xs text-slate-500 hover:text-slate-300 mt-1"
>
{locale === 'fr' ? 'Annuler' : 'Cancel'}
</button>
)}
</form>
);
}
/**
* Build threaded tree from flat list
*/
function buildThreadTree(posts: ForumPost[]): ForumPost[] {
const postMap = new Map<string, ForumPost & { replies: ForumPost[] }>();
posts.forEach((post) => {
postMap.set(post.id, { ...post, replies: [] });
});
const topLevelPosts: ForumPost[] = [];
posts.forEach((post) => {
const postWithReplies = postMap.get(post.id)!;
if (post.parent_post_id && postMap.has(post.parent_post_id)) {
const parent = postMap.get(post.parent_post_id)!;
parent.replies.push(postWithReplies);
} else {
topLevelPosts.push(postWithReplies);
}
});
// Sort by votes then by date
topLevelPosts.sort((a, b) => {
const aScore = (a.upvotes_count || 0) - (a.downvotes_count || 0);
const bScore = (b.upvotes_count || 0) - (b.downvotes_count || 0);
if (bScore !== aScore) return bScore - aScore;
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
return topLevelPosts;
}
/**
* Main component
*/
export function SectionMarginComments({
billNumber,
session,
sectionRef,
sectionLabel,
locale,
comments,
isActive,
onClose,
onCommentCreated,
topOffset = 0,
}: SectionMarginCommentsProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const threadedComments = buildThreadTree(comments);
const totalCount = comments.length;
const previewComments = threadedComments.slice(0, MAX_PREVIEW_COMMENTS);
const hasMore = threadedComments.length > MAX_PREVIEW_COMMENTS;
const handleDelete = async (postId: string) => {
const result = await deletePost(postId);
if (result.success) {
onCommentCreated?.();
} else {
alert(result.error || (locale === 'fr' ? 'Échec de la suppression' : 'Failed to delete'));
}
};
const handleReport = async (postId: string) => {
const result = await reportPost({ post_id: postId, reason: 'other' });
if (result.success) {
alert(locale === 'fr' ? 'Merci pour votre signalement' : 'Thank you for your report');
} else {
alert(result.error || (locale === 'fr' ? 'Échec du signalement' : 'Failed to report'));
}
};
const handleReply = (postId: string) => {
setReplyingTo(postId);
};
const handleReplySuccess = () => {
setReplyingTo(null);
onCommentCreated?.();
};
if (!isActive) return null;
return (
<div
className="absolute left-full ml-4 w-72 bg-slate-800 border border-slate-700 rounded-lg shadow-xl z-20 max-h-[400px] overflow-hidden flex flex-col"
style={{ top: topOffset }}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-slate-700 bg-slate-900 flex-shrink-0">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-blue-400" />
<span className="text-sm font-medium text-slate-200 truncate">
{sectionLabel}
</span>
{totalCount > 0 && (
<span className="text-xs bg-blue-600 text-white px-1.5 py-0.5 rounded-full">
{totalCount}
</span>
)}
</div>
<button
onClick={onClose}
className="p-1 text-slate-500 hover:text-slate-300 rounded"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-3 py-2">
{/* Comment form */}
<div className="mb-3">
<CompactCommentForm
billNumber={billNumber}
session={session}
sectionRef={sectionRef}
locale={locale}
onSuccess={onCommentCreated}
autoFocus={true}
/>
</div>
{/* Comments list */}
{totalCount === 0 ? (
<p className="text-xs text-slate-500 text-center py-2">
{locale === 'fr'
? 'Soyez le premier à commenter'
: 'Be the first to comment'}
</p>
) : (
<div className="space-y-2 divide-y divide-slate-700">
{(isExpanded ? threadedComments : previewComments).map((comment) => (
<div key={comment.id}>
<CommentItem
comment={comment}
locale={locale}
onDelete={handleDelete}
onReport={handleReport}
onReply={handleReply}
onVoteChange={onCommentCreated}
showReplies={true}
/>
{/* Reply form */}
{replyingTo === comment.id && (
<div className="mt-2 ml-4 pl-3 border-l-2 border-slate-600">
<CompactCommentForm
billNumber={billNumber}
session={session}
sectionRef={sectionRef}
locale={locale}
onSuccess={handleReplySuccess}
onCancel={() => setReplyingTo(null)}
parentPostId={comment.id}
autoFocus={true}
/>
</div>
)}
</div>
))}
</div>
)}
{/* Expand/collapse */}
{hasMore && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-center gap-1 py-2 text-xs text-blue-400 hover:text-blue-300"
>
{isExpanded ? (
<>
<ChevronUp className="w-3 h-3" />
{locale === 'fr' ? 'Voir moins' : 'Show less'}
</>
) : (
<>
<ChevronDown className="w-3 h-3" />
{locale === 'fr'
? `Voir ${threadedComments.length - MAX_PREVIEW_COMMENTS} de plus`
: `Show ${threadedComments.length - MAX_PREVIEW_COMMENTS} more`}
</>
)}
</button>
)}
</div>
</div>
);
}
export default SectionMarginComments;