diff options
| author | soryu <soryu@soryu.co> | 2025-12-24 00:23:05 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2025-12-24 00:23:05 +0000 |
| commit | cdfac7b3792d3813594daa36470465bd8c841ea9 (patch) | |
| tree | d2c42fff5683a7ba1eb2cfb1412e56396b0a6ffb | |
| parent | aa841b00ef05c2b89b5e8a136e80c94dfefa79fc (diff) | |
| download | soryu-cdfac7b3792d3813594daa36470465bd8c841ea9.tar.gz soryu-cdfac7b3792d3813594daa36470465bd8c841ea9.zip | |
Add overwrite mechanism for conflicting writes of files
| -rw-r--r-- | makima/frontend/src/components/files/BodyRenderer.tsx | 200 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/FileDetail.tsx | 9 | ||||
| -rw-r--r-- | makima/frontend/src/routes/files.tsx | 10 |
3 files changed, 187 insertions, 32 deletions
diff --git a/makima/frontend/src/components/files/BodyRenderer.tsx b/makima/frontend/src/components/files/BodyRenderer.tsx index 06b2b75..867fc4c 100644 --- a/makima/frontend/src/components/files/BodyRenderer.tsx +++ b/makima/frontend/src/components/files/BodyRenderer.tsx @@ -7,9 +7,12 @@ interface BodyRendererProps { isEditing?: boolean; onUpdate?: (index: number, element: BodyElement) => void; onReorder?: (fromIndex: number, toIndex: number) => void; + onEditingChange?: (isEditing: boolean) => void; + hasPendingRemoteUpdate?: boolean; + onOverwrite?: () => void; } -export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder }: BodyRendererProps) { +export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder, onEditingChange, hasPendingRemoteUpdate, onOverwrite }: BodyRendererProps) { const [draggedIndex, setDraggedIndex] = useState<number | null>(null); const [dragOverIndex, setDragOverIndex] = useState<number | null>(null); @@ -99,6 +102,9 @@ export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder <BodyElementRenderer element={element} onUpdate={isEditing && onUpdate ? (el) => onUpdate(index, el) : undefined} + onEditingChange={onEditingChange} + hasPendingRemoteUpdate={hasPendingRemoteUpdate} + onOverwrite={onOverwrite} /> </div> </div> @@ -110,9 +116,15 @@ export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder function BodyElementRenderer({ element, onUpdate, + onEditingChange, + hasPendingRemoteUpdate, + onOverwrite, }: { element: BodyElement; onUpdate?: (element: BodyElement) => void; + onEditingChange?: (isEditing: boolean) => void; + hasPendingRemoteUpdate?: boolean; + onOverwrite?: () => void; }) { switch (element.type) { case "heading": @@ -125,6 +137,9 @@ function BodyElementRenderer({ ? (text) => onUpdate({ ...element, text }) : undefined } + onEditingChange={onEditingChange} + hasPendingRemoteUpdate={hasPendingRemoteUpdate} + onOverwrite={onOverwrite} /> ); case "paragraph": @@ -136,6 +151,9 @@ function BodyElementRenderer({ ? (text) => onUpdate({ ...element, text }) : undefined } + onEditingChange={onEditingChange} + hasPendingRemoteUpdate={hasPendingRemoteUpdate} + onOverwrite={onOverwrite} /> ); case "chart": @@ -164,18 +182,27 @@ function HeadingElement({ level, text, onUpdate, + onEditingChange, + hasPendingRemoteUpdate, + onOverwrite, }: { level: number; text: string; onUpdate?: (text: string) => void; + onEditingChange?: (isEditing: boolean) => void; + hasPendingRemoteUpdate?: boolean; + onOverwrite?: () => void; }) { const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(text); const inputRef = useRef<HTMLInputElement>(null); useEffect(() => { - setEditText(text); - }, [text]); + // Only update editText if not currently editing + if (!isEditing) { + setEditText(text); + } + }, [text, isEditing]); useEffect(() => { if (isEditing && inputRef.current) { @@ -184,19 +211,52 @@ function HeadingElement({ } }, [isEditing]); + const startEditing = () => { + setIsEditing(true); + onEditingChange?.(true); + }; + + const stopEditing = () => { + setIsEditing(false); + onEditingChange?.(false); + }; + const handleSave = () => { + // Don't auto-save if there's a pending remote update + if (hasPendingRemoteUpdate) return; + if (onUpdate && editText.trim() !== text) { onUpdate(editText.trim()); } - setIsEditing(false); + stopEditing(); + }; + + const handleOverwrite = () => { + if (onUpdate && editText.trim() !== text) { + onUpdate(editText.trim()); + } + onOverwrite?.(); + stopEditing(); + }; + + const handleCancel = () => { + setEditText(text); + stopEditing(); }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { + // Disable Enter save if there's a pending remote update + if (e.key === "Enter" && !hasPendingRemoteUpdate) { handleSave(); } else if (e.key === "Escape") { - setEditText(text); - setIsEditing(false); + handleCancel(); + } + }; + + const handleBlur = () => { + // Don't auto-save on blur if there's a pending remote update + if (!hasPendingRemoteUpdate) { + handleSave(); } }; @@ -213,15 +273,36 @@ function HeadingElement({ 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`} - /> + <div> + <input + ref={inputRef} + type="text" + value={editText} + onChange={(e) => setEditText(e.target.value)} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + className={`${baseClassName} ${sizeClass} w-full bg-transparent border-b border-[#3f6fb3] outline-none`} + /> + {hasPendingRemoteUpdate && ( + <div className="flex items-center gap-2 mt-2"> + <span className="text-yellow-500 text-xs font-mono">Remote update pending</span> + <button + onClick={handleOverwrite} + onMouseDown={(e) => e.preventDefault()} + className="px-2 py-1 font-mono text-xs text-yellow-500 border border-yellow-500/50 hover:border-yellow-500 transition-colors" + > + Overwrite + </button> + <button + onClick={handleCancel} + onMouseDown={(e) => e.preventDefault()} + className="px-2 py-1 font-mono text-xs text-[#555] hover:text-white/70 transition-colors" + > + Cancel + </button> + </div> + )} + </div> ); } @@ -229,7 +310,7 @@ function HeadingElement({ 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)} + onClick={() => onUpdate && startEditing()} > {text} </HeadingTag> @@ -239,17 +320,26 @@ function HeadingElement({ function ParagraphElement({ text, onUpdate, + onEditingChange, + hasPendingRemoteUpdate, + onOverwrite, }: { text: string; onUpdate?: (text: string) => void; + onEditingChange?: (isEditing: boolean) => void; + hasPendingRemoteUpdate?: boolean; + onOverwrite?: () => void; }) { const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(text); const textareaRef = useRef<HTMLTextAreaElement>(null); useEffect(() => { - setEditText(text); - }, [text]); + // Only update editText if not currently editing + if (!isEditing) { + setEditText(text); + } + }, [text, isEditing]); useEffect(() => { if (isEditing && textareaRef.current) { @@ -260,20 +350,52 @@ function ParagraphElement({ } }, [isEditing]); + const startEditing = () => { + setIsEditing(true); + onEditingChange?.(true); + }; + + const stopEditing = () => { + setIsEditing(false); + onEditingChange?.(false); + }; + const handleSave = () => { + // Don't auto-save if there's a pending remote update + if (hasPendingRemoteUpdate) return; + if (onUpdate && editText.trim() !== text) { onUpdate(editText.trim()); } - setIsEditing(false); + stopEditing(); + }; + + const handleOverwrite = () => { + if (onUpdate && editText.trim() !== text) { + onUpdate(editText.trim()); + } + onOverwrite?.(); + stopEditing(); + }; + + const handleCancel = () => { + setEditText(text); + stopEditing(); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { - setEditText(text); - setIsEditing(false); + handleCancel(); + } + // Ctrl/Cmd + Enter to save - disabled if there's a pending remote update + if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && !hasPendingRemoteUpdate) { + handleSave(); } - // Ctrl/Cmd + Enter to save - if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + }; + + const handleBlur = () => { + // Don't auto-save on blur if there's a pending remote update + if (!hasPendingRemoteUpdate) { handleSave(); } }; @@ -292,13 +414,33 @@ function ParagraphElement({ ref={textareaRef} value={editText} onChange={handleInput} - onBlur={handleSave} + onBlur={handleBlur} 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> + {hasPendingRemoteUpdate ? ( + <div className="flex items-center gap-2 mt-2"> + <span className="text-yellow-500 text-xs font-mono">Remote update pending</span> + <button + onClick={handleOverwrite} + onMouseDown={(e) => e.preventDefault()} + className="px-2 py-1 font-mono text-xs text-yellow-500 border border-yellow-500/50 hover:border-yellow-500 transition-colors" + > + Overwrite + </button> + <button + onClick={handleCancel} + onMouseDown={(e) => e.preventDefault()} + className="px-2 py-1 font-mono text-xs text-[#555] hover:text-white/70 transition-colors" + > + Cancel + </button> + </div> + ) : ( + <div className="text-[10px] text-[#555] font-mono mt-1"> + Ctrl+Enter to save, Esc to cancel + </div> + )} </div> ); } @@ -306,7 +448,7 @@ function ParagraphElement({ 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)} + onClick={() => onUpdate && startEditing()} > {text} </p> diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx index 2bf4c03..29311b8 100644 --- a/makima/frontend/src/components/files/FileDetail.tsx +++ b/makima/frontend/src/components/files/FileDetail.tsx @@ -10,6 +10,9 @@ interface FileDetailProps { onDelete: (id: string) => void; onBodyElementUpdate?: (index: number, element: import("../../lib/api").BodyElement) => void; onBodyReorder?: (fromIndex: number, toIndex: number) => void; + onEditingChange?: (isEditing: boolean) => void; + hasPendingRemoteUpdate?: boolean; + onOverwrite?: () => void; } export function FileDetail({ @@ -20,6 +23,9 @@ export function FileDetail({ onDelete, onBodyElementUpdate, onBodyReorder, + onEditingChange, + hasPendingRemoteUpdate, + onOverwrite, }: FileDetailProps) { const [isEditing, setIsEditing] = useState(false); const [name, setName] = useState(file.name); @@ -152,6 +158,9 @@ export function FileDetail({ isEditing={isEditing} onUpdate={onBodyElementUpdate} onReorder={onBodyReorder} + onEditingChange={onEditingChange} + hasPendingRemoteUpdate={hasPendingRemoteUpdate} + onOverwrite={onOverwrite} /> </div> diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx index 423baa1..037df7e 100644 --- a/makima/frontend/src/routes/files.tsx +++ b/makima/frontend/src/routes/files.tsx @@ -22,6 +22,7 @@ export default function FilesPage() { const [creating, setCreating] = useState(false); const [remoteUpdate, setRemoteUpdate] = useState<FileUpdateEvent | null>(null); const [hasLocalChanges, setHasLocalChanges] = useState(false); + const [isActivelyEditing, setIsActivelyEditing] = useState(false); const pendingUpdateRef = useRef(false); // Load file detail when URL has an id @@ -48,8 +49,8 @@ export default function FilesPage() { return; } - // If no local changes, auto-refresh - if (!hasLocalChanges) { + // If no local changes and not actively editing, auto-refresh + if (!hasLocalChanges && !isActivelyEditing) { const detail = await fetchFile(event.fileId); setFileDetail(detail); } else { @@ -57,7 +58,7 @@ export default function FilesPage() { setRemoteUpdate(event); } }, - [hasLocalChanges, fetchFile] + [hasLocalChanges, isActivelyEditing, fetchFile] ); // Subscribe to file updates @@ -247,6 +248,9 @@ export default function FilesPage() { onDelete={handleDelete} onBodyElementUpdate={handleBodyElementUpdate} onBodyReorder={handleBodyReorder} + onEditingChange={setIsActivelyEditing} + hasPendingRemoteUpdate={!!remoteUpdate} + onOverwrite={handleRemoteUpdateDismiss} /> </div> <div className="shrink-0"> |
