1330 lines
No EOL
50 KiB
TypeScript
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; |