A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1import { useEffect, useState } from 'react';
2import type { Agent } from '@atproto/api';
3import { AuthProvider } from '../lib/auth/AuthProvider';
4import { useAuth } from '../lib/auth/useAuth';
5import LoginForm from '../lib/auth/LoginForm';
6import PublicationForm from './PublicationForm';
7import {
8 listAllPublications,
9 deletePublication,
10 type Publication,
11 type ForeignPublication,
12} from '../lib/publish/publications';
13import {
14 listPublicationArticles,
15 unpublish,
16 type MyArticle,
17} from '../lib/publish/publisher';
18import { buildGetBlobUrl, type BlobRefJson } from '../lib/media/blob';
19import AppBar from './AppBar';
20import ProviderLogo from './ProviderLogo';
21import { editLinkFor } from '../lib/editor/edit-link';
22
23type View =
24 | { kind: 'list' }
25 | { kind: 'create' }
26 | { kind: 'manage'; pub: Publication };
27
28/** The publication dashboard (SP10, step D). Authed, client-only — no `@wordpress/*`. */
29function DashboardGate() {
30 const { status, agent, handle, did, pdsUrl, error } = useAuth();
31 const [ data, setData ] = useState<
32 { owned: Publication[]; foreign: ForeignPublication[] } | null
33 >( null );
34 const [ view, setView ] = useState< View >( { kind: 'list' } );
35
36 useEffect( () => {
37 if ( ! agent || ! did ) {
38 return;
39 }
40 let cancelled = false;
41 listAllPublications( agent, did )
42 .then( ( result ) => ! cancelled && setData( result ) )
43 .catch( () => ! cancelled && setData( { owned: [], foreign: [] } ) );
44 return () => {
45 cancelled = true;
46 };
47 }, [ agent, did ] );
48
49 if ( status === 'loading' ) {
50 return (
51 <>
52 <AppBar current="dashboard" />
53 <p className="dash__loading">Connecting to your identity…</p>
54 </>
55 );
56 }
57
58 if ( status !== 'signed-in' || ! agent || ! did ) {
59 return (
60 <>
61 <AppBar current="dashboard" />
62 <div className="dash__login">
63 <LoginForm />
64 { status === 'error' && error && (
65 <p className="dash__error" role="alert">
66 Couldn't start the auth client: { error }
67 </p>
68 ) }
69 </div>
70 </>
71 );
72 }
73
74 const writerHandle = handle ?? did;
75
76 const reload = () => {
77 listAllPublications( agent, did )
78 .then( setData )
79 .catch( () => setData( { owned: [], foreign: [] } ) );
80 };
81
82 return (
83 <>
84 <AppBar current="dashboard" />
85 <div className="dash">
86 <header className="dash__bar">
87 <button
88 type="button"
89 className="dash__crumb"
90 onClick={ () => setView( { kind: 'list' } ) }
91 disabled={ view.kind === 'list' }
92 >
93 Your publications
94 </button>
95 </header>
96
97 { view.kind === 'create' && (
98 <PublicationForm
99 agent={ agent }
100 did={ did }
101 pdsUrl={ pdsUrl }
102 handle={ writerHandle }
103 onSaved={ () => {
104 reload();
105 setView( { kind: 'list' } );
106 } }
107 onCancel={ () => setView( { kind: 'list' } ) }
108 />
109 ) }
110
111 { view.kind === 'manage' && (
112 <PublicationManager
113 agent={ agent }
114 did={ did }
115 pdsUrl={ pdsUrl }
116 handle={ writerHandle }
117 pub={ view.pub }
118 onChanged={ ( pub ) => {
119 reload();
120 setView( { kind: 'manage', pub } );
121 } }
122 onDeleted={ () => {
123 reload();
124 setView( { kind: 'list' } );
125 } }
126 onBack={ () => setView( { kind: 'list' } ) }
127 />
128 ) }
129
130 { view.kind === 'list' && (
131 <PublicationList
132 publications={ data ? data.owned : null }
133 foreign={ data?.foreign ?? [] }
134 did={ did }
135 handle={ writerHandle }
136 pdsUrl={ pdsUrl }
137 onNew={ () => setView( { kind: 'create' } ) }
138 onManage={ ( pub ) => setView( { kind: 'manage', pub } ) }
139 />
140 ) }
141 </div>
142 </>
143 );
144}
145
146/** A publication's 48px logo, or a letter-fallback square when it has no icon. Shared by the owned and foreign lists. */
147function PublicationLogo( {
148 icon,
149 name,
150 pdsUrl,
151 did,
152}: {
153 icon?: BlobRefJson;
154 name: string;
155 pdsUrl: string | null;
156 did: string;
157} ) {
158 const logoUrl = icon && pdsUrl ? buildGetBlobUrl( pdsUrl, did, icon.ref.$link ) : null;
159 return logoUrl ? (
160 <img className="dash__publogo" src={ logoUrl } alt="" width={ 48 } height={ 48 } />
161 ) : (
162 <span className="dash__publogo dash__publogo--fallback" aria-hidden="true">
163 { name.charAt( 0 ).toUpperCase() }
164 </span>
165 );
166}
167
168function PublicationList( {
169 publications,
170 foreign,
171 did,
172 handle,
173 pdsUrl,
174 onNew,
175 onManage,
176}: {
177 publications: Publication[] | null;
178 foreign: ForeignPublication[];
179 did: string;
180 handle: string;
181 pdsUrl: string | null;
182 onNew: () => void;
183 onManage: ( pub: Publication ) => void;
184} ) {
185 if ( publications === null ) {
186 return <p className="dash__loading">Loading your publications…</p>;
187 }
188 return (
189 <section className="dash__section">
190 <div className="dash__section-head">
191 <h1 className="dash__h1">Your publications</h1>
192 </div>
193
194 { publications.length === 0 ? (
195 <div className="dash__empty-state">
196 <p>You don't have any publications yet. Create one to start publishing.</p>
197 <button type="button" className="dash__new" onClick={ onNew }>
198 + New publication
199 </button>
200 </div>
201 ) : (
202 <>
203 <ul className="dash__pubs">
204 { publications.map( ( pub ) => {
205 return (
206 <li className="dash__pub" key={ pub.uri }>
207 <PublicationLogo
208 icon={ pub.icon }
209 name={ pub.name }
210 pdsUrl={ pdsUrl }
211 did={ did }
212 />
213 <span className="dash__pubtext">
214 <span className="dash__pubname">{ pub.name }</span>
215 <span className="dash__pubslug">/{ pub.slug }</span>
216 </span>
217 <span className="dash__pubactions">
218 <a className="dash__link" href={ `/@${ handle }/${ pub.slug }` }>
219 View
220 </a>
221 <button type="button" onClick={ () => onManage( pub ) }>
222 Manage
223 </button>
224 </span>
225 </li>
226 );
227 } ) }
228 </ul>
229 <div className="dash__add">
230 <button type="button" className="dash__new-quiet" onClick={ onNew }>
231 + New publication
232 </button>
233 </div>
234 </>
235 ) }
236
237 { foreign.length > 0 && (
238 <div className="dash__foreign">
239 <h2 className="dash__h2">From other apps</h2>
240 <p className="dash__foreign-note">
241 These publications live in your repository but are managed by another app.
242 </p>
243 <ul className="dash__pubs">
244 { foreign.map( ( pub ) => {
245 return (
246 <li className="dash__pub" key={ pub.uri }>
247 <PublicationLogo
248 icon={ pub.icon }
249 name={ pub.name }
250 pdsUrl={ pdsUrl }
251 did={ did }
252 />
253 <span className="dash__pubtext">
254 <span className="dash__pubname">{ pub.name }</span>
255 <span className="dash__pubhost">
256 <ProviderLogo
257 provider={ pub.provider }
258 className="dash__pubhost-logo"
259 />
260 { pub.hostname }
261 </span>
262 </span>
263 <span className="dash__pubactions">
264 <a
265 className="dash__link"
266 href={ pub.url }
267 target="_blank"
268 rel="noopener noreferrer"
269 >
270 Visit ↗
271 </a>
272 </span>
273 </li>
274 );
275 } ) }
276 </ul>
277 </div>
278 ) }
279 </section>
280 );
281}
282
283type Tab = 'posts' | 'settings' | 'delete';
284
285function PublicationManager( {
286 agent,
287 did,
288 pdsUrl,
289 handle,
290 pub,
291 onChanged,
292 onDeleted,
293 onBack,
294}: {
295 agent: Agent;
296 did: string;
297 pdsUrl: string | null;
298 handle: string;
299 pub: Publication;
300 onChanged: ( pub: Publication ) => void;
301 onDeleted: () => void;
302 onBack: () => void;
303} ) {
304 const [ tab, setTab ] = useState< Tab >( 'posts' );
305 const [ articles, setArticles ] = useState< MyArticle[] | null >( null );
306 const [ busy, setBusy ] = useState< string | null >( null );
307 const [ deleting, setDeleting ] = useState( false );
308 const [ error, setError ] = useState< string | null >( null );
309
310 useEffect( () => {
311 let cancelled = false;
312 listPublicationArticles( agent, did, { uri: pub.uri, slug: pub.slug } )
313 .then( ( list ) => ! cancelled && setArticles( list ) )
314 .catch( () => ! cancelled && setArticles( [] ) );
315 return () => {
316 cancelled = true;
317 };
318 }, [ agent, did, pub.uri, pub.slug ] );
319
320 async function onUnpublish( article: MyArticle ) {
321 const ok = window.confirm(
322 `Unpublish “${ article.title }”?\n\nThis deletes the article AND its Bluesky post.`
323 );
324 if ( ! ok ) {
325 return;
326 }
327 setBusy( article.rkey );
328 setError( null );
329 try {
330 await unpublish( agent, did, { rkey: article.rkey, bskyPostRef: article.bskyPostRef } );
331 setArticles( ( prev ) => prev?.filter( ( a ) => a.rkey !== article.rkey ) ?? null );
332 } catch ( err ) {
333 setError( err instanceof Error ? err.message : String( err ) );
334 } finally {
335 setBusy( null );
336 }
337 }
338
339 async function onDelete() {
340 const count = articles?.length ?? 0;
341 const ok = window.confirm(
342 `Delete the publication “${ pub.name }”?\n\n` +
343 `This permanently removes the publication and its ${ count } article${
344 count === 1 ? '' : 's'
345 } — and each article's companion Bluesky post. This cannot be undone.`
346 );
347 if ( ! ok ) {
348 return;
349 }
350 setDeleting( true );
351 setError( null );
352 try {
353 await deletePublication( agent, did, { uri: pub.uri, rkey: pub.rkey } );
354 onDeleted();
355 } catch ( err ) {
356 setError(
357 `${ err instanceof Error ? err.message : String( err ) } — some articles or ` +
358 `posts may already have been removed. Reopen this publication and try again.`
359 );
360 setDeleting( false );
361 }
362 }
363
364 return (
365 <section className="dash__section">
366 <div className="dash__section-head">
367 <h1 className="dash__h1">{ pub.name }</h1>
368 <a className="dash__link" href={ `/@${ handle }/${ pub.slug }` }>
369 View public page
370 </a>
371 </div>
372
373 <nav className="dash__tabs">
374 <button
375 type="button"
376 className={ tab === 'posts' ? 'is-active' : '' }
377 onClick={ () => setTab( 'posts' ) }
378 >
379 Posts
380 </button>
381 <button
382 type="button"
383 className={ tab === 'settings' ? 'is-active' : '' }
384 onClick={ () => setTab( 'settings' ) }
385 >
386 Settings
387 </button>
388 <button
389 type="button"
390 className={ tab === 'delete' ? 'is-active' : '' }
391 onClick={ () => setTab( 'delete' ) }
392 >
393 Delete
394 </button>
395 </nav>
396
397 { error && (
398 <p className="dash__error" role="alert">
399 { error }
400 </p>
401 ) }
402
403 { tab === 'posts' && (
404 <div className="dash__posts">
405 { articles === null ? (
406 <p className="dash__loading">Loading posts…</p>
407 ) : articles.length === 0 ? (
408 <p className="dash__empty">
409 No posts yet. <a href="/editor">Open the studio</a> to write one.
410 </p>
411 ) : (
412 <ul className="dash__postlist">
413 { articles.map( ( article ) => (
414 <li className="dash__post" key={ article.rkey }>
415 <a
416 className="dash__postlink"
417 href={ `/@${ handle }/${ pub.slug }/${ article.rkey }` }
418 >
419 { article.title }
420 </a>
421 { article.publishedAt && (
422 <span className="dash__postdate">
423 { article.publishedAt.slice( 0, 10 ) }
424 </span>
425 ) }
426 <a
427 className="dash__edit"
428 href={ editLinkFor( article.rkey ) }
429 aria-label={ `Edit ${ article.title }` }
430 >
431 Edit
432 </a>
433 <button
434 type="button"
435 disabled={ busy === article.rkey }
436 onClick={ () => void onUnpublish( article ) }
437 >
438 { busy === article.rkey ? 'Unpublishing…' : 'Unpublish' }
439 </button>
440 </li>
441 ) ) }
442 </ul>
443 ) }
444 </div>
445 ) }
446
447 { tab === 'settings' && (
448 <PublicationForm
449 agent={ agent }
450 did={ did }
451 pdsUrl={ pdsUrl }
452 handle={ handle }
453 existing={ pub }
454 onSaved={ onChanged }
455 onCancel={ onBack }
456 />
457 ) }
458
459 { tab === 'delete' && (
460 <div className="dash__danger">
461 <h2>Delete this publication</h2>
462 <p>
463 This permanently removes <strong>{ pub.name }</strong>, all of its articles, and
464 each article's companion Bluesky post. This cannot be undone.
465 </p>
466 <button
467 type="button"
468 className="dash__delete"
469 onClick={ () => void onDelete() }
470 disabled={ deleting }
471 >
472 { deleting ? 'Deleting…' : 'Delete publication' }
473 </button>
474 </div>
475 ) }
476 </section>
477 );
478}
479
480export default function Dashboard() {
481 return (
482 <AuthProvider>
483 <DashboardGate />
484 </AuthProvider>
485 );
486}