semblance-dev/src/components/focus-group-session/DiscussionGuideViewer.tsx

1330 lines
No EOL
50 KiB
TypeScript

import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import {
ChevronRight,
ChevronDown,
Clock,
MessageCircle,
Activity,
CheckCircle,
Circle,
PlayCircle,
Target,
Edit3,
Trash2,
Save,
X,
Download,
Loader2,
Image as ImageIcon,
ChevronUp,
Check
} from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { focusGroupsApi } from '@/lib/api';
interface DiscussionGuideItem {
id: string;
content: string;
type: string;
time_limit?: number;
probes?: string[];
metadata?: Record<string, any>;
}
interface DiscussionGuideSubsection {
id: string;
title: string;
questions: DiscussionGuideItem[];
activities?: DiscussionGuideItem[];
metadata?: Record<string, any>;
}
interface DiscussionGuideSection {
id: string;
title: string;
content?: string;
questions?: DiscussionGuideItem[];
activities?: DiscussionGuideItem[];
subsections?: DiscussionGuideSubsection[];
metadata?: Record<string, any>;
}
interface StructuredDiscussionGuide {
title: string;
total_duration: number;
sections: DiscussionGuideSection[];
metadata?: Record<string, any>;
}
interface ModeratorStatus {
current_section: string;
current_section_id: string;
current_item: string;
current_item_id: string;
current_item_type: string;
progress: number;
section_progress: number;
total_sections: number;
moderator_position: {
section_index: number;
subsection_index?: number;
item_index: number;
item_type: string;
};
section_type: string;
legacy_format: boolean;
}
interface DiscussionGuideViewerProps {
discussionGuide: StructuredDiscussionGuide | string;
moderatorStatus?: ModeratorStatus;
onSectionSelect?: (sectionId: string, itemId?: string) => void;
onSetPosition?: (sectionId: string, itemId: string, content: string, sectionTitle: string, itemTitle?: string, itemType?: string, metadata?: Record<string, any>) => void;
onSave?: (updatedGuide: StructuredDiscussionGuide) => Promise<void>;
showProgress?: boolean;
collapsible?: boolean;
defaultExpanded?: boolean;
className?: string;
onDownload?: () => void;
isDownloading?: boolean;
focusGroupId?: string;
onEditingChange?: (isEditing: boolean) => void;
}
const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((props) => {
const {
discussionGuide,
moderatorStatus,
onSectionSelect,
onSetPosition,
onSave,
showProgress = true,
collapsible = true,
defaultExpanded = false,
className,
onDownload,
isDownloading = false,
focusGroupId,
onEditingChange
} = props;
// Handle legacy markdown format
const isLegacyFormat = typeof discussionGuide === 'string';
// Parse structured guide
const structuredGuide = useMemo<StructuredDiscussionGuide | null>(() => {
if (isLegacyFormat) {
return null;
}
const guide = discussionGuide as StructuredDiscussionGuide;
return guide;
}, [discussionGuide, isLegacyFormat]);
// Initialize openSections state
const [openSections, setOpenSections] = useState<Set<string>>(new Set());
// State for inline section editing
const [editingSectionId, setEditingSectionId] = useState<string | null>(null);
const [editingSection, setEditingSection] = useState<DiscussionGuideSection | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [editingSubsectionId, setEditingSubsectionId] = useState<string | null>(null);
const [tempSubsectionTitle, setTempSubsectionTitle] = useState('');
// Notify parent about editing state
useEffect(() => {
if (onEditingChange) {
onEditingChange(!!editingSectionId);
}
}, [editingSectionId, onEditingChange]);
// Sync editing section with main guide when it changes
useEffect(() => {
if (editingSectionId && structuredGuide) {
const currentSection = structuredGuide.sections.find(s => s.id === editingSectionId);
if (currentSection && !editingSection) {
setEditingSection({ ...currentSection });
}
}
}, [structuredGuide, editingSectionId, editingSection]);
// Functions for inline section editing
const startEditingSection = (section: DiscussionGuideSection) => {
setEditingSectionId(section.id);
setEditingSection({ ...section });
// Ensure the section is open when editing starts
setOpenSections(prev => new Set(prev).add(section.id));
};
const cancelEditingSection = () => {
setEditingSectionId(null);
setEditingSection(null);
};
const updateEditingSection = useCallback((updates: Partial<DiscussionGuideSection>) => {
setEditingSection(prevSection => {
if (!prevSection) return prevSection;
return { ...prevSection, ...updates };
});
}, []);
const updateEditingSectionItem = useCallback((itemId: string, updates: Partial<DiscussionGuideItem>, itemType: 'question' | 'activity') => {
setEditingSection(prevSection => {
if (!prevSection) return prevSection;
const newSection = { ...prevSection };
// First try to update in section-level items
if (itemType === 'question' && newSection.questions) {
const questionIndex = newSection.questions.findIndex(q => q.id === itemId);
if (questionIndex !== -1) {
newSection.questions = newSection.questions.map(q =>
q.id === itemId ? { ...q, ...updates } : q
);
return newSection;
}
} else if (itemType === 'activity' && newSection.activities) {
const activityIndex = newSection.activities.findIndex(a => a.id === itemId);
if (activityIndex !== -1) {
newSection.activities = newSection.activities.map(a =>
a.id === itemId ? { ...a, ...updates } : a
);
return newSection;
}
}
// If not found in section-level, search in subsections
if (newSection.subsections) {
newSection.subsections = newSection.subsections.map(subsection => {
const updatedSubsection = { ...subsection };
if (itemType === 'question' && updatedSubsection.questions) {
const questionIndex = updatedSubsection.questions.findIndex(q => q.id === itemId);
if (questionIndex !== -1) {
updatedSubsection.questions = updatedSubsection.questions.map(q =>
q.id === itemId ? { ...q, ...updates } : q
);
}
} else if (itemType === 'activity' && updatedSubsection.activities) {
const activityIndex = updatedSubsection.activities.findIndex(a => a.id === itemId);
if (activityIndex !== -1) {
updatedSubsection.activities = updatedSubsection.activities.map(a =>
a.id === itemId ? { ...a, ...updates } : a
);
}
}
return updatedSubsection;
});
}
return newSection;
});
}, []);
const addEditingSectionItem = (itemType: 'question' | 'activity') => {
if (!editingSection) return;
const newItem: DiscussionGuideItem = {
id: `${itemType}-${Date.now()}`,
content: `New ${itemType}`,
type: itemType === 'question' ? 'open_ended' : 'discussion',
time_limit: undefined
};
const updatedSection = { ...editingSection };
if (itemType === 'question') {
updatedSection.questions = [...(updatedSection.questions || []), newItem];
} else {
updatedSection.activities = [...(updatedSection.activities || []), newItem];
}
setEditingSection(updatedSection);
};
const addSubsectionItem = (subsectionIndex: number, itemType: 'question' | 'activity') => {
if (!editingSection || !editingSection.subsections) return;
const newItem: DiscussionGuideItem = {
id: `${itemType}-${Date.now()}`,
content: `New ${itemType}`,
type: itemType === 'question' ? 'open_ended' : 'discussion',
time_limit: undefined
};
const updatedSubsections = [...editingSection.subsections];
const targetSubsection = { ...updatedSubsections[subsectionIndex] };
if (itemType === 'question') {
targetSubsection.questions = [...(targetSubsection.questions || []), newItem];
} else {
targetSubsection.activities = [...(targetSubsection.activities || []), newItem];
}
updatedSubsections[subsectionIndex] = targetSubsection;
setEditingSection(prev => prev ? { ...prev, subsections: updatedSubsections } : prev);
};
const addSubsection = () => {
if (!editingSection) return;
const newSubsection = {
id: `subsection-${Date.now()}`,
title: 'New Subsection',
questions: [],
activities: []
};
const updatedSubsections = [...(editingSection.subsections || []), newSubsection];
setEditingSection(prev => prev ? { ...prev, subsections: updatedSubsections } : prev);
};
const deleteSubsection = (subsectionIndex: number) => {
if (!editingSection || !editingSection.subsections) return;
const updatedSubsections = editingSection.subsections.filter((_, index) => index !== subsectionIndex);
setEditingSection(prev => prev ? { ...prev, subsections: updatedSubsections } : prev);
};
const deleteEditingSectionItem = (itemId: string, itemType: 'question' | 'activity') => {
if (!editingSection) return;
const updatedSection = { ...editingSection };
if (itemType === 'question') {
updatedSection.questions = updatedSection.questions?.filter(q => q.id !== itemId);
} else {
updatedSection.activities = updatedSection.activities?.filter(a => a.id !== itemId);
}
setEditingSection(updatedSection);
};
const saveEditingSection = async () => {
if (!editingSection || !structuredGuide || !onSave) return;
setIsSaving(true);
try {
const updatedGuide = {
...structuredGuide,
sections: structuredGuide.sections.map(s =>
s.id === editingSectionId ? editingSection : s
)
};
await onSave(updatedGuide);
cancelEditingSection();
toast.success('Section updated successfully');
} catch (error) {
console.error('Error saving section:', error);
toast.error('Failed to save section');
} finally {
setIsSaving(false);
}
};
// Toggle section expand/collapse
const toggleSection = (sectionId: string) => {
setOpenSections(prev => {
const newSet = new Set(prev);
if (newSet.has(sectionId)) {
newSet.delete(sectionId);
} else {
newSet.add(sectionId);
}
return newSet;
});
};
// Effect to manage default expansion state
useEffect(() => {
if (structuredGuide && structuredGuide.sections.length > 0) {
if (defaultExpanded) {
setOpenSections(new Set(structuredGuide.sections.map(s => s.id)));
} else {
setOpenSections(new Set());
}
}
}, [defaultExpanded, structuredGuide]);
// Get item status based on moderator position
const getItemStatus = (
sectionIndex: number,
itemIndex: number,
itemType: string,
subsectionIndex?: number
) => {
if (!moderatorStatus || moderatorStatus.legacy_format) return null;
const pos = moderatorStatus.moderator_position;
// Check if we're in the right section
if (pos.section_index !== sectionIndex) {
return pos.section_index > sectionIndex ? 'completed' : null;
}
// Check subsection if applicable
if (subsectionIndex !== undefined) {
if (pos.subsection_index === undefined) return null;
if (pos.subsection_index !== subsectionIndex) {
return pos.subsection_index > subsectionIndex ? 'completed' : null;
}
} else if (pos.subsection_index !== undefined) {
// We're in main section but moderator is in a subsection
return 'completed';
}
// Check item type
if (pos.item_type !== itemType) {
// Activities typically come before questions in our structure
if (itemType === 'activity' && pos.item_type === 'question') {
return 'completed';
}
return null;
}
// Check item index
if (pos.item_index === itemIndex) {
return 'current';
}
return pos.item_index > itemIndex ? 'completed' : null;
};
// Helper function to check if content is default placeholder text
const isDefaultPlaceholderContent = (content: string, itemType: 'question' | 'activity'): boolean => {
return content === `New ${itemType}`;
};
// Helper functions for array reordering
const moveArrayItem = useCallback((array: any[], fromIndex: number, toIndex: number) => {
if (fromIndex < 0 || fromIndex >= array.length || toIndex < 0 || toIndex >= array.length) {
return array;
}
const newArray = [...array];
const [movedItem] = newArray.splice(fromIndex, 1);
newArray.splice(toIndex, 0, movedItem);
return newArray;
}, []);
const canMoveItemUp = useCallback((items: any[], index: number) => {
return index > 0;
}, []);
const canMoveItemDown = useCallback((items: any[], index: number) => {
return index < items.length - 1;
}, []);
// Move subsection up within section
const moveSubsectionUp = useCallback((subsectionIndex: number) => {
if (!editingSection || !editingSection.subsections) return;
const subsections = editingSection.subsections;
if (canMoveItemUp(subsections, subsectionIndex)) {
const newSubsections = moveArrayItem(subsections, subsectionIndex, subsectionIndex - 1);
setEditingSection(prev => prev ? { ...prev, subsections: newSubsections } : prev);
}
}, [editingSection, canMoveItemUp, moveArrayItem]);
// Move subsection down within section
const moveSubsectionDown = useCallback((subsectionIndex: number) => {
if (!editingSection || !editingSection.subsections) return;
const subsections = editingSection.subsections;
if (canMoveItemDown(subsections, subsectionIndex)) {
const newSubsections = moveArrayItem(subsections, subsectionIndex, subsectionIndex + 1);
setEditingSection(prev => prev ? { ...prev, subsections: newSubsections } : prev);
}
}, [editingSection, canMoveItemDown, moveArrayItem]);
// Subsection title editing functions
const startEditingSubsectionTitle = useCallback((subsectionId: string, currentTitle: string) => {
setEditingSubsectionId(subsectionId);
setTempSubsectionTitle(currentTitle);
}, []);
const cancelEditingSubsectionTitle = useCallback(() => {
setEditingSubsectionId(null);
setTempSubsectionTitle('');
}, []);
const saveSubsectionTitle = useCallback(() => {
if (!editingSubsectionId || !editingSection || !editingSection.subsections) return;
const updatedSubsections = editingSection.subsections.map(subsection =>
subsection.id === editingSubsectionId
? { ...subsection, title: tempSubsectionTitle.trim() }
: subsection
);
setEditingSection(prev => prev ? { ...prev, subsections: updatedSubsections } : prev);
cancelEditingSubsectionTitle();
}, [editingSubsectionId, editingSection, tempSubsectionTitle, cancelEditingSubsectionTitle]);
// Move item up within section or subsection
const moveEditingItemUp = useCallback((itemId: string, itemType: 'question' | 'activity', itemIndex: number, subsectionIndex?: number) => {
if (!editingSection) return;
const itemsKey = itemType === 'question' ? 'questions' : 'activities';
if (subsectionIndex !== undefined) {
// Handle subsection items
const subsections = editingSection.subsections || [];
if (subsectionIndex >= 0 && subsectionIndex < subsections.length) {
const subsection = subsections[subsectionIndex];
const items = subsection[itemsKey] || [];
if (canMoveItemUp(items, itemIndex)) {
const newItems = moveArrayItem(items, itemIndex, itemIndex - 1);
const newSubsections = [...subsections];
newSubsections[subsectionIndex] = { ...subsection, [itemsKey]: newItems };
setEditingSection(prev => prev ? { ...prev, subsections: newSubsections } : prev);
}
}
} else {
// Handle section-level items
const items = editingSection[itemsKey] || [];
if (canMoveItemUp(items, itemIndex)) {
const newItems = moveArrayItem(items, itemIndex, itemIndex - 1);
setEditingSection(prev => prev ? { ...prev, [itemsKey]: newItems } : prev);
}
}
}, [editingSection, canMoveItemUp, moveArrayItem]);
// Move item down within section or subsection
const moveEditingItemDown = useCallback((itemId: string, itemType: 'question' | 'activity', itemIndex: number, subsectionIndex?: number) => {
if (!editingSection) return;
const itemsKey = itemType === 'question' ? 'questions' : 'activities';
if (subsectionIndex !== undefined) {
// Handle subsection items
const subsections = editingSection.subsections || [];
if (subsectionIndex >= 0 && subsectionIndex < subsections.length) {
const subsection = subsections[subsectionIndex];
const items = subsection[itemsKey] || [];
if (canMoveItemDown(items, itemIndex)) {
const newItems = moveArrayItem(items, itemIndex, itemIndex + 1);
const newSubsections = [...subsections];
newSubsections[subsectionIndex] = { ...subsection, [itemsKey]: newItems };
setEditingSection(prev => prev ? { ...prev, subsections: newSubsections } : prev);
}
}
} else {
// Handle section-level items
const items = editingSection[itemsKey] || [];
if (canMoveItemDown(items, itemIndex)) {
const newItems = moveArrayItem(items, itemIndex, itemIndex + 1);
setEditingSection(prev => prev ? { ...prev, [itemsKey]: newItems } : prev);
}
}
}, [editingSection, canMoveItemDown, moveArrayItem]);
// Render an item (activity or question) with support for editing
const renderItem = (
item: DiscussionGuideItem,
sectionIndex: number,
itemIndex: number,
itemType: 'activity' | 'question',
subsectionIndex?: number
) => {
const section = structuredGuide?.sections[sectionIndex];
const isEditing = editingSectionId === section?.id;
const status = getItemStatus(sectionIndex, itemIndex, itemType, subsectionIndex);
const isCurrent = status === 'current';
const isCompleted = status === 'completed';
// Get visual asset filename from metadata instead of extracting from content
const getVisualAssetFilename = (item: DiscussionGuideItem): string | null => {
return item.metadata?.visual_asset?.filename || null;
};
const imageFilename = getVisualAssetFilename(item);
// Check if this is a default placeholder item
const isPlaceholder = isDefaultPlaceholderContent(item.content, itemType);
if (isEditing) {
// Render editable version
return (
<div
key={`edit-item-${item.id}`}
className="flex items-start gap-3 p-3 rounded-lg border bg-white border-blue-200"
>
<div className="flex-shrink-0 flex flex-col gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => moveEditingItemUp(item.id, itemType, itemIndex, subsectionIndex)}
disabled={(() => {
if (subsectionIndex !== undefined) {
// For subsection items, check the subsection's items array
const subsections = editingSection?.subsections || [];
const subsection = subsections[subsectionIndex];
const items = subsection?.[itemType === 'question' ? 'questions' : 'activities'] || [];
return !canMoveItemUp(items, itemIndex);
} else {
// For section-level items
const items = editingSection?.[itemType === 'question' ? 'questions' : 'activities'] || [];
return !canMoveItemUp(items, itemIndex);
}
})()}
className="h-6 w-6 p-0"
title="Move item up"
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => moveEditingItemDown(item.id, itemType, itemIndex, subsectionIndex)}
disabled={(() => {
if (subsectionIndex !== undefined) {
// For subsection items, check the subsection's items array
const subsections = editingSection?.subsections || [];
const subsection = subsections[subsectionIndex];
const items = subsection?.[itemType === 'question' ? 'questions' : 'activities'] || [];
return !canMoveItemDown(items, itemIndex);
} else {
// For section-level items
const items = editingSection?.[itemType === 'question' ? 'questions' : 'activities'] || [];
return !canMoveItemDown(items, itemIndex);
}
})()}
className="h-6 w-6 p-0"
title="Move item down"
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<div className="flex-1 min-w-0 space-y-3">
<div className="flex items-center gap-2 mb-2">
<Badge variant="outline" className="text-xs">
{itemType === 'activity' ? (
<>
<Activity className="h-3 w-3 mr-1" />
{typeof item.type === 'string' ? item.type.replace('_', ' ') : String(item.type || 'unknown')}
</>
) : (
<>
<MessageCircle className="h-3 w-3 mr-1" />
{typeof item.type === 'string' ? item.type.replace('_', ' ') : String(item.type || 'unknown')}
</>
)}
</Badge>
{item.time_limit && (
<div className="flex items-center gap-1 text-xs text-slate-500">
<Clock className="h-3 w-3" />
<Input
type="number"
value={item.time_limit}
onChange={(e) => updateEditingSectionItem(item.id, { time_limit: parseInt(e.target.value) || undefined }, itemType)}
className="w-16 h-6 text-xs"
placeholder="min"
/>
min
</div>
)}
</div>
<Textarea
value={isPlaceholder ? '' : item.content}
onChange={(e) => updateEditingSectionItem(item.id, { content: e.target.value }, itemType)}
placeholder={isPlaceholder ? item.content : "Enter content..."}
className="min-h-[60px]"
/>
{itemType === 'question' && (
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">
Probe Questions (one per line)
</label>
<Textarea
value={item.probes?.join('\n') || ''}
onChange={(e) => {
const probes = e.target.value.trim() ? e.target.value.split('\n').filter(p => p.trim()) : [];
updateEditingSectionItem(item.id, { probes }, itemType);
}}
placeholder="Enter probe questions, one per line..."
className="min-h-[40px]"
/>
</div>
)}
{(item.metadata?.image_url || item.metadata?.image_id || imageFilename) && (
<div className="mt-3">
<div className="flex items-center gap-2 mb-2">
<ImageIcon className="h-4 w-4 text-slate-600" />
<span className="text-sm font-medium text-slate-700">Visual Aid</span>
</div>
{item.metadata?.image_url ? (
<img
src={item.metadata.image_url}
alt="Visual aid for item"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
/>
) : item.metadata?.image_id && focusGroupId ? (
<img
src={focusGroupsApi.getAssetUrl(focusGroupId, item.metadata.image_id)}
alt="Visual aid for item"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
/>
) : imageFilename && focusGroupId ? (
<img
src={focusGroupsApi.getAssetUrl(focusGroupId, imageFilename)}
alt="Visual aid for item"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
/>
) : null}
</div>
)}
</div>
<div className="flex-shrink-0">
<Button
size="sm"
variant="ghost"
onClick={() => deleteEditingSectionItem(item.id, itemType)}
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
}
// Render read-only version
return (
<div
key={item.id}
className={cn(
"flex items-start gap-3 p-3 rounded-lg border transition-colors",
isCurrent && "bg-blue-50 border-blue-200",
isCompleted && "bg-green-50 border-green-200",
!isCurrent && !isCompleted && "bg-slate-50 border-slate-200",
onSectionSelect && "cursor-pointer hover:bg-slate-100"
)}
onClick={() => onSectionSelect?.(structuredGuide!.sections[sectionIndex].id, item.id)}
>
<div className="flex-shrink-0 mt-1">
{isCompleted ? (
<CheckCircle className="h-4 w-4 text-green-600" />
) : isCurrent ? (
<PlayCircle className="h-4 w-4 text-blue-600" />
) : (
<Circle className="h-4 w-4 text-slate-400" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<Badge variant="outline" className="text-xs whitespace-nowrap">
{itemType === 'activity' ? (
<>
<Activity className="h-3 w-3 mr-1" />
{typeof item.type === 'string' ? item.type.replace('_', ' ') : String(item.type || 'unknown')}
</>
) : (
<>
<MessageCircle className="h-3 w-3 mr-1" />
{typeof item.type === 'string' ? item.type.replace('_', ' ') : String(item.type || 'unknown')}
</>
)}
</Badge>
{item.time_limit && (
<div className="flex items-center gap-1 text-xs text-slate-500 whitespace-nowrap">
<Clock className="h-3 w-3" />
{item.time_limit} min
</div>
)}
{onSetPosition && (
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
const section = structuredGuide!.sections[sectionIndex];
const itemTitle = itemType === 'activity' ? `Activity ${itemIndex + 1}` : `Question ${itemIndex + 1}`;
onSetPosition(section.id, item.id, item.content, section.title, itemTitle, itemType, item.metadata);
}}
className="h-6 px-2 ml-auto"
>
<Target className="h-3 w-3 mr-1" />
Set Position
</Button>
)}
</div>
<p className="text-sm text-slate-700 whitespace-pre-wrap">{item.content}</p>
{item.probes && item.probes.length > 0 && (
<div className="mt-2 pl-4 border-l-2 border-slate-200">
<p className="text-xs font-medium text-slate-600 mb-1">Probe Questions:</p>
<ul className="space-y-1">
{item.probes.map((probe, idx) => (
<li key={idx} className="text-xs text-slate-600"> {probe}</li>
))}
</ul>
</div>
)}
{(item.metadata?.image_url || item.metadata?.image_id || imageFilename) && (
<div className="mt-3">
<div className="flex items-center gap-2 mb-2">
<ImageIcon className="h-4 w-4 text-slate-600" />
<span className="text-sm font-medium text-slate-700">Visual Aid</span>
</div>
{item.metadata?.image_url ? (
<img
src={item.metadata.image_url}
alt="Visual aid for item"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
/>
) : item.metadata?.image_id && focusGroupId ? (
<img
src={focusGroupsApi.getAssetUrl(focusGroupId, item.metadata.image_id)}
alt="Visual aid for item"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
/>
) : imageFilename && focusGroupId ? (
<img
src={focusGroupsApi.getAssetUrl(focusGroupId, imageFilename)}
alt="Visual aid for item"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
/>
) : null}
</div>
)}
</div>
</div>
);
};
// Render a section
const renderSection = (section: DiscussionGuideSection, sectionIndex: number) => {
const isOpen = openSections.has(section.id);
const isEditing = editingSectionId === section.id;
const displaySection = isEditing ? editingSection! : section;
const isCurrentSection = moderatorStatus?.moderator_position.section_index === sectionIndex;
return (
<div
key={section.id}
className={cn(
"border rounded-lg overflow-hidden transition-colors",
isCurrentSection && "border-blue-500 shadow-md",
!isCurrentSection && "border-slate-200"
)}
>
<div
className={cn(
"px-4 py-3 flex items-center justify-between cursor-pointer hover:bg-slate-50 transition-colors",
isCurrentSection && "bg-blue-50"
)}
onClick={() => !isEditing && toggleSection(section.id)}
>
<div className="flex items-center gap-3">
<div className="transition-transform" style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0deg)' }}>
<ChevronRight className="h-5 w-5 text-slate-500" />
</div>
<h3 className="font-semibold text-slate-800">
{isEditing ? (
<Input
value={displaySection.title}
onChange={(e) => updateEditingSection({ title: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="font-semibold"
/>
) : (
displaySection.title
)}
</h3>
{isCurrentSection && (
<Badge variant="default" className="text-xs">
Current
</Badge>
)}
</div>
<div className="flex items-center gap-2">
{onSave && !isEditing && (
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
startEditingSection(section);
}}
className="h-8 px-2"
>
<Edit3 className="h-3 w-3" />
</Button>
)}
{isEditing && (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<Button
size="sm"
variant="default"
onClick={saveEditingSection}
disabled={isSaving}
className="h-8"
>
{isSaving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Save className="h-3 w-3" />
)}
<span className="ml-1">Save</span>
</Button>
<Button
size="sm"
variant="ghost"
onClick={cancelEditingSection}
disabled={isSaving}
className="h-8"
>
<X className="h-3 w-3" />
<span className="ml-1">Cancel</span>
</Button>
</div>
)}
</div>
</div>
{isOpen && (
<div className="px-4 py-3 border-t border-slate-200 space-y-4">
{displaySection.content && (
<div className="prose prose-sm max-w-none">
{isEditing ? (
<Textarea
value={displaySection.content}
onChange={(e) => updateEditingSection({ content: e.target.value })}
placeholder="Section introduction or context..."
className="min-h-[80px] w-full"
/>
) : (
<p className="text-slate-700">{displaySection.content}</p>
)}
</div>
)}
{(displaySection.activities && displaySection.activities.length > 0) || isEditing ? (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h5 className="font-medium text-slate-700 flex items-center gap-2">
<Activity className="h-4 w-4" />
Activities
</h5>
{isEditing && (
<Button
size="sm"
variant="outline"
onClick={() => addEditingSectionItem('activity')}
className="h-7"
>
<Activity className="h-3 w-3 mr-1" />
Add Activity
</Button>
)}
</div>
<div className="space-y-2">
{displaySection.activities?.map((activity, idx) =>
renderItem(activity, sectionIndex, idx, 'activity')
)}
</div>
</div>
) : null}
{(displaySection.questions && displaySection.questions.length > 0) || isEditing ? (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h5 className="font-medium text-slate-700 flex items-center gap-2">
<MessageCircle className="h-4 w-4" />
Questions
</h5>
{isEditing && (
<Button
size="sm"
variant="outline"
onClick={() => addEditingSectionItem('question')}
className="h-7"
>
<MessageCircle className="h-3 w-3 mr-1" />
Add Question
</Button>
)}
</div>
<div className="space-y-2">
{displaySection.questions?.map((question, idx) =>
renderItem(question, sectionIndex, idx, 'question')
)}
</div>
</div>
) : null}
{isEditing && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h5 className="font-medium text-slate-700 flex items-center gap-2">
<Target className="h-4 w-4" />
Subsections
</h5>
<Button
size="sm"
variant="outline"
onClick={addSubsection}
className="h-7"
>
<Target className="h-3 w-3 mr-1" />
Add Subsection
</Button>
</div>
</div>
)}
{displaySection.subsections && displaySection.subsections.length > 0 && (
<div className="space-y-3 ml-4">
{displaySection.subsections.map((subsection, idx) => (
<div key={subsection.id} className="border-l-2 border-slate-200 pl-4">
<div className="flex items-center gap-2 mb-2">
{isEditing && (
<div className="flex flex-col gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => moveSubsectionUp(idx)}
disabled={!canMoveItemUp(displaySection.subsections || [], idx)}
className="h-7 w-7 p-0"
title="Move subsection up"
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => moveSubsectionDown(idx)}
disabled={!canMoveItemDown(displaySection.subsections || [], idx)}
className="h-7 w-7 p-0"
title="Move subsection down"
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
)}
{isEditing && editingSubsectionId === subsection.id ? (
<div className="flex items-center gap-2 flex-1">
<Input
value={tempSubsectionTitle}
onChange={(e) => setTempSubsectionTitle(e.target.value)}
className="flex-1"
onKeyDown={(e) => {
if (e.key === 'Enter') {
saveSubsectionTitle();
} else if (e.key === 'Escape') {
cancelEditingSubsectionTitle();
}
}}
autoFocus
/>
<Button size="sm" onClick={saveSubsectionTitle}>
<Check className="h-3 w-3" />
</Button>
<Button size="sm" variant="outline" onClick={cancelEditingSubsectionTitle}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<div className="flex items-center gap-2 flex-1">
<h5
className={cn(
"font-medium text-slate-700",
isEditing && "cursor-pointer hover:text-blue-600"
)}
onClick={() => isEditing && startEditingSubsectionTitle(subsection.id, subsection.title)}
>
{subsection.title}
</h5>
{isEditing && (
<>
<Button
size="sm"
variant="ghost"
onClick={() => startEditingSubsectionTitle(subsection.id, subsection.title)}
className="h-6 w-6 p-0 opacity-60 hover:opacity-100"
>
<Edit3 className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteSubsection(idx)}
className="h-6 w-6 p-0 opacity-60 hover:opacity-100 text-red-600 hover:text-red-700"
title="Delete subsection"
>
<Trash2 className="h-3 w-3" />
</Button>
</>
)}
</div>
)}
</div>
{(subsection.questions && subsection.questions.length > 0) || isEditing ? (
<div className="space-y-2 mb-3">
<div className="flex items-center justify-between">
<h6 className="text-sm font-medium text-slate-600 flex items-center gap-1">
<MessageCircle className="h-3 w-3" />
Questions
</h6>
{isEditing && (
<Button
size="sm"
variant="outline"
onClick={() => addSubsectionItem(idx, 'question')}
className="h-6"
>
<MessageCircle className="h-3 w-3 mr-1" />
Add Question
</Button>
)}
</div>
<div className="space-y-2">
{subsection.questions?.map((question, qIdx) =>
renderItem(question, sectionIndex, qIdx, 'question', idx)
)}
</div>
</div>
) : null}
{(subsection.activities && subsection.activities.length > 0) || isEditing ? (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h6 className="text-sm font-medium text-slate-600 flex items-center gap-1">
<Activity className="h-3 w-3" />
Activities
</h6>
{isEditing && (
<Button
size="sm"
variant="outline"
onClick={() => addSubsectionItem(idx, 'activity')}
className="h-6"
>
<Activity className="h-3 w-3 mr-1" />
Add Activity
</Button>
)}
</div>
<div className="space-y-2">
{subsection.activities?.map((activity, aIdx) =>
renderItem(activity, sectionIndex, aIdx, 'activity', idx)
)}
</div>
</div>
) : null}
</div>
))}
</div>
)}
{(section.metadata?.image_url || section.metadata?.image_id) && (
<div className="mt-4">
<div className="flex items-center gap-2 mb-2">
<ImageIcon className="h-4 w-4 text-slate-600" />
<span className="text-sm font-medium text-slate-700">Visual Aid</span>
</div>
{section.metadata.image_url ? (
<img
src={section.metadata.image_url}
alt="Visual aid for section"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
/>
) : section.metadata.image_id && focusGroupId ? (
<img
src={focusGroupsApi.getAssetUrl(focusGroupId, section.metadata.image_id)}
alt="Visual aid for section"
className="max-w-[400px] max-h-[400px] object-contain rounded-lg border border-slate-200"
/>
) : null}
</div>
)}
</div>
)}
</div>
);
};
// Render legacy markdown format
if (isLegacyFormat) {
return (
<div className={cn("space-y-4", className)}>
{showProgress && moderatorStatus && (
<div className="mb-4">
<div className="flex items-center justify-between text-sm text-slate-600 mb-2">
<span>Progress</span>
<span>{Math.round(moderatorStatus.progress)}%</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${moderatorStatus.progress}%` }}
/>
</div>
</div>
)}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-slate-800">Discussion Guide</h2>
{onDownload && (
<Button
size="sm"
variant="outline"
onClick={onDownload}
disabled={isDownloading}
>
{isDownloading ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download
</Button>
)}
</div>
<div className="prose prose-sm max-w-none">
<pre className="whitespace-pre-wrap text-sm text-slate-700 font-sans">
{discussionGuide as string}
</pre>
</div>
{moderatorStatus && (
<div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
<h3 className="font-medium text-blue-900 mb-2">Current Position</h3>
<p className="text-sm text-blue-800">{moderatorStatus.current_section}</p>
{moderatorStatus.current_item && (
<p className="text-sm text-blue-700 mt-1">{moderatorStatus.current_item}</p>
)}
</div>
)}
</div>
</div>
);
}
// Render structured format
if (!structuredGuide) {
return (
<div className={cn("bg-slate-50 rounded-lg p-8 text-center", className)}>
<p className="text-slate-600">No discussion guide available</p>
</div>
);
}
const content = (
<div className="space-y-4">
{showProgress && moderatorStatus && (
<div className="mb-4">
<div className="flex items-center justify-between text-sm text-slate-600 mb-2">
<span>Overall Progress</span>
<span>{Math.round(moderatorStatus.progress)}%</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${moderatorStatus.progress}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs text-slate-500 mt-2">
<span>Section {moderatorStatus.moderator_position.section_index + 1} of {moderatorStatus.total_sections}</span>
<span>{Math.round(moderatorStatus.section_progress)}% of current section</span>
</div>
</div>
)}
<div className="space-y-3">
{structuredGuide.sections.map((section, idx) =>
renderSection(section, idx)
)}
</div>
</div>
);
if (!collapsible) {
return <div className={className}>{content}</div>;
}
return (
<Collapsible defaultOpen={defaultExpanded} className={className}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 bg-white rounded-lg border border-slate-200 cursor-pointer hover:bg-slate-50 transition-colors">
<div className="flex items-center gap-3">
<ChevronRight className="h-5 w-5 text-slate-500 transition-transform data-[state=open]:rotate-90" />
<h2 className="text-lg font-semibold text-slate-800">
{structuredGuide.title || 'Discussion Guide'}
</h2>
<Badge variant="outline" className="text-xs">
{structuredGuide.total_duration} min
</Badge>
</div>
<div className="flex items-center gap-2">
{moderatorStatus && (
<Badge variant={moderatorStatus.progress === 100 ? "success" : "default"} className="text-xs">
{Math.round(moderatorStatus.progress)}% Complete
</Badge>
)}
{onDownload && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
onDownload();
}}
disabled={isDownloading}
>
{isDownloading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
)}
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="mt-4">
{content}
</CollapsibleContent>
</Collapsible>
);
});
DiscussionGuideViewer.displayName = 'DiscussionGuideViewer';
export default DiscussionGuideViewer;