SharedEditPage.jsx (6823B)
1 import { useCallback, useEffect, useRef, useState } from 'react'; 2 import { useParams } from 'react-router-dom'; 3 import EditorLayout from '../components/EditorLayout.jsx'; 4 import ReadOnlyBanner from '../components/ReadOnlyBanner.jsx'; 5 import SaveStatus from '../components/SaveStatus.jsx'; 6 import StatusBadge from '../components/StatusBadge.jsx'; 7 import VersionHistory from '../components/VersionHistory.jsx'; 8 import { useEditLock } from '../hooks/useEditLock.js'; 9 import { useServerAutosave } from '../hooks/useServerAutosave.js'; 10 import { 11 ApiError, 12 fetchEditDocument, 13 friendlyErrorMessage, 14 } from '../lib/api.js'; 15 import { downloadDocument } from '../lib/download.js'; 16 import { parseOrgDocument } from '../lib/org/parseDocument.js'; 17 import { STR } from '../lib/strings.js'; 18 import LinkErrorPage from './LinkErrorPage.jsx'; 19 20 export default function SharedEditPage() { 21 const { token } = useParams(); 22 const [doc, setDoc] = useState(null); 23 const [title, setTitle] = useState(''); 24 const [content, setContent] = useState(''); 25 const [mode, setMode] = useState('markdown'); 26 const [loadError, setLoadError] = useState(null); 27 const [loading, setLoading] = useState(true); 28 const [lockLost, setLockLost] = useState(false); 29 const editorRef = useRef(null); 30 const titleEditedRef = useRef(false); 31 32 const { lockState, acquire, release, hasLock, lockToken, clientId } = 33 useEditLock(token, !!doc && !loadError); 34 35 const canEdit = hasLock && !lockLost; 36 37 const { saveStatus, saveNow } = useServerAutosave({ 38 editToken: token, 39 clientId, 40 lockToken, 41 enabled: canEdit, 42 title, 43 mode, 44 content, 45 }); 46 47 useEffect(() => { 48 if (mode !== 'org' || titleEditedRef.current) return; 49 const { title: orgTitle } = parseOrgDocument(content); 50 if ( 51 orgTitle && 52 (title === STR.UNTITLED_DOCUMENT || title.trim() === '') 53 ) { 54 setTitle(orgTitle); 55 } 56 }, [content, mode, title]); 57 58 useEffect(() => { 59 let cancelled = false; 60 61 (async () => { 62 setLoading(true); 63 setLoadError(null); 64 titleEditedRef.current = false; 65 try { 66 const data = await fetchEditDocument(token); 67 if (cancelled) return; 68 setDoc(data); 69 setTitle(data.title); 70 setContent(data.content); 71 setMode(data.mode); 72 } catch (err) { 73 if (!cancelled) setLoadError(err); 74 } finally { 75 if (!cancelled) setLoading(false); 76 } 77 })(); 78 79 return () => { 80 cancelled = true; 81 }; 82 }, [token]); 83 84 const lockRequestedRef = useRef(false); 85 86 useEffect(() => { 87 lockRequestedRef.current = false; 88 }, [token]); 89 90 useEffect(() => { 91 if (!doc || loadError || lockRequestedRef.current) return; 92 lockRequestedRef.current = true; 93 acquire().catch(() => {}); 94 }, [doc, loadError, acquire]); 95 96 useEffect(() => { 97 if (lockState.status === 'lost') { 98 setLockLost(true); 99 } 100 if (saveStatus === 'no_permission') { 101 setLockLost(true); 102 } 103 }, [lockState.status, saveStatus]); 104 105 const handleSaveServer = useCallback(async () => { 106 const ok = await saveNow(); 107 if (!ok) setLockLost(true); 108 }, [saveNow]); 109 110 const handleRelease = useCallback(async () => { 111 await release(); 112 setLockLost(true); 113 }, [release]); 114 115 const handleDownload = useCallback(() => { 116 downloadDocument(content, mode, title); 117 }, [content, mode, title]); 118 119 const handleVersionRestored = useCallback( 120 (data) => { 121 setTitle(data.title); 122 setMode(data.mode); 123 setContent(data.content); 124 saveNow(); 125 }, 126 [saveNow], 127 ); 128 129 if (loading) { 130 return ( 131 <div className="app"> 132 <p className="page-loading">{STR.LOADING_DOCUMENT}</p> 133 </div> 134 ); 135 } 136 137 if (loadError instanceof ApiError) { 138 if (loadError.status === 410) { 139 return ( 140 <LinkErrorPage 141 title={STR.LINK_EXPIRED_TITLE} 142 message={STR.LINK_EXPIRED_EDIT} 143 /> 144 ); 145 } 146 if (loadError.status === 404) { 147 return ( 148 <LinkErrorPage 149 title={STR.DOCUMENT_NOT_FOUND_TITLE} 150 message={friendlyErrorMessage(loadError)} 151 /> 152 ); 153 } 154 } 155 156 if (loadError || !doc) { 157 return ( 158 <LinkErrorPage 159 title={STR.LOAD_ERROR_TITLE} 160 message={friendlyErrorMessage(loadError)} 161 /> 162 ); 163 } 164 165 const saveLabel = mode === 'org' ? STR.DOWNLOAD_ORG : STR.DOWNLOAD_MD; 166 const readOnly = 167 !canEdit || lockState.status === 'blocked' || lockState.status === 'acquiring'; 168 169 return ( 170 <div className="app"> 171 <header className="app-header"> 172 <div className="app-header-text"> 173 <div className="app-header-top"> 174 <input 175 className="doc-title-input" 176 value={title} 177 onChange={(e) => { 178 titleEditedRef.current = true; 179 setTitle(e.target.value); 180 }} 181 readOnly={readOnly} 182 aria-label="Document title" 183 /> 184 <StatusBadge variant="shared">{STR.BADGE_SHARED}</StatusBadge> 185 {canEdit ? ( 186 <StatusBadge variant="editing">{STR.BADGE_EDITING}</StatusBadge> 187 ) : ( 188 <StatusBadge variant="readonly">{STR.BADGE_READONLY}</StatusBadge> 189 )} 190 </div> 191 <p className="app-subtitle"> 192 {canEdit ? STR.SHARED_EDIT : STR.SHARED_VIEW} 193 </p> 194 </div> 195 <div className="toolbar"> 196 {canEdit && ( 197 <> 198 <button type="button" className="btn" onClick={handleSaveServer}> 199 {STR.SAVE_TO_SERVER} 200 </button> 201 <VersionHistory 202 editToken={token} 203 clientId={clientId} 204 lockToken={lockToken} 205 onRestored={handleVersionRestored} 206 /> 207 <button type="button" className="btn btn-ghost" onClick={handleRelease}> 208 {STR.RELEASE_EDIT_LOCK} 209 </button> 210 </> 211 )} 212 <button type="button" className="btn" onClick={handleDownload}> 213 {saveLabel} 214 </button> 215 <SaveStatus status={saveStatus} /> 216 </div> 217 </header> 218 219 {lockState.status === 'blocked' && ( 220 <ReadOnlyBanner 221 message={STR.LOCKED_BY_OTHER} 222 lockExpiresAt={lockState.blockedExpiresAt} 223 /> 224 )} 225 226 {lockLost && ( 227 <ReadOnlyBanner variant="warning" message={STR.LOCK_LOST} /> 228 )} 229 230 <EditorLayout 231 mode={mode} 232 content={content} 233 onContentChange={canEdit ? setContent : undefined} 234 readOnly={readOnly} 235 editorRef={editorRef} 236 showEditor 237 /> 238 239 <footer className="app-footer"> 240 <p className="app-meta">{STR.FOOTER_SHARED}</p> 241 </footer> 242 </div> 243 ); 244 }