Spaces:
Running
Running
| /** | |
| * Context Document Modal Component | |
| * | |
| * Modal for viewing and editing context documents with markdown rendering support | |
| */ | |
| import React, { useState, useEffect } from "react"; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogHeader, | |
| DialogTitle, | |
| } from "@/components/ui/dialog"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Label } from "@/components/ui/label"; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from "@/components/ui/select"; | |
| import { | |
| Edit, | |
| Save, | |
| Download, | |
| FileText, | |
| Trash2, | |
| X, | |
| Calendar, | |
| AlertCircle, | |
| } from "lucide-react"; | |
| import { | |
| ContextDocument, | |
| ContextDocumentType, | |
| CONTEXT_DOCUMENT_TYPES, | |
| } from "@/types/context"; | |
| import { shouldRenderAsMarkdown } from "@/lib/markdown-utils"; | |
| import Editor from "@monaco-editor/react"; | |
| import ReactMarkdown from "react-markdown"; | |
| interface ContextDocumentModalProps { | |
| document: ContextDocument; | |
| isOpen: boolean; | |
| onClose: () => void; | |
| onSave: (updates: { | |
| title?: string; | |
| content?: string; | |
| document_type?: ContextDocumentType; | |
| }) => Promise<boolean>; | |
| onDelete: () => Promise<boolean>; | |
| } | |
| export function ContextDocumentModal({ | |
| document, | |
| isOpen, | |
| onClose, | |
| onSave, | |
| onDelete, | |
| }: ContextDocumentModalProps) { | |
| const [isEditing, setIsEditing] = useState(false); | |
| const [editedTitle, setEditedTitle] = useState(document.title); | |
| const [editedContent, setEditedContent] = useState(document.content); | |
| const [editedType, setEditedType] = useState(document.document_type); | |
| const [isSaving, setIsSaving] = useState(false); | |
| const [isDeleting, setIsDeleting] = useState(false); | |
| const [isDirty, setIsDirty] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| // Update local state when document changes | |
| useEffect(() => { | |
| setEditedTitle(document.title); | |
| setEditedContent(document.content); | |
| setEditedType(document.document_type); | |
| setIsDirty(false); | |
| setError(null); | |
| }, [document]); | |
| // Check if content has changed | |
| useEffect(() => { | |
| const hasChanges = | |
| editedTitle !== document.title || | |
| editedContent !== document.content || | |
| editedType !== document.document_type; | |
| setIsDirty(hasChanges); | |
| }, [editedTitle, editedContent, editedType, document]); | |
| const handleEdit = () => { | |
| setIsEditing(true); | |
| setError(null); | |
| }; | |
| const handleSave = async () => { | |
| if (!isDirty) return; | |
| setIsSaving(true); | |
| setError(null); | |
| try { | |
| const updates: any = {}; | |
| if (editedTitle !== document.title) updates.title = editedTitle; | |
| if (editedContent !== document.content) updates.content = editedContent; | |
| if (editedType !== document.document_type) | |
| updates.document_type = editedType; | |
| const success = await onSave(updates); | |
| if (success) { | |
| setIsEditing(false); | |
| setIsDirty(false); | |
| } | |
| } catch (err) { | |
| setError(err instanceof Error ? err.message : "Failed to save document"); | |
| } finally { | |
| setIsSaving(false); | |
| } | |
| }; | |
| const handleCancel = () => { | |
| setEditedTitle(document.title); | |
| setEditedContent(document.content); | |
| setEditedType(document.document_type); | |
| setIsEditing(false); | |
| setIsDirty(false); | |
| setError(null); | |
| }; | |
| const handleDownload = () => { | |
| const blob = new Blob([document.content], { type: "text/plain" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = window.document.createElement("a"); | |
| a.href = url; | |
| a.download = `${document.title}.${ | |
| shouldRenderAsMarkdown(document.content, document.file_name) | |
| ? "md" | |
| : "txt" | |
| }`; | |
| window.document.body.appendChild(a); | |
| a.click(); | |
| window.document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| }; | |
| const handleDelete = async () => { | |
| if ( | |
| !window.confirm( | |
| "Are you sure you want to delete this context document? This action cannot be undone." | |
| ) | |
| ) { | |
| return; | |
| } | |
| setIsDeleting(true); | |
| setError(null); | |
| try { | |
| const success = await onDelete(); | |
| if (success) { | |
| onClose(); | |
| } | |
| } catch (err) { | |
| setError( | |
| err instanceof Error ? err.message : "Failed to delete document" | |
| ); | |
| } finally { | |
| setIsDeleting(false); | |
| } | |
| }; | |
| const formatDate = (dateString: string) => { | |
| return new Date(dateString).toLocaleDateString("en-US", { | |
| year: "numeric", | |
| month: "short", | |
| day: "numeric", | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| }); | |
| }; | |
| const formatDocumentType = (type: ContextDocumentType) => { | |
| const typeConfig = CONTEXT_DOCUMENT_TYPES.find((t) => t.value === type); | |
| return typeConfig?.label || type.replace(/_/g, " "); | |
| }; | |
| const isMarkdown = shouldRenderAsMarkdown( | |
| document.content, | |
| document.file_name | |
| ); | |
| return ( | |
| <Dialog open={isOpen} onOpenChange={onClose}> | |
| <DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col"> | |
| <DialogHeader className="space-y-3"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-2 flex-1 min-w-0"> | |
| <FileText className="h-5 w-5 text-primary" /> | |
| {isEditing ? ( | |
| <Input | |
| value={editedTitle} | |
| onChange={(e) => setEditedTitle(e.target.value)} | |
| className="text-lg font-semibold" | |
| placeholder="Document title" | |
| /> | |
| ) : ( | |
| <DialogTitle className="truncate">{document.title}</DialogTitle> | |
| )} | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| {!isEditing ? ( | |
| <> | |
| <Button variant="outline" size="sm" onClick={handleEdit}> | |
| <Edit className="h-4 w-4 mr-2" /> | |
| Edit | |
| </Button> | |
| <Button variant="outline" size="sm" onClick={handleDownload}> | |
| <Download className="h-4 w-4 mr-2" /> | |
| Download | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={handleDelete} | |
| disabled={isDeleting} | |
| className="text-destructive hover:text-destructive" | |
| > | |
| <Trash2 className="h-4 w-4 mr-2" /> | |
| {isDeleting ? "Deleting..." : "Delete"} | |
| </Button> | |
| </> | |
| ) : ( | |
| <> | |
| <Button variant="outline" size="sm" onClick={handleCancel}> | |
| <X className="h-4 w-4 mr-2" /> | |
| Cancel | |
| </Button> | |
| <Button | |
| size="sm" | |
| onClick={handleSave} | |
| disabled={!isDirty || isSaving} | |
| > | |
| <Save className="h-4 w-4 mr-2" /> | |
| {isSaving ? "Saving..." : "Save"} | |
| </Button> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| {/* Document metadata */} | |
| <div className="flex flex-wrap gap-2 text-sm"> | |
| {isEditing ? ( | |
| <div className="flex items-center gap-2"> | |
| <Label htmlFor="document-type">Type:</Label> | |
| <Select | |
| value={editedType} | |
| onValueChange={(value: ContextDocumentType) => | |
| setEditedType(value) | |
| } | |
| > | |
| <SelectTrigger className="w-48"> | |
| <SelectValue /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| {CONTEXT_DOCUMENT_TYPES.map((type) => ( | |
| <SelectItem key={type.value} value={type.value}> | |
| {type.label} | |
| </SelectItem> | |
| ))} | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| ) : ( | |
| <Badge variant="outline" className="flex items-center gap-1"> | |
| <FileText className="h-3 w-3" /> | |
| {formatDocumentType(document.document_type)} | |
| </Badge> | |
| )} | |
| <Badge variant="outline" className="flex items-center gap-1"> | |
| <Calendar className="h-3 w-3" /> | |
| {formatDate(document.created_at)} | |
| </Badge> | |
| <Badge variant="outline"> | |
| {document.content.length.toLocaleString()} chars | |
| </Badge> | |
| </div> | |
| {error && ( | |
| <div className="flex items-center gap-2 text-destructive text-sm p-3 bg-destructive/10 rounded-lg"> | |
| <AlertCircle className="h-4 w-4" /> | |
| {error} | |
| </div> | |
| )} | |
| </DialogHeader> | |
| <div className="flex-1 overflow-hidden flex flex-col"> | |
| {/* Single content area - conditional rendering based on editing state */} | |
| <div className="flex-1 overflow-hidden"> | |
| {isEditing ? ( | |
| /* Edit Mode */ | |
| <div className="border rounded-lg h-full overflow-hidden"> | |
| <Editor | |
| height="100%" | |
| defaultLanguage={isMarkdown ? "markdown" : "plaintext"} | |
| value={editedContent} | |
| onChange={(value) => setEditedContent(value || "")} | |
| theme="vs" | |
| options={{ | |
| minimap: { enabled: false }, | |
| scrollBeyondLastLine: false, | |
| wordWrap: "on", | |
| lineNumbers: "on", | |
| folding: false, | |
| renderWhitespace: "selection", | |
| fontSize: 14, | |
| lineHeight: 20, | |
| fontFamily: | |
| "'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace", | |
| padding: { top: 10, bottom: 10 }, | |
| automaticLayout: true, | |
| }} | |
| /> | |
| </div> | |
| ) : ( | |
| /* View Mode */ | |
| <div className="border rounded-lg h-full overflow-auto"> | |
| <div className="p-4"> | |
| {isMarkdown ? ( | |
| <div className="prose prose-sm max-w-none"> | |
| <ReactMarkdown>{document.content}</ReactMarkdown> | |
| </div> | |
| ) : ( | |
| <pre className="whitespace-pre-wrap font-mono text-sm text-foreground"> | |
| {document.content} | |
| </pre> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </DialogContent> | |
| </Dialog> | |
| ); | |
| } | |