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



                                                            
                                                        

                                                                                
                                             



                                      
                                                                            



                                             
                                                                                                                   

                                                                            
                                                  


                                                                                 




                                        
                                





                                      
                                


                      


























                                                     
























                                                                  






                                                                                                
      
                          

    












                                                      











                                                            
                                 
 






                                                                                          

















                                                            
                                 
 






                                                                                          




















                                                            








































                                                                                              



                                                                          
                                                                                
                   
                                                                                                                   




                             







                                                                        

                                                             





                                                                  






                                                                              
                                         

                                       
                                   


             

















                                                         


          
import { useState, useCallback, useEffect, useRef } from "react";
import { useParams, useNavigate } from "react-router";
import { Masthead } from "../components/Masthead";
import { FileList } from "../components/files/FileList";
import { FileDetail } from "../components/files/FileDetail";
import { CliInput } from "../components/files/CliInput";
import { ConflictNotification } from "../components/files/ConflictNotification";
import { UpdateNotification } from "../components/files/UpdateNotification";
import { useFiles } from "../hooks/useFiles";
import {
  useFileSubscription,
  type FileUpdateEvent,
} from "../hooks/useFileSubscription";
import type { FileDetail as FileDetailType, BodyElement } from "../lib/api";

export default function FilesPage() {
  const { id } = useParams<{ id: string }>();
  const navigate = useNavigate();
  const { files, loading, error, conflict, clearConflict, fetchFile, editFile, removeFile, saveFile } = useFiles();
  const [fileDetail, setFileDetail] = useState<FileDetailType | null>(null);
  const [detailLoading, setDetailLoading] = useState(false);
  const [creating, setCreating] = useState(false);
  const [remoteUpdate, setRemoteUpdate] = useState<FileUpdateEvent | null>(null);
  const [hasLocalChanges, setHasLocalChanges] = useState(false);
  const pendingUpdateRef = useRef(false);

  // Load file detail when URL has an id
  useEffect(() => {
    if (id) {
      setDetailLoading(true);
      setHasLocalChanges(false);
      fetchFile(id).then((detail) => {
        setFileDetail(detail);
        setDetailLoading(false);
      });
    } else {
      setFileDetail(null);
      setHasLocalChanges(false);
    }
  }, [id, fetchFile]);

  // Handle file update events from WebSocket
  const handleFileUpdate = useCallback(
    async (event: FileUpdateEvent) => {
      // Ignore our own updates
      if (pendingUpdateRef.current) {
        pendingUpdateRef.current = false;
        return;
      }

      // If no local changes, auto-refresh
      if (!hasLocalChanges) {
        const detail = await fetchFile(event.fileId);
        setFileDetail(detail);
      } else {
        // Show notification about remote update
        setRemoteUpdate(event);
      }
    },
    [hasLocalChanges, fetchFile]
  );

  // Subscribe to file updates
  useFileSubscription({
    fileId: id || null,
    onUpdate: handleFileUpdate,
  });

  const handleSelectFile = useCallback(
    (fileId: string) => {
      navigate(`/files/${fileId}`);
    },
    [navigate]
  );

  const handleBack = useCallback(() => {
    navigate("/files");
  }, [navigate]);

  const handleDelete = useCallback(
    async (fileId: string) => {
      if (confirm("Are you sure you want to delete this file?")) {
        const success = await removeFile(fileId);
        if (success && id === fileId) {
          navigate("/files");
        }
      }
    },
    [removeFile, id, navigate]
  );

  const handleSave = useCallback(
    async (fileId: string, name: string, description: string) => {
      if (!fileDetail) return;
      pendingUpdateRef.current = true;
      const result = await editFile(fileId, { name, description, version: fileDetail.version });
      if (result) {
        setFileDetail(result);
        setHasLocalChanges(false);
      }
    },
    [editFile, fileDetail]
  );

  const handleBodyUpdate = useCallback(
    (body: BodyElement[], summary: string | null) => {
      if (fileDetail) {
        setFileDetail({
          ...fileDetail,
          body,
          summary,
        });
      }
    },
    [fileDetail]
  );

  const handleBodyElementUpdate = useCallback(
    async (index: number, element: BodyElement) => {
      if (fileDetail && id) {
        // Create new body array with updated element
        const newBody = [...fileDetail.body];
        newBody[index] = element;

        // Update local state immediately for responsiveness
        setFileDetail({
          ...fileDetail,
          body: newBody,
        });
        setHasLocalChanges(true);

        // Save to backend with version for optimistic locking
        pendingUpdateRef.current = true;
        const result = await editFile(id, { body: newBody, version: fileDetail.version });
        if (result) {
          setFileDetail(result);
          setHasLocalChanges(false);
        }
      }
    },
    [fileDetail, id, editFile]
  );

  const handleBodyReorder = useCallback(
    async (fromIndex: number, toIndex: number) => {
      if (fileDetail && id) {
        // Create new body array with reordered elements
        const newBody = [...fileDetail.body];
        const [movedElement] = newBody.splice(fromIndex, 1);
        newBody.splice(toIndex, 0, movedElement);

        // Update local state immediately for responsiveness
        setFileDetail({
          ...fileDetail,
          body: newBody,
        });
        setHasLocalChanges(true);

        // Save to backend with version for optimistic locking
        pendingUpdateRef.current = true;
        const result = await editFile(id, { body: newBody, version: fileDetail.version });
        if (result) {
          setFileDetail(result);
          setHasLocalChanges(false);
        }
      }
    },
    [fileDetail, id, editFile]
  );

  const handleCreate = useCallback(async () => {
    if (creating) return;
    setCreating(true);
    try {
      const newFile = await saveFile({
        name: `Untitled ${new Date().toLocaleDateString()}`,
        transcript: [],
      });
      if (newFile) {
        navigate(`/files/${newFile.id}`);
      }
    } finally {
      setCreating(false);
    }
  }, [creating, saveFile, navigate]);

  // Conflict resolution handlers
  const handleConflictReload = useCallback(async () => {
    if (id) {
      clearConflict();
      const detail = await fetchFile(id);
      setFileDetail(detail);
      setHasLocalChanges(false);
    }
  }, [id, clearConflict, fetchFile]);

  const handleConflictForceOverwrite = useCallback(async () => {
    if (id && fileDetail) {
      clearConflict();
      // Fetch latest version first
      const latest = await fetchFile(id);
      if (latest) {
        // Retry with latest version
        pendingUpdateRef.current = true;
        const result = await editFile(id, { body: fileDetail.body, version: latest.version });
        if (result) {
          setFileDetail(result);
          setHasLocalChanges(false);
        }
      }
    }
  }, [id, fileDetail, clearConflict, fetchFile, editFile]);

  // Remote update handlers
  const handleRemoteUpdateRefresh = useCallback(async () => {
    if (id) {
      const detail = await fetchFile(id);
      setFileDetail(detail);
      setRemoteUpdate(null);
      setHasLocalChanges(false);
    }
  }, [id, fetchFile]);

  const handleRemoteUpdateDismiss = useCallback(() => {
    setRemoteUpdate(null);
  }, []);

  return (
    <div className="relative z-10 h-screen flex flex-col overflow-hidden">
      <Masthead showTicker={false} showNav />

      <main className="flex-1 p-4 md:p-6 min-h-0 overflow-hidden flex flex-col">
        {error && (
          <div className="mb-4 p-3 border border-red-400/50 bg-red-400/10 text-red-400 font-mono text-sm shrink-0">
            {error}
          </div>
        )}

        {id && fileDetail ? (
          <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
            <div className="flex-1 min-h-0 overflow-hidden">
              <FileDetail
                file={fileDetail}
                loading={detailLoading}
                onBack={handleBack}
                onSave={handleSave}
                onDelete={handleDelete}
                onBodyElementUpdate={handleBodyElementUpdate}
                onBodyReorder={handleBodyReorder}
              />
            </div>
            <div className="shrink-0">
              <CliInput fileId={id} onUpdate={handleBodyUpdate} />
            </div>
          </div>
        ) : id && detailLoading ? (
          <div className="panel h-full flex items-center justify-center">
            <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div>
          </div>
        ) : (
          <FileList
            files={files}
            loading={loading || creating}
            onSelect={handleSelectFile}
            onDelete={handleDelete}
            onCreate={handleCreate}
          />
        )}
      </main>

      {/* Conflict notification */}
      {conflict?.hasConflict && (
        <ConflictNotification
          onReload={handleConflictReload}
          onForceOverwrite={handleConflictForceOverwrite}
          onDismiss={clearConflict}
        />
      )}

      {/* Remote update notification */}
      {remoteUpdate && (
        <UpdateNotification
          updatedBy={remoteUpdate.updatedBy}
          onRefresh={handleRemoteUpdateRefresh}
          onDismiss={handleRemoteUpdateDismiss}
        />
      )}
    </div>
  );
}