Skip to main content
Glama
northernvariables

FedMCP - Federal Parliamentary Information

BillDiscussionPanel.tsx16.5 kB
'use client'; import React, { useState, useEffect, useCallback } from 'react'; import { MessageSquare, Plus, Clock, TrendingUp, ArrowLeft, FileText, Users } from 'lucide-react'; import { useAuth } from '@/contexts/AuthContext'; import { PostCard, CreatePostForm, ReportModal } from '@/components/forum'; import { getPosts, deletePost } from '@/actions/forum'; import { useRealtimePosts } from '@/hooks/useRealtimePosts'; import type { ForumPost } from '@/types/forum'; /** * Discussion scope for section-level discussions */ export interface DiscussionScope { /** Full anchor ID (e.g., "bill:45-1:c-234:s2.1.a") */ anchorId: string; /** Section reference extracted from anchor (e.g., "s2.1.a") */ sectionRef: string; /** Human-readable label for the section */ label?: string; } interface BillDiscussionPanelProps { /** Bill number (e.g., "C-234") */ billNumber: string; /** Parliamentary session (e.g., "45-1") */ session: string; /** Bill title for display */ billTitle?: string; /** Current locale */ locale: string; /** Currently selected section scope (null = whole bill) */ selectedSection?: DiscussionScope | null; /** Callback to clear section selection */ onClearSection?: () => void; /** Callback when a section is mentioned in a comment (for navigation) */ onSectionMention?: (sectionRef: string) => void; /** Whether to show compact header */ compactHeader?: boolean; } type SortOption = 'recent' | 'top'; type ViewMode = 'all' | 'section'; /** * Extract section reference from full anchor ID */ function extractSectionRef(anchorId: string): string { const parts = anchorId.split(':'); return parts[parts.length - 1]; } /** * Generate human-readable label for a section */ function getSectionLabel(sectionRef: string, locale: string): string { // Parse section reference like "s2.1.a" or "part-1" if (sectionRef.startsWith('part-')) { const partNum = sectionRef.replace('part-', ''); return locale === 'fr' ? `Partie ${partNum}` : `Part ${partNum}`; } if (sectionRef.startsWith('s')) { const ref = sectionRef.slice(1); // Remove 's' prefix return locale === 'fr' ? `Section ${ref}` : `Section ${ref}`; } return sectionRef; } /** * Discussion count badge */ const DiscussionCount: React.FC<{ count: number; loading?: boolean }> = ({ count, loading }) => { if (loading) { return ( <span className="text-xs text-gray-400 dark:text-gray-500 animate-pulse"> ... </span> ); } if (count === 0) return null; return ( <span className=" inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300 rounded-full "> {count > 99 ? '99+' : count} </span> ); }; /** * Section scope indicator */ const SectionScopeIndicator: React.FC<{ scope: DiscussionScope; locale: string; onClear: () => void; }> = ({ scope, locale, onClear }) => { const label = scope.label || getSectionLabel(scope.sectionRef, locale); return ( <div className=" flex items-center gap-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg mb-4 "> <FileText className="h-4 w-4 text-blue-600 dark:text-blue-400" /> <span className="text-sm text-blue-700 dark:text-blue-300 flex-1"> {locale === 'fr' ? `Discussion pour ${label}` : `Discussion for ${label}`} </span> <button onClick={onClear} className=" text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 underline " > {locale === 'fr' ? 'Voir tout' : 'View all'} </button> </div> ); }; /** * Empty state for discussions */ const EmptyState: React.FC<{ locale: string; sectionScope?: DiscussionScope | null; onCreateClick?: () => void; canCreate: boolean; }> = ({ locale, sectionScope, onCreateClick, canCreate }) => { const sectionLabel = sectionScope ? getSectionLabel(sectionScope.sectionRef, locale) : null; return ( <div className="text-center py-8"> <MessageSquare className="h-12 w-12 mx-auto text-gray-300 dark:text-gray-600 mb-4" /> <h4 className="text-lg font-medium text-gray-700 dark:text-gray-300 mb-2"> {sectionScope ? (locale === 'fr' ? `Aucune discussion pour ${sectionLabel}` : `No discussions for ${sectionLabel}`) : (locale === 'fr' ? 'Aucune discussion' : 'No discussions yet')} </h4> <p className="text-sm text-gray-500 dark:text-gray-400 mb-4 max-w-xs mx-auto"> {locale === 'fr' ? 'Soyez le premier a partager votre opinion sur cette section.' : 'Be the first to share your thoughts on this section.'} </p> {canCreate ? ( <button onClick={onCreateClick} className=" inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors " > <Plus className="h-4 w-4" /> {locale === 'fr' ? 'Commencer la discussion' : 'Start discussion'} </button> ) : ( <p className="text-xs text-gray-400 dark:text-gray-500"> {locale === 'fr' ? 'Connectez-vous pour participer' : 'Sign in to participate'} </p> )} </div> ); }; /** * BillDiscussionPanel - Section-level discussions for bills * * Features: * - Section-scoped discussions (linked to specific bill sections) * - Fall back to whole-bill discussions when no section selected * - Real-time updates via Supabase subscription * - Sort by recent or top-voted * - Create new comments with section context */ export const BillDiscussionPanel: React.FC<BillDiscussionPanelProps> = ({ billNumber, session, billTitle, locale, selectedSection, onClearSection, onSectionMention, compactHeader = false, }) => { const { user } = useAuth(); const [initialPosts, setInitialPosts] = useState<ForumPost[]>([]); const [isLoading, setIsLoading] = useState(true); const [sortBy, setSortBy] = useState<SortOption>('recent'); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isReportModalOpen, setIsReportModalOpen] = useState(false); const [reportTarget, setReportTarget] = useState<string | null>(null); const [hasMore, setHasMore] = useState(false); const [offset, setOffset] = useState(0); const limit = 10; // Determine the current discussion scope const sectionRef = selectedSection?.sectionRef || null; // Fetch posts based on current scope useEffect(() => { const fetchPosts = async () => { setIsLoading(true); // For section discussions, we pass the section_ref // For whole-bill discussions, we don't const params: Parameters<typeof getPosts>[0] = { post_type: 'bill_comment', bill_number: billNumber, bill_session: session, sort_by: sortBy, limit, offset, }; // If a section is selected, add section filtering // Note: This requires the forum API to support section_ref filtering // We'll add the section_ref to entity_metadata if (sectionRef) { // For now, we'll filter client-side until the API supports it // TODO: Add section_ref support to getPosts } const result = await getPosts(params); if (result.success && result.data) { let postsData = result.data.data; // Client-side filtering by section (temporary until API supports it) if (sectionRef) { postsData = postsData.filter( (post) => post.entity_metadata?.section_ref === sectionRef ); } setInitialPosts( offset === 0 ? postsData : [...initialPosts, ...postsData] ); setHasMore(result.data.has_more); } setIsLoading(false); }; fetchPosts(); }, [billNumber, session, sectionRef, sortBy, offset]); // Reset pagination when scope changes useEffect(() => { setOffset(0); }, [selectedSection]); // Real-time updates const posts = useRealtimePosts(initialPosts, { billNumber, billSession: session, postType: 'bill_comment', enabled: true, }); // Filter posts by section if needed const filteredPosts = sectionRef ? posts.filter((post) => post.entity_metadata?.section_ref === sectionRef) : posts; const handleSortChange = useCallback((newSort: SortOption) => { setSortBy(newSort); setOffset(0); }, []); const handleLoadMore = useCallback(() => { setOffset((prev) => prev + limit); }, []); const handlePostCreated = useCallback(() => { setOffset(0); setSortBy('recent'); setIsCreateModalOpen(false); }, []); const handleDelete = useCallback(async (postId: string) => { const result = await deletePost(postId); if (!result.success) { alert(result.error || 'Failed to delete post'); } }, []); const handleReport = useCallback((postId: string) => { if (!user) { alert(locale === 'fr' ? 'Connectez-vous pour signaler' : 'Please sign in to report posts'); return; } setReportTarget(postId); setIsReportModalOpen(true); }, [user, locale]); const handleReportSuccess = useCallback(() => { alert(locale === 'fr' ? 'Merci pour votre signalement.' : 'Thank you for your report.'); }, [locale]); const handleClearSection = useCallback(() => { onClearSection?.(); }, [onClearSection]); return ( <div className="h-full flex flex-col"> {/* Header */} <div className={` flex-shrink-0 px-4 py-3 border-b border-gray-200 dark:border-gray-700 ${compactHeader ? '' : 'bg-white dark:bg-gray-800'} `}> <div className="flex items-center justify-between"> <div className="flex items-center gap-2"> <MessageSquare className="h-5 w-5 text-blue-600 dark:text-blue-400" /> <h3 className={`font-semibold text-gray-900 dark:text-gray-100 ${compactHeader ? 'text-sm' : ''}`}> {locale === 'fr' ? 'Discussion' : 'Discussion'} </h3> <DiscussionCount count={filteredPosts.length} loading={isLoading} /> </div> {user && ( <button onClick={() => setIsCreateModalOpen(true)} className=" flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors " > <Plus className="h-4 w-4" /> <span className="hidden sm:inline"> {locale === 'fr' ? 'Commenter' : 'Comment'} </span> </button> )} </div> </div> {/* Section scope indicator */} {selectedSection && ( <div className="flex-shrink-0 px-4 pt-4"> <SectionScopeIndicator scope={selectedSection} locale={locale} onClear={handleClearSection} /> </div> )} {/* Sort controls */} {filteredPosts.length > 0 && ( <div className="flex-shrink-0 px-4 py-2 flex items-center gap-2"> <button onClick={() => handleSortChange('recent')} className={` flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors ${sortBy === 'recent' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700' } `} > <Clock className="h-3.5 w-3.5" /> {locale === 'fr' ? 'Récent' : 'Recent'} </button> <button onClick={() => handleSortChange('top')} className={` flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors ${sortBy === 'top' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700' } `} > <TrendingUp className="h-3.5 w-3.5" /> {locale === 'fr' ? 'Populaire' : 'Top'} </button> </div> )} {/* Posts list */} <div className="flex-1 overflow-y-auto px-4 py-2"> {isLoading && offset === 0 ? ( // Loading skeleton <div className="space-y-3"> {[...Array(3)].map((_, i) => ( <div key={i} className="bg-gray-100 dark:bg-gray-800 rounded-lg p-4 animate-pulse" > <div className="flex items-center gap-3 mb-3"> <div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700" /> <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24" /> </div> <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded mb-2" /> <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6" /> </div> ))} </div> ) : filteredPosts.length === 0 ? ( <EmptyState locale={locale} sectionScope={selectedSection} onCreateClick={() => setIsCreateModalOpen(true)} canCreate={!!user} /> ) : ( <> <div className="space-y-3"> {filteredPosts.map((post) => ( <PostCard key={post.id} post={post} showReplyButton={false} onDelete={handleDelete} onReport={handleReport} variant="compact" /> ))} </div> {/* Load more */} {hasMore && ( <div className="mt-4 text-center"> <button onClick={handleLoadMore} disabled={isLoading} className=" px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 disabled:opacity-50 " > {isLoading ? (locale === 'fr' ? 'Chargement...' : 'Loading...') : (locale === 'fr' ? 'Voir plus' : 'Load more')} </button> </div> )} </> )} </div> {/* Create comment modal */} <CreatePostForm postType="bill_comment" billNumber={billNumber} billSession={session} isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} onSuccess={handlePostCreated} placeholder={ selectedSection ? (locale === 'fr' ? `Commentez ${getSectionLabel(selectedSection.sectionRef, locale)}...` : `Comment on ${getSectionLabel(selectedSection.sectionRef, locale)}...`) : (locale === 'fr' ? `Partagez votre avis sur ${billTitle || `le projet de loi ${billNumber}`}...` : `Share your thoughts on ${billTitle || `Bill ${billNumber}`}...`) } submitButtonText={locale === 'fr' ? 'Publier' : 'Post'} entityMetadata={ selectedSection ? { section_ref: selectedSection.sectionRef } : undefined } /> {/* Report modal */} {reportTarget && ( <ReportModal postId={reportTarget} isOpen={isReportModalOpen} onClose={() => { setIsReportModalOpen(false); setReportTarget(null); }} onSuccess={handleReportSuccess} /> )} </div> ); }; export default BillDiscussionPanel;

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/northernvariables/FedMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server