- FocusGroupSession: add missing DiscussionGuideViewer import (was crashing) - PersonaProfile: wrap TabsTrigger in TabsList (RovingFocusGroup crash) - Dashboard: fix Invalid Date bug — tx.ts vs tx.created_at field mismatch - ParticipantPanel, UserCard, SyntheticUsers: replace hardcoded light-mode colors (bg-blue-50, text-slate-*, bg-white) with dark theme tokens - AutonomousDashboard, NotesPanel, QuickNoteModal: same dark theme sweep Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
281 lines
No EOL
9.2 KiB
TypeScript
Executable file
281 lines
No EOL
9.2 KiB
TypeScript
Executable file
import { useState, useEffect } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Download, StickyNote, Trash2, Quote } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { focusGroupsApi } from '@/lib/api';
|
|
import { Note } from './types';
|
|
|
|
interface NotesPanelProps {
|
|
focusGroupId: string;
|
|
focusGroupName?: string;
|
|
onNoteClick?: (associatedMessageId: string) => void;
|
|
}
|
|
|
|
const NotesPanel = ({
|
|
focusGroupId,
|
|
focusGroupName = 'Focus Group',
|
|
onNoteClick
|
|
}: NotesPanelProps) => {
|
|
const [notes, setNotes] = useState<Note[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isDeleting, setIsDeleting] = useState<string | null>(null);
|
|
|
|
// Fetch notes when component mounts or focusGroupId changes
|
|
useEffect(() => {
|
|
fetchNotes();
|
|
}, [focusGroupId]);
|
|
|
|
const fetchNotes = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
const response = await focusGroupsApi.getNotes(focusGroupId);
|
|
|
|
if (response.data && Array.isArray(response.data)) {
|
|
const formattedNotes = response.data.map((note: any) => ({
|
|
...note,
|
|
timestamp: new Date(note.timestamp),
|
|
createdAt: new Date(note.createdAt)
|
|
}));
|
|
|
|
setNotes(sortNotesByCreatedAt(formattedNotes));
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching notes:', error);
|
|
toast.error('Failed to load notes', {
|
|
description: 'Please refresh the page to try again.'
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteNote = async (noteId: string) => {
|
|
setIsDeleting(noteId);
|
|
try {
|
|
await focusGroupsApi.deleteNote(focusGroupId, noteId);
|
|
setNotes(notes.filter(note => note.id !== noteId));
|
|
toast.success('Note deleted successfully');
|
|
} catch (error) {
|
|
console.error('Error deleting note:', error);
|
|
toast.error('Failed to delete note', {
|
|
description: 'Please try again.'
|
|
});
|
|
} finally {
|
|
setIsDeleting(null);
|
|
}
|
|
};
|
|
|
|
const handleNoteClick = (note: Note) => {
|
|
if (note.associatedMessageId && onNoteClick) {
|
|
onNoteClick(note.associatedMessageId);
|
|
} else {
|
|
toast.info('No associated message', {
|
|
description: 'This note is not linked to a specific discussion point.'
|
|
});
|
|
}
|
|
};
|
|
|
|
const exportNotes = () => {
|
|
if (notes.length === 0) {
|
|
toast.warning('No notes to export', {
|
|
description: 'Create some notes first before exporting.'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Create markdown content
|
|
const markdownContent = generateMarkdownExport();
|
|
|
|
// Create and download file
|
|
const element = document.createElement('a');
|
|
const file = new Blob([markdownContent], { type: 'text/markdown' });
|
|
element.href = URL.createObjectURL(file);
|
|
element.download = `${focusGroupName.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_notes.md`;
|
|
document.body.appendChild(element);
|
|
element.click();
|
|
document.body.removeChild(element);
|
|
|
|
toast.success('Notes exported successfully', {
|
|
description: `Downloaded ${notes.length} notes as Markdown file.`
|
|
});
|
|
};
|
|
|
|
const generateMarkdownExport = (): string => {
|
|
const lines = [
|
|
`# Notes: ${focusGroupName}`,
|
|
'',
|
|
`Exported on: ${new Date().toLocaleString()}`,
|
|
`Total notes: ${notes.length}`,
|
|
'',
|
|
'---',
|
|
''
|
|
];
|
|
|
|
notes.forEach((note, index) => {
|
|
lines.push(`## Note ${index + 1}`);
|
|
lines.push('');
|
|
lines.push(`**Created:** ${note.createdAt.toLocaleString()}`);
|
|
|
|
if (note.sectionInfo?.sectionTitle) {
|
|
lines.push(`**Section:** ${note.sectionInfo.sectionTitle}`);
|
|
}
|
|
|
|
lines.push(`**Elapsed Time:** ${formatElapsedTime(note.elapsedTime)}`);
|
|
lines.push('');
|
|
lines.push('**Content:**');
|
|
lines.push(note.content);
|
|
lines.push('');
|
|
lines.push('---');
|
|
lines.push('');
|
|
});
|
|
|
|
return lines.join('\n');
|
|
};
|
|
|
|
const formatElapsedTime = (milliseconds: number): string => {
|
|
const totalSeconds = Math.floor(milliseconds / 1000);
|
|
const minutes = Math.floor(totalSeconds / 60);
|
|
const seconds = totalSeconds % 60;
|
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
const formatTimestamp = (date: Date): string => {
|
|
return date.toLocaleString(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
};
|
|
|
|
// Helper function to sort notes by creation time (newest first)
|
|
const sortNotesByCreatedAt = (notesToSort: Note[]): Note[] => {
|
|
return [...notesToSort].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
};
|
|
|
|
// Public method to add a new note (called from parent)
|
|
const addNote = (note: Note) => {
|
|
setNotes(prevNotes => sortNotesByCreatedAt([...prevNotes, note]));
|
|
};
|
|
|
|
// Expose addNote method to parent via ref or callback
|
|
useEffect(() => {
|
|
// Store reference in a way parent can access
|
|
(window as any).notesPanelAddNote = addNote;
|
|
return () => {
|
|
delete (window as any).notesPanelAddNote;
|
|
};
|
|
}, []);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Header with export button */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center">
|
|
<StickyNote className="h-5 w-5 text-primary mr-2" />
|
|
<h2 className="font-sf text-xl font-semibold">Notes</h2>
|
|
{notes.length > 0 && (
|
|
<span className="ml-2 text-sm text-muted-foreground">({notes.length})</span>
|
|
)}
|
|
</div>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={exportNotes}
|
|
disabled={notes.length === 0}
|
|
>
|
|
<Download className="mr-2 h-4 w-4" />
|
|
Export Notes
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Notes list */}
|
|
<ScrollArea className="flex-1">
|
|
{notes.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center p-8 text-center bg-secondary/30 rounded-lg">
|
|
<StickyNote className="h-8 w-8 text-muted-foreground/60 mb-3" />
|
|
<p className="text-muted-foreground">No notes yet.</p>
|
|
<p className="text-sm text-muted-foreground mt-2">
|
|
Click the "Note" button during the session to add contextual notes.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{notes.map((note) => (
|
|
<Card
|
|
key={note.id}
|
|
className="hover:shadow-md transition-shadow cursor-pointer group"
|
|
onClick={() => handleNoteClick(note)}
|
|
>
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
|
{formatTimestamp(note.createdAt)}
|
|
</CardTitle>
|
|
{note.sectionInfo?.sectionTitle && (
|
|
<div className="text-xs text-muted-foreground mt-1">
|
|
<span>{note.sectionInfo.sectionTitle}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
{note.associatedMessageId && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleNoteClick(note);
|
|
}}
|
|
title="Go to discussion point"
|
|
>
|
|
<Quote className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteNote(note.id);
|
|
}}
|
|
disabled={isDeleting === note.id}
|
|
title="Delete note"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="pt-0">
|
|
<p className="text-sm text-foreground/80 whitespace-pre-wrap">
|
|
{note.content}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default NotesPanel; |