A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

at trunk 7.7 kB View raw
1// src/components/WriteStudio.tsx 2import { useEffect, useMemo, useRef, useState } from 'react'; 3import type { BlockInstance } from '@wordpress/blocks'; 4import { AuthProvider } from '../lib/auth/AuthProvider'; 5import { useAuth } from '../lib/auth/useAuth'; 6import EditorCanvas from './EditorCanvas'; 7import AppBar from './AppBar'; 8import PublishedPill from './PublishedPill'; 9import SignInPanel from './SignInPanel'; 10import WritePublishFlow from './WritePublishFlow'; 11import { createDeferredMediaUpload } from '../lib/write/deferred-media'; 12import { createDraftStore, type WriteDraft } from '../lib/write/draft-store'; 13import { listPublications, type Publication } from '../lib/publish/publications'; 14import { normalizeBlocks } from '../lib/publish/records'; 15import { validateCoverFile, type CoverUpload } from '../lib/media/cover'; 16import type { BlockNode } from '../lib/blocks/render'; 17import type { BlobRefJson } from '../lib/media/blob'; 18 19/** Read a file into a `data:` URL for a held (not-yet-uploaded) cover preview. */ 20function readAsDataUrl( file: File ): Promise< string > { 21 return new Promise( ( resolve, reject ) => { 22 const reader = new FileReader(); 23 reader.onload = () => resolve( reader.result as string ); 24 reader.onerror = () => reject( reader.error ?? new Error( 'Could not read the file.' ) ); 25 reader.readAsDataURL( file ); 26 } ); 27} 28 29/** A placeholder ref for a held cover — only `previewUrl` (a data URL) is used before publish. */ 30function deferredCoverRef( file: File ): BlobRefJson { 31 return { $type: 'blob', ref: { $link: '' }, mimeType: file.type, size: file.size }; 32} 33 34function WriteSurface() { 35 const { status, agent, did, handle, pdsUrl, error, signIn } = useAuth(); 36 37 const draftStore = useMemo( () => createDraftStore(), [] ); 38 const mediaUpload = useMemo( () => createDeferredMediaUpload(), [] ); 39 40 const [ title, setTitle ] = useState( '' ); 41 const [ lede, setLede ] = useState( '' ); 42 const [ blocks, setBlocks ] = useState< BlockNode[] >( [] ); 43 const [ cover, setCover ] = useState< CoverUpload | null >( null ); 44 const [ publications, setPublications ] = useState< Publication[] | null >( null ); 45 const [ flowOpen, setFlowOpen ] = useState( false ); 46 const [ signinOpen, setSigninOpen ] = useState( false ); 47 const [ published, setPublished ] = useState< { url: string } | null >( null ); 48 const [ editorKey, setEditorKey ] = useState( 0 ); 49 50 // Restored content fed to the editor canvas once, captured so a later `blocks` 51 // change can't remount SkyEditor (which would wipe the canvas). 52 const initialBlocksRef = useRef< BlockNode[] >( [] ); 53 const intentRef = useRef( false ); 54 55 // One-shot restore + intent read on mount. 56 useEffect( () => { 57 let cancelled = false; 58 intentRef.current = draftStore.consumePublishIntent(); 59 draftStore.load().then( ( d: WriteDraft | null ) => { 60 if ( cancelled || ! d ) { 61 return; 62 } 63 initialBlocksRef.current = d.blocks; 64 setTitle( d.title ); 65 setLede( d.lede ); 66 setBlocks( d.blocks ); 67 if ( d.coverDataUrl ) { 68 setCover( { 69 ref: { $type: 'blob', ref: { $link: '' }, mimeType: '', size: 0 }, 70 previewUrl: d.coverDataUrl, 71 } ); 72 } 73 setEditorKey( ( k ) => k + 1 ); 74 } ); 75 return () => { 76 cancelled = true; 77 }; 78 }, [ draftStore ] ); 79 80 // Load publications once signed in. 81 useEffect( () => { 82 if ( ! agent || ! did ) { 83 return; 84 } 85 let cancelled = false; 86 listPublications( agent, did ) 87 .then( ( list ) => ! cancelled && setPublications( list ) ) 88 .catch( () => ! cancelled && setPublications( [] ) ); 89 return () => { 90 cancelled = true; 91 }; 92 }, [ agent, did ] ); 93 94 // Resume: a publish intent + a signed-in session auto-opens the publish flow. 95 useEffect( () => { 96 if ( status === 'signed-in' && intentRef.current ) { 97 intentRef.current = false; 98 setFlowOpen( true ); 99 } 100 }, [ status ] ); 101 102 const reloadPublications = () => { 103 if ( agent && did ) { 104 listPublications( agent, did ) 105 .then( setPublications ) 106 .catch( () => setPublications( [] ) ); 107 } 108 }; 109 110 async function persistDraft() { 111 await draftStore.save( { 112 title, 113 lede, 114 blocks, 115 coverDataUrl: cover?.previewUrl ?? null, 116 } ); 117 } 118 119 async function onSignIn( value: string ) { 120 // Sign-in is only ever reached via Publish, so always stamp the intent that 121 // auto-resumes the publish flow when the OAuth redirect returns. 122 await persistDraft(); 123 draftStore.setPublishIntent(); 124 await signIn( value ); // full-page redirect; never resolves 125 } 126 127 function onPublishClicked() { 128 if ( status === 'signed-in' ) { 129 void persistDraft(); 130 setFlowOpen( true ); 131 return; 132 } 133 // Signed out → reveal the sign-in panel framed around publishing. Signing in is 134 // only offered here, on the way to publishing — never as standalone chrome. 135 setSigninOpen( true ); 136 } 137 138 async function uploadCover( file: File ): Promise< CoverUpload > { 139 const message = validateCoverFile( file ); 140 if ( message ) { 141 throw new Error( message ); 142 } 143 const previewUrl = await readAsDataUrl( file ); 144 return { ref: deferredCoverRef( file ), previewUrl }; 145 } 146 147 const canPublish = title.trim().length > 0 && blocks.length > 0; 148 const signedIn = status === 'signed-in' && agent && did; 149 150 return ( 151 <> 152 <AppBar current="editor" /> 153 154 { /* The Publish action, right-aligned in the shared content column. The signed-in 155 identity + sign-out live in the app bar above, so there is no pill here — and 156 no standalone "Sign in" affordance (signing in happens on the way to publish). 157 Hidden once the publish stepper takes over, so the button isn't shown twice. */ } 158 { ! ( signedIn && flowOpen ) && ( 159 <div className="write-actions"> 160 <button 161 type="button" 162 className="write-publish" 163 disabled={ ! canPublish } 164 onClick={ onPublishClicked } 165 > 166 Publish 167 </button> 168 </div> 169 ) } 170 171 { published && <PublishedPill url={ published.url } isEditing={ false } /> } 172 173 { ! signedIn && signinOpen && ( 174 <div className="write-signin"> 175 <SignInPanel 176 forPublish 177 error={ error } 178 onSubmit={ ( value ) => void onSignIn( value ) } 179 onCancel={ () => setSigninOpen( false ) } 180 /> 181 </div> 182 ) } 183 184 { signedIn && flowOpen && ( 185 <WritePublishFlow 186 agent={ agent! } 187 identity={ { did: did!, handle } } 188 pdsUrl={ pdsUrl ?? '' } 189 blocks={ blocks } 190 coverDataUrl={ cover?.previewUrl ?? null } 191 title={ title } 192 description={ lede } 193 publications={ publications } 194 onReloadPublications={ reloadPublications } 195 onPublished={ ( result ) => { 196 setFlowOpen( false ); 197 setPublished( { url: result.articleUrl } ); 198 void draftStore.clear(); 199 // Reset to a fresh new article. 200 setTitle( '' ); 201 setLede( '' ); 202 setBlocks( [] ); 203 setCover( null ); 204 initialBlocksRef.current = []; 205 setEditorKey( ( k ) => k + 1 ); 206 } } 207 onCancel={ () => setFlowOpen( false ) } 208 /> 209 ) } 210 211 <div key={ editorKey }> 212 <EditorCanvas 213 title={ title } 214 onTitleChange={ ( value ) => { 215 setPublished( null ); 216 setTitle( value ); 217 } } 218 lede={ lede } 219 onLedeChange={ setLede } 220 onBlocksChange={ ( instances: BlockInstance[] ) => 221 setBlocks( normalizeBlocks( instances ) ) 222 } 223 mediaUpload={ mediaUpload } 224 initialBlocks={ initialBlocksRef.current } 225 cover={ cover } 226 onCoverUpload={ uploadCover } 227 onCoverChange={ setCover } 228 /> 229 </div> 230 </> 231 ); 232} 233 234/** The writing-first island: editor for every auth status, publish deferred (design 2026-06-17). */ 235export default function WriteStudio() { 236 return ( 237 <AuthProvider> 238 <WriteSurface /> 239 </AuthProvider> 240 ); 241}