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




                                                        


                                                           

 



                                                                                                       







                                                            

































                                                                      
          
                               
                                         











































                                                                                                                                                                                                         




          






                                            

                         










                                                        
                     









                                                        





















                                       






























































                                                                                                                
   









                                                                                                                                       

 










































































                                                                                                                                                             
















































                                                                     
import { useState, useRef, useEffect } from "react";
import type { BodyElement } from "../../lib/api";
import { ChartRenderer } from "../charts/ChartRenderer";

interface BodyRendererProps {
  elements: BodyElement[];
  isEditing?: boolean;
  onUpdate?: (index: number, element: BodyElement) => void;
  onReorder?: (fromIndex: number, toIndex: number) => void;
}

export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder }: BodyRendererProps) {
  const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
  const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);

  if (elements.length === 0) {
    return (
      <div className="text-[#555] font-mono text-sm italic">
        No content yet. Use the CLI below to add content.
      </div>
    );
  }

  const handleDragStart = (index: number) => (e: React.DragEvent) => {
    setDraggedIndex(index);
    e.dataTransfer.effectAllowed = "move";
    e.dataTransfer.setData("text/plain", index.toString());
  };

  const handleDragOver = (index: number) => (e: React.DragEvent) => {
    e.preventDefault();
    e.dataTransfer.dropEffect = "move";
    if (draggedIndex !== null && draggedIndex !== index) {
      setDragOverIndex(index);
    }
  };

  const handleDragLeave = () => {
    setDragOverIndex(null);
  };

  const handleDrop = (toIndex: number) => (e: React.DragEvent) => {
    e.preventDefault();
    const fromIndex = draggedIndex;
    setDraggedIndex(null);
    setDragOverIndex(null);

    if (fromIndex !== null && fromIndex !== toIndex && onReorder) {
      onReorder(fromIndex, toIndex);
    }
  };

  const handleDragEnd = () => {
    setDraggedIndex(null);
    setDragOverIndex(null);
  };

  return (
    <div className="space-y-1">
      {elements.map((element, index) => (
        <div
          key={index}
          className={`group flex items-start gap-2 py-1 transition-all ${
            draggedIndex === index ? "opacity-50" : ""
          } ${
            dragOverIndex === index
              ? "border-t-2 border-[#75aafc] -mt-[2px] pt-[calc(0.25rem+2px)]"
              : ""
          }`}
          onDragOver={handleDragOver(index)}
          onDragLeave={handleDragLeave}
          onDrop={handleDrop(index)}
        >
          {/* Drag handle - only show in edit mode */}
          {isEditing && onReorder && (
            <div
              draggable
              onDragStart={handleDragStart(index)}
              onDragEnd={handleDragEnd}
              className="flex-shrink-0 w-5 h-6 flex items-center justify-center cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-100 transition-opacity text-[#555] hover:text-[#75aafc]"
              title="Drag to reorder"
            >
              <svg
                width="12"
                height="12"
                viewBox="0 0 12 12"
                fill="currentColor"
              >
                <circle cx="3" cy="2" r="1.5" />
                <circle cx="9" cy="2" r="1.5" />
                <circle cx="3" cy="6" r="1.5" />
                <circle cx="9" cy="6" r="1.5" />
                <circle cx="3" cy="10" r="1.5" />
                <circle cx="9" cy="10" r="1.5" />
              </svg>
            </div>
          )}
          <div className="flex-1">
            <BodyElementRenderer
              element={element}
              onUpdate={isEditing && onUpdate ? (el) => onUpdate(index, el) : undefined}
            />
          </div>
        </div>
      ))}
    </div>
  );
}

function BodyElementRenderer({
  element,
  onUpdate,
}: {
  element: BodyElement;
  onUpdate?: (element: BodyElement) => void;
}) {
  switch (element.type) {
    case "heading":
      return (
        <HeadingElement
          level={element.level}
          text={element.text}
          onUpdate={
            onUpdate
              ? (text) => onUpdate({ ...element, text })
              : undefined
          }
        />
      );
    case "paragraph":
      return (
        <ParagraphElement
          text={element.text}
          onUpdate={
            onUpdate
              ? (text) => onUpdate({ ...element, text })
              : undefined
          }
        />
      );
    case "chart":
      return (
        <ChartElement
          chartType={element.chartType}
          data={element.data}
          title={element.title}
          config={element.config}
        />
      );
    case "image":
      return (
        <ImageElement
          src={element.src}
          alt={element.alt}
          caption={element.caption}
        />
      );
    default:
      return null;
  }
}

