'use client';
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useQuery } from '@apollo/client';
import { BillTextViewer } from './BillTextViewer';
import { BillSectionShareButton } from './BillSectionShareButton';
import { getActivityLevel, type ActivityLevel } from './DiscussionActivityIndicator';
import { BillDiscussionPanel, type DiscussionScope } from './BillDiscussionPanel';
import type { ForumPost } from '@/types/forum';
import { MessageSquare, X, Maximize2, Minimize2, FileText, ChevronDown, Check } from 'lucide-react';
import { useMobileDetect } from '@/hooks/useMobileDetect';
import { GET_BILL_STRUCTURE } from '@/lib/queries';
/**
* View modes for the split view layout
*/
export type SplitViewMode = 'read' | 'discuss-50' | 'discuss-33';
/**
* Section discussion count data
*/
export interface SectionDiscussionCount {
/** Section anchor ID */
sectionId: string;
/** Number of comments/replies */
count: number;
}
interface BillSplitViewProps {
/** Bill number (e.g., "C-234") */
billNumber: string;
/** Parliamentary session (e.g., "45-1") */
session: string;
/** Current locale for i18n */
locale: string;
/** Initial view mode */
initialMode?: SplitViewMode;
/** Initial section to scroll to (anchor ID) */
initialSection?: string;
/** Callback when a section is selected for discussion */
onSectionSelect?: (sectionAnchorId: string) => void;
/** Callback when view mode changes */
onModeChange?: (mode: SplitViewMode) => void;
/** Children to render in the discussion panel */
discussionPanel?: React.ReactNode;
/** Whether discussions are enabled */
discussionsEnabled?: boolean;
/** Discussion counts per section for heatmap */
sectionDiscussionCounts?: SectionDiscussionCount[];
/** Whether to show the heatmap margin */
showHeatmap?: boolean;
/** Action button to render in Discussion header */
discussionHeaderAction?: React.ReactNode;
/** Comments organized by section reference (for inline mode) */
commentsBySection?: Record<string, ForumPost[]>;
/** Callback when a comment is created (for inline mode) */
onCommentCreated?: () => void;
/** Use inline margin comments instead of panel */
useInlineComments?: boolean;
/** Whether the view is maximized (hides parent header/tabs) */
isMaximized?: boolean;
/** Callback when maximize state changes */
onMaximizeChange?: (isMaximized: boolean) => void;
}
/**
* Mode configuration for split view layouts
*/
const MODE_CONFIG: Record<SplitViewMode, { billWidth: string; discussionWidth: string; label: string }> = {
'read': {
billWidth: '100%',
discussionWidth: '0%',
label: 'Read',
},
'discuss-50': {
billWidth: '50%',
discussionWidth: '50%',
label: '50/50',
},
'discuss-33': {
billWidth: '33.333%',
discussionWidth: '66.666%',
label: '1/3 - 2/3',
},
};
/**
* Icon components for the mode toggle buttons
*/
const ReadIcon = () => (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
);
const SplitIcon = () => (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="8" height="18" rx="1" />
<rect x="13" y="3" width="8" height="18" rx="1" />
</svg>
);
const DiscussIcon = () => (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="6" height="18" rx="1" />
<rect x="11" y="3" width="10" height="18" rx="1" />
</svg>
);
/**
* Mode toggle button component
*/
interface ModeButtonProps {
mode: SplitViewMode;
currentMode: SplitViewMode;
onClick: (mode: SplitViewMode) => void;
disabled?: boolean;
}
const ModeButton: React.FC<ModeButtonProps> = ({ mode, currentMode, onClick, disabled }) => {
const isActive = mode === currentMode;
const config = MODE_CONFIG[mode];
const Icon = mode === 'read' ? ReadIcon : mode === 'discuss-50' ? SplitIcon : DiscussIcon;
return (
<button
onClick={() => onClick(mode)}
disabled={disabled}
className={`
flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md
transition-colors duration-200
${isActive
? 'bg-accent-red/20 text-accent-red'
: 'text-text-secondary hover:bg-bg-secondary'
}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
title={config.label}
aria-pressed={isActive}
>
<Icon />
<span className="hidden sm:inline">{config.label}</span>
</button>
);
};
/**
* Empty state for discussion panel when no section is selected
*/
const EmptyDiscussionState: React.FC<{ locale: string }> = ({ locale }) => (
<div className="flex flex-col items-center justify-center h-full text-center p-6 text-text-tertiary">
<svg
className="w-16 h-16 mb-4 text-text-tertiary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<h3 className="text-lg font-medium text-text-primary mb-2">
{locale === 'fr' ? 'Aucune section sélectionnée' : 'No Section Selected'}
</h3>
<p className="text-sm text-text-secondary max-w-xs">
{locale === 'fr'
? 'Cliquez sur le bouton de discussion d\'une section pour voir ou démarrer une discussion.'
: 'Click on a section\'s discussion button to view or start a discussion.'}
</p>
</div>
);
/**
* Resizable divider between panels
*/
interface DividerProps {
onDragStart: () => void;
onDrag: (deltaX: number) => void;
onDragEnd: () => void;
}
const ResizableDivider: React.FC<DividerProps> = ({ onDragStart, onDrag, onDragEnd }) => {
const dividerRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
const startX = useRef(0);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
isDragging.current = true;
startX.current = e.clientX;
onDragStart();
e.preventDefault();
}, [onDragStart]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.current) return;
const deltaX = e.clientX - startX.current;
startX.current = e.clientX;
onDrag(deltaX);
};
const handleMouseUp = () => {
if (isDragging.current) {
isDragging.current = false;
onDragEnd();
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [onDrag, onDragEnd]);
return (
<div
ref={dividerRef}
onMouseDown={handleMouseDown}
className="
w-1 bg-border-subtle
hover:bg-accent-red
cursor-col-resize
transition-colors duration-150
flex-shrink-0
relative
group
"
role="separator"
aria-orientation="vertical"
aria-valuenow={50}
tabIndex={0}
>
{/* Visual grip indicator */}
<div className="
absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2
w-1 h-8
bg-text-tertiary
group-hover:bg-accent-red
rounded-full
opacity-50 group-hover:opacity-100
transition-all duration-150
" />
</div>
);
};
/**
* Activity level color mapping
*/
const ACTIVITY_COLORS: Record<ActivityLevel, string> = {
none: 'bg-transparent',
low: 'bg-text-tertiary/30',
medium: 'bg-blue-500',
hot: 'bg-orange-500',
};
/**
* Section item for the dropdown
*/
interface SectionItem {
/** Section reference (e.g., "s1", "s2") */
sectionRef: string;
/** Display label (e.g., "Section 1", "Section 2 - Definitions") */
label: string;
/** Number of comments for this section */
commentCount: number;
}
/**
* Props for the section discussion dropdown
*/
interface SectionDiscussionDropdownProps {
/** Currently selected section (null = general discussion) */
selectedSection: string | null;
/** Callback when a section is selected */
onSelect: (sectionRef: string | null) => void;
/** List of bill sections */
sections: SectionItem[];
/** Count of general discussion comments */
generalCommentsCount: number;
/** Current locale for i18n */
locale: string;
/** Whether the dropdown is active (panel is open) */
isActive: boolean;
}
/**
* Dropdown component for selecting discussion scope
*/
const SectionDiscussionDropdown: React.FC<SectionDiscussionDropdownProps> = ({
selectedSection,
onSelect,
sections,
generalCommentsCount,
locale,
isActive,
}) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Get display label for current selection
const getDisplayLabel = () => {
if (selectedSection === null) {
return locale === 'fr' ? 'Discussion générale' : 'General Discussion';
}
const section = sections.find(s => s.sectionRef === selectedSection);
return section?.label || selectedSection;
};
// Get comment count for current selection
const getCurrentCount = () => {
if (selectedSection === null) {
return generalCommentsCount;
}
const section = sections.find(s => s.sectionRef === selectedSection);
return section?.commentCount || 0;
};
const handleSelect = (sectionRef: string | null) => {
onSelect(sectionRef);
setIsOpen(false);
};
return (
<div ref={dropdownRef} className="relative">
{/* Dropdown trigger button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`
flex items-center gap-2 px-3 py-1.5
text-sm font-medium rounded-lg transition-colors
${isActive
? 'bg-blue-700 text-white'
: 'bg-blue-600 hover:bg-blue-700 text-white'
}
`}
>
<MessageSquare className="w-4 h-4" />
<span className="max-w-[200px] truncate">{getDisplayLabel()}</span>
{getCurrentCount() > 0 && (
<span className="bg-white/20 text-white text-xs px-1.5 py-0.5 rounded-full">
{getCurrentCount()}
</span>
)}
<ChevronDown className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{/* Dropdown menu */}
{isOpen && (
<div className="absolute top-full left-0 mt-1 w-72 max-h-80 overflow-y-auto bg-bg-elevated rounded-lg shadow-lg border border-border-subtle z-50">
{/* General Discussion option */}
<button
onClick={() => handleSelect(null)}
className={`
w-full flex items-center justify-between px-4 py-3
hover:bg-bg-overlay transition-colors text-left
${selectedSection === null ? 'bg-blue-500/10' : ''}
`}
>
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-blue-500" />
<span className="font-medium text-text-primary">
{locale === 'fr' ? 'Discussion générale' : 'General Discussion'}
</span>
</div>
<div className="flex items-center gap-2">
{generalCommentsCount > 0 && (
<span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-0.5 rounded-full">
{generalCommentsCount}
</span>
)}
{selectedSection === null && (
<Check className="w-4 h-4 text-blue-500" />
)}
</div>
</button>
{/* Divider */}
{sections.length > 0 && (
<div className="border-t border-border-subtle my-1">
<div className="px-4 py-2 text-xs font-medium text-text-tertiary uppercase">
{locale === 'fr' ? 'Sections' : 'Sections'}
</div>
</div>
)}
{/* Section options */}
{sections.map((section) => (
<button
key={section.sectionRef}
onClick={() => handleSelect(section.sectionRef)}
className={`
w-full flex items-center justify-between px-4 py-2.5
hover:bg-bg-overlay transition-colors text-left
${selectedSection === section.sectionRef ? 'bg-blue-500/10' : ''}
`}
>
<span className="text-sm text-text-primary truncate pr-2">
{section.label}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
{section.commentCount > 0 && (
<span className="text-xs bg-gray-500/20 text-text-secondary px-2 py-0.5 rounded-full">
{section.commentCount}
</span>
)}
{selectedSection === section.sectionRef && (
<Check className="w-4 h-4 text-blue-500" />
)}
</div>
</button>
))}
{/* Empty state */}
{sections.length === 0 && (
<div className="px-4 py-3 text-sm text-text-tertiary text-center">
{locale === 'fr' ? 'Aucune section disponible' : 'No sections available'}
</div>
)}
</div>
)}
</div>
);
};
/**
* Heatmap margin column showing discussion activity per section
*/
interface HeatmapMarginProps {
/** Discussion counts per section */
sectionCounts: SectionDiscussionCount[];
/** Currently selected section */
selectedSection?: string;
/** Callback when a section heatbar is clicked */
onSectionClick?: (sectionId: string) => void;
/** Current locale for tooltips */
locale: string;
}
const HeatmapMargin: React.FC<HeatmapMarginProps> = ({
sectionCounts,
selectedSection,
onSectionClick,
locale,
}) => {
// Get tooltip text
const getTooltip = (sectionId: string, count: number) => {
const section = sectionId.split(':').pop() || sectionId;
if (count === 0) {
return locale === 'fr'
? `Section ${section}: Aucun commentaire`
: `Section ${section}: No comments`;
}
return locale === 'fr'
? `Section ${section}: ${count} commentaire${count > 1 ? 's' : ''}`
: `Section ${section}: ${count} comment${count > 1 ? 's' : ''}`;
};
return (
<div
className="
w-3 flex-shrink-0
bg-bg-secondary
border-l border-r border-border-subtle
overflow-y-auto
scrollbar-hide
"
aria-label={locale === 'fr' ? 'Indicateur d\'activité des discussions' : 'Discussion activity indicator'}
>
<div className="flex flex-col py-2 gap-1 min-h-full">
{sectionCounts.map(({ sectionId, count }) => {
const level = getActivityLevel(count);
const isSelected = sectionId === selectedSection;
return (
<button
key={sectionId}
onClick={() => onSectionClick?.(sectionId)}
className={`
w-2 h-6 mx-auto rounded-full
${ACTIVITY_COLORS[level]}
${isSelected ? 'ring-2 ring-accent-red ring-offset-1 ring-offset-bg-secondary' : ''}
${count > 0 ? 'cursor-pointer hover:opacity-80' : 'cursor-default'}
transition-all duration-200
`}
title={getTooltip(sectionId, count)}
aria-label={getTooltip(sectionId, count)}
/>
);
})}
</div>
</div>
);
};
/**
* BillSplitView - Layout component for bill text with optional discussion panel
*
* Provides three view modes:
* - Read (100% bill text)
* - Discuss 50/50 (equal split)
* - Discuss 1/3-2/3 (more space for discussion)
*
* Features:
* - Resizable divider between panels
* - Section selection for focused discussions
* - Responsive layout with mobile fallback
* - Keyboard navigation support
*/
export const BillSplitView: React.FC<BillSplitViewProps> = ({
billNumber,
session,
locale,
initialMode = 'discuss-50',
initialSection,
onSectionSelect,
onModeChange,
discussionPanel,
discussionsEnabled = true,
sectionDiscussionCounts = [],
showHeatmap = true,
discussionHeaderAction,
commentsBySection,
onCommentCreated,
useInlineComments = false,
isMaximized = false,
onMaximizeChange,
}) => {
const [mode, setMode] = useState<SplitViewMode>(initialMode);
const [selectedSection, setSelectedSection] = useState<string | undefined>(initialSection);
const [customBillWidth, setCustomBillWidth] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState(false);
// When true, shows the discussion panel in split view (general or section-specific)
const [showDiscussionPanel, setShowDiscussionPanel] = useState(false);
// The currently selected section for discussion (null = general discussion)
const [discussionSectionRef, setDiscussionSectionRef] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Mobile-specific state
const { isMobile, isTablet } = useMobileDetect();
const isMobileView = isMobile || isTablet;
// Mobile view: 'text' shows bill text, 'discussion' shows discussion panel
const [mobileView, setMobileView] = useState<'text' | 'discussion'>('text');
// Refs for preserving scroll positions
const billTextScrollRef = useRef<number>(0);
const discussionScrollRef = useRef<number>(0);
const mobileTextContainerRef = useRef<HTMLDivElement>(null);
const mobileDiscussionContainerRef = useRef<HTMLDivElement>(null);
// Query bill structure to get sections for the dropdown
const { data: billStructureData } = useQuery(GET_BILL_STRUCTURE, {
variables: { number: billNumber, session },
// This query is likely already cached from BillTextViewer
});
// Get general discussion comments count
const generalCommentsCount = commentsBySection?.['general']?.length || 0;
// Build sections list from bill structure for the dropdown
const dropdownSections = useMemo<SectionItem[]>(() => {
const bill = billStructureData?.bills?.[0];
if (!bill) return [];
const sections: SectionItem[] = [];
// Helper to extract section ref from anchor_id (e.g., "bill:45-1:c-234:s2" -> "s2")
const extractSectionRef = (anchorId: string): string => {
const parts = anchorId.split(':');
return parts[parts.length - 1];
};
// Helper to get comment count for a section
const getCommentCount = (sectionRef: string): number => {
return commentsBySection?.[sectionRef]?.length || 0;
};
// Process parts -> sections (only top-level sections, not subsections)
if (bill.parts && bill.parts.length > 0) {
for (const part of bill.parts) {
if (part.sections) {
for (const section of part.sections) {
const sectionRef = extractSectionRef(section.anchor_id);
const marginalNote = locale === 'fr' && section.marginal_note_fr
? section.marginal_note_fr
: section.marginal_note_en;
sections.push({
sectionRef,
label: marginalNote
? `${locale === 'fr' ? 'Section' : 'Section'} ${section.number} - ${marginalNote}`
: `${locale === 'fr' ? 'Section' : 'Section'} ${section.number}`,
commentCount: getCommentCount(sectionRef),
});
}
}
}
}
// Process loose sections (not in any part)
if (bill.sections && bill.sections.length > 0) {
for (const section of bill.sections) {
const sectionRef = extractSectionRef(section.anchor_id);
const marginalNote = locale === 'fr' && section.marginal_note_fr
? section.marginal_note_fr
: section.marginal_note_en;
sections.push({
sectionRef,
label: marginalNote
? `${locale === 'fr' ? 'Section' : 'Section'} ${section.number} - ${marginalNote}`
: `${locale === 'fr' ? 'Section' : 'Section'} ${section.number}`,
commentCount: getCommentCount(sectionRef),
});
}
}
return sections;
}, [billStructureData, commentsBySection, locale]);
// Auto-open discussion panel if URL has #discussions hash or there's a conversation draft
useEffect(() => {
if (typeof window === 'undefined') return;
const hash = window.location.hash;
const hasDiscussionsHash = hash === '#discussions';
const hasDraft = sessionStorage.getItem('share_conversation_draft');
if (hasDiscussionsHash || hasDraft) {
// Open general discussion panel
setDiscussionSectionRef(null);
setShowDiscussionPanel(true);
setMode('discuss-50');
// Clear the hash after opening (to prevent re-triggering on refresh)
if (hasDiscussionsHash) {
// Use replaceState to remove hash without triggering navigation
window.history.replaceState(null, '', window.location.pathname + window.location.search);
}
}
}, []); // Run once on mount
// Handle toggling general discussion - switches to split view
const handleToggleGeneralDiscussion = useCallback(() => {
if (showDiscussionPanel && discussionSectionRef === null) {
// Close general discussion - go back to read mode
setShowDiscussionPanel(false);
setMode('read');
} else {
// Open general discussion - switch to split mode, clear any section filter
setDiscussionSectionRef(null);
setShowDiscussionPanel(true);
setMode('discuss-50');
}
}, [showDiscussionPanel, discussionSectionRef]);
// Handle dropdown selection - select a section or general discussion
const handleDropdownSelect = useCallback((sectionRef: string | null) => {
if (showDiscussionPanel && discussionSectionRef === sectionRef) {
// If clicking the same section that's already open, close the panel
setShowDiscussionPanel(false);
setMode('read');
} else if (!showDiscussionPanel) {
// If panel is closed, open it
setShowDiscussionPanel(true);
setMode('discuss-50');
setDiscussionSectionRef(sectionRef);
} else {
// Switch to a different section
setDiscussionSectionRef(sectionRef);
}
// Scroll to the selected section in the bill text
if (sectionRef) {
// Small delay to allow state to update
setTimeout(() => {
const sectionElement = document.getElementById(sectionRef);
if (sectionElement) {
const scrollableParent = sectionElement.closest('[data-bill-text-container]') as HTMLElement;
if (scrollableParent) {
const elementTop = sectionElement.offsetTop;
scrollableParent.scrollTo({ top: elementTop - 16, behavior: 'smooth' });
}
}
}, 50);
}
}, [showDiscussionPanel, discussionSectionRef]);
// Handle mode change
const handleModeChange = useCallback((newMode: SplitViewMode) => {
setMode(newMode);
setCustomBillWidth(null); // Reset custom width when mode changes
onModeChange?.(newMode);
}, [onModeChange]);
// Handle section selection from bill viewer
const handleSectionClick = useCallback((sectionAnchorId: string) => {
setSelectedSection(sectionAnchorId);
onSectionSelect?.(sectionAnchorId);
// Auto-switch to discussion mode if in read mode
if (mode === 'read' && discussionsEnabled) {
handleModeChange('discuss-50');
}
}, [mode, discussionsEnabled, handleModeChange, onSectionSelect]);
// Handle section comments toggle - open split view with section filter
const handleSectionCommentsToggle = useCallback((sectionRef: string) => {
if (showDiscussionPanel && discussionSectionRef === sectionRef) {
// Clicking same section - close the panel
setShowDiscussionPanel(false);
setDiscussionSectionRef(null);
setMode('read');
} else {
// Open split view with section filter
setDiscussionSectionRef(sectionRef);
setShowDiscussionPanel(true);
setMode('discuss-50');
}
}, [showDiscussionPanel, discussionSectionRef]);
// Close discussion panel
const handleCloseDiscussion = useCallback(() => {
setShowDiscussionPanel(false);
setDiscussionSectionRef(null);
setMode('read');
}, []);
// Clear section filter (go to general discussion)
const handleClearSectionFilter = useCallback(() => {
setDiscussionSectionRef(null);
}, []);
// Toggle maximize mode
const handleToggleMaximize = useCallback(() => {
onMaximizeChange?.(!isMaximized);
}, [isMaximized, onMaximizeChange]);
// Mobile view toggle with scroll position preservation (uses window scroll)
const handleMobileViewToggle = useCallback(() => {
// Save current window scroll position before switching
if (mobileView === 'text') {
billTextScrollRef.current = window.scrollY;
} else {
discussionScrollRef.current = window.scrollY;
}
// Toggle view
setMobileView(prev => prev === 'text' ? 'discussion' : 'text');
}, [mobileView]);
// Restore scroll position after view change (uses window scroll)
useEffect(() => {
if (!isMobileView) return;
// Small delay to ensure the DOM has updated
const timer = setTimeout(() => {
if (mobileView === 'text') {
window.scrollTo(0, billTextScrollRef.current);
} else {
window.scrollTo(0, discussionScrollRef.current);
}
}, 50);
return () => clearTimeout(timer);
}, [mobileView, isMobileView]);
// Get human-readable label for the current section
const getSectionLabel = (sectionRef: string): string => {
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);
return locale === 'fr' ? `Section ${ref}` : `Section ${ref}`;
}
return sectionRef;
};
// Build discussion scope object for BillDiscussionPanel
const discussionScope: DiscussionScope | null = discussionSectionRef
? {
anchorId: `bill:${session}:${billNumber}:${discussionSectionRef}`,
sectionRef: discussionSectionRef,
label: getSectionLabel(discussionSectionRef),
}
: null;
// Handle divider drag
const handleDragStart = useCallback(() => {
setIsDragging(true);
}, []);
const handleDrag = useCallback((deltaX: number) => {
if (!containerRef.current) return;
const containerWidth = containerRef.current.offsetWidth;
const currentWidth = customBillWidth ??
(mode === 'discuss-50' ? 50 : mode === 'discuss-33' ? 33.333 : 100);
const deltaPercent = (deltaX / containerWidth) * 100;
const newWidth = Math.max(20, Math.min(80, currentWidth + deltaPercent));
setCustomBillWidth(newWidth);
}, [mode, customBillWidth]);
const handleDragEnd = useCallback(() => {
setIsDragging(false);
}, []);
// Calculate panel widths
const billWidth = mode === 'read'
? '100%'
: customBillWidth !== null
? `${customBillWidth}%`
: MODE_CONFIG[mode].billWidth;
const discussionWidth = mode === 'read'
? '0%'
: customBillWidth !== null
? `${100 - customBillWidth}%`
: MODE_CONFIG[mode].discussionWidth;
const showDiscussion = mode !== 'read';
// Mobile Layout
if (isMobileView) {
return (
<div className="flex flex-col w-full">
{/* Mobile Sticky Toggle Button - starts below tabs, sticks below header when scrolled */}
<div className="sticky top-16 z-40 bg-bg-secondary border-b border-border-subtle -mx-4 px-4 py-2 mb-1">
<div className="flex items-center justify-between gap-2">
{/* Bill info */}
<div className="flex items-center gap-1 min-w-0">
<span className="text-sm font-medium text-text-primary">
{billNumber}
</span>
<span className="text-xs text-text-tertiary">
({session})
</span>
</div>
{/* Toggle Button */}
<button
onClick={handleMobileViewToggle}
className={`
flex items-center gap-1.5 px-3 py-1.5
text-sm font-medium rounded-full
transition-all duration-300
${mobileView === 'text'
? 'bg-blue-600 text-white'
: 'bg-accent-red text-white'
}
`}
>
{mobileView === 'text' ? (
<>
<MessageSquare className="w-4 h-4" />
<span>Discussion</span>
{generalCommentsCount > 0 && (
<span className="bg-white/20 text-xs px-1.5 py-0.5 rounded-full">
{generalCommentsCount}
</span>
)}
</>
) : (
<>
<FileText className="w-4 h-4" />
<span>{locale === 'fr' ? 'Texte' : 'Text'}</span>
</>
)}
</button>
</div>
</div>
{/* Mobile Sliding Container - scrolls with page */}
<div className="relative w-full overflow-x-hidden">
<div
className="flex transition-transform duration-300 ease-in-out"
style={{
width: '200%',
transform: mobileView === 'text' ? 'translateX(0)' : 'translateX(-50%)',
}}
>
{/* Bill Text Panel - scrolls with page */}
<div
ref={mobileTextContainerRef}
className="w-1/2 min-h-screen overflow-hidden"
style={{ maxWidth: '100vw' }}
>
<div className="max-w-full overflow-x-hidden break-words">
<BillTextViewer
billNumber={billNumber}
session={session}
locale={locale}
onSectionDiscuss={discussionsEnabled && !useInlineComments ? handleSectionClick : undefined}
highlightedSection={selectedSection}
commentsBySection={commentsBySection}
onCommentCreated={onCommentCreated}
useInlineComments={useInlineComments}
onSectionCommentsToggle={(sectionRef) => {
// On mobile, when clicking a section's comments, switch to discussion and filter by that section
setDiscussionSectionRef(sectionRef);
setMobileView('discussion');
}}
forceCloseAllComments={true} // Always close inline comments on mobile since we have the sliding view
/>
</div>
</div>
{/* Discussion Panel - scrolls with page */}
<div
ref={mobileDiscussionContainerRef}
className="w-1/2 min-h-screen bg-bg-primary overflow-hidden"
style={{ maxWidth: '100vw' }}
>
{/* Section Filter Header - only show when filtering by specific section */}
{discussionSectionRef && (
<div className="sticky top-[104px] z-10 bg-bg-primary border-b border-border-subtle px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<MessageSquare className="h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<span className="text-sm font-medium text-text-primary truncate">
{getSectionLabel(discussionSectionRef)}
</span>
</div>
<button
onClick={() => setDiscussionSectionRef(null)}
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 rounded-full hover:bg-blue-200 dark:hover:bg-blue-900/60 transition-colors flex-shrink-0"
>
<span>{locale === 'fr' ? 'Voir tout' : 'View All'}</span>
</button>
</div>
</div>
)}
{/* Discussion Content */}
<div className="px-4 pb-8 pt-2">
<BillDiscussionPanel
billNumber={billNumber}
session={session}
locale={locale}
selectedSection={discussionSectionRef ? {
anchorId: `bill:${session}:${billNumber}:${discussionSectionRef}`,
sectionRef: discussionSectionRef,
label: getSectionLabel(discussionSectionRef),
} : null}
onCommentCreated={onCommentCreated}
/>
</div>
</div>
</div>
</div>
</div>
);
}
// Desktop Layout (existing)
return (
<div className="flex flex-col">
{/* Toolbar - sticky below main header */}
<div className="
sticky top-16
flex items-center justify-between
px-4 py-2
border-b border-border-subtle
bg-bg-secondary
flex-shrink-0
z-30
">
{/* Bill info */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-primary">
{locale === 'fr' ? 'Projet de loi' : 'Bill'} {billNumber}
</span>
<span className="text-xs text-text-tertiary">
({session})
</span>
</div>
{/* Mode toggles - show when not in inline mode, OR when general discussion is open */}
{discussionsEnabled && (!useInlineComments || showDiscussionPanel) && (
<div className="flex items-center gap-2">
{/* Maximize button (inline mode only) */}
{useInlineComments && onMaximizeChange && (
<button
onClick={handleToggleMaximize}
className={`
flex items-center gap-2 px-3 py-1.5
text-sm font-medium rounded-lg transition-colors
${isMaximized
? 'bg-gray-700 text-white'
: 'bg-gray-600 hover:bg-gray-700 text-white'
}
`}
title={isMaximized
? (locale === 'fr' ? 'Réduire' : 'Exit fullscreen')
: (locale === 'fr' ? 'Agrandir' : 'Fullscreen')
}
>
{isMaximized ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
)}
{/* Discussion dropdown (inline mode only) */}
{useInlineComments && (
<SectionDiscussionDropdown
selectedSection={discussionSectionRef}
onSelect={handleDropdownSelect}
sections={dropdownSections}
generalCommentsCount={generalCommentsCount}
locale={locale}
isActive={showDiscussionPanel}
/>
)}
{/* Mode toggles - show when general discussion is open */}
{showDiscussionPanel && (
<div className="flex items-center gap-1 bg-bg-elevated rounded-lg p-1">
<ModeButton
mode="discuss-50"
currentMode={mode}
onClick={handleModeChange}
/>
<ModeButton
mode="discuss-33"
currentMode={mode}
onClick={handleModeChange}
/>
</div>
)}
{/* Standard mode toggles (non-inline mode) */}
{!useInlineComments && (
<div className="flex items-center gap-1 bg-bg-elevated rounded-lg p-1">
<ModeButton
mode="read"
currentMode={mode}
onClick={handleModeChange}
/>
<ModeButton
mode="discuss-50"
currentMode={mode}
onClick={handleModeChange}
/>
<ModeButton
mode="discuss-33"
currentMode={mode}
onClick={handleModeChange}
/>
</div>
)}
</div>
)}
{/* Inline comments hint - only show when general discussion is NOT open */}
{useInlineComments && !showDiscussionPanel && (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
{/* Maximize button */}
{onMaximizeChange && (
<button
onClick={handleToggleMaximize}
className={`
flex items-center gap-2 px-3 py-1.5
text-sm font-medium rounded-lg transition-colors
${isMaximized
? 'bg-gray-700 text-white'
: 'bg-gray-600 hover:bg-gray-700 text-white'
}
`}
title={isMaximized
? (locale === 'fr' ? 'Réduire' : 'Exit fullscreen')
: (locale === 'fr' ? 'Agrandir' : 'Fullscreen')
}
>
{isMaximized ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
)}
<SectionDiscussionDropdown
selectedSection={discussionSectionRef}
onSelect={handleDropdownSelect}
sections={dropdownSections}
generalCommentsCount={generalCommentsCount}
locale={locale}
isActive={showDiscussionPanel}
/>
</div>
<span className="text-sm text-text-secondary hidden sm:inline">
{locale === 'fr'
? 'ou cliquez sur une section pour commenter'
: 'or click a section to comment'}
</span>
</div>
)}
{/* Selected section indicator (for panel mode only) */}
{selectedSection && showDiscussion && !useInlineComments && (
<div className="hidden md:flex items-center gap-2">
<span className="text-xs text-text-tertiary">
{locale === 'fr' ? 'Section:' : 'Section:'}
</span>
<code className="text-xs bg-bg-elevated px-2 py-0.5 rounded text-text-primary">
{selectedSection.split(':').pop()}
</code>
<button
onClick={() => setSelectedSection(undefined)}
className="text-text-tertiary hover:text-text-primary"
title={locale === 'fr' ? 'Effacer la sélection' : 'Clear selection'}
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
</div>
{/* Column Headers Row - directly connected to toolbar (no gap) */}
<div className="flex border-b border-border-subtle bg-bg-primary">
{/* Bill Text Header */}
<div
className={`px-4 py-3 flex items-center justify-between ${(showDiscussion && !useInlineComments) || showDiscussionPanel ? 'border-r border-border-subtle' : ''}`}
style={{ width: (useInlineComments && !showDiscussionPanel) ? '100%' : billWidth }}
>
<div className="flex items-center gap-2">
<svg className="h-5 w-5 text-accent-red" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
<h3 className="text-lg font-bold text-text-primary">
{useInlineComments && !showDiscussionPanel
? (locale === 'fr' ? 'Texte du projet de loi et discussion' : 'Bill Text & Discussion')
: (locale === 'fr' ? 'Texte du projet de loi' : 'Bill Text')}
</h3>
</div>
<BillSectionShareButton
billNumber={billNumber}
session={session}
sectionHash=""
sectionLabel=""
locale={locale}
size="sm"
className=""
/>
</div>
{/* Discussion Header - show when in discussion mode OR when discussion panel is open */}
{(showDiscussion && !useInlineComments) || showDiscussionPanel ? (
<div
className="px-4 py-3 flex items-center justify-between"
style={{ width: discussionWidth }}
>
<div className="flex items-center gap-2">
<svg className="h-5 w-5 text-blue-600 dark:text-blue-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
<h3 className="text-lg font-bold text-text-primary">
{showDiscussionPanel
? (discussionSectionRef
? getSectionLabel(discussionSectionRef)
: (locale === 'fr' ? 'Discussion générale' : 'General Discussion'))
: (locale === 'fr' ? 'Discussion' : 'Discussion')}
</h3>
{/* Section badge with option to clear */}
{showDiscussionPanel && discussionSectionRef && (
<button
onClick={handleClearSectionFilter}
className="flex items-center gap-1 px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 rounded-full hover:bg-blue-200 dark:hover:bg-blue-900/60 transition-colors"
title={locale === 'fr' ? 'Voir discussion générale' : 'View general discussion'}
>
<span>{locale === 'fr' ? 'Voir tout' : 'View all'}</span>
</button>
)}
</div>
<div className="flex items-center gap-2">
{discussionHeaderAction && (
<div>{discussionHeaderAction}</div>
)}
{/* Close button for discussion panel */}
{showDiscussionPanel && useInlineComments && (
<button
onClick={handleCloseDiscussion}
className="p-1.5 text-text-tertiary hover:text-text-primary hover:bg-bg-elevated rounded transition-colors"
title={locale === 'fr' ? 'Fermer' : 'Close'}
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
) : null}
</div>
{/* Main content area - fixed height for independent scrolling */}
<div
ref={containerRef}
className={`
flex
${isDragging ? 'select-none cursor-col-resize' : ''}
`}
style={{ height: 'calc(100vh - 180px)' }}
>
{/* Bill text panel - independently scrollable */}
<div
data-bill-text-container
className="transition-all duration-300 ease-in-out overflow-y-auto"
style={{ width: (useInlineComments && !showDiscussionPanel) ? '100%' : billWidth }}
>
<BillTextViewer
billNumber={billNumber}
session={session}
locale={locale}
onSectionDiscuss={discussionsEnabled && !useInlineComments ? handleSectionClick : undefined}
highlightedSection={selectedSection}
commentsBySection={commentsBySection}
onCommentCreated={onCommentCreated}
useInlineComments={useInlineComments}
onSectionCommentsToggle={handleSectionCommentsToggle}
forceCloseAllComments={showDiscussionPanel}
/>
</div>
{/* Heatmap margin - always visible when there are discussion counts (not in inline mode) */}
{showHeatmap && sectionDiscussionCounts.length > 0 && !useInlineComments && !showDiscussionPanel && (
<HeatmapMargin
sectionCounts={sectionDiscussionCounts}
selectedSection={selectedSection}
onSectionClick={handleSectionClick}
locale={locale}
/>
)}
{/* Resizable divider - show for standard discussion mode OR general discussion */}
{((showDiscussion && !useInlineComments) || showDiscussionPanel) && (
<ResizableDivider
onDragStart={handleDragStart}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
/>
)}
{/* Discussion panel - standard mode (not inline comments) - independently scrollable */}
{showDiscussion && !useInlineComments && !showDiscussionPanel && (
<div
className="bg-bg-primary transition-all duration-300 ease-in-out overflow-y-auto"
style={{ width: discussionWidth }}
>
{discussionPanel ?? (
selectedSection ? (
<div className="p-4">
<div className="text-sm text-text-secondary mb-4">
{locale === 'fr'
? `Discussion pour la section ${selectedSection.split(':').pop()}`
: `Discussion for section ${selectedSection.split(':').pop()}`}
</div>
{/* Placeholder - will be replaced by BillDiscussionPanel */}
<div className="bg-bg-secondary rounded-lg p-4 shadow-sm">
<p className="text-text-tertiary text-sm italic">
{locale === 'fr'
? 'Le panneau de discussion sera implémenté prochainement...'
: 'Discussion panel will be implemented soon...'}
</p>
</div>
</div>
) : (
<EmptyDiscussionState locale={locale} />
)
)}
</div>
)}
{/* Discussion panel - when showDiscussionPanel is true (general or section-specific) - independently scrollable */}
{showDiscussionPanel && (
<div
className="bg-bg-primary transition-all duration-300 ease-in-out overflow-y-auto"
style={{ width: discussionWidth }}
>
<BillDiscussionPanel
billNumber={billNumber}
session={session}
locale={locale}
selectedSection={discussionScope}
onCommentCreated={onCommentCreated}
/>
</div>
)}
</div>
{/* Mobile FAB removed - now using sliding view for mobile */}
</div>
);
};
export default BillSplitView;