summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/files/FileList.tsx
blob: c537846b422eb5d3d728756dccc704ab6db90943 (plain) (tree)
1
2
3
4
5
6
7
8
9

                                                              





                                 
                       



















































































































































                                                                               
























                                                         
           
                   
                   























                                                                            









                                                                                



                                                                                                                         
























                                                                                                                                                                                                  


















































                                                                                                                                                                             
import { useRef } from "react";
import type { FileSummary, BodyElement } from "../../lib/api";

interface FileListProps {
  files: FileSummary[];
  loading: boolean;
  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 {
  if (seconds === null) return "-";
  const mins = Math.floor(seconds / 60);
  const secs = Math.floor(seconds % 60);
  return `${mins}:${secs.toString().padStart(2, "0")}`;
}

function formatDate(dateStr: string): string {
  const date = new Date(dateStr);
  return date.toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
    year: "numeric",
    hour: "2-digit",
    minute: "2-digit",
  });
}

export function FileList({
  files,
  loading,
  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">
        <div className="font-mono text-[#9bc3ff] text-sm">Loading files...</div>
      </div>
    );
  }

  return (
    <div className="panel h-full flex flex-col">
      <div className="flex items-center justify-between p-4 pb-2 border-b border-dashed border-[rgba(117,170,252,0.35)]">
        <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
          FILES//
        </div>
        <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">
        {files.length === 0 ? (
          <div className="text-center text-[#9bc3ff] text-sm font-mono opacity-60 py-8">
            No saved files yet. Start recording to create one.
          </div>
        ) : (
          <div className="divide-y divide-[rgba(117,170,252,0.15)]">
            {files.map((file) => (
              <div
                key={file.id}
                className="p-4 hover:bg-[rgba(117,170,252,0.05)] transition-colors"
              >
                <div className="flex items-start justify-between gap-4">
                  <button
                    onClick={() => onSelect(file.id)}
                    className="flex-1 text-left"
                  >
                    <h3 className="font-mono text-sm text-[#dbe7ff] mb-1">
                      {file.name}
                    </h3>
                    {file.description && (
                      <p className="font-mono text-xs text-[#9bc3ff] mb-2 line-clamp-2">
                        {file.description}
                      </p>
                    )}
                    <div className="flex gap-4 font-mono text-[10px] text-[#75aafc]">
                      <span>{file.transcriptCount} segments</span>
                      <span>{formatDuration(file.duration)}</span>
                      <span>{formatDate(file.createdAt)}</span>
                    </div>
                  </button>
                  <button
                    onClick={(e) => {
                      e.stopPropagation();
                      onDelete(file.id);
                    }}
                    className="px-2 py-1 font-mono text-[10px] text-red-400 hover:bg-red-400/10 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase"
                  >
                    Delete
                  </button>
                </div>
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}