summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/files
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/files')
-rw-r--r--makima/frontend/src/components/files/BodyRenderer.tsx121
-rw-r--r--makima/frontend/src/components/files/CliInput.tsx58
-rw-r--r--makima/frontend/src/components/files/ElementContextMenu.tsx292
-rw-r--r--makima/frontend/src/components/files/FileDetail.tsx59
-rw-r--r--makima/frontend/src/components/files/FileList.tsx207
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">&times;</span>
+ </button>
+ )}
+
<span className="text-[#9bc3ff] font-mono text-sm">&gt;</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]">&gt;</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]">&rsaquo;</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]">&rsaquo;</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 ![alt](url) 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">