diff options
| author | soryu <soryu@soryu.co> | 2026-01-06 04:08:11 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-11 03:01:13 +0000 |
| commit | 8b17a175c3e7e27b789812eba4e3cd760beadb10 (patch) | |
| tree | 7864dcaa2fa9db47fdfd4e8bfdb0b1dde832aa33 /makima/frontend/src/components/files | |
| parent | f79c416c58557d2f946aa5332989afdfa8c021cd (diff) | |
| download | soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.tar.gz soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.zip | |
Initial Control system
Diffstat (limited to 'makima/frontend/src/components/files')
| -rw-r--r-- | makima/frontend/src/components/files/BodyRenderer.tsx | 121 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/CliInput.tsx | 58 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/ElementContextMenu.tsx | 292 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/FileDetail.tsx | 59 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/FileList.tsx | 207 |
5 files changed, 724 insertions, 13 deletions
diff --git a/makima/frontend/src/components/files/BodyRenderer.tsx b/makima/frontend/src/components/files/BodyRenderer.tsx index 867fc4c..cf99fde 100644 --- a/makima/frontend/src/components/files/BodyRenderer.tsx +++ b/makima/frontend/src/components/files/BodyRenderer.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useEffect } from "react"; import type { BodyElement } from "../../lib/api"; import { ChartRenderer } from "../charts/ChartRenderer"; +import { ElementContextMenu } from "./ElementContextMenu"; interface BodyRendererProps { elements: BodyElement[]; @@ -10,11 +11,54 @@ interface BodyRendererProps { onEditingChange?: (isEditing: boolean) => void; hasPendingRemoteUpdate?: boolean; onOverwrite?: () => void; + onFocusElement?: (index: number) => void; + onDeleteElement?: (index: number) => void; + onDuplicateElement?: (index: number) => void; + onConvertElement?: (index: number, toType: string) => void; + onGenerateFromElement?: (index: number, action: string) => void; + onCreateTaskFromElement?: (index: number, selectedText?: string) => void; } -export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder, onEditingChange, hasPendingRemoteUpdate, onOverwrite }: BodyRendererProps) { +export function BodyRenderer({ + elements, + isEditing = false, + onUpdate, + onReorder, + onEditingChange, + hasPendingRemoteUpdate, + onOverwrite, + onFocusElement, + onDeleteElement, + onDuplicateElement, + onConvertElement, + onGenerateFromElement, + onCreateTaskFromElement, +}: BodyRendererProps) { const [draggedIndex, setDraggedIndex] = useState<number | null>(null); const [dragOverIndex, setDragOverIndex] = useState<number | null>(null); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + elementIndex: number; + selectedText?: string; + } | null>(null); + + const handleContextMenu = (index: number) => (e: React.MouseEvent) => { + e.preventDefault(); + // Get any selected text + const selection = window.getSelection(); + const selectedText = selection?.toString().trim() || undefined; + setContextMenu({ + x: e.clientX, + y: e.clientY, + elementIndex: index, + selectedText, + }); + }; + + const closeContextMenu = () => { + setContextMenu(null); + }; if (elements.length === 0) { return ( @@ -73,6 +117,7 @@ export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder, onDragOver={handleDragOver(index)} onDragLeave={handleDragLeave} onDrop={handleDrop(index)} + onContextMenu={handleContextMenu(index)} > {/* Drag handle - only show in edit mode */} {isEditing && onReorder && ( @@ -109,6 +154,24 @@ export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder, </div> </div> ))} + + {/* Context Menu */} + {contextMenu && ( + <ElementContextMenu + x={contextMenu.x} + y={contextMenu.y} + element={elements[contextMenu.elementIndex]} + elementIndex={contextMenu.elementIndex} + selectedText={contextMenu.selectedText} + onClose={closeContextMenu} + onFocus={(index) => onFocusElement?.(index)} + onDelete={(index) => onDeleteElement?.(index)} + onDuplicate={(index) => onDuplicateElement?.(index)} + onConvert={(index, toType) => onConvertElement?.(index, toType)} + onGenerate={(index, action) => onGenerateFromElement?.(index, action)} + onCreateTask={(index, selectedText) => onCreateTaskFromElement?.(index, selectedText)} + /> + )} </div> ); } @@ -156,6 +219,20 @@ function BodyElementRenderer({ onOverwrite={onOverwrite} /> ); + case "code": + return ( + <CodeElement + language={element.language} + content={element.content} + /> + ); + case "list": + return ( + <ListElement + ordered={element.ordered} + items={element.items} + /> + ); case "chart": return ( <ChartElement @@ -502,3 +579,45 @@ function ImageElement({ </figure> ); } + +function CodeElement({ + language, + content, +}: { + language?: string; + content: string; +}) { + return ( + <div className="relative"> + {language && ( + <div className="absolute top-0 right-0 px-2 py-0.5 font-mono text-[10px] text-[#555] bg-[#1a1a1a] border-b border-l border-[#333]"> + {language} + </div> + )} + <pre className="bg-[#0d0d0d] border border-[#333] p-4 overflow-x-auto"> + <code className="font-mono text-sm text-[#9bc3ff] whitespace-pre"> + {content} + </code> + </pre> + </div> + ); +} + +function ListElement({ + ordered, + items, +}: { + ordered: boolean; + items: string[]; +}) { + const ListTag = ordered ? "ol" : "ul"; + return ( + <ListTag className={`font-mono text-sm text-white/80 leading-relaxed pl-6 space-y-1 ${ordered ? "list-decimal" : "list-disc"}`}> + {items.map((item, index) => ( + <li key={index} className="pl-1"> + {item} + </li> + ))} + </ListTag> + ); +} diff --git a/makima/frontend/src/components/files/CliInput.tsx b/makima/frontend/src/components/files/CliInput.tsx index ff2b0a4..47e7616 100644 --- a/makima/frontend/src/components/files/CliInput.tsx +++ b/makima/frontend/src/components/files/CliInput.tsx @@ -8,10 +8,15 @@ import { type UserAnswer, } from "../../lib/api"; import { SimpleMarkdown } from "../SimpleMarkdown"; +import type { FocusedElement } from "./FileDetail"; interface CliInputProps { fileId: string; onUpdate: (body: BodyElement[], summary: string | null) => void; + focusedElement?: FocusedElement | null; + onClearFocus?: () => void; + suggestedPrompt?: string | null; + onClearSuggestedPrompt?: () => void; } interface Message { @@ -28,7 +33,7 @@ const MODEL_OPTIONS: { value: LlmModel; label: string }[] = [ { value: "groq", label: "Groq Kimi" }, ]; -export function CliInput({ fileId, onUpdate }: CliInputProps) { +export function CliInput({ fileId, onUpdate, focusedElement, onClearFocus, suggestedPrompt, onClearSuggestedPrompt }: CliInputProps) { const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [messages, setMessages] = useState<Message[]>([]); @@ -53,6 +58,21 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { } }, [messages]); + // Auto-focus input when an element is focused + useEffect(() => { + if (focusedElement && inputRef.current) { + inputRef.current.focus(); + } + }, [focusedElement]); + + // Handle suggested prompt from generate actions + useEffect(() => { + if (suggestedPrompt) { + setInput(suggestedPrompt); + onClearSuggestedPrompt?.(); + } + }, [suggestedPrompt, onClearSuggestedPrompt]); + const handleSubmit = useCallback( async (e: React.FormEvent) => { e.preventDefault(); @@ -73,7 +93,13 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { try { // Send request with conversation history for context - const response = await chatWithFile(fileId, userMessage, model, conversationHistory); + const response = await chatWithFile( + fileId, + userMessage, + model, + conversationHistory, + focusedElement?.index + ); // Add assistant response const assistantMsgId = (Date.now() + 1).toString(); @@ -128,7 +154,7 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { inputRef.current?.focus(); } }, - [input, loading, fileId, model, onUpdate, conversationHistory] + [input, loading, fileId, model, onUpdate, conversationHistory, focusedElement] ); // Handle option selection for a question @@ -206,7 +232,13 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { try { // Send answers as the next message - const response = await chatWithFile(fileId, answerText, model, conversationHistory); + const response = await chatWithFile( + fileId, + answerText, + model, + conversationHistory, + focusedElement?.index + ); // Add assistant response const assistantMsgId = (Date.now() + 1).toString(); @@ -258,7 +290,7 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { } finally { setLoading(false); } - }, [pendingQuestions, userAnswers, customInputs, loading, fileId, model, conversationHistory, onUpdate]); + }, [pendingQuestions, userAnswers, customInputs, loading, fileId, model, conversationHistory, onUpdate, focusedElement]); // Cancel answering questions const handleCancelQuestions = useCallback(() => { @@ -397,6 +429,22 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { </option> ))} </select> + + {/* Focus Badge */} + {focusedElement && ( + <button + type="button" + onClick={onClearFocus} + className="flex items-center gap-1 px-2 py-0.5 font-mono text-[10px] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.3)] text-[#9bc3ff] hover:border-[#75aafc] transition-colors group" + title="Click to clear focus" + > + <span className="text-[#75aafc]">{focusedElement.type}</span> + <span className="text-[#555]">:</span> + <span>{focusedElement.index}</span> + <span className="text-[#555] group-hover:text-red-400 ml-1">×</span> + </button> + )} + <span className="text-[#9bc3ff] font-mono text-sm">></span> <input ref={inputRef} diff --git a/makima/frontend/src/components/files/ElementContextMenu.tsx b/makima/frontend/src/components/files/ElementContextMenu.tsx new file mode 100644 index 0000000..dcb430c --- /dev/null +++ b/makima/frontend/src/components/files/ElementContextMenu.tsx @@ -0,0 +1,292 @@ +import { useEffect, useRef, useState } from "react"; +import type { BodyElement } from "../../lib/api"; + +interface ElementContextMenuProps { + x: number; + y: number; + element: BodyElement; + elementIndex: number; + selectedText?: string; + onClose: () => void; + onFocus: (index: number) => void; + onDelete: (index: number) => void; + onDuplicate: (index: number) => void; + onConvert: (index: number, toType: string) => void; + onGenerate: (index: number, action: string) => void; + onCreateTask: (index: number, selectedText?: string) => void; +} + +export function ElementContextMenu({ + x, + y, + element, + elementIndex, + selectedText, + onClose, + onFocus, + onDelete, + onDuplicate, + onConvert, + onGenerate, + onCreateTask, +}: ElementContextMenuProps) { + const menuRef = useRef<HTMLDivElement>(null); + const [activeSubmenu, setActiveSubmenu] = useState<"generate" | "convert" | null>(null); + + // Close on click outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [onClose]); + + // Adjust position if menu would overflow viewport + useEffect(() => { + if (menuRef.current) { + const rect = menuRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + if (rect.right > viewportWidth) { + menuRef.current.style.left = `${x - rect.width}px`; + } + if (rect.bottom > viewportHeight) { + menuRef.current.style.top = `${y - rect.height}px`; + } + } + }, [x, y]); + + const getElementTypeLabel = () => { + switch (element.type) { + case "heading": + return `Heading ${element.level}`; + case "paragraph": + return "Paragraph"; + case "code": + return element.language ? `Code (${element.language})` : "Code"; + case "list": + return element.ordered ? "Ordered List" : "Bullet List"; + case "chart": + return `Chart (${element.chartType})`; + case "image": + return "Image"; + default: + return "Element"; + } + }; + + const menuItemClass = + "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2"; + const submenuTriggerClass = + "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center justify-between"; + const dividerClass = "border-t border-[rgba(117,170,252,0.2)] my-1"; + + return ( + <div + ref={menuRef} + className="fixed z-50 bg-[#0a0a0a] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]" + style={{ left: x, top: y }} + > + {/* Header showing element type */} + <div className="px-3 py-1.5 text-[10px] font-mono text-[#555] uppercase border-b border-[rgba(117,170,252,0.2)]"> + {getElementTypeLabel()} [{elementIndex}] + </div> + + {/* Focus action */} + <button + className={menuItemClass} + onClick={() => { + onFocus(elementIndex); + onClose(); + }} + > + <span className="text-[#75aafc]">></span> + Focus on this element + </button> + + {/* Create task action */} + <button + className={menuItemClass} + onClick={() => { + onCreateTask(elementIndex, selectedText); + onClose(); + }} + > + <span className="text-[#75aafc]">@</span> + {selectedText ? "Create task from selection" : "Create task from this"} + </button> + + <div className={dividerClass} /> + + {/* Generate submenu */} + <div + className="relative" + onMouseEnter={() => setActiveSubmenu("generate")} + onMouseLeave={() => setActiveSubmenu(null)} + > + <button className={submenuTriggerClass}> + <span className="flex items-center gap-2"> + <span className="text-[#75aafc]">+</span> + Generate from this + </span> + <span className="text-[#555]">›</span> + </button> + + {activeSubmenu === "generate" && ( + <div className="absolute left-full top-0 bg-[#0a0a0a] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[160px]"> + <button + className={menuItemClass} + onClick={() => { + onGenerate(elementIndex, "elaborate"); + onClose(); + }} + > + Elaborate/Expand + </button> + <button + className={menuItemClass} + onClick={() => { + onGenerate(elementIndex, "summarize"); + onClose(); + }} + > + Summarize + </button> + <button + className={menuItemClass} + onClick={() => { + onGenerate(elementIndex, "extract_actions"); + onClose(); + }} + > + Extract action items + </button> + </div> + )} + </div> + + {/* Convert submenu */} + <div + className="relative" + onMouseEnter={() => setActiveSubmenu("convert")} + onMouseLeave={() => setActiveSubmenu(null)} + > + <button className={submenuTriggerClass}> + <span className="flex items-center gap-2"> + <span className="text-[#75aafc]">~</span> + Convert to... + </span> + <span className="text-[#555]">›</span> + </button> + + {activeSubmenu === "convert" && ( + <div className="absolute left-full top-0 bg-[#0a0a0a] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[140px]"> + {element.type !== "paragraph" && ( + <button + className={menuItemClass} + onClick={() => { + onConvert(elementIndex, "paragraph"); + onClose(); + }} + > + Paragraph + </button> + )} + {element.type !== "list" && ( + <> + <button + className={menuItemClass} + onClick={() => { + onConvert(elementIndex, "list_unordered"); + onClose(); + }} + > + Bullet List + </button> + <button + className={menuItemClass} + onClick={() => { + onConvert(elementIndex, "list_ordered"); + onClose(); + }} + > + Numbered List + </button> + </> + )} + {element.type !== "code" && ( + <button + className={menuItemClass} + onClick={() => { + onConvert(elementIndex, "code"); + onClose(); + }} + > + Code Block + </button> + )} + {element.type !== "heading" && ( + <> + <div className={dividerClass} /> + {[1, 2, 3, 4, 5, 6].map((level) => ( + <button + key={level} + className={menuItemClass} + onClick={() => { + onConvert(elementIndex, `heading_${level}`); + onClose(); + }} + > + Heading {level} + </button> + ))} + </> + )} + </div> + )} + </div> + + <div className={dividerClass} /> + + {/* Duplicate */} + <button + className={menuItemClass} + onClick={() => { + onDuplicate(elementIndex); + onClose(); + }} + > + <span className="text-[#75aafc]">++</span> + Duplicate + </button> + + {/* Delete */} + <button + className={`${menuItemClass} text-red-400 hover:bg-red-400/10`} + onClick={() => { + onDelete(elementIndex); + onClose(); + }} + > + <span className="text-red-400">x</span> + Delete + </button> + </div> + ); +} diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx index c7b716a..60458e9 100644 --- a/makima/frontend/src/components/files/FileDetail.tsx +++ b/makima/frontend/src/components/files/FileDetail.tsx @@ -3,6 +3,12 @@ import type { FileDetail as FileDetailType, FileVersionSummary, FileVersion } fr import { BodyRenderer } from "./BodyRenderer"; import { VersionHistoryDropdown } from "./VersionHistoryDropdown"; +export interface FocusedElement { + index: number; + type: string; + preview: string; +} + interface FileDetailProps { file: FileDetailType; loading: boolean; @@ -11,9 +17,17 @@ interface FileDetailProps { onDelete: (id: string) => void; onBodyElementUpdate?: (index: number, element: import("../../lib/api").BodyElement) => void; onBodyReorder?: (fromIndex: number, toIndex: number) => void; + onBodyElementDelete?: (index: number) => void; + onBodyElementDuplicate?: (index: number) => void; onEditingChange?: (isEditing: boolean) => void; hasPendingRemoteUpdate?: boolean; onOverwrite?: () => void; + // Focus element props + focusedElement?: FocusedElement | null; + onFocusElement?: (element: FocusedElement | null) => void; + onGenerateFromElement?: (index: number, action: string) => void; + onConvertElement?: (index: number, toType: string) => void; + onCreateTaskFromElement?: (index: number, selectedText?: string) => void; // Version history props versions?: FileVersionSummary[]; versionsLoading?: boolean; @@ -33,9 +47,16 @@ export function FileDetail({ onDelete, onBodyElementUpdate, onBodyReorder, + onBodyElementDelete, + onBodyElementDuplicate, onEditingChange, hasPendingRemoteUpdate, onOverwrite, + focusedElement: _focusedElement, + onFocusElement, + onGenerateFromElement, + onConvertElement, + onCreateTaskFromElement, versions = [], versionsLoading = false, selectedVersion = null, @@ -50,6 +71,38 @@ export function FileDetail({ const [description, setDescription] = useState(file.description || ""); const [transcriptExpanded, setTranscriptExpanded] = useState(false); + // Helper to get element preview text + const getElementPreview = (index: number): string => { + const element = file.body[index]; + if (!element) return ""; + switch (element.type) { + case "heading": + case "paragraph": + return element.text.slice(0, 50) + (element.text.length > 50 ? "..." : ""); + case "code": + return element.content.slice(0, 50) + (element.content.length > 50 ? "..." : ""); + case "list": + return element.items[0]?.slice(0, 40) + (element.items.length > 1 ? ` (+${element.items.length - 1} more)` : ""); + case "chart": + return element.title || `${element.chartType} chart`; + case "image": + return element.alt || element.caption || "Image"; + default: + return "Element"; + } + }; + + // Handler for focus action from context menu + const handleFocusElement = (index: number) => { + const element = file.body[index]; + if (!element || !onFocusElement) return; + onFocusElement({ + index, + type: element.type, + preview: getElementPreview(index), + }); + }; + // Update local state when file changes useEffect(() => { setName(file.name); @@ -192,6 +245,12 @@ export function FileDetail({ onEditingChange={onEditingChange} hasPendingRemoteUpdate={hasPendingRemoteUpdate} onOverwrite={onOverwrite} + onFocusElement={handleFocusElement} + onDeleteElement={onBodyElementDelete} + onDuplicateElement={onBodyElementDuplicate} + onConvertElement={onConvertElement} + onGenerateFromElement={onGenerateFromElement} + onCreateTaskFromElement={onCreateTaskFromElement} /> </div> diff --git a/makima/frontend/src/components/files/FileList.tsx b/makima/frontend/src/components/files/FileList.tsx index a859aa1..c537846 100644 --- a/makima/frontend/src/components/files/FileList.tsx +++ b/makima/frontend/src/components/files/FileList.tsx @@ -1,4 +1,5 @@ -import type { FileSummary } from "../../lib/api"; +import { useRef } from "react"; +import type { FileSummary, BodyElement } from "../../lib/api"; interface FileListProps { files: FileSummary[]; @@ -6,6 +7,154 @@ interface FileListProps { onSelect: (id: string) => void; onDelete: (id: string) => void; onCreate: () => void; + onUploadMarkdown?: (name: string, body: BodyElement[]) => void; +} + +/** + * Parse markdown text into BodyElements. + * Converts image embeds to links instead of images. + */ +function parseMarkdown(markdown: string): BodyElement[] { + const elements: BodyElement[] = []; + const lines = markdown.split('\n'); + let currentParagraph: string[] = []; + let inCodeBlock = false; + let codeBlockLanguage: string | undefined; + let codeBlockContent: string[] = []; + let currentList: { ordered: boolean; items: string[] } | null = null; + + const flushParagraph = () => { + if (currentParagraph.length > 0) { + const text = currentParagraph.join('\n').trim(); + if (text) { + elements.push({ type: "paragraph", text }); + } + currentParagraph = []; + } + }; + + const flushCodeBlock = () => { + if (codeBlockContent.length > 0 || inCodeBlock) { + elements.push({ + type: "code", + language: codeBlockLanguage || undefined, + content: codeBlockContent.join('\n'), + }); + codeBlockContent = []; + codeBlockLanguage = undefined; + } + }; + + const flushList = () => { + if (currentList && currentList.items.length > 0) { + elements.push({ + type: "list", + ordered: currentList.ordered, + items: currentList.items, + }); + currentList = null; + } + }; + + // Convert image syntax  to link syntax [alt](url) or [image](url) + const convertImagesToLinks = (text: string): string => { + return text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => { + const linkText = alt || 'image'; + return `[${linkText}](${url})`; + }); + }; + + for (const rawLine of lines) { + // Check for code block fence (``` or ~~~) + const codeFenceMatch = rawLine.match(/^(`{3,}|~{3,})(\w*)?$/); + if (codeFenceMatch) { + if (!inCodeBlock) { + // Starting a code block + flushParagraph(); + flushList(); + inCodeBlock = true; + codeBlockLanguage = codeFenceMatch[2] || undefined; + codeBlockContent = []; + } else { + // Ending a code block + flushCodeBlock(); + inCodeBlock = false; + } + continue; + } + + // If inside a code block, add line as-is + if (inCodeBlock) { + codeBlockContent.push(rawLine); + continue; + } + + // Convert images to links in all lines + const line = convertImagesToLinks(rawLine); + + // Check for headings + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + flushParagraph(); + flushList(); + const level = headingMatch[1].length; + const text = headingMatch[2].trim(); + elements.push({ type: "heading", level, text }); + continue; + } + + // Check for unordered list items (-, *, +) + const unorderedMatch = line.match(/^[\s]*[-*+]\s+(.+)$/); + if (unorderedMatch) { + flushParagraph(); + const itemText = unorderedMatch[1].trim(); + if (currentList && currentList.ordered) { + // Switch from ordered to unordered + flushList(); + } + if (!currentList) { + currentList = { ordered: false, items: [] }; + } + currentList.items.push(itemText); + continue; + } + + // Check for ordered list items (1. 2. etc) + const orderedMatch = line.match(/^[\s]*\d+\.\s+(.+)$/); + if (orderedMatch) { + flushParagraph(); + const itemText = orderedMatch[1].trim(); + if (currentList && !currentList.ordered) { + // Switch from unordered to ordered + flushList(); + } + if (!currentList) { + currentList = { ordered: true, items: [] }; + } + currentList.items.push(itemText); + continue; + } + + // Empty line - flush everything + if (line.trim() === '') { + flushParagraph(); + flushList(); + continue; + } + + // Regular text - flush list first, then add to paragraph + flushList(); + currentParagraph.push(line); + } + + // Flush any remaining content + if (inCodeBlock) { + flushCodeBlock(); + } + flushParagraph(); + flushList(); + + return elements; } function formatDuration(seconds: number | null): string { @@ -32,7 +181,32 @@ export function FileList({ onSelect, onDelete, onCreate, + onUploadMarkdown, }: FileListProps) { + const fileInputRef = useRef<HTMLInputElement>(null); + + const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0]; + if (!file || !onUploadMarkdown) return; + + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target?.result as string; + if (content) { + const body = parseMarkdown(content); + // Use filename without extension as the name + const name = file.name.replace(/\.md$/i, '') || 'Imported Document'; + onUploadMarkdown(name, body); + } + }; + reader.readAsText(file); + + // Reset input so the same file can be uploaded again + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + if (loading) { return ( <div className="panel h-full flex items-center justify-center"> @@ -47,12 +221,31 @@ export function FileList({ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> FILES// </div> - <button - onClick={onCreate} - className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors uppercase" - > - + New - </button> + <div className="flex items-center gap-2"> + {onUploadMarkdown && ( + <> + <input + ref={fileInputRef} + type="file" + accept=".md,.markdown,text/markdown" + onChange={handleFileUpload} + className="hidden" + /> + <button + onClick={() => fileInputRef.current?.click()} + className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors uppercase" + > + Upload .md + </button> + </> + )} + <button + onClick={onCreate} + className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors uppercase" + > + + New + </button> + </div> </div> <div className="flex-1 overflow-y-auto"> |
