A calm place to write long-form, and publish it to the open social web.
skypress.blog/
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}