diff options
| author | soryu <soryu@soryu.co> | 2025-12-23 22:20:52 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2025-12-23 22:20:52 +0000 |
| commit | 72c2590571104b8d10e3f72d7a5b984d0b520c51 (patch) | |
| tree | 735aa03056a44a93b9abdf915545ad034ee2b597 /makima/frontend/src/routes | |
| parent | f5222a7ae5ade5589436778cb01fc0abe625b3c3 (diff) | |
| download | soryu-72c2590571104b8d10e3f72d7a5b984d0b520c51.tar.gz soryu-72c2590571104b8d10e3f72d7a5b984d0b520c51.zip | |
Add conflict notification and file update WS endpoint
Diffstat (limited to 'makima/frontend/src/routes')
| -rw-r--r-- | makima/frontend/src/routes/files.tsx | 133 |
1 files changed, 123 insertions, 10 deletions
diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx index 79544c5..423baa1 100644 --- a/makima/frontend/src/routes/files.tsx +++ b/makima/frontend/src/routes/files.tsx @@ -1,33 +1,71 @@ -import { useState, useCallback, useEffect } from "react"; +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, fetchFile, editFile, removeFile, saveFile } = useFiles(); + 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}`); @@ -53,11 +91,15 @@ export default function FilesPage() { const handleSave = useCallback( async (fileId: string, name: string, description: string) => { - await editFile(fileId, { name, description }); - const detail = await fetchFile(fileId); - setFileDetail(detail); + if (!fileDetail) return; + pendingUpdateRef.current = true; + const result = await editFile(fileId, { name, description, version: fileDetail.version }); + if (result) { + setFileDetail(result); + setHasLocalChanges(false); + } }, - [editFile, fetchFile] + [editFile, fileDetail] ); const handleBodyUpdate = useCallback( @@ -85,9 +127,15 @@ export default function FilesPage() { ...fileDetail, body: newBody, }); + setHasLocalChanges(true); - // Save to backend - await editFile(id, { body: newBody }); + // 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] @@ -106,9 +154,15 @@ export default function FilesPage() { ...fileDetail, body: newBody, }); + setHasLocalChanges(true); - // Save to backend - await editFile(id, { body: newBody }); + // 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] @@ -130,6 +184,47 @@ export default function FilesPage() { } }, [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 /> @@ -172,6 +267,24 @@ export default function FilesPage() { /> )} </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> ); } |
