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