snow-editor

small markdown and org-mode editor
Log | Files | Refs | README

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 }