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

                                                 

                                       

                            



                        









































































                                                                                       

                                    

             



                                                                          

                                                   

          














                                                                                                                                        


                



























                                                                                                                                                 


          



































































                                                                                                                                
import type { BodyElement } from "../../lib/api";

interface UpdateNotificationProps {
  updatedBy: "user" | "llm" | "system";
  localBody: BodyElement[];
  remoteBody: BodyElement[];
  onRefresh: () => void;
  onDismiss: () => void;
}

// Get text content from a body element for comparison
function getElementText(element: BodyElement): string {
  switch (element.type) {
    case "heading":
    case "paragraph":
      return element.text;
    case "chart":
      return `[Chart: ${element.title || element.chartType}]`;
    case "image":
      return `[Image: ${element.alt || element.src}]`;
    default:
      return "[Unknown element]";
  }
}

// Get element type label
function getElementTypeLabel(element: BodyElement): string {
  switch (element.type) {
    case "heading":
      return `H${element.level}`;
    case "paragraph":
      return "P";
    case "chart":
      return "Chart";
    case "image":
      return "Image";
    default:
      return "?";
  }
}

interface DiffItem {
  type: "added" | "removed" | "modified" | "unchanged";
  localElement?: BodyElement;
  remoteElement?: BodyElement;
  localIndex?: number;
  remoteIndex?: number;
}

// Simple diff algorithm - compares elements by their text content
function computeDiff(localBody: BodyElement[], remoteBody: BodyElement[]): DiffItem[] {
  const diffs: DiffItem[] = [];
  const maxLen = Math.max(localBody.length, remoteBody.length);

  for (let i = 0; i < maxLen; i++) {
    const local = localBody[i];
    const remote = remoteBody[i];

    if (!local && remote) {
      // Element added remotely
      diffs.push({ type: "added", remoteElement: remote, remoteIndex: i });
    } else if (local && !remote) {
      // Element removed remotely (exists locally but not remotely)
      diffs.push({ type: "removed", localElement: local, localIndex: i });
    } else if (local && remote) {
      const localText = getElementText(local);
      const remoteText = getElementText(remote);
      if (localText !== remoteText || local.type !== remote.type) {
        // Element modified
        diffs.push({
          type: "modified",
          localElement: local,
          remoteElement: remote,
          localIndex: i,
          remoteIndex: i,
        });
      }
      // Skip unchanged elements
    }
  }

  return diffs;
}

export function UpdateNotification({
  updatedBy,
  localBody,
  remoteBody,
  onRefresh,
  onDismiss,
}: UpdateNotificationProps) {
  const source = updatedBy === "llm" ? "AI assistant" : "another session";
  const diffs = computeDiff(localBody, remoteBody);
  const hasChanges = diffs.length > 0;

  return (
    <div className="fixed bottom-4 right-4 w-[480px] max-h-[70vh] flex flex-col bg-[#1a2332] border border-[#3f6fb3]/50 shadow-lg z-50">
      {/* Header */}
      <div className="p-4 border-b border-[#3f6fb3]/30">
        <div className="flex items-start gap-3">
          <div className="text-[#75aafc] text-xl font-bold shrink-0">i</div>
          <div className="flex-1 min-w-0">
            <h3 className="font-mono text-sm text-[#9bc3ff] font-semibold mb-1">
              Remote Changes Detected
            </h3>
            <p className="font-mono text-xs text-white/70">
              This file was updated by {source}.
              {hasChanges
                ? ` ${diffs.length} change${diffs.length > 1 ? "s" : ""} found.`
                : " No content changes detected."}
            </p>
          </div>
        </div>
      </div>

      {/* Diff Content */}
      {hasChanges && (
        <div className="flex-1 overflow-y-auto p-4 space-y-3 min-h-0">
          <div className="font-mono text-xs text-[#555] uppercase tracking-wider mb-2">
            Changes
          </div>
          {diffs.map((diff, index) => (
            <DiffItemView key={index} diff={diff} />
          ))}
        </div>
      )}

      {/* Actions */}
      <div className="p-4 border-t border-[#3f6fb3]/30 flex gap-2">
        <button
          onClick={onRefresh}
          className="flex-1 px-3 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors"
        >
          Accept Remote Changes
        </button>
        <button
          onClick={onDismiss}
          className="px-3 py-2 font-mono text-xs text-[#555] border border-[#333] hover:text-white/70 hover:border-[#555] transition-colors"
        >
          Keep Local
        </button>
      </div>
    </div>
  );
}

function DiffItemView({ diff }: { diff: DiffItem }) {
  if (diff.type === "added") {
    return (
      <div className="border border-green-500/30 bg-green-500/5 p-3">
        <div className="flex items-center gap-2 mb-1">
          <span className="text-green-400 text-xs font-mono font-bold">+ ADDED</span>
          <span className="text-[#555] text-xs font-mono">
            [{getElementTypeLabel(diff.remoteElement!)}]
          </span>
        </div>
        <div className="font-mono text-sm text-green-300/80 break-words">
          {truncateText(getElementText(diff.remoteElement!), 150)}
        </div>
      </div>
    );
  }

  if (diff.type === "removed") {
    return (
      <div className="border border-red-500/30 bg-red-500/5 p-3">
        <div className="flex items-center gap-2 mb-1">
          <span className="text-red-400 text-xs font-mono font-bold">- REMOVED</span>
          <span className="text-[#555] text-xs font-mono">
            [{getElementTypeLabel(diff.localElement!)}]
          </span>
        </div>
        <div className="font-mono text-sm text-red-300/80 break-words line-through">
          {truncateText(getElementText(diff.localElement!), 150)}
        </div>
      </div>
    );
  }

  if (diff.type === "modified") {
    return (
      <div className="border border-yellow-500/30 bg-yellow-500/5 p-3">
        <div className="flex items-center gap-2 mb-2">
          <span className="text-yellow-400 text-xs font-mono font-bold">~ MODIFIED</span>
          <span className="text-[#555] text-xs font-mono">
            [{getElementTypeLabel(diff.localElement!)}]
          </span>
        </div>
        <div className="space-y-2">
          <div>
            <div className="text-[#555] text-[10px] font-mono uppercase mb-1">Your version:</div>
            <div className="font-mono text-xs text-red-300/70 break-words bg-red-500/10 p-2 border-l-2 border-red-500/50">
              {truncateText(getElementText(diff.localElement!), 120)}
            </div>
          </div>
          <div>
            <div className="text-[#555] text-[10px] font-mono uppercase mb-1">Remote version:</div>
            <div className="font-mono text-xs text-green-300/70 break-words bg-green-500/10 p-2 border-l-2 border-green-500/50">
              {truncateText(getElementText(diff.remoteElement!), 120)}
            </div>
          </div>
        </div>
      </div>
    );
  }

  return null;
}

function truncateText(text: string, maxLength: number): string {
  if (text.length <= maxLength) return text;
  return text.slice(0, maxLength) + "...";
}