function HeadingElement({
  level,
  text,
  onUpdate,
}: {
  level: number;
  text: string;
  onUpdate?: (text: string) => void;
}) {
  const [isEditing, setIsEditing] = useState(false);
  const [editText, setEditText] = useState(text);
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    setEditText(text);
  }, [text]);

  useEffect(() => {
    if (isEditing && inputRef.current) {
      inputRef.current.focus();
      inputRef.current.select();
    }
  }, [isEditing]);

  const handleSave = () => {
    if (onUpdate && editText.trim() !== text) {
      onUpdate(editText.trim());
    }
    setIsEditing(false);
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "Enter") {
      handleSave();
    } else if (e.key === "Escape") {
      setEditText(text);
      setIsEditing(false);
    }
  };

  const baseClassName = "font-mono text-[#9bc3ff]";
  const sizeClasses: Record<number, string> = {
    1: "text-2xl font-bold",
    2: "text-xl font-bold",
    3: "text-lg font-semibold",
    4: "text-base font-semibold",
    5: "text-sm font-semibold",
    6: "text-xs font-semibold",
  };
  const sizeClass = sizeClasses[level] || sizeClasses[3];

  if (isEditing && onUpdate) {
    return (
      <input
        ref={inputRef}
        type="text"
        value={editText}
        onChange={(e) => setEditText(e.target.value)}
        onBlur={handleSave}
        onKeyDown={handleKeyDown}
        className={`${baseClassName} ${sizeClass} w-full bg-transparent border-b border-[#3f6fb3] outline-none`}
      />
    );
  }

  const HeadingTag = `h${level}` as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
  return (
    <HeadingTag
      className={`${baseClassName} ${sizeClass} ${onUpdate ? "cursor-text hover:bg-[rgba(117,170,252,0.05)] px-1 -mx-1 rounded" : ""}`}
      onClick={() => onUpdate && setIsEditing(true)}
    >
      {text}
    </HeadingTag>
  );
}

function ParagraphElement({
  text,
  onUpdate,
}: {
  text: string;
  onUpdate?: (text: string) => void;
}) {
  const [isEditing, setIsEditing] = useState(false);
  const [editText, setEditText] = useState(text);
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  useEffect(() => {
    setEditText(text);
  }, [text]);

  useEffect(() => {
    if (isEditing && textareaRef.current) {
      textareaRef.current.focus();
      // Auto-resize textarea
      textareaRef.current.style.height = "auto";
      textareaRef.current.style.height = textareaRef.current.scrollHeight + "px";
    }
  }, [isEditing]);

  const handleSave = () => {
    if (onUpdate && editText.trim() !== text) {
      onUpdate(editText.trim());
    }
    setIsEditing(false);
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "Escape") {
      setEditText(text);
      setIsEditing(false);
    }
    // Ctrl/Cmd + Enter to save
    if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
      handleSave();
    }
  };

  const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    setEditText(e.target.value);
    // Auto-resize
    e.target.style.height = "auto";
    e.target.style.height = e.target.scrollHeight + "px";
  };

  if (isEditing && onUpdate) {
    return (
      <div className="relative">
        <textarea
          ref={textareaRef}
          value={editText}
          onChange={handleInput}
          onBlur={handleSave}
          onKeyDown={handleKeyDown}
          className="font-mono text-sm text-white/80 leading-relaxed w-full bg-transparent border border-[#3f6fb3] outline-none p-2 resize-none min-h-[60px]"
        />
        <div className="text-[10px] text-[#555] font-mono mt-1">
          Ctrl+Enter to save, Esc to cancel
        </div>
      </div>
    );
  }

  return (
    <p
      className={`font-mono text-sm text-white/80 leading-relaxed ${onUpdate ? "cursor-text hover:bg-[rgba(117,170,252,0.05)] px-1 -mx-1 rounded" : ""}`}
      onClick={() => onUpdate && setIsEditing(true)}
    >
      {text}
    </p>
  );
}

function ChartElement({
  chartType,
  data,
  title,
  config,
}: {
  chartType: "line" | "bar" | "pie" | "area";
  data: Record<string, unknown>[];
  title?: string;
  config?: Record<string, unknown>;
}) {
  return (
    <div className="border border-[#333] p-4 bg-black/30">
      <ChartRenderer
        chartType={chartType}
        data={data}
        title={title}
        config={config}
      />
    </div>
  );
}

function ImageElement({
  src,
  alt,
  caption,
}: {
  src: string;
  alt?: string;
  caption?: string;
}) {
  return (
    <figure className="space-y-2">
      <img
        src={src}
        alt={alt || ""}
        className="max-w-full border border-[#333]"
      />
      {caption && (
        <figcaption className="text-[#555] font-mono text-xs italic">
          {caption}
        </figcaption>
      )}
    </figure>
  );
}