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 8.4 kB View raw
1import { useEffect, useMemo, useRef, useState } from 'react'; 2import type { BlockInstance } from '@wordpress/blocks'; 3import { AuthProvider } from '../lib/auth/AuthProvider'; 4import { useAuth } from '../lib/auth/useAuth'; 5import LoginForm from '../lib/auth/LoginForm'; 6import EditorCanvas from './EditorCanvas'; 7import PublishPanel from './PublishPanel'; 8import PublishedPill from './PublishedPill'; 9import AppBar from './AppBar'; 10import { createMediaUpload, revokeBlobRegistry, type BlobRegistry } from '../lib/media/mediaUpload'; 11import { 12 coverFromStoredRef, 13 uploadCoverViaMedia, 14 type CoverUpload, 15} from '../lib/media/cover'; 16import { getMyArticle, type MyArticle } from '../lib/publish/publisher'; 17import { editRkeyFromSearch } from '../lib/editor/edit-link'; 18import { listPublications, type Publication } from '../lib/publish/publications'; 19 20/** 21 * The authenticated writing surface. Gates the editor behind atproto OAuth: 22 * loading → (signed-out: login form) | (signed-in: editor). 23 */ 24function StudioGate() { 25 const { status, agent, handle, did, pdsUrl, error } = useAuth(); 26 const [ blocks, setBlocks ] = useState< BlockInstance[] >( [] ); 27 const [ title, setTitle ] = useState( '' ); 28 const [ excerpt, setExcerpt ] = useState( '' ); 29 const [ editing, setEditing ] = useState< MyArticle | null >( null ); 30 const [ cover, setCover ] = useState< CoverUpload | null >( null ); 31 // Set when an `?edit=<rkey>` load fails to fetch (vs. simply not found). 32 const [ editLoadError, setEditLoadError ] = useState< string | null >( null ); 33 const [ refreshKey, setRefreshKey ] = useState( 0 ); 34 // The just-published/updated article — drives the success pill. Lives here 35 // (above the keyed editor div) so it survives the post-publish remount. 36 const [ published, setPublished ] = useState< { 37 url: string; 38 isEditing: boolean; 39 } | null >( null ); 40 // `null` = still loading; `[]` = loaded, none exist. PublishPanel needs the distinction. 41 const [ publications, setPublications ] = useState< Publication[] | null >( null ); 42 // Shared between mediaUpload (writes blob refs) and publish (reads them). 43 const registry = useRef< BlobRegistry >( new Map() ).current; 44 45 // Load the writer's SkyPress publications (the publish targets / selector). 46 useEffect( () => { 47 if ( ! agent || ! did ) { 48 return; 49 } 50 let cancelled = false; 51 listPublications( agent, did ) 52 .then( ( list ) => ! cancelled && setPublications( list ) ) 53 .catch( () => ! cancelled && setPublications( [] ) ); 54 return () => { 55 cancelled = true; 56 }; 57 }, [ agent, did, refreshKey ] ); 58 59 // One-shot: if the page was opened as /editor?edit=<rkey>, load that article. 60 const editLoadedRef = useRef( false ); 61 useEffect( () => { 62 if ( editLoadedRef.current || ! agent || ! did ) { 63 return; 64 } 65 const rkey = editRkeyFromSearch( window.location.search ); 66 if ( ! rkey ) { 67 editLoadedRef.current = true; 68 return; 69 } 70 editLoadedRef.current = true; 71 let cancelled = false; 72 getMyArticle( agent, did, rkey ) 73 .then( ( article ) => { 74 if ( cancelled ) { 75 return; 76 } 77 if ( article ) { 78 setEditing( article ); 79 setBlocks( article.blocks as unknown as BlockInstance[] ); 80 setTitle( article.title ); 81 setExcerpt( article.description ?? '' ); 82 } 83 // `article === null` → no owned document has this rkey (stale/bad edit 84 // link). Silently start a new article, as before. 85 } ) 86 .catch( ( err ) => { 87 // The fetch itself failed (network/auth) — distinct from a stale link. 88 // Surface it so the blank "New article" editor isn't mistaken for the 89 // requested article having loaded. 90 if ( ! cancelled ) { 91 setEditLoadError( 92 err instanceof Error ? err.message : String( err ) 93 ); 94 } 95 } ); 96 return () => { 97 cancelled = true; 98 }; 99 }, [ agent, did ] ); 100 101 // Hydrate the cover preview once an edit-loaded article AND the PDS URL are both known. 102 // pdsUrl resolves after agent/did (adoptSession awaits the profile + PDS lookup), so it can 103 // arrive after the edit-load fetch — keying this on pdsUrl (not the one-shot loader above) 104 // avoids dropping a stored cover that would otherwise be stripped on the next save. 105 useEffect( () => { 106 const next = coverFromStoredRef( editing?.coverImage, { pdsUrl, did } ); 107 if ( next ) { 108 setCover( next ); 109 } 110 }, [ editing, pdsUrl, did ] ); 111 112 // Release the preview object URLs this session minted when the Studio unmounts. 113 useEffect( () => () => revokeBlobRegistry( registry ), [ registry ] ); 114 115 const mediaUpload = useMemo( () => { 116 if ( ! agent || ! did || ! pdsUrl ) { 117 return undefined; 118 } 119 return createMediaUpload( { agent, did, pdsUrl, registry } ); 120 }, [ agent, did, pdsUrl, registry ] ); 121 122 const uploadCover = useMemo( () => { 123 if ( ! mediaUpload ) { 124 return undefined; 125 } 126 return ( file: File ) => uploadCoverViaMedia( file, mediaUpload, registry ); 127 }, [ mediaUpload, registry ] ); 128 129 if ( status === 'loading' ) { 130 return ( 131 <> 132 <AppBar current="editor" /> 133 <p className="studio__loading">Connecting to your identity</p> 134 </> 135 ); 136 } 137 138 if ( status === 'signed-in' && agent && did ) { 139 // Re-mount the editor when switching article (or after a new publish) so the 140 // SkyEditor canvas resets via onLoad/initialBlocks. The title is Studio-owned 141 // state now, so it doesn't reset on remount — the title/blocks reset for a new 142 // publish happens in PublishPanel's `onComplete` below. 143 const editorKey = editing ? `edit-${ editing.rkey }` : `new-${ refreshKey }`; 144 145 const startNew = () => { 146 revokeBlobRegistry( registry ); 147 setPublished( null ); 148 setEditing( null ); 149 setBlocks( [] ); 150 setCover( null ); 151 setTitle( '' ); 152 setExcerpt( '' ); 153 setEditLoadError( null ); 154 }; 155 156 return ( 157 <> 158 <AppBar current="editor" /> 159 160 <div className="studio__mode"> 161 <span>{ editing ? `Editing: ${ editing.title }` : 'New article' }</span> 162 { editing && ( 163 <button type="button" onClick={ startNew }> 164 + New article 165 </button> 166 ) } 167 </div> 168 169 { published && ( 170 <PublishedPill url={ published.url } isEditing={ published.isEditing } /> 171 ) } 172 173 { editLoadError && ( 174 <p className="studio__error studio__error--banner" role="alert"> 175 Couldn't open that article for editing: { editLoadError }. You can 176 retry from your dashboard, or start a new article below. 177 </p> 178 ) } 179 180 <div key={ editorKey }> 181 <PublishPanel 182 agent={ agent } 183 identity={ { did, handle } } 184 blocks={ blocks } 185 blobRegistry={ registry } 186 publications={ publications } 187 editing={ 188 editing 189 ? { 190 rkey: editing.rkey, 191 siteUri: editing.siteUri, 192 siteSlug: editing.siteSlug, 193 publishedAt: editing.publishedAt ?? new Date().toISOString(), 194 bskyPostRef: editing.bskyPostRef, 195 } 196 : undefined 197 } 198 title={ title } 199 description={ excerpt } 200 coverImage={ cover?.ref } 201 onComplete={ ( result ) => { 202 setPublished( { 203 url: result.articleUrl, 204 isEditing: result.isEditing, 205 } ); 206 setRefreshKey( ( k ) => k + 1 ); 207 // A new publish leaves the editor on a fresh "new article": clear the 208 // title + blocks (the editorKey bump remounts SkyEditor empty). On an 209 // update we stay on the same article, so keep its content in place. 210 if ( ! editing ) { 211 setTitle( '' ); 212 setExcerpt( '' ); 213 setBlocks( [] ); 214 setCover( null ); 215 } 216 } } 217 /> 218 <EditorCanvas 219 title={ title } 220 onTitleChange={ ( value ) => { 221 setPublished( null ); 222 setTitle( value ); 223 } } 224 lede={ excerpt } 225 onLedeChange={ ( value ) => { 226 setPublished( null ); 227 setExcerpt( value ); 228 } } 229 onBlocksChange={ setBlocks } 230 mediaUpload={ mediaUpload } 231 initialBlocks={ editing?.blocks } 232 cover={ cover } 233 onCoverUpload={ uploadCover } 234 onCoverChange={ setCover } 235 /> 236 </div> 237 </> 238 ); 239 } 240 241 // signed-out or error 242 return ( 243 <> 244 <AppBar current="editor" /> 245 <div className="studio__login"> 246 <LoginForm /> 247 { status === 'error' && error && ( 248 <p className="studio__error" role="alert"> 249 Couldn't start the auth client: { error } 250 </p> 251 ) } 252 </div> 253 </> 254 ); 255} 256 257export default function Studio() { 258 return ( 259 <AuthProvider> 260 <StudioGate /> 261 </AuthProvider> 262 ); 263}